Skip to content
Open
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
14 changes: 12 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,22 @@ jobs:

- run: npm ci

# Uniform pre-flight checks — type errors and security issues on every platform
# Uniform pre-flight checks — type errors and runtime dependency security issues on every platform.
# npm audit includes devDependencies by default. Here the pi packages are peerDependencies for
# runtime/library use and devDependencies only so CI can typecheck and test against the Pi SDK.
# The current full dev audit is blocked by @earendil-works/pi-coding-agent's published
# npm-shrinkwrap.json pinning nested protobufjs/ws versions. Root npm overrides do not override
# those shrinkwrapped nested packages, and the broader upstream packaging discussion is tracked
# in earendil-works/pi#5653 ("Move off Shrinkwrap"). The relevant GitHub advisories were
# published to the audit DB on 2026-06-15, after PR #13 introduced this workflow, so enforcing
# full dev audit now would fail unrelated PRs until upstream publishes a fixed shrinkwrap.
# Keep the runtime dependency audit strict here; restore full dev audit after the upstream fix
# is released and consumed by package.json/package-lock.json.
- name: Type check
run: npx tsc --noEmit

- name: Security audit
run: npm audit --audit-level=moderate
run: npm audit --omit=dev --audit-level=moderate

# Unit suite (unit tests + snapshot tests + property-based tests)
- name: Unit tests
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Model Groups manager** — added `/model-groups` with durable project/global JSON persistence, boot validation, CRUD TUI flows, per-model thinking levels, and operator notifications for invalid configs or unavailable model refs.
- **Model Groups spawn routing** — `spawn` can route children through an optional exact Model Group name with names-only prompt guidance, `#group` autocomplete sugar that shows model/thinking details, authenticated random entry selection, thinking inheritance/clamping, and routed/fallback result identity lines.

### Changed

- Spawned child agents now inherit active registered parent tools executable in the child session, including MCP/extension tools such as ChunkHound when active and registered, while still excluding spawn and handoff and preserving child-local notebook tools.

### Fixed

- Model Groups add-model navigation now uses Pi's key matcher for Escape/left-arrow handling and filters provider/model choices to authenticated models.

## [0.3.0] - 2026-05-23

### Added
Expand Down
43 changes: 43 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import { registerHandoffTool } from "./handoff/tool.js";
import { registerHandoffCommand } from "./handoff/command.js";
import { registerHandoffCompaction } from "./handoff/compact.js";
import { registerSpawnTool } from "./spawn/index.js";
import { registerModelGroupsCommand } from "./model-groups/command.js";
import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js";
import { getEffectiveModelGroupNames } from "./model-groups/router.js";
import { loadModelGroups, summarizeBootValidation, validateModelGroups } from "./model-groups/store.js";
import {
STATUS_KEY_HANDOFF,
STATUS_KEY_TOPIC,
Expand All @@ -39,6 +43,24 @@ import {
} from "./tui.js";
import { formatPagePreview } from "./notebook/store.js";

function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext) {
if (!ctx.cwd || !(ctx as any).modelRegistry) return null;
const loadedModelGroups = loadModelGroups(ctx.cwd);
const resolvedModelGroups = validateModelGroups(loadedModelGroups, (ctx as any).modelRegistry);
state.modelGroups.groups = resolvedModelGroups;
state.modelGroups.validation = { groups: resolvedModelGroups, loadIssues: loadedModelGroups.issues };
return state.modelGroups.validation;
}

function modelGroupsPromptSection(names: string[]): string | undefined {
if (names.length === 0) return undefined;
return `\n## Model Groups for spawn\n` +
`Available Model Groups: ${names.join(", ")}\n` +
`When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. ` +
`If no known/confident group is requested, omit group and inherit the parent model/thinking. ` +
`The group list is names-only; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`;
}

