Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f01d4c2264 | |||
| 6213281b3c | |||
| d29ee5ce46 | |||
| 299f66c8f2 | |||
| bff187da20 |
36
AGENTS.md
Normal file
36
AGENTS.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# AGENTS.md
|
||||
|
||||
Pygienium is a `pi` extension: code-hygiene checks that scan a target with isolated sub-agent sessions (analysis), optionally apply fixes, then verify artifacts. Sub-agents are defined as markdown in `agents/*.md` (YAML frontmatter: `name`, `allowedTools`; body = system prompt).
|
||||
|
||||
## Commands
|
||||
|
||||
- `bun run typecheck` — `tsc --noEmit` on `src/`
|
||||
- `bun test` — full suite (bun test)
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/checks/` — check definitions; self-register on import (`registerCheck`)
|
||||
- `src/agent-runner.ts` — spawns sub-agent sessions; runner injectable (`setAgentRunner`), tests use `fakeAgentRunner` (no real model calls)
|
||||
- `src/commands.ts` — slash-command handlers, exported for direct test invocation with a stub `PygieniumCtx`
|
||||
- `tests/` — bun tests; stub ctx with `{ cwd, mode: "print", hasUI: false, ui: undefined } as PygieniumCtx`
|
||||
- `port-to-omp.mjs` — regenerates the self-contained omp port (see commit process)
|
||||
|
||||
## Conventions
|
||||
|
||||
- Checks: `gate` (skip reason), `buildScanTask`, optional `buildFixTask`, optional `verify`. Scan tasks embed `!write`/`!echo` lines as deterministic fallback for the fake runner.
|
||||
- Sub-agent sessions must be pinned to the invoking session's model: thread `ctx.model` through `PygieniumCtx` → run options → `AgentTaskOptions.model` → `createAgentSession({ model })`.
|
||||
- A pinned model needs its provider too: a provider an extension registered (`pi.registerProvider`) exists only in the invoking `ModelRegistry`, and the sub-session runs `noExtensions`, so thread `ctx.modelRegistry` (narrowed by `asProviderRegistry`) through the same chain to `AgentTaskOptions.modelRegistry` → `providerForwardingExtension`, which re-registers that one provider through an inline factory. Drop that link and every check dies with `No API key found for <provider>`.
|
||||
- Keep files modular; no compatibility shims.
|
||||
|
||||
## Commit process (IMPORTANT)
|
||||
|
||||
Commits are gated by the pre-commit hook (enable: `git config core.hooksPath .githooks`). It regenerates the omp port into a temp dir and typechecks it — exactly what CI runs. A failing hook blocks the commit; never bypass with `--no-verify`.
|
||||
|
||||
`port-to-omp.mjs` rewrites source via exact-string matchers (`FILE_RULES`). Editing these files **requires** keeping the matching ops in sync or the port regeneration throws and the commit is blocked:
|
||||
|
||||
- `src/index.ts` — removes `mode: ctx.mode` from the `pygieniumCtx` literal
|
||||
- `src/commands.ts` — removes `mode` from the `PygieniumCtx` Pick; swaps `ctx.mode` for `ctx.hasUI` in `print`
|
||||
- `src/agent-runner.ts` — swaps the SDK import + loader for the omp SDK's `createAgentSession` options (`toolNames`, `systemPrompt`, `agentRegistry`, …); reduces `ProviderRegistry` to an SDK-free shape and stubs `providerForwardingExtension` (the port disables extension discovery, so it has nothing to forward)
|
||||
- `src/agents.ts` — `find` → `glob`
|
||||
|
||||
After touching any of those, run `bun port-to-omp.mjs --out <tmp>` and `(cd <tmp> && bun run typecheck)` before committing; the hook does the same.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mikefreno/pygienium",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
@@ -36,6 +36,10 @@ const OMP = join(HOME, ".omp", "agent", "extensions");
|
||||
const SKIP = new Set([
|
||||
"port-to-omp.mjs",
|
||||
"release-tag.sh",
|
||||
// Pins pi-only behaviour: provider forwarding into an in-memory session. The
|
||||
// port disables extension discovery, so it has no such behaviour to assert
|
||||
// (see the agent-runner rules below).
|
||||
"provider-forwarding.test.ts",
|
||||
".gitea",
|
||||
".github",
|
||||
"node_modules",
|
||||
@@ -246,9 +250,28 @@ const FILE_RULES = {
|
||||
},
|
||||
],
|
||||
"src/agent-runner.ts": [
|
||||
{
|
||||
// The port has no extension provider registrations to read: its sessions
|
||||
// are created with extension discovery disabled and its ModelRegistry
|
||||
// has no getRegistered*Provider lookups. Keep only the shape the plumbing
|
||||
// passes around, so the ported files stay type-correct.
|
||||
re: /\/\*\* The invoking session's provider registry[\s\S]*?\n\}\n/,
|
||||
to:
|
||||
"/**\n * Provider registrations as the omp port passes them around. The port does not\n * forward any: omp sessions disable extension discovery, and its ModelRegistry\n * keeps no extension provider registrations to forward.\n */\nexport interface ProviderRegistry {\n\tgetRegisteredNativeProvider(providerId: string): object | undefined;\n\tgetRegisteredProviderConfig(providerId: string): object | undefined;\n}\n",
|
||||
label: "provider registry typing",
|
||||
},
|
||||
{
|
||||
// The inline provider extension has no purpose in the port — extension
|
||||
// discovery is disabled when the session is created — so the port carries
|
||||
// only the stub the plumbing references.
|
||||
re: /\n\/\*\*\n \* Build the inline extension that re-registers a provider inside a sub-session\.[\s\S]*?\n\t\};\n\}\n/,
|
||||
to:
|
||||
"\n/**\n * The omp port forwards no provider registrations (see `ProviderRegistry`), so\n * sub-agent sessions get no inline extension.\n */\nexport function providerForwardingExtension(): undefined {\n\treturn undefined;\n}\n",
|
||||
label: "provider forwarding stub",
|
||||
},
|
||||
{
|
||||
from:
|
||||
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package.\n\tconst {\n\t\tcreateAgentSession,\n\t\tDefaultResourceLoader,\n\t\tgetAgentDir,\n\t\tSessionManager,\n\t} = await import(\"@oh-my-pi/pi-coding-agent\");\n\tconst loader = new DefaultResourceLoader({\n\t\tcwd: opts.cwd,\n\t\tagentDir: getAgentDir(),\n\t\tsystemPromptOverride: () => agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.\n\t\tnoExtensions: true,\n\t\tnoSkills: true,\n\t\tnoThemes: true,\n\t\tnoPromptTemplates: true,\n\t});\n\tawait loader.reload();",
|
||||
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package.\n\tconst {\n\t\tcreateAgentSession,\n\t\tDefaultResourceLoader,\n\t\tgetAgentDir,\n\t\tSessionManager,\n\t} = await import(\"@oh-my-pi/pi-coding-agent\");\n\t// Re-register the pinned model's provider inside the sub-session: inline\n\t// extension factories load even under `noExtensions`, so this is the one\n\t// registration a pinned model needs in order to authenticate.\n\tconst providerExtension = providerForwardingExtension(\n\t\topts.modelRegistry,\n\t\topts.model?.provider,\n\t);\n\tconst loader = new DefaultResourceLoader({\n\t\tcwd: opts.cwd,\n\t\tagentDir: getAgentDir(),\n\t\tsystemPromptOverride: () => agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.\n\t\tnoExtensions: true,\n\t\tnoSkills: true,\n\t\tnoThemes: true,\n\t\tnoPromptTemplates: true,\n\t\textensionFactories: providerExtension ? [providerExtension] : [],\n\t});\n\tawait loader.reload();",
|
||||
to:
|
||||
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package. The specifier\n\t// is static by intent (dynamic-import exception: module is intentionally\n\t// excluded from the import-time graph to keep fake-runner tests SDK-free).\n\tconst { createAgentSession, AgentRegistry, SessionManager } = await import(\n\t\t\"@oh-my-pi/pi-coding-agent\"\n\t);",
|
||||
label: "SDK import + loader removal",
|
||||
@@ -256,9 +279,9 @@ const FILE_RULES = {
|
||||
{ from: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" },
|
||||
{
|
||||
from:
|
||||
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});",
|
||||
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\t// Pin the sub-agent to the model the user has selected in the invoking\n\t\t// session rather than the settings default. Omitted when the caller\n\t\t// has no live session model (print/RPC modes), which keeps the\n\t\t// settings-default fallback.\n\t\t...(opts.model ? { model: opts.model } : {}),\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});",
|
||||
to:
|
||||
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});",
|
||||
"\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\t// Pin the sub-agent to the model the user has selected in the invoking\n\t\t// session rather than the settings default.\n\t\t...(opts.model ? { model: opts.model } : {}),\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});",
|
||||
label: "createAgentSession",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -20,9 +20,61 @@ import { dirname, isAbsolute, join } from "node:path";
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
||||
|
||||
/** The invoking session's provider registry, as pi types it. */
|
||||
type HostModelRegistry = ExtensionCommandContext["modelRegistry"];
|
||||
|
||||
/**
|
||||
* The two capabilities a sub-agent needs from the invoking session's
|
||||
* `ModelRegistry`: the registrations extensions made through
|
||||
* `pi.registerProvider`. Values are left in their host types — this module
|
||||
* relays them, it never interprets them.
|
||||
*/
|
||||
export interface ProviderRegistry {
|
||||
getRegisteredNativeProvider(
|
||||
providerId: string,
|
||||
): ReturnType<HostModelRegistry["getRegisteredNativeProvider"]>;
|
||||
getRegisteredProviderConfig(
|
||||
providerId: string,
|
||||
): ReturnType<HostModelRegistry["getRegisteredProviderConfig"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline extension that re-registers a provider in a sub-session. Spelled out
|
||||
* structurally — an `InlineExtension` in pi — so the generated omp port, which
|
||||
* names the type differently, still compiles.
|
||||
*/
|
||||
export interface InlineProviderExtension {
|
||||
name: string;
|
||||
hidden: boolean;
|
||||
factory: (pi: ExtensionAPI) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a host context's `modelRegistry` to a {@link ProviderRegistry},
|
||||
* returning `undefined` when it cannot answer the two questions this module
|
||||
* asks. This is the decode boundary: everything downstream works with the
|
||||
* narrow interface, and a host whose registry does not know extension
|
||||
* registrations simply gets no provider forwarding.
|
||||
*/
|
||||
export function asProviderRegistry(
|
||||
candidate: unknown,
|
||||
): ProviderRegistry | undefined {
|
||||
if (!candidate || typeof candidate !== "object") return undefined;
|
||||
const registry = candidate as Partial<ProviderRegistry>;
|
||||
if (
|
||||
typeof registry.getRegisteredNativeProvider !== "function" ||
|
||||
typeof registry.getRegisteredProviderConfig !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return registry as ProviderRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Env var overriding the per-agent settle timeout (ms). Guards against a
|
||||
* sub-agent session that never settles (stalled provider stream, hung retry /
|
||||
@@ -52,6 +104,17 @@ export interface AgentTaskOptions {
|
||||
task: string;
|
||||
/** Optional tool allowlist override (else uses the agent's `allowedTools`). */
|
||||
allowedTools?: string[];
|
||||
/**
|
||||
* The currently selected model from the invoking session. When omitted,
|
||||
* `createAgentSession` falls back to the settings default model.
|
||||
*/
|
||||
model?: ExtensionCommandContext["model"];
|
||||
/**
|
||||
* The invoking session's provider registry, used to make the pinned
|
||||
* `model`'s provider resolvable inside the sub-session (see
|
||||
* {@link providerForwardingExtension}).
|
||||
*/
|
||||
modelRegistry?: ProviderRegistry;
|
||||
/** Optional explicit agent definition (skips `loadAgents`). */
|
||||
agent?: AgentDef;
|
||||
/**
|
||||
@@ -160,6 +223,49 @@ export function resetAgentRunner(): void {
|
||||
currentRunner = defaultAgentRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the inline extension that re-registers a provider inside a sub-session.
|
||||
*
|
||||
* A provider that exists only because an extension called
|
||||
* `pi.registerProvider` (custom base URL, wrapped stream, its own auth wiring)
|
||||
* lives in the invoking session's registry and nowhere else — not in
|
||||
* `auth.json`, not in `models.json`. Sub-sessions are deliberately created with
|
||||
* `noExtensions: true`, so their own `ModelRuntime` cannot resolve credentials
|
||||
* for such a provider: pinning the invoking session's model then dies with
|
||||
* pi's `No API key found for <provider>` before the first token, which reads as
|
||||
* a pygienium failure on a perfectly healthy login.
|
||||
*
|
||||
* Inline extension factories load regardless of `noExtensions`, so registering
|
||||
* the pinned model's provider there is the narrow cost of pinning: the
|
||||
* sub-agent authenticates as the host does, while every other extension stays
|
||||
* out of its scope.
|
||||
*
|
||||
* Returns `undefined` when there is nothing to forward — no registry, no pinned
|
||||
* model, or a provider pi already knows from its own catalogue (built-ins and
|
||||
* `models.json` providers need no registration).
|
||||
*/
|
||||
export function providerForwardingExtension(
|
||||
registry: ProviderRegistry | undefined,
|
||||
providerId: string | undefined,
|
||||
): InlineProviderExtension | undefined {
|
||||
if (!registry || !providerId) return undefined;
|
||||
// A native provider carries its own stream/auth implementation, so it wins
|
||||
// over a config-only registration (which only augments a built-in one).
|
||||
const native = registry.getRegisteredNativeProvider(providerId);
|
||||
const config = native
|
||||
? undefined
|
||||
: registry.getRegisteredProviderConfig(providerId);
|
||||
if (!native && !config) return undefined;
|
||||
return {
|
||||
name: "pygienium-provider",
|
||||
hidden: true,
|
||||
factory: (pi) => {
|
||||
if (native) pi.registerProvider(native);
|
||||
else if (config) pi.registerProvider(providerId, config);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`,
|
||||
* overrides the system prompt with the agent definition's body, restricts tools
|
||||
@@ -191,6 +297,13 @@ export async function defaultAgentRunner(
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
} = await import("@earendil-works/pi-coding-agent");
|
||||
// Re-register the pinned model's provider inside the sub-session: inline
|
||||
// extension factories load even under `noExtensions`, so this is the one
|
||||
// registration a pinned model needs in order to authenticate.
|
||||
const providerExtension = providerForwardingExtension(
|
||||
opts.modelRegistry,
|
||||
opts.model?.provider,
|
||||
);
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd: opts.cwd,
|
||||
agentDir: getAgentDir(),
|
||||
@@ -200,6 +313,7 @@ export async function defaultAgentRunner(
|
||||
noSkills: true,
|
||||
noThemes: true,
|
||||
noPromptTemplates: true,
|
||||
extensionFactories: providerExtension ? [providerExtension] : [],
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
@@ -208,6 +322,11 @@ export async function defaultAgentRunner(
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: opts.cwd,
|
||||
// Pin the sub-agent to the model the user has selected in the invoking
|
||||
// session rather than the settings default. Omitted when the caller
|
||||
// has no live session model (print/RPC modes), which keeps the
|
||||
// settings-default fallback.
|
||||
...(opts.model ? { model: opts.model } : {}),
|
||||
tools,
|
||||
sessionManager: SessionManager.inMemory(opts.cwd),
|
||||
resourceLoader: loader,
|
||||
|
||||
@@ -44,12 +44,20 @@ import {
|
||||
type ExportFilters,
|
||||
} from "./export.js";
|
||||
import type { SendChatMessage } from "./phases.js";
|
||||
import type { ProviderRegistry } from "./agent-runner.js";
|
||||
|
||||
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
|
||||
export type PygieniumCtx = Pick<
|
||||
ExtensionCommandContext,
|
||||
"cwd" | "mode" | "hasUI" | "ui"
|
||||
"cwd" | "mode" | "hasUI" | "ui" | "model"
|
||||
> & {
|
||||
/**
|
||||
* The invoking session's extension provider registrations, already narrowed
|
||||
* at the extension entry point (see `asProviderRegistry`). Carried into every
|
||||
* sub-agent so a pinned model whose provider only an extension registered
|
||||
* stays resolvable there.
|
||||
*/
|
||||
modelRegistry?: ProviderRegistry;
|
||||
/** Optional callback to post messages to the chat window. */
|
||||
sendChatMessage?: SendChatMessage;
|
||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||
@@ -176,6 +184,8 @@ export async function handleCheckCommand(
|
||||
existingState: existing,
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
model: ctx.model,
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
onAgentEvent: ctx.onAgentEvent,
|
||||
sendPhaseLine: ctx.sendPhaseLine,
|
||||
@@ -215,6 +225,8 @@ export async function handleAllCommand(
|
||||
only: parsed.only,
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
model: ctx.model,
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
onAgentEvent: ctx.onAgentEvent,
|
||||
sendPhaseLine: ctx.sendPhaseLine,
|
||||
@@ -303,6 +315,8 @@ export async function handleResumeCommand(
|
||||
scope: { cwd, target: cwd, fix: entry.fix, rest: [] },
|
||||
ui: ctx.ui,
|
||||
hasUI: ctx.hasUI,
|
||||
model: ctx.model,
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
existingState: state,
|
||||
sendChatMessage: ctx.sendChatMessage,
|
||||
onAgentEvent: ctx.onAgentEvent,
|
||||
|
||||
17
src/index.ts
17
src/index.ts
@@ -26,13 +26,12 @@ import { homedir } from "node:os";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
MessageRenderer,
|
||||
SessionStartEvent,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Box, Text } from "@earendil-works/pi-tui";
|
||||
import "./checks/all.js";
|
||||
import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js";
|
||||
import { asProviderRegistry } from "./agent-runner.js";
|
||||
import {
|
||||
type SendChatMessage,
|
||||
type CheckCompletionDetails,
|
||||
@@ -40,10 +39,6 @@ import {
|
||||
} from "./phases.js";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** Startup hint mirrored after piolium's convention. */
|
||||
export const PYGIENIUM_STARTUP_HINT =
|
||||
"Pygienium loaded. Run /pygienium-help for available checks and flags.";
|
||||
|
||||
/** Custom message type for the live tool-event stream (mirrors piolium-stream). */
|
||||
export const PYGIENIUM_STREAM = "pygienium-stream";
|
||||
|
||||
@@ -410,6 +405,8 @@ export default async function pygieniumExtension(
|
||||
mode: ctx.mode,
|
||||
hasUI: ctx.hasUI,
|
||||
ui: ctx.ui,
|
||||
model: ctx.model,
|
||||
modelRegistry: asProviderRegistry(ctx.modelRegistry),
|
||||
sendChatMessage,
|
||||
onAgentEvent,
|
||||
sendPhaseLine,
|
||||
@@ -418,12 +415,4 @@ export default async function pygieniumExtension(
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pi.on(
|
||||
"session_start",
|
||||
async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.ui.notify(PYGIENIUM_STARTUP_HINT, "info");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,14 @@
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
AgentSessionEvent,
|
||||
ExtensionCommandContext,
|
||||
ExtensionUIContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
|
||||
import { runCheck } from "./check-runner.js";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import type { ProviderRegistry } from "../agent-runner.js";
|
||||
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
||||
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
||||
import { runRecon } from "../recon.js";
|
||||
@@ -74,6 +78,18 @@ export interface AllRunOptions {
|
||||
ui?: ExtensionUIContext;
|
||||
/** Whether dialog-capable UI is available. */
|
||||
hasUI?: boolean;
|
||||
/**
|
||||
* The currently selected model from the invoking session; forwarded to
|
||||
* each check's sub-agents so scans run on the model the user picked, not
|
||||
* the settings default.
|
||||
*/
|
||||
model?: ExtensionCommandContext["model"];
|
||||
/**
|
||||
* The invoking session's provider registry; carried into every check's
|
||||
* sub-agents so a provider only an extension registered stays resolvable
|
||||
* (see `providerForwardingExtension` in `agent-runner.ts`).
|
||||
*/
|
||||
modelRegistry?: ProviderRegistry;
|
||||
/** Optional callback to post completion messages into the chat. */
|
||||
sendChatMessage?: SendChatMessage;
|
||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||
@@ -369,6 +385,8 @@ export async function runAllChecks(
|
||||
scope: { cwd, target, fix, rest: [] },
|
||||
ui: opts.ui,
|
||||
hasUI,
|
||||
model: opts.model,
|
||||
modelRegistry: opts.modelRegistry,
|
||||
existingState: state,
|
||||
sendChatMessage: opts.sendChatMessage,
|
||||
onAgentEvent: opts.onAgentEvent,
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
|
||||
import { rm } from "node:fs/promises";
|
||||
import { resolve, join } from "node:path";
|
||||
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
ExtensionCommandContext,
|
||||
ExtensionUIContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
|
||||
import { runAgentTask } from "../agent-runner.js";
|
||||
import { runAgentTask, type ProviderRegistry } from "../agent-runner.js";
|
||||
import { runRecon } from "../recon.js";
|
||||
import {
|
||||
createPhaseStrip,
|
||||
@@ -84,6 +87,18 @@ export interface RunCheckOptions {
|
||||
ui?: ExtensionUIContext;
|
||||
/** Whether dialog-capable UI is available. */
|
||||
hasUI?: boolean;
|
||||
/**
|
||||
* The currently selected model from the invoking session; forwarded to
|
||||
* each sub-agent so scans run on the model the user picked, not the
|
||||
* settings default.
|
||||
*/
|
||||
model?: ExtensionCommandContext["model"];
|
||||
/**
|
||||
* The invoking session's provider registry; carried into every sub-agent so a
|
||||
* provider only an extension registered stays resolvable (see
|
||||
* `providerForwardingExtension` in `agent-runner.ts`).
|
||||
*/
|
||||
modelRegistry?: ProviderRegistry;
|
||||
/** Pre-existing run state to update (for `/pygienium-all` and resume). */
|
||||
existingState?: RunState;
|
||||
/** Optional callback to post completion messages into the chat. */
|
||||
@@ -278,6 +293,8 @@ async function runCheckImplInner(
|
||||
cwd: scope.target,
|
||||
agentName: check.agentName,
|
||||
task: scanTask,
|
||||
model: opts.model,
|
||||
modelRegistry: opts.modelRegistry,
|
||||
onEvent: forward(PHASE_ANALYSIS),
|
||||
});
|
||||
findings = scanResult.text;
|
||||
@@ -310,6 +327,8 @@ async function runCheckImplInner(
|
||||
cwd: scope.target,
|
||||
agentName: check.fixAgentName ?? "fixer",
|
||||
task: fixTask,
|
||||
model: opts.model,
|
||||
modelRegistry: opts.modelRegistry,
|
||||
onEvent: forward(PHASE_FIX),
|
||||
});
|
||||
changes = fixResult.text;
|
||||
|
||||
@@ -110,6 +110,28 @@ describe("check-runner integration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the selected model to every sub-agent", async () => {
|
||||
const check = smokeCheck();
|
||||
const selectedModel = {
|
||||
provider: "test-provider",
|
||||
id: "test-model",
|
||||
} as unknown as PygieniumCtx["model"];
|
||||
const seen: unknown[] = [];
|
||||
setAgentRunner(async (opts) => {
|
||||
seen.push(opts.model);
|
||||
return fakeAgentRunner(opts);
|
||||
});
|
||||
await handleCheckCommand(
|
||||
check,
|
||||
"--fix",
|
||||
{ ...stubCtx(cwd), model: selectedModel } as PygieniumCtx,
|
||||
);
|
||||
|
||||
// Analysis + fix phases each dispatch one sub-agent.
|
||||
expect(seen.length).toBe(2);
|
||||
for (const m of seen) expect(m).toBe(selectedModel);
|
||||
});
|
||||
|
||||
it("persists run-state.json at the expected path", async () => {
|
||||
const check = smokeCheck();
|
||||
await handleCheckCommand(check, "", stubCtx(cwd));
|
||||
|
||||
105
tests/provider-forwarding.test.ts
Normal file
105
tests/provider-forwarding.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* provider-forwarding.test.ts — the provider hand-off from the invoking session
|
||||
* into a sub-agent session.
|
||||
*
|
||||
* A sub-agent session is created with `noExtensions: true`, so a provider that
|
||||
* exists only because an extension called `pi.registerProvider` is absent from
|
||||
* its `ModelRuntime` and its credentials cannot be resolved — pinning the
|
||||
* invoking session's model then fails with `No API key found for <provider>`
|
||||
* before a single token, on a perfectly healthy login. These tests pin the two
|
||||
* halves of the fix: narrowing the host registry at the boundary, and the inline
|
||||
* extension that re-registers the pinned model's provider inside the sub-session
|
||||
* (inline factories load even under `noExtensions`).
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
asProviderRegistry,
|
||||
providerForwardingExtension,
|
||||
type ProviderRegistry,
|
||||
} from "../src/agent-runner.js";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** Record `registerProvider` calls so the relay can be asserted on. */
|
||||
function recorder(): {
|
||||
pi: ExtensionAPI;
|
||||
calls: unknown[][];
|
||||
} {
|
||||
const calls: unknown[][] = [];
|
||||
const pi = {
|
||||
registerProvider: (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
return { pi, calls };
|
||||
}
|
||||
|
||||
/** Registry stub holding one provider id, native and/or config form. */
|
||||
function registry(entry: {
|
||||
native?: unknown;
|
||||
config?: unknown;
|
||||
}): ProviderRegistry {
|
||||
return {
|
||||
getRegisteredNativeProvider: (_id) =>
|
||||
entry.native === undefined ? undefined : entry.native,
|
||||
getRegisteredProviderConfig: (_id) =>
|
||||
entry.config === undefined ? undefined : entry.config,
|
||||
};
|
||||
}
|
||||
|
||||
describe("asProviderRegistry", () => {
|
||||
it("accepts a registry that exposes both registration lookups", () => {
|
||||
const host = registry({ native: { id: "neuralwatt" } });
|
||||
expect(asProviderRegistry(host)).toBe(host);
|
||||
});
|
||||
|
||||
it("rejects values that cannot answer both lookups", () => {
|
||||
expect(asProviderRegistry(undefined)).toBeUndefined();
|
||||
expect(asProviderRegistry(null)).toBeUndefined();
|
||||
expect(asProviderRegistry("modelRegistry")).toBeUndefined();
|
||||
// A registry from a host that never records extension providers, such as
|
||||
// the omp port, degrades to "nothing to forward" instead of throwing.
|
||||
expect(asProviderRegistry({})).toBeUndefined();
|
||||
expect(
|
||||
asProviderRegistry({ getRegisteredNativeProvider: () => undefined }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerForwardingExtension", () => {
|
||||
it("is a no-op without a registry, a pinned model, or a registered provider", () => {
|
||||
// Built-in and models.json providers need no forwarding: pi's own
|
||||
// catalogue already answers for them.
|
||||
const empty = registry({});
|
||||
expect(providerForwardingExtension(undefined, "neuralwatt")).toBeUndefined();
|
||||
expect(providerForwardingExtension(empty, undefined)).toBeUndefined();
|
||||
expect(providerForwardingExtension(empty, "neuralwatt")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-registers a native provider, keeping its own stream and auth wiring", () => {
|
||||
const native = { id: "neuralwatt" };
|
||||
const extension = providerForwardingExtension(
|
||||
registry({ native, config: { baseUrl: "ignored-when-native" } }),
|
||||
"neuralwatt",
|
||||
);
|
||||
if (!extension) throw new Error("expected a forwarding extension");
|
||||
expect(extension.name).toBe("pygienium-provider");
|
||||
// Hidden: the sub-agent's extension list must not advertise an extension
|
||||
// the user never installed for it.
|
||||
expect(extension.hidden).toBe(true);
|
||||
|
||||
const { pi, calls } = recorder();
|
||||
extension.factory(pi);
|
||||
// The native provider wins over a config-only registration, which would
|
||||
// only augment a built-in provider and lose the wrapped stream.
|
||||
expect(calls).toEqual([[native]]);
|
||||
});
|
||||
|
||||
it("re-registers a config-only provider under its provider id", () => {
|
||||
const config = { baseUrl: "config-only-provider" };
|
||||
const extension = providerForwardingExtension(registry({ config }), "probe");
|
||||
if (!extension) throw new Error("expected a forwarding extension");
|
||||
const { pi, calls } = recorder();
|
||||
extension.factory(pi);
|
||||
expect(calls).toEqual([["probe", config]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user