Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f6a4615
feat(model-groups): surface effective modalities and fail early on spawn
grzegorznowak Aug 20, 2026
cc03b1d
fix(model-groups): validate override on read for all versions and clo…
grzegorznowak Aug 21, 2026
ef1d095
Merge remote-tracking branch 'origin/main' into feat/spawn-modalities-26
grzegorznowak Aug 21, 2026
78d3e13
test(model-groups): address code-review findings on spawn routing and…
grzegorznowak Aug 21, 2026
d854e93
refactor(model-groups): debt-easy fixes — derived-key strip, empty-la…
grzegorznowak Aug 21, 2026
0091fdc
refactor(model-groups): Option C pluggable constraint kernel (debt #1)
grzegorznowak Aug 21, 2026
1834d35
refactor(model-groups): drop modalityOverride/requiredModalities alia…
grzegorznowak Aug 21, 2026
fef9cc9
feat(model-groups): inline color-coded modality chips in group list
grzegorznowak Aug 22, 2026
a6f8d6f
feat(model-groups): single-letter colored modality markers aligned wi…
grzegorznowak Aug 22, 2026
2c71388
feat(model-groups): per-modality toggle editor, reasoning kept out of…
grzegorznowak Aug 22, 2026
2b8135b
feat(model-groups): modality toggles stay on screen; Space also toggles
grzegorznowak Aug 22, 2026
57caa58
feat(model-groups): text is always-present base modality, not toggleable
grzegorznowak Aug 22, 2026
c0ea888
refactor(model-groups): modality-scoped TUI presentation polish
grzegorznowak Aug 22, 2026
85b4835
feat(model-groups): visual+conceptual separation of modalities from m…
grzegorznowak Aug 22, 2026
c71b840
feat(model-groups): surface capability letters in #-mention autocompl…
grzegorznowak Aug 22, 2026
1084533
refine(model-groups): align caps/route columns in #-autocomplete (ADJ…
grzegorznowak Aug 22, 2026
7171dcf
feat(model-groups): consolidate implied text letter in #-autocomplete…
grzegorznowak Aug 22, 2026
210f969
feat(spawn): orient child to model-group capability ceiling (ADJ-005 L1)
grzegorznowak Aug 22, 2026
5e36ff3
feat(spawn): named model group is binding (ADJ-005 policy B)
grzegorznowak Aug 22, 2026
2566eb1
feat(spawn): capability-aware pre-selection with round-robin (D1) and…
grzegorznowak Aug 22, 2026
d53c3ae
feat(model-groups): automatic groups default effective modalities to …
grzegorznowak Aug 23, 2026
6dd3952
feat(tui): per-model capability chips in the editor and limited/unlim…
grzegorznowak Aug 23, 2026
1c066d8
feat(tui): rebuild modalities editor around disabling union capabilities
grzegorznowak Aug 23, 2026
cf588b0
feat(tui): advertise toggleability on editable modality rows
grzegorznowak Aug 23, 2026
601374d
feat(tui): streamline modalities editor to a single toggle with accen…
grzegorznowak Aug 23, 2026
84b54e2
fix(review): fold cross-cutting capability-generic seam (PR #27)
grzegorznowak Aug 23, 2026
9733cad
fix(review): fold batch 2 — cursor, schema seam, debt, readability (P…
grzegorznowak Aug 23, 2026
a34a65c
refactor(router): un-clunk SpawnRouteError (PR #27)
grzegorznowak Aug 23, 2026
0649ee8
refactor(pr27): un-clunk multi-statement one-liners across router/sto…
grzegorznowak Aug 23, 2026
8eb1e06
refactor(router): reduce cyclomatic complexity of resolveSpawnModelRoute
grzegorznowak Aug 23, 2026
502b507
docs(pr27): document model capabilities and spawn constraint routing
grzegorznowak Aug 23, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **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.
- **Capability-aware spawn routing** — a Model Group now carries a capability set (currently input modalities: `text` and `image`) derived from its configured, authenticated members, with the option to narrow it via an explicit override capped at the member union. A `spawn` call can declare `constraints` the delegated task needs; the router checks the group and the exact selected model and fails before any child would be created when the requirement can't be met. The main-session prompt lists each group's capabilities.
- **Pluggable capability kernel** — added a small constraint system so each capability is one descriptor (read fact → aggregate → reconcile override → satisfy check → present). Modalities are the first, and only, production capability; a synthetic test-only descriptor exercises the full extension point so a future capability (e.g. min context window) needs no edits to config load, spawn routing, or UI render.

### Changed

- Improved Model Groups editing with a searchable complete-result, ten-visible-row add-model picker and a prompt-free inline group-name editor.
- Migrated child spawning to Pi'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.82.0 and Node 22.19.0 are now the documented minimums; parent-only transient provider/auth state fails explicitly without model fallback.
- **Model Group editing** — the editor now shows per-member capability chips (T/I) and lets users narrow the automatically derived modality set with an explicit override; group capability summaries also appear in `#group` autocomplete.

### Fixed

Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Deeper rationale: [docs/why.md](docs/why.md) · companion book: [agenticoding.ai

- **Spawn** — run research or implementation in a clean child context so the parent stays focused
- **Model Groups** — manage durable project/global model pools with `/model-groups`; route `spawn` by an exact group name, with `#group` autocomplete showing model/thinking details
- **Capabilities** — model pickers and editor rows show a chip beside each model for the input modalities it handles (`T` text, `I` image); a group advertises what its members can do, and a spawn that needs a capability the group can't deliver fails early instead of working around it
- **Notebook** — task-scoped named pages for facts and decisions; survives handoff, dies with the conversation (`/new`) — no forever-memory rot
- **Handoff** — deliberate clean restart with a task prompt when the topic changes or context turns to noise
- **Topic** — same problem → prefer spawn; new problem → prefer handoff (human-set topics win)
Expand Down Expand Up @@ -76,13 +77,21 @@ The agent set a topic, spawned research, saved decisions, delegated implementati

| | |
|---|---|
| **Spawn** | Subtask in a clean child context. Parent orchestrates; siblings run in parallel. Children inherit active registered parent tools executable in the child session — MCP/extension tools such as ChunkHound — plus child-local notebook tools. Children cannot spawn grandchildren or handoff. Omit `group` to inherit the parent model/thinking. An unknown group reports fallback to the parent. A known group randomly selects among configured/authenticated usable entries and fails before child creation if none are usable. The selected entry supplies the model and, when configured, overrides explicit/inherited thinking before Pi clamps it; the final selected public model runs in the child-owned runtime. |
| **Spawn** | Subtask in a clean child context. Parent orchestrates; siblings run in parallel. Children inherit active registered parent tools executable in the child session — MCP/extension tools such as ChunkHound — plus child-local notebook tools. Children cannot spawn grandchildren or handoff. Omit `group` to inherit the parent model/thinking. An unknown group reports fallback to the parent. A known group randomly selects among configured/authenticated usable entries and fails before child creation if none are usable. When the delegated task declares a `constraints` requirement, the group **and** the exact selected model are both checked before any child is created, and the child is told which capabilities the group allows. The selected entry supplies the model and, when configured, overrides explicit/inherited thinking before Pi clamps it; the final selected public model runs in the child-owned runtime. |
| **Notebook** | Named pages coupled to this conversation/task. Carries memory across handoff; cleared on `/new`. Not a long-lived memory store — lifetime matches the work, so it cannot go stale across unrelated sessions. |
| **Handoff** | Write a prompt, compact, resume clean. Notebook holds reusable memory for this task; the prompt holds only remaining situational context. |
| **Readonly** | Blocks write/edit and guards bash while researching. Spawn inherits the posture. **macOS/Linux:** bash can run under OS sandbox (`sandbox-exec` / `bwrap`) — syscall-level write denial outside temp. **Windows:** no OS sandbox — **best-effort command classifier only** (interpreters and clever pipes can bypass). A coding guardrail on every OS — not a hardened security boundary. |

**Commands:** `/handoff` · `/notebook` · `/notebook <topic>` · `/readonly` · `Ctrl+Shift+R` · `--readonly`

## Capabilities

Model groups advertise what their members can do. Today that means input modalities: `text` and `image`.

Model pickers and editor rows put a small chip beside each model (`T` for text, `I` for image) so you can tell at a glance which inputs it handles. A group's set is derived from its members — you can **narrow** it, but never widen it beyond what the members actually support.

When you delegate a task with `spawn`, you can declare which capabilities it needs. Spawn checks that requirement against the group and the exact model it selects — and if it can't be met, it fails before creating any child rather than improvising around the gap.

## Comparison

| Approach | Who decides | Across cuts |
Expand Down
28 changes: 16 additions & 12 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ import { registerSpawnTool } from "./spawn/index.js";
import { registerModelGroupsCommand } from "./model-groups/command.js";
import { resolveSpawnModelRoute, SpawnRouteError } from "./model-groups/router.js";
import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js";
import { getEffectiveModelGroupNames } from "./model-groups/router.js";
import { getEffectiveModelGroups, getEffectiveModelGroupNames } from "./model-groups/router.js";
import { MODEL_GROUP_MODALITY_PROSE, type ResolvedModelGroup, type ModelGroupsAccess } from "./model-groups/types.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 { presentConstraintPrompt } from "./model-groups/constraints/presentation.js";
import { productionConstraintRegistry } from "./model-groups/constraints/registry.js";
import {
cacheLookupCommand,
cacheLookupCommandExplicitModel,
Expand All @@ -100,6 +102,7 @@ import {
updateIndicators,
} from "./tui.js";
import { applyReadonlyBashGuard } from "./readonly-bash.js";

// ── Helpers ────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -461,13 +464,14 @@ function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext
return state.modelGroups.validation;
}

function modelGroupsPromptSection(names: string[]): string | undefined {
if (names.length === 0) return undefined;
function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefined {
if (groups.length === 0) return undefined;
const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${(group.evaluations ? presentConstraintPrompt(group.evaluations, productionConstraintRegistry).filter(Boolean).join(", ") : group.modalities?.effective.join(", ")) || "no common modalities"})`);
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.`;
`Available Model Groups: ${labels.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 a delegated task requires ${MODEL_GROUP_MODALITY_PROSE} capability, pass those requirements as constraints. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` +
`An explicitly-named group is binding: if the operator requests a specific group and the task also needs a capability that group lacks, do NOT fall back to a different group, inherit, or work around the missing capability. Stop and report to the operator that the named group cannot do the task; ask whether to pick a different group or drop the capability. ` +
`The group list exposes only names and effective modalities; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`;
}

export default function (pi: ExtensionAPI): void {
Expand Down Expand Up @@ -756,7 +760,7 @@ export default function (pi: ExtensionAPI): void {
);
}

const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroupNames(state.modelGroups.groups));
const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroups(state.modelGroups.groups));
if (modelGroupSection) {
parts.push(modelGroupSection);
}
Expand Down Expand Up @@ -920,9 +924,9 @@ export default function (pi: ExtensionAPI): void {
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");
const { unavailableCount, overrideCount, emptyModalityCount, staleModalityOverrideCount } = summarizeBootValidation(validation.groups);
if (unavailableCount > 0 || overrideCount > 0 || emptyModalityCount > 0 || staleModalityOverrideCount > 0) {
ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides · ${emptyModalityCount} groups with no common modalities · ${staleModalityOverrideCount} stale modality overrides`, "warning");
}
}

Expand Down
79 changes: 67 additions & 12 deletions model-groups/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
import type { AgenticodingState } from "../state.js";
import { getEffectiveModelGroups } from "./router.js";
import { MODALITY_FG, modalityLetterRun } from "./modality.js";
import type { ModelGroupModel, ResolvedModelGroup } from "./types.js";

const registeredUis = new WeakSet<object>();
Expand All @@ -20,7 +22,46 @@ function formatModelGroupRouteDetails(group: ResolvedModelGroup): string {
.join("; ");
}

export function createModelGroupAutocompleteProvider(state: AgenticodingState) {
export type DescriptionColorizer = (color: ThemeColor, text: string) => string;

/**
* Build one colored/muted single-letter capability run for a group, padded so
* the subsequent per-model route column starts at the same offset across rows.
* Handles an edge case where the group has no effective media modalities.
*/
function buildCapsLetters(group: ResolvedModelGroup, colorize: DescriptionColorizer): string {
return modalityLetterRun(group.modalities?.effective, {
render: (modality, letter) => colorize(MODALITY_FG[modality], letter),
separator: colorize("muted", " "),
// OpenRouter-style: text is the implied base, so only show I when image is
// present; text-only rows keep the single T.
hideTextWhenOtherMedia: true,
});
}

/**
* Assemble the `#`-mention tooltip description.
*
* With a colorizer: `T I provider/model • thinking` — the colored media
* letters (reasoning excluded) are padded to a fixed `capsWidth` column, then
* the per-model route details in muted. The muted color is re-asserted after
* every colored span and after the padding so the one-line row stays legible.
*
* Without a colorizer: the plain per-model route details are returned
* unchanged (rows are inherently aligned to the left edge).
*/
function buildSuggestionDescription(route: string, letters: string, capsWidth: number, colorize?: DescriptionColorizer): string {
if (!colorize) return route;
const gap = colorize("muted", " ");
if (capsWidth === 0) return colorize("muted", route);
const pad = Math.max(0, capsWidth - visibleWidth(letters));
const padded = letters
? `${letters}${colorize("muted", " ".repeat(pad))}`
: colorize("muted", " ".repeat(capsWidth));
return `${padded}${gap}${colorize("muted", route)}`;
}

export function createModelGroupAutocompleteProvider(state: AgenticodingState, colorize?: DescriptionColorizer) {
return (current: any) => ({
async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: unknown) {
const line = lines[cursorLine] ?? "";
Expand All @@ -32,13 +73,20 @@ export function createModelGroupAutocompleteProvider(state: AgenticodingState) {

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),
}));
const matched = groups.filter((group) => group.name.toLowerCase().startsWith(partial));
const rows = matched.map((group) => ({
value: `#${group.name}`,
label: `#${group.name}`,
route: formatModelGroupRouteDetails(group),
letters: colorize ? buildCapsLetters(group, colorize) : "",
}));
// Align the route column across every visible suggestion row.
const capsWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row.letters)), 0);
const items = rows.map((row) => ({
value: row.value,
label: row.label,
description: buildSuggestionDescription(row.route, row.letters, capsWidth, colorize),
}));
return { prefix: `#${match[1] ?? ""}`, items };
},

Expand All @@ -54,10 +102,17 @@ export function createModelGroupAutocompleteProvider(state: AgenticodingState) {

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 };
const ui = ctx.ui as unknown as {
addAutocompleteProvider?: (factory: ReturnType<typeof createModelGroupAutocompleteProvider>) => void;
theme?: { fg: (color: ThemeColor, text: string) => string };
};
if (typeof ui.addAutocompleteProvider !== "function") return;
const key = ui as object;
if (registeredUis.has(key)) return;
registeredUis.add(key);
ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state));
}
// Lazy adapter so the colorizer reflects the live theme (not a snapshot).
const colorize: DescriptionColorizer | undefined = ui.theme
? (color, text) => ui.theme!.fg(color, text)
: undefined;
ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state, colorize));
}
45 changes: 45 additions & 0 deletions model-groups/constraints/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ConstraintRegistry } from "./registry.js";
import type { AnyConstraintDescriptor, ConstraintEvaluation, ConstraintMemberResolution, ConstraintViolation, ErasedConstraintEvaluation } from "./types.js";

