pi-better-harness 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,6 +43,16 @@ uses `agents_catalog` to discover current role descriptions and delegates every
43
43
  nontrivial role-owned task, while the foreground coordinates, integrates, and
44
44
  verifies. See [usage notes](docs/usage.md#delegation-mode).
45
45
 
46
+ `agents_catalog` shows each role's and named agent's default model and effort,
47
+ such as `role developer "Developer" … default openai/gpt-6-sol@high`. Roles are
48
+ listed and passed by short name (`role: "developer"`); the stored id
49
+ `role.developer` also works, and named agents keep their full id
50
+ (`agent: "agent.payments"`). To launch on that default, omit
51
+ `model` and `thinking` on a role or agent spawn; name one only for a stated
52
+ reason. When a launch's model or effort differs from the default, its launch
53
+ line says so, for example
54
+ `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
55
+
46
56
  ## When To Use
47
57
 
48
58
  Use this package for independent coding, review, research, or verification work that can finish later. Do not use it for steps that need immediate foreground interaction or user clarification.
@@ -15,7 +15,9 @@ import {
15
15
  CATALOG_SCHEMA_VERSION,
16
16
  DiagnosticCodes,
17
17
  EFFORT_LEVELS,
18
+ canonicalRoleId,
18
19
  parseDefinition,
20
+ shortRoleName,
19
21
  type AgentDefinition,
20
22
  type Diagnostic,
21
23
  type InstructionMode,
@@ -52,9 +54,9 @@ const KNOWN_FLAGS = new Set(["role", "scope", "name", "id", "mode", "description
52
54
  const USAGE = [
53
55
  "/agents list",
54
56
  "/agents show <id> (inspect is the same command)",
55
- "/agents create [--role <role-id>] [--name <name>] [--scope user|project] [--mode add|replace] [--instructions <text>] [--model <provider/model>] [--effort <level>] [--tier <label>]",
57
+ "/agents create [--role <role>] [--name <name>] [--scope user|project] [--mode add|replace] [--instructions <text>] [--model <provider/model>] [--effort <level>] [--tier <label>]",
56
58
  "/agents reload",
57
- "/agents import-codex <file.toml> [--scope user|project] [--role <role-id>]",
59
+ "/agents import-codex <file.toml> [--scope user|project] [--role <role>]",
58
60
  "Create and import write to the personal catalog unless --scope project is set.",
59
61
  "Import confirms one base role and replace mode. It does not scan .codex/agents.",
60
62
  ].join("\n");
@@ -263,14 +265,14 @@ async function runCreate(
263
265
  deps: AgentCommandDeps,
264
266
  ): Promise<AgentCommandResult> {
265
267
  if (parsed.positionals.length > 1) {
266
- return result("error", "create", false, "create takes flags, not a bare role name. Use --role <role-id>. Nothing was written.");
268
+ return result("error", "create", false, "create takes flags, not a bare role name. Use --role <role>. Nothing was written.");
267
269
  }
268
270
  const scope = readScope(parsed.flags);
269
271
  if (scope.error) return result("error", "create", false, `${scope.error} Nothing was written.`);
270
272
  if (scope.value === "project" && !loc.projectTrusted) return untrusted("create");
271
273
  const snapshot = readCatalog(loc);
272
274
  const roles = catalogRoles(snapshot);
273
- let roleIds = [...(parsed.flags.get("role") ?? [])];
275
+ let roleIds = (parsed.flags.get("role") ?? []).map(canonicalRoleId);
274
276
  if (roleIds.length > 1) {
275
277
  const decision = await resolveRoleAssignment(roleIds.map((roleId) => ({ roleId })), {
276
278
  hasUI: host.hasUI,
@@ -284,7 +286,7 @@ async function runCreate(
284
286
  let roleId = roleIds[0];
285
287
  if (!roleId) {
286
288
  if (!host.hasUI) {
287
- return clarification("create", `create needs --role <role-id>. UI is unavailable, so no role was chosen. Roles: ${roles.map((role) => role.id).join(", ") || "none"}. Nothing was written.`);
289
+ return clarification("create", `create needs --role <role>. UI is unavailable, so no role was chosen. Roles: ${roles.map((role) => shortRoleName(role.id)).join(", ") || "none"}. Nothing was written.`);
288
290
  }
289
291
  if (roles.length === 0) return result("error", "create", false, "No roles are available to use as a base. Nothing was written.");
290
292
  const selected = await host.ui.select("Choose one base role", roles.map(roleOption));
@@ -292,7 +294,7 @@ async function runCreate(
292
294
  roleId = selected.split(" ")[0];
293
295
  }
294
296
  if (!roleId || !roles.some((role) => role.id === roleId)) {
295
- return result("error", "create", false, `Unknown role ${roleId ?? ""}. Nothing was written. Use /agents list and pass one role id.`);
297
+ return result("error", "create", false, `Unknown role ${shortRoleName(roleId ?? "")}. Valid roles: ${roles.map((role) => shortRoleName(role.id)).join(", ") || "none"}. Nothing was written.`);
296
298
  }
297
299
  let name = parsed.flags.get("name")?.[0];
298
300
  if (!name?.trim()) {
@@ -407,7 +409,7 @@ async function runImport(
407
409
  const roles = catalogRoles(snapshot);
408
410
  if (roles.length === 0) return result("error", "import-codex", false, "No roles are available to use as the one base role. Nothing was imported.");
409
411
  const suggestion = suggestBaseRoles(`${document.name}\n${document.description}\n${document.developerInstructions}`, roles);
410
- const requestedRoles = [...(parsed.flags.get("role") ?? [])];
412
+ const requestedRoles = (parsed.flags.get("role") ?? []).map(canonicalRoleId);
411
413
  if (requestedRoles.length > 1) {
412
414
  const decision = await resolveRoleAssignment(requestedRoles.map((roleId) => ({ roleId })), {
413
415
  hasUI: host.hasUI,
@@ -420,7 +422,7 @@ async function runImport(
420
422
  }
421
423
  let roleId = requestedRoles[0];
422
424
  if (roleId && !roles.some((role) => role.id === roleId)) {
423
- return result("error", "import-codex", false, `Unknown role ${roleId}. Nothing was imported.`);
425
+ return result("error", "import-codex", false, `Unknown role ${shortRoleName(roleId)}. Valid roles: ${roles.map((role) => shortRoleName(role.id)).join(", ")}. Nothing was imported.`);
424
426
  }
425
427
  const sourceRef = codexSourceRef(resolved);
426
428
  const existingLookup = matchExisting(listScopeAgents(loc, scope.value).agents, document.proposedId, sourceRef);
@@ -8,7 +8,7 @@
8
8
  * and a catalog block stays not launchable even if enrichment disagrees.
9
9
  */
10
10
  import { inspectCatalog, listCatalog, type CatalogInspection } from "./catalog-resolver.ts";
11
- import { formatDiagnostic, type Diagnostic } from "./catalog-schema.ts";
11
+ import { formatDiagnostic, shortRoleName, type Diagnostic } from "./catalog-schema.ts";
12
12
  import type { CatalogSnapshot } from "./catalog-store.ts";
13
13
  import { loadConfig, normalizeTools, SAFE_DEFAULT_TOOLS, type SubagentConfig } from "./config.ts";
14
14
  import { resolveExtensions } from "./extensions.ts";
@@ -80,6 +80,23 @@ export interface OperationField {
80
80
  inherited: boolean;
81
81
  }
82
82
 
83
+ export interface DefinitionDefaults {
84
+ model: string | null;
85
+ effort: string | null;
86
+ /** Compact `model@effort` form, as shown in the catalog list and launch notes. */
87
+ label: string;
88
+ }
89
+
90
+ /** `model@effort`, `model`, or `effort <level>` when only an effort is set. Undefined when neither is set. */
91
+ export function formatModelEffort(model: string | null | undefined, effort: string | null | undefined): string | undefined {
92
+ const m = typeof model === "string" && model.trim() !== "" ? model : undefined;
93
+ const e = typeof effort === "string" && effort.trim() !== "" ? effort : undefined;
94
+ if (m && e) return `${m}@${e}`;
95
+ if (m) return m;
96
+ if (e) return `effort ${e}`;
97
+ return undefined;
98
+ }
99
+
83
100
  export interface OperationCapabilities {
84
101
  grantedByCatalog: false;
85
102
  sameAsLegacySpawn: true;
@@ -113,6 +130,8 @@ export interface OperationView {
113
130
  instructionMode?: string;
114
131
  instructions?: string;
115
132
  fields?: { model: OperationField; effort: OperationField; tier: OperationField };
133
+ /** The definition's own default model and effort (role default, or the agent's override or inherited value). Null when neither is set. */
134
+ defaults: DefinitionDefaults | null;
116
135
  requestedModel: string | null;
117
136
  requestedEffort: string | null;
118
137
  actualModel: string | null;
@@ -208,7 +227,7 @@ export function presentCatalog(snapshot: CatalogSnapshot, enrich?: LaunchEnriche
208
227
  "Definition validity is not launchability. Model availability is decided only by the injected resolver.",
209
228
  ...snapshot.diagnostics.map((diagnostic) => formatDiagnostic(diagnostic)),
210
229
  ...entries.flatMap((entry) => [
211
- entry.text.split("\n")[0] ?? entry.id,
230
+ renderCatalogListLine(entry),
212
231
  ` description: ${JSON.stringify(entry.identity.description ?? "")}`,
213
232
  ]),
214
233
  ];
@@ -311,6 +330,7 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
311
330
  instructionMode: inspection.instructionMode,
312
331
  instructions: inspection.instructions,
313
332
  fields,
333
+ defaults: definitionDefaults(fields),
314
334
  requestedModel: enrichment?.requestedModel ?? fields?.model.value ?? null,
315
335
  requestedEffort: enrichment?.requestedEffort ?? (fields?.effort.value ?? null),
316
336
  actualModel: enrichment?.actualModel ?? null,
@@ -341,16 +361,30 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
341
361
  return view;
342
362
  }
343
363
 
344
- export function renderOperationView(view: OperationView): string {
345
- const header = [
364
+ function renderHeader(view: OperationView, shownId: string): string {
365
+ return [
346
366
  view.identity.kind ?? "missing",
347
- view.id,
367
+ shownId,
348
368
  view.identity.name ? JSON.stringify(view.identity.name) : "unnamed",
349
369
  `scope=${view.identity.scope ?? "none"}`,
350
370
  `definitionValid=${yesNo(view.definitionValid)}`,
351
371
  `catalogLaunchable=${yesNo(view.catalogLaunchable)}`,
352
372
  `launchable=${view.launchable === null ? "unknown" : yesNo(view.launchable)}`,
373
+ ...(view.defaults ? [`default ${view.defaults.label}`] : []),
353
374
  ].join(" ");
375
+ }
376
+
377
+ /**
378
+ * One `agents_catalog` list line. A role shows its short name (`role developer
379
+ * "Developer" …`), which is what the `role` field accepts; a named agent keeps
380
+ * its full id. Inspect output and structured details keep the full id.
381
+ */
382
+ export function renderCatalogListLine(view: OperationView): string {
383
+ return renderHeader(view, view.identity.kind === "role" ? shortRoleName(view.id) : view.id);
384
+ }
385
+
386
+ export function renderOperationView(view: OperationView): string {
387
+ const header = renderHeader(view, view.id);
354
388
  const lines = [
355
389
  header,
356
390
  `identity: id=${view.id} kind=${view.identity.kind ?? "unknown"} name=${JSON.stringify(view.identity.name ?? "")} description=${JSON.stringify(view.identity.description ?? "")}`,
@@ -397,6 +431,13 @@ export function renderOperationView(view: OperationView): string {
397
431
  return lines.join("\n");
398
432
  }
399
433
 
434
+ function definitionDefaults(fields: OperationView["fields"]): DefinitionDefaults | null {
435
+ const model = fields?.model.value ?? null;
436
+ const effort = fields?.effort.value ?? null;
437
+ const label = formatModelEffort(model, effort);
438
+ return label ? { model, effort, label } : null;
439
+ }
440
+
400
441
  function field(value: { value: string | null; source: OperationField["source"]; explicit: boolean }): OperationField {
401
442
  return {
402
443
  value: value.value,
@@ -4,6 +4,7 @@
4
4
  * collected. This module does not write files.
5
5
  */
6
6
  import { presentCatalog, presentCatalogEntry, type LaunchEnricher } from "./agent-inspection.ts";
7
+ import { canonicalCatalogId } from "./catalog-schema.ts";
7
8
  import { defaultUserRoot, loadCatalog } from "./catalog-store.ts";
8
9
 
9
10
  type TypeModule = {
@@ -37,19 +38,21 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
37
38
  return {
38
39
  name: "agents_catalog" as const,
39
40
  label: "Agents catalog",
40
- description: "List or inspect role and named-agent definitions, including inheritance, diagnostics, restrictions, and whether launchability is actually known. This tool does not create, import, or edit definitions.",
41
+ description: "List or inspect role and named-agent definitions, including inheritance, default model and effort, diagnostics, restrictions, and whether launchability is actually known. This tool does not create, import, or edit definitions.",
41
42
  promptSnippet: "List and inspect catalog roles and named agents, including inheritance and whether launchability is known.",
42
43
  promptGuidelines: [
43
44
  "Use agents_catalog to discover roles and named agents before subagent_spawn. It is read-only.",
45
+ "Roles are listed by short name. Pass that name as role (role: \"developer\"). Pass a named agent by its full id (agent: \"agent.payments\").",
44
46
  "Do not treat a valid definition as launchable. definitionValid and catalogLaunchable do not select a model or start a child.",
45
47
  "A role name or instruction that says read-only is not enforcement. Unsupported execution restrictions stay blocking.",
46
48
  "Ask the user to run /agents create or /agents import-codex for writes. Those commands confirm role, scope, and import replacement.",
47
49
  "Launch with the existing subagent_spawn or batch tool. Pass one agent or one role, not both. Two roles for one run need the user to choose one or split the work.",
48
50
  "Put an authoritative model or effort on the structured invocation. Do not rely on copying a model name into the child prompt. Explicit invocation and workflow choices override definition defaults.",
51
+ "Each entry shows its default model@effort when set. Omit model and thinking on the spawn to use it; name one only for a stated reason.",
49
52
  ],
50
53
  parameters: Type.Object({
51
54
  action: Type.String({ description: "list or inspect. No other action is accepted." }),
52
- id: Type.Optional(Type.String({ description: "Role or agent id for inspect. Display names and filenames are not ids." })),
55
+ id: Type.Optional(Type.String({ description: "What to inspect: a role's short name (developer) or a named agent's id (agent.payments). The prefixed role id (role.developer) also works. Display names and filenames are not ids." })),
53
56
  }),
54
57
  async execute(
55
58
  _toolCallId: string,
@@ -71,8 +74,8 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
71
74
  details: { action, wrote: false, revision: listed.revision, entries: listed.entries, diagnostics: listed.diagnostics },
72
75
  };
73
76
  }
74
- if (!params.id?.trim()) return toolError("inspect needs an id. Call agents_catalog with action list to see ids. Nothing was written.");
75
- const view = presentCatalogEntry(loadCatalog(host), params.id.trim(), deps.enrich);
77
+ if (!params.id?.trim()) return toolError("inspect needs a role name or agent id. Call agents_catalog with action list to see them. Nothing was written.");
78
+ const view = presentCatalogEntry(loadCatalog(host), canonicalCatalogId(params.id), deps.enrich);
76
79
  return {
77
80
  content: [{ type: "text" as const, text: view.text }],
78
81
  details: { action, wrote: false, view },
@@ -215,8 +215,8 @@ export function formatBatchLaunchResponse({ batchId, batchName, launched, skippe
215
215
  lines.push(
216
216
  `Batch ${label} launched ${launched.length} subagent(s):`,
217
217
  );
218
- for (const { name, id } of launched) {
219
- lines.push(`• ${name} → ${id}`);
218
+ for (const { name, id, modelNote } of launched) {
219
+ lines.push(`• ${name} → ${id}${modelNote ? ` · ${modelNote}` : ""}`);
220
220
  }
221
221
  }
222
222
 
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  DiagnosticCodes,
10
10
  hasBlockingDiagnostic,
11
+ shortRoleName,
11
12
  type CatalogDefinition,
12
13
  type CatalogKind,
13
14
  type Diagnostic,
@@ -161,9 +162,12 @@ export function listCatalog(snapshot: CatalogSnapshot): CatalogListEntry[] {
161
162
  export function inspectCatalog(snapshot: CatalogSnapshot, id: string): CatalogInspection {
162
163
  const entry = findEntry(snapshot, id);
163
164
  if (!entry) {
165
+ const isAgent = id.trim().toLowerCase().startsWith("agent.");
164
166
  const diagnostic = blockedDiagnostic(
165
- id.startsWith("agent.") ? DiagnosticCodes.unknownAgent : DiagnosticCodes.unknownRole,
166
- `No catalog definition has id ${id}. Check the id, or reload after adding the file. Display names and filenames are not ids.`,
167
+ isAgent ? DiagnosticCodes.unknownAgent : DiagnosticCodes.unknownRole,
168
+ isAgent
169
+ ? `No catalog definition has id ${id}. Check the id, or reload after adding the file. Display names and filenames are not ids.`
170
+ : `No role named ${shortRoleName(id)}. ${validRolesSentence(snapshot)} Check the name, or reload after adding the file. Display names and filenames are not ids.`,
167
171
  id,
168
172
  );
169
173
  return {
@@ -212,7 +216,7 @@ function resolveRole(snapshot: CatalogSnapshot, roleId: string): Resolution {
212
216
  return {
213
217
  status: "not-found",
214
218
  launchable: false,
215
- diagnostics: [blockedDiagnostic(DiagnosticCodes.unknownRole, `Unknown role ${roleId}. It was not launched.`, roleId)],
219
+ diagnostics: [blockedDiagnostic(DiagnosticCodes.unknownRole, `Unknown role ${shortRoleName(roleId)}. ${validRolesSentence(snapshot)} It was not launched.`, roleId)],
216
220
  };
217
221
  }
218
222
  if (entry.duplicate || !entry.definition || entry.definition.kind !== "role") {
@@ -393,6 +397,16 @@ function absentField<T>(): FieldValue<T> {
393
397
  return { value: null, source: "absent", explicit: false };
394
398
  }
395
399
 
400
+ /** Valid short role names, for an unknown-role message. */
401
+ function validRoleNames(snapshot: CatalogSnapshot): string[] {
402
+ return [...snapshot.roles.keys()].map(shortRoleName).sort();
403
+ }
404
+
405
+ function validRolesSentence(snapshot: CatalogSnapshot): string {
406
+ const names = validRoleNames(snapshot);
407
+ return names.length > 0 ? `Valid roles: ${names.join(", ")}.` : "No roles are defined.";
408
+ }
409
+
396
410
  function findEntry(snapshot: CatalogSnapshot, id: string): CatalogEntry | undefined {
397
411
  return snapshot.roles.get(id) ?? snapshot.agents.get(id) ?? snapshot.blocked.find((entry) => entry.id === id);
398
412
  }
@@ -4,7 +4,7 @@
4
4
  * `spawnSubagentRun`. This module does not grant tools, sandbox modes, or
5
5
  * extensions, and it does not read task prose for model choices.
6
6
  */
7
- import { describeDefaultLaunchCapabilities, LEGACY_CAPABILITY_CONTROLS, type LaunchEnricher, type LaunchEnrichment } from "./agent-inspection.ts";
7
+ import { describeDefaultLaunchCapabilities, formatModelEffort, LEGACY_CAPABILITY_CONTROLS, type LaunchEnricher, type LaunchEnrichment } from "./agent-inspection.ts";
8
8
  import { resolveSelection, type EffectiveDefinition } from "./catalog-resolver.ts";
9
9
  import { defaultUserRoot, loadCatalog, type CatalogSnapshot } from "./catalog-store.ts";
10
10
  import { loadConfig, type SubagentConfig } from "./config.ts";
@@ -22,7 +22,7 @@ import {
22
22
  import { allocateCatalogLabel } from "./catalog-identity.ts";
23
23
  import { resolveRoleAssignment, type RoleAssignment } from "./role-assignment.ts";
24
24
  import { configureTierPolicy, DEFAULT_TIER_POLICY, type TierPolicy, type TierSpec } from "./tier-policy.ts";
25
- import type { Diagnostic, ThinkingLevel } from "./catalog-schema.ts";
25
+ import { canonicalAgentId, canonicalRoleId, shortRoleName, type Diagnostic, type ThinkingLevel } from "./catalog-schema.ts";
26
26
 
27
27
  export interface CatalogHost {
28
28
  cwd: string;
@@ -252,6 +252,59 @@ export async function clarifyCatalogRequest(
252
252
  return { status: "resolved", jobs: ordered.map(stripBookkeeping) };
253
253
  }
254
254
 
255
+ /**
256
+ * Launch-line note for a catalog run whose effective model or effort differs
257
+ * from the role or agent default. The wording names the cause, so a fallback
258
+ * or a capped effort is not mistaken for a caller override:
259
+ * - override: `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`
260
+ * - fallback: `model xai/grok-4.7@high (role developer default openai/gpt-6-sol@high unavailable; foreground fallback)`
261
+ * - capped effort: `model openai/gpt-6-sol@medium (role developer default openai/gpt-6-sol@high; effort capped at medium by the model)`
262
+ * Undefined for a non-catalog run, a definition with no default, or a launch
263
+ * that matches the default. Only the fields the definition sets are compared.
264
+ */
265
+ export function catalogDefaultNote(
266
+ record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> & { id?: string } | undefined,
267
+ model: string | undefined,
268
+ thinking: string | undefined,
269
+ ): string | undefined {
270
+ if (!record) return undefined;
271
+ const defaultModel = record.effective.model.value;
272
+ const defaultEffort = record.effective.effort.value;
273
+ const defaultLabel = formatModelEffort(defaultModel, defaultEffort);
274
+ if (!defaultLabel) return undefined;
275
+ const modelSource = record.modelSelection?.source;
276
+ const modelFallback = defaultModel !== null && modelSource !== undefined && FALLBACK_SOURCES.has(modelSource);
277
+ const modelDiffers = defaultModel !== null && (modelFallback || !sameModel(defaultModel, model, modelSource));
278
+ const effortCapped = defaultEffort !== null && record.effortSelection?.adjusted === true && (thinking ?? null) !== defaultEffort;
279
+ const effortDiffers = defaultEffort !== null && (thinking ?? null) !== defaultEffort;
280
+ if (!modelDiffers && !effortDiffers) return undefined;
281
+ const actual = formatModelEffort(model ?? "Pi default", thinking) ?? "Pi default";
282
+ // A role run names its short role (`role developer default …`); an agent run keeps `agent default …`.
283
+ const owner = record.kind === "role" && record.id ? `role ${shortRoleName(record.id)}` : record.kind;
284
+ const causes = [
285
+ modelFallback ? `${owner} default ${defaultLabel} unavailable; ${FALLBACK_LABELS[modelSource!] ?? modelSource} fallback` : `${owner} default ${defaultLabel}`,
286
+ ...(effortCapped ? [`effort capped at ${thinking ?? "the model default"} by the model`] : []),
287
+ ];
288
+ return `model ${actual} (${causes.join("; ")})`;
289
+ }
290
+
291
+ /** Model sources that mean the default could not be used, not that the caller chose another model. */
292
+ const FALLBACK_SOURCES: ReadonlySet<string> = new Set(["tier-candidate", "foreground", "configured-default"]);
293
+ const FALLBACK_LABELS: Record<string, string> = {
294
+ "tier-candidate": "same-tier",
295
+ foreground: "foreground",
296
+ "configured-default": "configured-default",
297
+ };
298
+
299
+ function sameModel(defaultModel: string, actual: string | undefined, source: string | undefined): boolean {
300
+ // The resolver launched the definition's own preference: same model, however it was spelled.
301
+ if (source === "role-default" || source === "agent-override") return true;
302
+ if (actual === undefined) return false;
303
+ if (defaultModel === actual) return true;
304
+ // A providerless default names the id; the resolved provider/id (the id may itself contain "/") still matches.
305
+ return !defaultModel.includes("/") ? actual.endsWith(`/${defaultModel}`) : false;
306
+ }
307
+
255
308
  export async function prepareCatalogJob(snapshot: CatalogSnapshot, job: CatalogJobFields, host: CatalogHost): Promise<PreparedCatalogJob> {
256
309
  const agent = agentIdOf(job);
257
310
  const roles = roleIdsOf(job);
@@ -496,8 +549,7 @@ async function allocateDirectRoleLabel(input: { roleId: string; roleName: string
496
549
  }
497
550
 
498
551
  export function roleSlug(roleId: string): string {
499
- const raw = roleId.startsWith("role.") ? roleId.slice("role.".length) : roleId;
500
- return raw.trim().toLowerCase();
552
+ return shortRoleName(roleId).trim().toLowerCase();
501
553
  }
502
554
 
503
555
  function composePrompt(instructions: string, task: string): string {
@@ -507,7 +559,28 @@ function composePrompt(instructions: string, task: string): string {
507
559
  }
508
560
 
509
561
  function agentIdOf(job: { agent?: unknown }): string | undefined {
510
- return typeof job.agent === "string" && job.agent.trim() !== "" ? job.agent.trim() : undefined;
562
+ return typeof job.agent === "string" && job.agent.trim() !== "" ? canonicalAgentId(job.agent) : undefined;
563
+ }
564
+
565
+ /**
566
+ * Error text for a selector that is present but blank (`role: " "`,
567
+ * `agent: ""`, or an empty or blank role array). Absent or null means no
568
+ * selector and returns undefined. `where` names the field's owner, such as
569
+ * `shared` or `jobs[1]`.
570
+ */
571
+ export function blankCatalogSelector(input: { agent?: unknown; role?: unknown } | undefined, where?: string): string | undefined {
572
+ if (!input) return undefined;
573
+ const field = (name: string) => (where ? `${where}.${name}` : name);
574
+ const blank = (value: unknown) => typeof value !== "string" || value.trim() === "";
575
+ if (input.agent !== undefined && input.agent !== null && blank(input.agent)) {
576
+ return `${field("agent")} is blank. Pass a named agent id such as agent.payments, or omit agent. No child was started.`;
577
+ }
578
+ const role = input.role;
579
+ if (role === undefined || role === null) return undefined;
580
+ if (Array.isArray(role) ? role.length === 0 || role.some(blank) : blank(role)) {
581
+ return `${field("role")} is blank. Pass a role name such as developer, or omit role. No child was started.`;
582
+ }
583
+ return undefined;
511
584
  }
512
585
 
513
586
  function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
@@ -518,8 +591,11 @@ function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
518
591
  const ids: string[] = [];
519
592
  for (const value of values) {
520
593
  if (typeof value !== "string") continue;
521
- const trimmed = value.trim();
522
- if (trimmed && !ids.includes(trimmed)) ids.push(trimmed);
594
+ // A bare name (`developer`) and the prefixed id (`role.developer`) are
595
+ // the same role. The canonical id always starts with `role.`, so a
596
+ // role field never reaches an `agent.*` named agent.
597
+ const id = canonicalRoleId(value);
598
+ if (id && !ids.includes(id)) ids.push(id);
523
599
  }
524
600
  return ids;
525
601
  }
@@ -252,6 +252,40 @@ export function kindForId(id: string): CatalogKind | undefined {
252
252
  return undefined;
253
253
  }
254
254
 
255
+ /** Prefix every role id carries. Stored ids and files keep it; people type and read the short name. */
256
+ export const ROLE_ID_PREFIX = "role.";
257
+
258
+ /** Short, user-facing role name: `role.developer` becomes `developer`. Any other id is returned unchanged. */
259
+ export function shortRoleName(id: string): string {
260
+ return id.startsWith(ROLE_ID_PREFIX) ? id.slice(ROLE_ID_PREFIX.length) : id;
261
+ }
262
+
263
+ /**
264
+ * Canonical role id for a value typed into a `role` field. Trimmed and
265
+ * lowercased; a bare name gains the `role.` prefix, so `Developer` becomes
266
+ * `role.developer`. The result always starts with `role.`, so a role field
267
+ * can never select an `agent.*` named agent.
268
+ */
269
+ export function canonicalRoleId(value: string): string {
270
+ const trimmed = value.trim().toLowerCase();
271
+ if (trimmed === "") return trimmed;
272
+ return trimmed.startsWith(ROLE_ID_PREFIX) ? trimmed : `${ROLE_ID_PREFIX}${trimmed}`;
273
+ }
274
+
275
+ /** Canonical named-agent id: trimmed and lowercased, since ids are lowercase-only. */
276
+ export function canonicalAgentId(value: string): string {
277
+ return value.trim().toLowerCase();
278
+ }
279
+
280
+ /**
281
+ * Canonical id for a field that takes either kind, such as `agents_catalog`
282
+ * inspect. An `agent.` id (any case) is lowercased; anything else is read as a role.
283
+ */
284
+ export function canonicalCatalogId(value: string): string {
285
+ const lowered = value.trim().toLowerCase();
286
+ return lowered.startsWith("agent.") ? lowered : canonicalRoleId(lowered);
287
+ }
288
+
255
289
  export function isPreferenceKey(value: string): value is PreferenceKey {
256
290
  return (PREFERENCE_KEYS as readonly string[]).includes(value);
257
291
  }
@@ -15,7 +15,7 @@ export function delegationPrompt(mode: DelegationMode): string {
15
15
  case "manual":
16
16
  return "Delegation mode: manual. Do not proactively delegate work. Use subagents only when the user explicitly asks for delegation or an active workflow explicitly requires it. A structured plan or plan mode does not override this restriction; perform planned work in the foreground unless explicitly required otherwise.";
17
17
  case "coordinator":
18
- return "Delegation mode: coordinator. The foreground coordinates, integrates, and verifies. Call agents_catalog to discover current available roles and inspect their descriptions. Delegate every nontrivial task covered by an available role according to its current description; do not assume bundled roles are unchanged. Keep orchestration, cross-role decisions, unowned or ambiguous work, integration, and final verification in the foreground. Inspect results and failures before concluding; do not poll for running children.";
18
+ return "Delegation mode: coordinator. The foreground coordinates, integrates, and verifies. Call agents_catalog to discover current available roles and inspect their descriptions. Pass a role to subagent_spawn by its short name, as listed (role: \"developer\"). Delegate every nontrivial task covered by an available role according to its current description; do not assume bundled roles are unchanged. Keep orchestration, cross-role decisions, unowned or ambiguous work, integration, and final verification in the foreground. Inspect results and failures before concluding; do not poll for running children.";
19
19
  case "adaptive":
20
20
  return "Delegation mode: adaptive. Delegate bounded, substantial independent work when a suitable subagent is available and useful; continue unblocked foreground work. Keep small, tightly coupled, or interactive work in the foreground. Inspect and integrate delegated results before final verification; do not poll.";
21
21
  }
@@ -6,6 +6,8 @@ Catalog launches use the existing `spawnSubagentRun` path. There is no second sc
6
6
 
7
7
  `subagent_spawn`, batch `shared`, and each batch job accept optional `agent`, `role`, and `alias`.
8
8
 
9
+ - `role` takes a role's short name (`role: "developer"`). It is trimmed and case-insensitive, and the stored id (`role.developer`) also works. A bare name only ever selects a role, never an `agent.*` named agent. An unknown name fails and lists the valid role names. `agent` ids are trimmed and lowercased the same way.
10
+ - A `role` or `agent` that is present but blank (`role: " "`) is rejected before any launch. Only an absent field means no selector.
9
11
  - A per-job `agent` or `role` replaces the shared selector pair. It does not combine `shared.agent` with `job.role`.
10
12
  - `model` and `thinking` still merge per field. Per-job wins.
11
13
  - One agent or one role is a normal launch. Both, or more than one role id, asks the UI to choose one or split into separate runs.
@@ -28,7 +30,7 @@ The first `meta.json` write stores the launched `model` and `effort` plus a JSON
28
30
 
29
31
  A named agent displays its defined name. A direct role asks `allocateCatalogLabel({ roleId, roleName, alias })` when that module is present. `roleName` is the slug (`developer`), not `role.developer`. The label is allocated in the local run registry. An explicit `alias` (or the legacy `name` when `alias` is omitted) is the alias; collisions are the allocator's numeric suffix. Batch-generated `job-N` labels are not aliases.
30
32
 
31
- Navigator rows keep the display name, model, and effort. Details include the run id and, for catalog runs, the role id.
33
+ Navigator rows keep the display name, model, and effort. Details include the run id and, for catalog runs, the short role name (`developer`). The full `roleId` stays in `meta.json`.
32
34
 
33
35
  ## Tier candidates
34
36
 
@@ -52,6 +52,8 @@ Codex applies the agent file's model and `model_reasoning_effort` ahead of the c
52
52
 
53
53
  `agents_catalog` accepts `list` and `inspect` only. It uses the same inspection view as `/agents show`. It has no write path. Launch remains `subagent_spawn` or the batch tool, with at most one `agent` or `role` selector. Those selectors are lifecycle's wiring, not this tool.
54
54
 
55
+ A list line names a role by its short name (`role developer "Developer" …`), the form the `role` field takes; the inspect header, `identity.id`, and the structured `id` keep the stored id `role.developer`. Named agents show their full `agent.<slug>` id in both. Each list line and the inspect header end with the definition's default model and effort when one is set, such as `default openai/gpt-6-sol@high`: a role's own default, or a named agent's override or inherited role default. The structured view carries the same value as `defaults: { model, effort, label }`, or `null` when neither is set. A spawn that omits `model` and `thinking` uses it. When a catalog launch's model or effort differs from it, the launch line (and each batch job line) adds a note such as `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`; a named agent's note reads `agent default …`. Only the fields the definition sets are compared. The note names the cause: a fallback from an unavailable default reads `(role developer default openai/gpt-6-sol@high unavailable; foreground fallback)` (or `same-tier` / `configured-default`), and an effort the model cannot run adds `effort capped at <level> by the model`.
56
+
55
57
  ## Lifecycle attachment
56
58
 
57
59
  ```ts
@@ -50,6 +50,8 @@ Look at payment edge cases before editing.
50
50
  | `name` | Display text. Changing it does not change `id`. |
51
51
  | Filename | Not an id. Discovery reads every `*.md` file in the scope directory, one level deep. |
52
52
 
53
+ People type and read a role by its short name: `developer` is `role.developer`. The `role` field of `subagent_spawn` and `subagent_spawn_batch` and the `agents_catalog` inspect id accept it (trimmed, case-insensitive), and `agents_catalog` lists roles by it. The prefixed id is what files, `roleId`, and run metadata store, and it keeps working as input. Named agents keep their full `agent.<slug>` id everywhere.
54
+
53
55
  `kind` must match the id prefix. `roleIds`, `baseRoles`, `inherits`, and a list of roles are rejected. Agentier executable-role memberships are eligibility data, not extra inheritance parents, and are not accepted as additional `roleId`s.
54
56
 
55
57
  ## Portable preferences vs host controls
@@ -96,14 +98,14 @@ Model availability, same-tier fallback, and nearest-effort adjustment are not de
96
98
 
97
99
  ## Approved bundled roles
98
100
 
99
- | Id | Name | Model | Effort | Tier |
100
- | --- | --- | --- | --- | --- |
101
- | `role.researcher` | Researcher | `openai/gpt-6-sol` | medium | balanced |
102
- | `role.explorer` | Explorer | `openai/gpt-6-luna` | medium | efficient |
103
- | `role.product-manager` | Product Manager | `openai/gpt-6-sol` | medium | balanced |
104
- | `role.developer` | Developer | `openai/gpt-6-sol` | high | balanced |
105
- | `role.reviewer` | Reviewer | `openai/gpt-6-astra` | medium | frontier |
106
- | `role.architect` | Architect | `openai/gpt-6-astra` | high | frontier |
101
+ | Role | Stored id | Name | Model | Effort | Tier |
102
+ | --- | --- | --- | --- | --- | --- |
103
+ | `researcher` | `role.researcher` | Researcher | `openai/gpt-6-sol` | medium | balanced |
104
+ | `explorer` | `role.explorer` | Explorer | `openai/gpt-6-luna` | medium | efficient |
105
+ | `product-manager` | `role.product-manager` | Product Manager | `openai/gpt-6-sol` | medium | balanced |
106
+ | `developer` | `role.developer` | Developer | `openai/gpt-6-sol` | high | balanced |
107
+ | `reviewer` | `role.reviewer` | Reviewer | `openai/gpt-6-astra` | medium | frontier |
108
+ | `architect` | `role.architect` | Architect | `openai/gpt-6-astra` | high | frontier |
107
109
 
108
110
  Bundled descriptions are routing boundaries for coordinator mode. Researcher owns
109
111
  external and documentary evidence; Explorer maps repository code paths; Product
@@ -147,10 +147,12 @@ import { createAgentOperations } from "./agent-operations.ts";
147
147
  import {
148
148
  clarifyCatalogRequest,
149
149
  createLaunchEnricher,
150
+ blankCatalogSelector,
150
151
  hasCatalogSelector,
151
152
  loadLaunchSnapshot,
152
153
  noteCatalogHost,
153
154
  prepareCatalogJob,
155
+ catalogDefaultNote,
154
156
  tiersForLaunch,
155
157
  type CatalogHost,
156
158
  type CatalogJobFields,
@@ -175,8 +177,9 @@ const projectConfigDirName = typeof (PiCodingAgent as { CONFIG_DIR_NAME?: unknow
175
177
  : ".pi";
176
178
 
177
179
  const CATALOG_GUIDELINES = [
180
+ "With an agent or role, omit model and thinking to launch on its default model and effort, which agents_catalog shows. Name a model or effort only for a stated reason, such as a task or workflow instruction, and never copy one from another role's runs.",
178
181
  "When a task, workflow, or skill instruction names a model or effort, translate that authoritative choice into the structured model and thinking arguments before spawning. The runtime does not parse prose, quoted model names, or comparisons, and copying a model into the child prompt does not change the launch.",
179
- "Optional agent, role, and alias select a catalog definition. Pass one agent id or one role id. Pass an array of role ids when one run was given more than one role: that call asks to choose one or split, and without a UI choice it returns clarification-needed and starts no child. Naming both an agent and a role does the same. One job's choice does not change another job. A named agent displays its defined name. A direct role displays the label allocated from the local run registry. Calls without agent or role keep the existing name and model chain.",
182
+ "Optional agent, role, and alias select a catalog definition. Pass one named agent id (agent: \"agent.payments\") or one role by its short name (role: \"developer\"); the role.developer form also works. Pass an array of role names when one run was given more than one role: that call asks to choose one or split, and without a UI choice it returns clarification-needed and starts no child. Naming both an agent and a role does the same. One job's choice does not change another job. A named agent displays its defined name. A direct role displays the label allocated from the local run registry. Calls without agent or role keep the existing name and model chain.",
180
183
  "Catalog model and effort are resolved before the child starts. An unavailable explicit model or unsupported explicit effort does not launch and does not fall back. The catalog grants no tools, sandbox modes, extensions, or permissions.",
181
184
  ];
182
185
 
@@ -1423,8 +1426,8 @@ function timingSchemaFields() {
1423
1426
 
1424
1427
  /** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
1425
1428
  function catalogRoleSchema(purpose: string) {
1426
- const one = `One role id (role.<slug>) for this ${purpose}. Mutually exclusive with agent.`;
1427
- const many = `Two or more role ids for this ${purpose}. An array is ambiguous and asks to choose one role or split into separate runs. No child launches until that choice is made.`;
1429
+ const one = `One role by short name (developer) for this ${purpose}; role.developer also works, case-insensitive. Mutually exclusive with agent.`;
1430
+ const many = `Two or more role names for this ${purpose}. An array is ambiguous and asks to choose one role or split into separate runs. No child launches until that choice is made.`;
1428
1431
  return Type.Optional(Type.Union([
1429
1432
  Type.String({ description: one }),
1430
1433
  Type.Array(Type.String({ minLength: 1 }), { minItems: 1, description: many }),
@@ -1551,6 +1554,8 @@ export default function (pi: ExtensionAPI) {
1551
1554
  runtime: string;
1552
1555
  warn: string;
1553
1556
  sandboxDir?: string;
1557
+ /** Set only when a catalog run's model or effort differs from its role or agent default. */
1558
+ modelNote?: string;
1554
1559
  }> {
1555
1560
  assertThinkingLevel(p.thinking);
1556
1561
  const permissionPlan = resolveSubagentPermissions(pi, p.sandbox);
@@ -1766,7 +1771,8 @@ export default function (pi: ExtensionAPI) {
1766
1771
  `${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
1767
1772
  `Add a toolExtensions entry in config.json.\n`
1768
1773
  : "");
1769
- return { id, meta, spawned, runtime: runtime + (meta.timing ? `${formatTimingLimits(meta.timing, startedAt)}\n` : ""), warn, sandboxDir };
1774
+ const modelNote = catalogDefaultNote(p.catalog, model, thinking);
1775
+ return { id, meta, spawned, runtime: runtime + (meta.timing ? `${formatTimingLimits(meta.timing, startedAt)}\n` : ""), warn, sandboxDir, modelNote };
1770
1776
  }
1771
1777
 
1772
1778
  // ---- subagent_spawn -------------------------------------------------
@@ -1783,7 +1789,7 @@ export default function (pi: ExtensionAPI) {
1783
1789
  "After subagent_spawn, do NOT call subagent_output or subagent_result in a loop to wait for the result, and do NOT sleep. The run completes on its own and reports back on the next turn.",
1784
1790
  "Call subagent_result after a completion or attention callback, or when the user explicitly asks for the result. Use subagent_output only when the user explicitly asks how a run is progressing; never use either tool to poll.",
1785
1791
  ...SUBAGENT_ORCHESTRATION_GUIDELINES,
1786
- "The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
1792
+ "The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
1787
1793
  ...CATALOG_GUIDELINES,
1788
1794
  "By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
1789
1795
  "Every run is timed by the harness: a soft deadline (default 30 min) steers the child to wrap up and wakes you once, the run is stopped after grace_minutes (reason deadline), a hard ceiling (default 90 min) stops it without grace (reason ceiling), and no progress for stuck_minutes (default 10) wakes you once (reason stuck). Set deadline_minutes/max_minutes/stuck_minutes to fit the task instead of writing a time limit into the prompt; do not stop a slow child that is still making progress.",
@@ -1795,8 +1801,8 @@ export default function (pi: ExtensionAPI) {
1795
1801
  agent: Type.Optional(Type.String({ description: "Named agent id (agent.<slug>). Mutually exclusive with role. The navigator shows the defined agent name." })),
1796
1802
  role: catalogRoleSchema("direct role launch"),
1797
1803
  alias: Type.Optional(Type.String({ description: "Per-run display alias for a direct role launch, such as checkout. Not a reusable agent. Colliding aliases gain a numeric suffix." })),
1798
- model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (for example openai/gpt-5.5@high). Providerless patterns are resolved by Pi. Default: inherit foreground model. Put authoritative model choices here; do not rely on prompt text." })),
1799
- thinking: Type.Optional(Type.String({ description: "Reasoning effort for the child: off, minimal, low, medium, high, xhigh, or max (default: Pi/model default)." })),
1804
+ model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (for example openai/gpt-5.5@high). Providerless patterns are resolved by Pi. Omit to use the agent or role default; without one, inherit the foreground model. Put authoritative model choices here; do not rely on prompt text." })),
1805
+ thinking: Type.Optional(Type.String({ description: "Reasoning effort for the child: off, minimal, low, medium, high, xhigh, or max. Omit to use the agent or role default; without one, the Pi/model default." })),
1800
1806
  tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
1801
1807
  exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
1802
1808
  clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
@@ -1813,6 +1819,9 @@ export default function (pi: ExtensionAPI) {
1813
1819
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1814
1820
  const p = params as SpawnParams;
1815
1821
  if (p.prompt.trim() === "") throw new Error("prompt is empty.");
1822
+ // A present-but-blank role or agent is a mistake, not a request for a catalog-free child.
1823
+ const blankSelector = blankCatalogSelector(p);
1824
+ if (blankSelector) throw new Error(blankSelector);
1816
1825
 
1817
1826
  const cfg = loadConfig();
1818
1827
  const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
@@ -1841,21 +1850,22 @@ export default function (pi: ExtensionAPI) {
1841
1850
  throw new Error(`Choosing split needs ${catalog.jobs.length} subagent slots, but only one was free. Nothing was launched.`);
1842
1851
  }
1843
1852
  reserved += extra;
1844
- const launched: { name?: string; id: string }[] = [];
1853
+ const launched: { name?: string; id: string; modelNote?: string }[] = [];
1845
1854
  for (const job of catalog.jobs) {
1846
- const { id } = await spawnSubagentRun(ctx, job);
1855
+ const { id, modelNote } = await spawnSubagentRun(ctx, job);
1847
1856
  gate.commit(1);
1848
1857
  reserved -= 1;
1849
- launched.push({ name: job.name, id });
1858
+ launched.push({ name: job.name, id, modelNote });
1850
1859
  }
1851
- return text(launched.map((item) => `Subagent launched: ${item.name ? `${item.name} ` : ""}id=${item.id}.`).join("\n"));
1860
+ return text(launched.map((item) => `Subagent launched: ${item.name ? `${item.name} ` : ""}id=${item.id}.${item.modelNote ? ` ${item.modelNote}.` : ""}`).join("\n"));
1852
1861
  }
1853
1862
  Object.assign(p, catalog.jobs[0]);
1854
- const { id, spawned, runtime, warn, sandboxDir } = await spawnSubagentRun(ctx, p);
1863
+ const { id, spawned, runtime, warn, sandboxDir, modelNote } = await spawnSubagentRun(ctx, p);
1855
1864
  gate.commit(1);
1856
1865
  reserved = 0;
1857
1866
  return text(
1858
1867
  `Subagent launched: ${p.name ? `${p.name} ` : ""}id=${id} (pid ${spawned.pid}).\n` +
1868
+ (modelNote ? `${modelNote[0]!.toUpperCase()}${modelNote.slice(1)}.\n` : "") +
1859
1869
  (p.callback === false
1860
1870
  ? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
1861
1871
  : `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
@@ -1898,7 +1908,7 @@ export default function (pi: ExtensionAPI) {
1898
1908
  agent: Type.Optional(Type.String({ description: "Named agent id applied to jobs that do not select their own agent or role." })),
1899
1909
  role: catalogRoleSchema("shared role selector"),
1900
1910
  alias: Type.Optional(Type.String({ description: "Direct-role alias applied when a job does not set alias." })),
1901
- model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (default: inherit foreground model). Put authoritative model choices here; prompt text is not parsed." })),
1911
+ model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort. Omit to use the agent or role default; without one, inherit the foreground model. Put authoritative model choices here; prompt text is not parsed." })),
1902
1912
  thinking: Type.Optional(Type.String({ description: "Reasoning effort applied to every job: off, minimal, low, medium, high, xhigh, or max." })),
1903
1913
  tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
1904
1914
  exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
@@ -1956,6 +1966,10 @@ export default function (pi: ExtensionAPI) {
1956
1966
  const gate = getSharedCapacityGate(countRunning);
1957
1967
 
1958
1968
  validateBatchPlan({ shared: p.shared, jobs: p.jobs, onCapacity: p.onCapacity, config: cfg });
1969
+ // A present-but-blank role or agent rejects the whole batch before any launch.
1970
+ const blankSelector = blankCatalogSelector(p.shared, "shared")
1971
+ ?? p.jobs.map((job, index) => blankCatalogSelector(job, `jobs[${index}]`)).find(Boolean);
1972
+ if (blankSelector) throw new Error(blankSelector);
1959
1973
 
1960
1974
  let catalogSnapshot: CatalogSnapshot | undefined;
1961
1975
  let catalogHost: CatalogHost | undefined;
@@ -1997,7 +2011,7 @@ export default function (pi: ExtensionAPI) {
1997
2011
 
1998
2012
  const names = assignBatchJobNames(p.jobs);
1999
2013
  const batchId = nextBatchId();
2000
- const launched: { name: string; id: string }[] = [];
2014
+ const launched: { name: string; id: string; modelNote?: string }[] = [];
2001
2015
  const failed: { name: string; reason: string }[] = [];
2002
2016
  const skipped: { name: string }[] = [];
2003
2017
  // How many reject-mode reserved slots are still held (not yet committed/released).
@@ -2035,10 +2049,10 @@ export default function (pi: ExtensionAPI) {
2035
2049
  name = prepared.assign.name;
2036
2050
  }
2037
2051
  }
2038
- const { id } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
2052
+ const { id, modelNote } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
2039
2053
  gate.commit(1);
2040
2054
  if (!launchAvailable) reservedRemaining -= 1;
2041
- launched.push({ name, id });
2055
+ launched.push({ name, id, ...(modelNote ? { modelNote } : {}) });
2042
2056
  } catch (err) {
2043
2057
  gate.release(1);
2044
2058
  if (!launchAvailable) reservedRemaining -= 1;
@@ -399,7 +399,7 @@ export function buildNavigatorDetail(id, deps) {
399
399
  return {
400
400
  id: meta.id,
401
401
  name: meta.name,
402
- role: meta.catalog?.roleId || meta.catalog?.roleName || undefined,
402
+ role: meta.catalog?.roleName || (meta.catalog?.roleId ? String(meta.catalog.roleId).replace(/^role\./, "") : undefined) || undefined,
403
403
  status,
404
404
  model: deps.shortModel(meta.model),
405
405
  effort: effortRaw ? String(effortRaw) : undefined,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,7 +55,7 @@
55
55
  "pi-better-plan": "0.5.0",
56
56
  "pi-better-sandbox": "0.7.1",
57
57
  "pi-better-ssh": "0.1.1",
58
- "pi-better-subagents": "0.7.1",
58
+ "pi-better-subagents": "0.9.0",
59
59
  "smol-toml": "1.9.0",
60
60
  "yaml": "^2.9.1"
61
61
  },