pi-better-harness 0.11.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.
@@ -44,11 +44,14 @@ nontrivial role-owned task, while the foreground coordinates, integrates, and
44
44
  verifies. See [usage notes](docs/usage.md#delegation-mode).
45
45
 
46
46
  `agents_catalog` shows each role's and named agent's default model and effort,
47
- such as `default openai/gpt-6-sol@high`. To launch on that default, omit
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
48
51
  `model` and `thinking` on a role or agent spawn; name one only for a stated
49
52
  reason. When a launch's model or effort differs from the default, its launch
50
53
  line says so, for example
51
- `model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`.
54
+ `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
52
55
 
53
56
  ## When To Use
54
57
 
@@ -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";
@@ -227,7 +227,7 @@ export function presentCatalog(snapshot: CatalogSnapshot, enrich?: LaunchEnriche
227
227
  "Definition validity is not launchability. Model availability is decided only by the injected resolver.",
228
228
  ...snapshot.diagnostics.map((diagnostic) => formatDiagnostic(diagnostic)),
229
229
  ...entries.flatMap((entry) => [
230
- entry.text.split("\n")[0] ?? entry.id,
230
+ renderCatalogListLine(entry),
231
231
  ` description: ${JSON.stringify(entry.identity.description ?? "")}`,
232
232
  ]),
233
233
  ];
@@ -361,10 +361,10 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
361
361
  return view;
362
362
  }
363
363
 
364
- export function renderOperationView(view: OperationView): string {
365
- const header = [
364
+ function renderHeader(view: OperationView, shownId: string): string {
365
+ return [
366
366
  view.identity.kind ?? "missing",
367
- view.id,
367
+ shownId,
368
368
  view.identity.name ? JSON.stringify(view.identity.name) : "unnamed",
369
369
  `scope=${view.identity.scope ?? "none"}`,
370
370
  `definitionValid=${yesNo(view.definitionValid)}`,
@@ -372,6 +372,19 @@ export function renderOperationView(view: OperationView): string {
372
372
  `launchable=${view.launchable === null ? "unknown" : yesNo(view.launchable)}`,
373
373
  ...(view.defaults ? [`default ${view.defaults.label}`] : []),
374
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);
375
388
  const lines = [
376
389
  header,
377
390
  `identity: id=${view.id} kind=${view.identity.kind ?? "unknown"} name=${JSON.stringify(view.identity.name ?? "")} description=${JSON.stringify(view.identity.description ?? "")}`,
@@ -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 = {
@@ -41,6 +42,7 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
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.",
@@ -50,7 +52,7 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
50
52
  ],
51
53
  parameters: Type.Object({
52
54
  action: Type.String({ description: "list or inspect. No other action is accepted." }),
53
- 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." })),
54
56
  }),
55
57
  async execute(
56
58
  _toolCallId: string,
@@ -72,8 +74,8 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
72
74
  details: { action, wrote: false, revision: listed.revision, entries: listed.entries, diagnostics: listed.diagnostics },
73
75
  };
74
76
  }
75
- if (!params.id?.trim()) return toolError("inspect needs an id. Call agents_catalog with action list to see ids. Nothing was written.");
76
- 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);
77
79
  return {
78
80
  content: [{ type: "text" as const, text: view.text }],
79
81
  details: { action, wrote: false, view },
@@ -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
  }
@@ -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;
@@ -256,14 +256,14 @@ export async function clarifyCatalogRequest(
256
256
  * Launch-line note for a catalog run whose effective model or effort differs
257
257
  * from the role or agent default. The wording names the cause, so a fallback
258
258
  * or a capped effort is not mistaken for a caller override:
259
- * - override: `model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`
260
- * - fallback: `model xai/grok-4.7@high (role default openai/gpt-6-sol@high unavailable; foreground fallback)`
261
- * - capped effort: `model openai/gpt-6-sol@medium (role default openai/gpt-6-sol@high; effort capped at medium by the model)`
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
262
  * Undefined for a non-catalog run, a definition with no default, or a launch
263
263
  * that matches the default. Only the fields the definition sets are compared.
264
264
  */
265
265
  export function catalogDefaultNote(
266
- record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> | undefined,
266
+ record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> & { id?: string } | undefined,
267
267
  model: string | undefined,
268
268
  thinking: string | undefined,
269
269
  ): string | undefined {
@@ -279,8 +279,10 @@ export function catalogDefaultNote(
279
279
  const effortDiffers = defaultEffort !== null && (thinking ?? null) !== defaultEffort;
280
280
  if (!modelDiffers && !effortDiffers) return undefined;
281
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;
282
284
  const causes = [
283
- modelFallback ? `${record.kind} default ${defaultLabel} unavailable; ${FALLBACK_LABELS[modelSource!] ?? modelSource} fallback` : `${record.kind} default ${defaultLabel}`,
285
+ modelFallback ? `${owner} default ${defaultLabel} unavailable; ${FALLBACK_LABELS[modelSource!] ?? modelSource} fallback` : `${owner} default ${defaultLabel}`,
284
286
  ...(effortCapped ? [`effort capped at ${thinking ?? "the model default"} by the model`] : []),
285
287
  ];
286
288
  return `model ${actual} (${causes.join("; ")})`;
@@ -547,8 +549,7 @@ async function allocateDirectRoleLabel(input: { roleId: string; roleName: string
547
549
  }
548
550
 
549
551
  export function roleSlug(roleId: string): string {
550
- const raw = roleId.startsWith("role.") ? roleId.slice("role.".length) : roleId;
551
- return raw.trim().toLowerCase();
552
+ return shortRoleName(roleId).trim().toLowerCase();
552
553
  }
553
554
 
554
555
  function composePrompt(instructions: string, task: string): string {
@@ -558,7 +559,28 @@ function composePrompt(instructions: string, task: string): string {
558
559
  }
559
560
 
560
561
  function agentIdOf(job: { agent?: unknown }): string | undefined {
561
- 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;
562
584
  }
563
585
 
564
586
  function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
@@ -569,8 +591,11 @@ function roleIdsOf(job: { role?: unknown; roleIds?: unknown }): string[] {
569
591
  const ids: string[] = [];
570
592
  for (const value of values) {
571
593
  if (typeof value !== "string") continue;
572
- const trimmed = value.trim();
573
- 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);
574
599
  }
575
600
  return ids;
576
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,7 +52,7 @@ 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
- 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 default openai/gpt-6-sol@high)`. Only the fields the definition sets are compared. The note names the cause: a fallback from an unavailable default reads `(role 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`.
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
56
 
57
57
  ## Lifecycle attachment
58
58
 
@@ -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,6 +147,7 @@ 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,
@@ -178,7 +179,7 @@ const projectConfigDirName = typeof (PiCodingAgent as { CONFIG_DIR_NAME?: unknow
178
179
  const CATALOG_GUIDELINES = [
179
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.",
180
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.",
181
- "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.",
182
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.",
183
184
  ];
184
185
 
@@ -1425,8 +1426,8 @@ function timingSchemaFields() {
1425
1426
 
1426
1427
  /** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
1427
1428
  function catalogRoleSchema(purpose: string) {
1428
- const one = `One role id (role.<slug>) for this ${purpose}. Mutually exclusive with agent.`;
1429
- 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.`;
1430
1431
  return Type.Optional(Type.Union([
1431
1432
  Type.String({ description: one }),
1432
1433
  Type.Array(Type.String({ minLength: 1 }), { minItems: 1, description: many }),
@@ -1818,6 +1819,9 @@ export default function (pi: ExtensionAPI) {
1818
1819
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1819
1820
  const p = params as SpawnParams;
1820
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);
1821
1825
 
1822
1826
  const cfg = loadConfig();
1823
1827
  const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
@@ -1962,6 +1966,10 @@ export default function (pi: ExtensionAPI) {
1962
1966
  const gate = getSharedCapacityGate(countRunning);
1963
1967
 
1964
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);
1965
1973
 
1966
1974
  let catalogSnapshot: CatalogSnapshot | undefined;
1967
1975
  let catalogHost: CatalogHost | undefined;
@@ -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.8.0",
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.11.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.8.0",
58
+ "pi-better-subagents": "0.9.0",
59
59
  "smol-toml": "1.9.0",
60
60
  "yaml": "^2.9.1"
61
61
  },