function evaluateDescriptor(descriptor: AnyConstraintDescriptor, resolution: ConstraintMemberResolution, override: unknown): ErasedConstraintEvaluation {
const members = resolution.members.map(({ ref, model }) => ({ ref, ...(model ? { fact: descriptor.modelFact(model) } : {}) }));
const aggregate = descriptor.aggregate({ members });
const reconciled = descriptor.reconcile({ aggregate, override });
return { key: descriptor.key, aggregate, effective: reconciled.effective, diagnostics: reconciled.diagnostics };
}

/** Pure evaluator: resolution is supplied by the host and no registry APIs are reachable here. */
export function evaluateConstraints(
resolution: ConstraintMemberResolution,
overrides: Readonly<Record<string, unknown>>,
registry: ConstraintRegistry,
): readonly ErasedConstraintEvaluation[] {
return registry.descriptors.map((descriptor) => evaluateDescriptor(descriptor, resolution, overrides[descriptor.key]));
}

export function evaluateConstraint<Aggregate, Effective>(
descriptor: AnyConstraintDescriptor,
resolution: ConstraintMemberResolution,
override: unknown,
): ConstraintEvaluation<Aggregate, Effective> {
return evaluateDescriptor(descriptor, resolution, override) as ConstraintEvaluation<Aggregate, Effective>;
}

export function evaluateGroupRequirement(
descriptor: AnyConstraintDescriptor,
evaluation: ErasedConstraintEvaluation,
requirement: unknown,
): ConstraintViolation | undefined {
const satisfaction = descriptor.groupSatisfies({ aggregate: evaluation.aggregate, effective: evaluation.effective, requirement });
return satisfaction.satisfied ? undefined : { key: descriptor.key, scope: "group", satisfaction };
}

export function evaluateModelRequirement(
descriptor: AnyConstraintDescriptor,
model: ConstraintMemberResolution["members"][number]["model"],
requirement: unknown,
): ConstraintViolation | undefined {
if (!model) return { key: descriptor.key, scope: "model", satisfaction: { satisfied: false, missing: "unresolved" } };
const satisfaction = descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement });
return satisfaction.satisfied ? undefined : { key: descriptor.key, scope: "model", satisfaction };
}
Loading
Loading