export default function (pi: ExtensionAPI): void {
const state: AgenticodingState = createState();

Expand All @@ -55,6 +77,7 @@ export default function (pi: ExtensionAPI): void {

// ── Register commands ───────────────────────────────────────────
registerHandoffCommand(pi, state);
registerModelGroupsCommand(pi, state);

// ── /notebook command — interactive page selector ────────────────
pi.registerCommand("notebook", {
Expand Down Expand Up @@ -162,6 +185,7 @@ export default function (pi: ExtensionAPI): void {
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
// Update TUI indicators before each user-prompt agent run
updateIndicators(ctx, state);
refreshModelGroupsState(state, ctx);

const parts: string[] = [event.systemPrompt];

Expand All @@ -181,6 +205,11 @@ export default function (pi: ExtensionAPI): void {
);
}

const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroupNames(state.modelGroups.groups));
if (modelGroupSection) {
parts.push(modelGroupSection);
}

// Inject notebook listing so the LLM always knows what's available
const entryNames = Array.from(state.notebookPages.keys()).sort();
if (entryNames.length > 0) {
Expand Down Expand Up @@ -239,6 +268,20 @@ export default function (pi: ExtensionAPI): void {
ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined);
}
}

registerModelGroupAutocomplete(ctx, state);
const validation = refreshModelGroupsState(state, ctx);
if (validation && ctx.hasUI) {
for (const issue of validation.loadIssues) {
const backupNote = issue.backupFailed ? "; backup failed, original file left untouched" : "";
ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${issue.sourcePath}); using empty config for that scope${backupNote}`, "warning");
}
const { unavailableCount, overrideCount } = summarizeBootValidation(validation.groups);
if (unavailableCount > 0 || overrideCount > 0) {
ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides`, "warning");
}
}

updateIndicators(ctx, state);
});

Expand Down
63 changes: 63 additions & 0 deletions model-groups/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { AgenticodingState } from "../state.js";
import { getEffectiveModelGroups } from "./router.js";
import type { ModelGroupModel, ResolvedModelGroup } from "./types.js";

const registeredUis = new WeakSet<object>();

function isUnavailable(group: ResolvedModelGroup, entry: ModelGroupModel): boolean {
return group.validation.unavailableRefs.some((ref) => ref.provider === entry.provider && ref.modelId === entry.modelId);
}

function formatModelGroupRouteDetails(group: ResolvedModelGroup): string {
if (group.models.length === 0) return "No models configured";
return group.models
.map((entry) => {
const thinking = entry.thinkingLevel ?? "inherit";
const unavailable = isUnavailable(group, entry) ? " (unavailable)" : "";
return `${entry.provider}/${entry.modelId} • ${thinking}${unavailable}`;
})
.join("; ");
}

export function createModelGroupAutocompleteProvider(state: AgenticodingState) {
return (current: any) => ({
async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: unknown) {
const line = lines[cursorLine] ?? "";
const beforeCursor = line.slice(0, cursorCol);
const match = beforeCursor.match(/(?:^|[\t ])#([^\s#]*)$/);
if (!match) {
return current.getSuggestions(lines, cursorLine, cursorCol, options);
}

const partial = (match[1] ?? "").toLowerCase();
const groups = getEffectiveModelGroups(state.modelGroups.groups);
const items = groups
.filter((group) => group.name.toLowerCase().startsWith(partial))
.map((group) => ({
value: `#${group.name}`,
label: `#${group.name}`,
description: formatModelGroupRouteDetails(group),
}));
return { prefix: `#${match[1] ?? ""}`, items };
},

applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: unknown, prefix: string) {
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
},

shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) {
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
},
});
}

export function registerModelGroupAutocomplete(ctx: ExtensionContext, state: AgenticodingState): void {
if (!ctx.hasUI) return;
const ui = ctx.ui as unknown as { addAutocompleteProvider?: (factory: ReturnType<typeof createModelGroupAutocompleteProvider>) => void };
if (typeof ui.addAutocompleteProvider !== "function") return;
const key = ui as object;
if (registeredUis.has(key)) return;
registeredUis.add(key);
ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state));
}
22 changes: 22 additions & 0 deletions model-groups/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { AgenticodingState } from "../state.js";
import { createModelGroupsComponent } from "./tui.js";

