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
9 changes: 6 additions & 3 deletions .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 All @@ -69,13 +71,13 @@ jobs:
- name: E2E tests
run: npm run test:e2e

# Upload test results for debugging — artifacts available for 30 days.
# Compatibility contracts run from a foreign cwd on the floor and Windows lanes.
- name: Exact-floor compatibility contract
if: (matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest'
working-directory: ${{ runner.temp }}
run: node "${{ github.workspace }}/scripts/test-compat-floor.mjs"

- name: Packed Pi 0.80.8 host contract
- name: Packed Pi 0.81.0 host contract
if: (matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest'
working-directory: ${{ runner.temp }}
run: node "${{ github.workspace }}/scripts/test-package-host.mjs"
Expand All @@ -85,6 +87,7 @@ jobs:
working-directory: ${{ runner.temp }}
run: node "${{ github.workspace }}/scripts/test-compat-current.mjs"

# Upload test results for debugging — artifacts available for 30 days.
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
Expand Down
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@ 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

- Migrated child spawning to Pi 0.80.8's public selected-model and child-owned runtime APIs, added `max` thinking support, and dispose every created child session exactly once across completion, failure, abort, and reset races. Pi 0.80.8 and Node 22.19.0 are now the documented minimums; parent-only transient provider/auth state fails explicitly without model fallback.
- Migrated child spawning to Pi 0.81.0's public selected-model and child-owned runtime APIs, added `max` thinking support, and disposed every created child session exactly once across completion, failure, abort, and reset races. Pi 0.81.0 and Node 22.19.0 are now the documented minimums; parent-only transient provider/auth state fails explicitly without model fallback.
- 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ That's it. Your agent now has `spawn`, `notebook_write`, `notebook_read`, `noteb

### Compatibility

pi-agenticoding requires **Pi 0.80.8 or later** and **Node.js 22.19.0 or later**. Spawn passes Pi's already-selected public model into a child-owned runtime. Persisted, environment-based, and extension-rediscoverable provider/auth configuration is available to children; parent-only transient credentials, inline provider factories, or in-memory catalog changes are not guaranteed to be rediscoverable. In that unsupported case, spawn reports the child resolution/auth failure and does not silently select another model.
pi-agenticoding requires **Pi 0.81.0 or later** and **Node.js 22.19.0 or later**. Spawn passes Pi's already-selected public model into a child-owned runtime. Persisted, environment-based, and extension-rediscoverable provider/auth configuration is available to children; parent-only transient credentials, inline provider factories, or in-memory catalog changes are not guaranteed to be rediscoverable. In that unsupported case, spawn reports the child resolution/auth failure and does not silently select another model.

---

Expand Down
6 changes: 3 additions & 3 deletions audit-ci.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
"moderate": true,
"allowlist": [
// npm's audit report exposes this hoisted advisory as the module path
// npm's audit report exposes this shrinkwrapped advisory as the module path
// `protobufjs`; a config invariant separately rejects any vulnerable
// protobufjs occurrence outside the exact Pi 0.80.8 development path.
{ "GHSA-f38q-mgvj-vph7|protobufjs": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs 7.6.1 only via @earendil-works/pi-ai > @google/genai in the exact Pi 0.80.8 development graph" } }
// occurrence outside Pi coding agent's exact 0.81.0 shrinkwrapped path.
{ "GHSA-j3f2-48v5-ccww|protobufjs": { "active": true, "expiry": "2026-09-01", "notes": "protobufjs 7.6.4 only in @earendil-works/pi-coding-agent's shrinkwrap via @earendil-works/pi-ai > @google/genai; consumer overrides cannot replace it, so remove when Pi publishes protobufjs 7.6.5+" } }
]
}
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