Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pi-subagents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Support extension-provided models through the owning Pi registry's live catalog, authentication, and streaming services without inheriting parent extensions, tools, or conversation (#59).
- Revalidate model, scope, authentication, and thinking before admission and resume; reject removed or virtual models without fallback.
- Cancel pending resume validation on stop or tool cancellation without spending an attempt; validate batch policies concurrently with task-specific errors and a separate admission deadline.
- Identify conflicting batch tasks and active runs with cwd and effective workspace roots; explain directory-based isolation and whole-worktree Git reservations (#67).

## 0.0.7

Release the current extension updates together.
Expand Down
24 changes: 24 additions & 0 deletions pi-subagents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ the batch is rejected before any sub-agent launches and the error lists the vali
levels. Set `tasks[].thinking` or the profile's `thinking` explicitly; the package
never silently falls back to a different model or thinking level.

Built-in, `models.json`, and extension-provided physical chat models use the
owning Pi session's live provider services, including custom streaming and
request-time authentication. Provider registration/removal and refreshed catalogs
are visible without reloading this package; credentials are not copied. Virtual
models and aliases remain unsupported. Launch and resume recheck the exact model,
scope, authentication, and thinking level. Model metadata is pinned per attempt;
a catalog change during an active attempt fails explicitly rather than mixing old
metadata with new provider configuration. A resume uses current metadata for the
same exact model and thinking level. Resume preserves the original profile prompt
and capability mode; start a new run to apply profile changes. Stopping during
resume validation cancels that admission without spending a resume attempt.
Provider code is trusted; parent tools, hooks, skills, and conversation are not inherited.

### Workspace admission

Inspectors may share a workspace. If either task edits, workspace roots must not
overlap—even between tasks in the same batch. Non-Git directories reserve their
canonical directory; Git directories reserve the whole worktree root, so sibling
subdirectories of one checkout still conflict. Symlink aliases and nested roots
also overlap. Use sequential runs or separate existing directories, clones, or
worktrees with non-overlapping roots. The package does not create them. Conflict
errors identify both task indices (`tasks[0]`, etc.) or exact run IDs, their modes,
cwd paths, and effective workspace roots before allocating any runs.

### Human navigation

Rows use stable names such as `Worker 1 · Implement the parser · Running`.
Expand Down
24 changes: 14 additions & 10 deletions pi-subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import path from "node:path";
import { createHash } from "node:crypto";
import type { JsonValue } from "@earendil-works/pi-ai";
import {
getAgentDir, ModelRuntime, SettingsManager, VERSION,
getAgentDir, SettingsManager, VERSION,
type ExtensionAPI, type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { loadProfiles } from "./src/agents.ts";
import { FileArtifacts } from "./src/artifacts.ts";
import { parseConfig } from "./src/config.ts";
import { RunManager } from "./src/runs.ts";
import { prepareNative } from "./src/session.ts";
import { ProviderBridge } from "./src/models.ts";
import { attach, parseCommand, resolveRun, type InspectActions } from "./src/inspect.ts";
import { DESCRIPTION, OutputSchema, Parameters, parseRequest, presentLaunch, presentNotice, presentRun, presentSummary, resultPreview } from "./src/tool.ts";
import { errorText, isLive, type PreparedTask, type Profile } from "./src/types.ts";
Expand Down Expand Up @@ -55,7 +56,7 @@ function admittedCount(ctx: ExtensionContext, owner: string): number {
export default function subagents(pi: ExtensionAPI): void {
let host: { owner: string; ctx: ExtensionContext; manager: RunManager; widget: WidgetSlot; unsubInput?: () => void; inspecting?: boolean; closeThread?: () => void } | undefined;
let closing: Promise<void> | undefined;
let runtime: Promise<ModelRuntime> | undefined;
let models: Promise<ProviderBridge> | undefined;
let refresh: ReturnType<typeof setTimeout> | undefined;
let clock: ReturnType<typeof setInterval> | undefined;
const inspectors = new Set<() => void>();
Expand Down Expand Up @@ -218,7 +219,7 @@ export default function subagents(pi: ExtensionAPI): void {
host = undefined; // notify() already no-ops once host.manager is no longer this manager.
clearTimeout(refresh); refresh = undefined;
clearInterval(clock); clock = undefined;
runtime = undefined;
models = undefined;
try { previous?.closeThread?.(); previous?.unsubInput?.(); previous?.ctx.ui.setWidget("minimal-subagents", undefined); } catch { /* UI may already be disposed. */ }
inspectors.clear();
closing = Promise.resolve(previous?.manager.shutdown()).finally(() => { closing = undefined; });
Expand Down Expand Up @@ -279,7 +280,7 @@ export default function subagents(pi: ExtensionAPI): void {
case "steer": await manager.steer(request.id, request.message); result = { ok: true }; break;
case "stop":
case "resume": {
await (request.action === "stop" ? manager.stop(request.id) : manager.resume(request.id));
await (request.action === "stop" ? manager.stop(request.id) : manager.resume(request.id, signal));
const record = manager.status(request.id);
details = record; result = presentRun(record);
break;
Expand All @@ -288,13 +289,14 @@ export default function subagents(pi: ExtensionAPI): void {
case "run": {
assertHealthy();
if (!ctx.isProjectTrusted()) throw new Error("Trust the agent project in Pi before delegating");
runtime ??= ModelRuntime.create({
authPath: path.join(agentDir, "auth.json"), modelsPath: path.join(agentDir, "models.json"), allowModelNetwork: false,
}).catch(error => { runtime = undefined; throw error; });
const modelRuntime = await runtime;
models ??= ProviderBridge.create(() => {
if (host !== state) throw new Error("Owning model registry is no longer active");
return state.ctx.modelRegistry;
}).catch(error => { models = undefined; throw error; });
const bridge = await models;
const trusted = ctx.isProjectTrusted();
const agentThinking = pi.getThinkingLevel();
const scopedModels = ctx.scopedModels.map(({ model }) => `${model.provider}/${model.id}`);
const scopedModels = () => state.ctx.scopedModels.map(({ model }) => `${model.provider}/${model.id}`);
signal?.throwIfAborted();
// Share target discovery only within this batch; no stale cross-call cache.
const targets = new Map<string, Promise<{ workspace: string; profiles: Map<string, Profile> }>>();
Expand All @@ -309,8 +311,10 @@ export default function subagents(pi: ExtensionAPI): void {
const { workspace, profiles } = await target;
const profile = profiles.get(task.agent);
if (!profile) throw new Error(`Unknown agent '${task.agent}'. Use list; only worker and reviewer are bundled.`);
const name = task.model ?? profile.model ?? modelLabel(ctx);
if (name?.includes("/")) bridge.ensure(name.slice(0, name.indexOf("/")));
return prepareNative({
agentDir, runtime: modelRuntime, task, profile, cwd, workspace,
agentDir, runtime: bridge.runtime, checkAuth: bridge.checkAuth, task, profile, cwd, workspace,
agentModel: modelLabel(ctx), agentThinking, scopedModels,
});
}));
Expand Down
59 changes: 59 additions & 0 deletions pi-subagents/src/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { isDeepStrictEqual } from "node:util";
import { InMemoryCredentialStore, type Api, type Model } from "@earendil-works/pi-ai";
import { ModelRuntime, type ModelRegistry } from "@earendil-works/pi-coding-agent";

/** A credentialless SDK transport. The owning registry remains the only catalog/auth authority.
* No provider registrations, credentials, refresh jobs, or extension runners are copied. */
export class ProviderBridge {
private readonly installed = new Set<string>();
readonly runtime: ModelRuntime;
private readonly registry: () => ModelRegistry;
private constructor(runtime: ModelRuntime, registry: () => ModelRegistry) {
this.runtime = runtime;
this.registry = registry;
}

static async create(registry: () => ModelRegistry): Promise<ProviderBridge> {
const runtime = await ModelRuntime.create({
modelsPath: null, credentials: new InMemoryCredentialStore(),
refreshOnCreate: false, allowModelNetwork: false,
});
return new ProviderBridge(runtime, registry);
}

ensure(provider: string): void {
if (this.installed.has(provider)) return;
this.runtime.registerNativeProvider({
id: provider, name: provider,
// Look up the current composed provider, never a captured registration or catalog.
getModels: () => this.registry().getProvider(provider)?.getModels() ?? [],
auth: { apiKey: {
name: "Owning Pi registry",
// This transport is keyless; admission checks the parent, which authenticates each request.
check: async () => ({ type: "api_key", source: "forwarding transport" }),
// The parent authenticates the actual request. Do not resolve or copy its secrets here.
resolve: async () => { this.registry(); return { auth: {}, source: "owning registry" }; },
} },
stream: (model, context, options) => this.registry().stream(this.current(model), context, options),
streamSimple: (model, context, options) => this.registry().streamSimple(this.current(model), context, options),
});
this.installed.add(provider);
}

checkAuth = async (model: Model<Api>, signal?: AbortSignal): Promise<boolean> => {
const available = await this.registry().getAvailableOfType("chat", model.provider, { signal });
return available.some(candidate => candidate.provider === model.provider && candidate.id === model.id);
};

private current(model: Model<Api>): Model<Api> {
// Metadata is pinned for this attempt; removal/virtual replacement must never route or fall back.
const current = this.runtime.getPhysicalModel(model.provider, model.id);
if (!current) {
throw new Error(`Exact native model '${model.provider}/${model.id}' is unavailable; no aliases, virtual models, or fallback`);
}
if (!isDeepStrictEqual(model, current)) {
throw new Error(`Exact native model '${model.provider}/${model.id}' changed during this attempt; start a new run`);
}
return model;
}
}
Loading
Loading