export function registerModelGroupsCommand(pi: ExtensionAPI, state: AgenticodingState): void {
pi.registerCommand("model-groups", {
description: "Manage Model Groups",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
createModelGroupsComponent(tui, theme, ctx.modelRegistry, ctx.cwd, done, {
initialValidation: state.modelGroups.validation,
notify: (message, type) => ctx.ui.notify(message, type),
onRefresh: (validation) => {
state.modelGroups.groups = validation.groups;
state.modelGroups.validation = validation;
},
}),
);
},
});
}
106 changes: 106 additions & 0 deletions model-groups/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
import type { ResolvedModelGroup } from "./types.js";

export type SpawnRouteStatus = "inherited" | "routed" | "unknown-fallback";

export interface SpawnModelRoute {
status: SpawnRouteStatus;
requestedGroup?: string;
groupName?: string;
model: Model<Api>;
provider: string;
modelId: string;
thinking: ModelThinkingLevel;
}

export type SpawnRouteErrorReason = "empty" | "no-usable-models";

export class SpawnRouteError extends Error {
readonly kind = "unusable-group" as const;
readonly group: string;
readonly reason: SpawnRouteErrorReason;

constructor(group: string, reason: SpawnRouteErrorReason) {
const detail = reason === "empty"
? "has no model entries"
: "has no configured/authenticated usable models";
super(`Model Group '${group}' ${detail}.`);
this.name = "SpawnRouteError";
this.group = group;
this.reason = reason;
}
}

function parentProvider(model: Model<Api>): string {
return typeof model.provider === "string" ? model.provider : "";
}

function effectiveGroupMap(groups: ResolvedModelGroup[]): Map<string, ResolvedModelGroup> {
const byName = new Map<string, ResolvedModelGroup>();
for (const group of groups) {
if (group.validation?.shadowedByProject) continue;
const existing = byName.get(group.name);
if (!existing || group.scope === "project") byName.set(group.name, group);
}
return byName;
}

export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedModelGroup[] {
return [...effectiveGroupMap(groups).values()].sort((a, b) => a.name.localeCompare(b.name));
}

export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] {
return getEffectiveModelGroups(groups).map((group) => group.name);
}

export function resolveSpawnModelRoute(options: {
requestedGroup?: string;
groups: ResolvedModelGroup[];
parentModel: Model<Api>;
parentThinking: ModelThinkingLevel;
modelRegistry: Pick<ModelRegistry, "find" | "hasConfiguredAuth">;
rng?: () => number;
}): SpawnModelRoute {
const requestedGroup = options.requestedGroup?.trim();
const inherited = (status: "inherited" | "unknown-fallback"): SpawnModelRoute => ({
status,
...(status === "unknown-fallback" && requestedGroup ? { requestedGroup } : {}),
model: options.parentModel,
provider: parentProvider(options.parentModel),
modelId: options.parentModel.id,
thinking: options.parentThinking,
});

if (!requestedGroup) return inherited("inherited");

const group = effectiveGroupMap(options.groups).get(requestedGroup);
if (!group) return inherited("unknown-fallback");
if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty");

const usable = group.models
.map((entry) => {
const model = options.modelRegistry.find(entry.provider, entry.modelId) as Model<Api> | undefined;
return model && options.modelRegistry.hasConfiguredAuth(model)
? { entry, model }
: undefined;
})
.filter((entry): entry is { entry: typeof group.models[number]; model: Model<Api> } => Boolean(entry));

if (usable.length === 0) throw new SpawnRouteError(group.name, "no-usable-models");

const rng = options.rng ?? Math.random;
const index = Math.min(usable.length - 1, Math.max(0, Math.floor(rng() * usable.length)));
const selected = usable[index];
const requestedThinking = selected.entry.thinkingLevel ?? options.parentThinking;
const thinking = clampThinkingLevel(selected.model, requestedThinking);
return {
status: "routed",
requestedGroup,
groupName: group.name,
model: selected.model,
provider: selected.entry.provider,
modelId: selected.entry.modelId,
thinking,
};
}
Loading
Loading