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
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ jobs:

- run: npm ci

# Uniform pre-flight checks — type errors and security issues on every platform
# Uniform pre-flight checks — type errors and security issues on every platform.
# audit-ci fails on moderate-or-higher advisories; reviewed exceptions, if ever
# needed, must be explicit and expiry-tracked in audit-ci.jsonc.
- name: Type check
run: npx tsc --noEmit

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
18 changes: 3 additions & 15 deletions audit-ci.jsonc
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
{
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
"moderate": true,
"allowlist": [
// Upstream: pi-coding-agent ships a shrinkwrap that pins vulnerable transitive
// deps (ws, protobufjs, undici). The fix must happen in pi-coding-agent itself
// — nothing to do from this extension.
{ "GHSA-96hv-2xvq-fx4p": { "active": true, "expiry": "2026-09-01", "notes": "ws <8.21.0 via pi-coding-agent shrinkwrap" } },
{ "GHSA-f38q-mgvj-vph7": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.2 via pi-coding-agent shrinkwrap" } },
{ "GHSA-wcpc-wj8m-hjx6": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs <=7.6.0 via pi-coding-agent shrinkwrap" } },
{ "GHSA-vmh5-mc38-953g|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } },
{ "GHSA-pr7r-676h-xcf6|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } },
{ "GHSA-38rv-x7px-6hhq|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } },
{ "GHSA-p88m-4jfj-68fv|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } },
{ "GHSA-vxpw-j846-p89q|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-09-01", "notes": "undici 8.x via pi-coding-agent shrinkwrap" } }
// Low-severity undici advisories (GHSA-35p6-xmwp-9g52, GHSA-g8m3-5g58-fq7m) are
// not allowlisted — the config gates moderate+ only, so they don't block CI.
]
// Pi 0.80.7's resolved graph is currently clean at moderate severity and above.
// Keep this explicit so future advisories fail CI until reviewed.
"allowlist": []
}
54 changes: 54 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ import {
buildReadonlyTopicBoundaryNotification,
} from "./readonly-copy.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 { escapeDisplayLabel } from "./model-groups/display.js";
import type { ModelGroupsAccess } from "./model-groups/types.js";
import {
cacheLookupCommand,
cacheLookupCommandIssue,
Expand Down Expand Up @@ -186,6 +192,30 @@ function consumePendingReadonlyToggle(
}
}

function modelGroupsAccess(ctx: ExtensionContext): ModelGroupsAccess {
return { cwd: ctx.cwd, policy: ctx.isProjectTrusted() ? "global-project" : "global-only" };
}

function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext) {
state.modelGroups.groups = [];
state.modelGroups.validation = null;
if (!ctx.cwd || !(ctx as any).modelRegistry) return null;
const loadedModelGroups = loadModelGroups(modelGroupsAccess(ctx));
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 @@ -202,6 +232,7 @@ export default function (pi: ExtensionAPI): void {

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

// ── Readonly mode ───────────────────────────────────────────────

Expand Down Expand Up @@ -459,6 +490,7 @@ export default function (pi: ExtensionAPI): void {

// Update TUI indicators before each user-prompt agent run
updateIndicators(ctx, state);
refreshModelGroupsState(state, ctx);

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

Expand All @@ -478,6 +510,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 @@ -626,6 +663,23 @@ 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 sourcePath = escapeDisplayLabel(issue.sourcePath);
const backupPath = issue.backupPath ? escapeDisplayLabel(issue.backupPath) : undefined;
const detail = escapeDisplayLabel(issue.message);
const backupNote = issue.backupFailed ? `; backup failed${backupPath ? ` (${backupPath})` : ""}, original file left untouched` : "";
ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${sourcePath}); using empty config for that scope${backupNote}; ${detail}`, "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");
}
}

rehydrateReadonlyState(ctx);
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));
}
31 changes: 31 additions & 0 deletions model-groups/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { AgenticodingState } from "../state.js";
import type { ModelGroupsAccess } from "./types.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.mode !== "tui") {
if (ctx.mode === "rpc") ctx.ui.notify("/model-groups requires TUI mode");
return;
}
if (!ctx.cwd || !ctx.modelRegistry) {
ctx.ui.notify("Cannot manage model groups: missing working directory or model registry", "error");
return;
}
const access: ModelGroupsAccess = { cwd: ctx.cwd, policy: ctx.isProjectTrusted() ? "global-project" : "global-only" };
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
createModelGroupsComponent(tui, theme, ctx.modelRegistry, access, done, {
initialValidation: state.modelGroups.validation,
notify: (message, type) => ctx.ui.notify(message, type),
onRefresh: (validation) => {
state.modelGroups.groups = validation.groups;
state.modelGroups.validation = validation;
},
}),
);
},
});
}
53 changes: 53 additions & 0 deletions model-groups/display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export type DisplayDecodeResult = { ok: true; value: string } | { ok: false; message: string };

function hex(value: number): string {
return value.toString(16).toUpperCase().padStart(2, "0");
}

export function escapeDisplayLabel(raw: string): string {
let encoded = "";
for (const char of raw) {
const code = char.codePointAt(0)!;
if (char === "\\") encoded += "\\\\";
else if (char === "\n") encoded += "\\n";
else if (char === "\r") encoded += "\\r";
else if (char === "\t") encoded += "\\t";
else if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) encoded += `\\x${hex(code)}`;
else if (code === 0x2028 || code === 0x2029) encoded += `\\u${code.toString(16).toUpperCase()}`;
else encoded += char;
}
return encoded;
}

export function decodeDisplayLabel(encoded: string): DisplayDecodeResult {
let value = "";
for (let index = 0; index < encoded.length; index++) {
const char = encoded[index];
if (char !== "\\") {
value += char;
continue;
}
const escape = encoded[++index];
if (escape === undefined) return { ok: false, message: "Incomplete display escape" };
if (escape === "\\") value += "\\";
else if (escape === "n") value += "\n";
else if (escape === "r") value += "\r";
else if (escape === "t") value += "\t";
else if (escape === "x") {
const digits = encoded.slice(index + 1, index + 3);
if (!/^[0-9A-F]{2}$/.test(digits)) return { ok: false, message: "Invalid or non-canonical \\xHH display escape" };
const code = Number.parseInt(digits, 16);
if (!(code < 0x20 || (code >= 0x7f && code <= 0x9f)) || code === 0x09 || code === 0x0a || code === 0x0d) {
return { ok: false, message: "Display escape does not encode a canonical control" };
}
value += String.fromCharCode(code);
index += 2;
} else if (escape === "u") {
const digits = encoded.slice(index + 1, index + 5);
if (digits !== "2028" && digits !== "2029") return { ok: false, message: "Invalid canonical Unicode display escape" };
value += String.fromCharCode(Number.parseInt(digits, 16));
index += 4;
} else return { ok: false, message: `Unknown display escape \\${escape}` };
}
return { ok: true, value };
}
3 changes: 3 additions & 0 deletions model-groups/names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function canonicalizeModelGroupName(raw: string): string {
return raw.trim();
}
Loading
Loading