@signalridge/pi-subagents 1.5.0 → 1.6.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.1
4
+ ### Patch Changes
5
+
6
+ - f714ea0: Publish the package versions already prepared by the previous release transition after its first publish attempt was blocked before npm publication.
7
+ - Updated dependencies [f714ea0]
8
+ - @signalridge/pi-subagents-protocol@1.3.1
9
+
10
+ ## 1.6.0
11
+ ### Minor Changes
12
+
13
+ - bae8689: Complete the managed workflow parity and hardening pass. Workflows now forward and validate exact model/thinking, toolset, denylist, thread, and per-call worktree intent while pi-subagents remains the policy and lifecycle owner. Script resume accepts edited revisions, workflow removal is durable, stale lifecycle events are rejected, provider-limit errors pause runs, and the live progress/effort/trigger settings are persisted and bounded. Run identity is durable before dispatch, pause/dispose stop owned agents and reject waiters, and every background start/resume delivers its result. The protocol adds only optional fields and capability metadata, preserving older policy-free managed-spawn consumers; workflows fail closed with a diagnostic when the peer does not advertise managed policy support.
14
+ - b6cf242: - `name:` frontmatter is now the agent type, matching Claude Code: a `.md` agent declaring `name: code-reviewer` registers as `code-reviewer` regardless of filename; a colon in the declared name is refused (reserved for plugin scopes) and an empty/whitespace name falls back to the filename. Priority/skip keys use the declared name.
15
+ - Transcript streaming re-anchors safely on resume: new `ensureOutputFile()` creates a transcript without truncating it, and `streamToOutputFile()` accepts a `startIndex` so a resumed run appends after turns already on disk instead of duplicating or destroying them.
16
+ - A background resume now notifies under its **own** tool call id. `record.toolCallId` is written when an agent is spawned and emitted as `<tool-use-id>` on completion; `resume` clears `resultConsumed`, so a resumed run notifies again. Leaving the field alone pointed that notification at the tool call the original spawn had already answered, and a resume carrying no tool call of its own (a prompt mention) inherited a stale one instead of clearing it. Assigned unconditionally, ahead of both the foreground and background resume paths.
17
+ - Settings persistence (`saveSettings`) is now atomic (temp-file + rename), matching the canonical pattern, so `/agents → Settings` writes can never tear.
18
+ - `@handle message` typed at the prompt now addresses that subagent instead of the main model, matching Claude Code's prompt-mention grammar. One handle covers the agent's whole lifecycle: it is steered while running, resumed once finished, reopened from its session file after eviction, and started fresh when the name is an agent type that has never run. `@main` addresses the main conversation and is the one name never allocated; a bare `@handle`, `@src/foo.ts summarize this`, and ordinary prose all fall through untouched. Top-level conversations now persist to disk by default (`rememberAgents`) so there is something to reopen — frontmatter `persist_session:` still wins, and nested agents never persist. `@` completion lists what each row will actually do (send message / resume / start agent), with every description sanitized before truncation. Dispatch is TUI-only: headless would answer with silence. Toggle with `agentMentions` in `subagents.json` or `/agents → Settings`.
19
+ - `agentMentions` becomes tri-state — `model` (new default), `direct`, `off`; a boolean still reads as `model`/`off`. In `model` mode a mention that names an agent *type* has its turn taken by an off-screen clone of the conversation: same messages, same system prompt, same model, in a throwaway in-memory session holding exactly one tool. The started agent therefore gets a prompt written with conversation context instead of only the words after the handle, and nothing reaches the transcript — the visible turn Claude Code spends narrating a decision the user already made by typing the handle is skipped. The spawn belongs to the real session, not the copy: the clone is handed the actual registered `Agent` tool, re-bound to the main `ExtensionContext` (the fork's would file the transcript and `rootSessionId` under the throwaway session) and called with no tool-call id, since the real session never issued the one the clone's turn produced. The copy gets one use of that tool. The clone never rejects — a failure falls back to starting the agent directly rather than leaving a toast and nothing running. Messaging and resuming an existing agent stay direct in every mode.
20
+ - New `gate:` frontmatter — one shell command the HOST runs after the agent finishes, whose pass/fail is appended to the agent's result. An agent saying it finished is not evidence that it did, and an acceptance level meaning "the agent asserted it did the work" is a claim from the party whose claim is in doubt; a command that either exits 0 or does not is different in kind. The verdict lands directly beneath the agent's own summary, so a confident claim and a failing check are read together. It does not steer the agent to fix what failed — the gate is evidence for the caller, not another turn. Verdicts are memoized on (command, workspace fingerprint), where the fingerprint hashes HEAD, `git status --porcelain`, AND the diff, since porcelain reports *that* a file changed but not to what; outside a repository, or whenever the state cannot be established, the cache is skipped rather than guessed, because a wrong hit reports a stale pass. A gate that cannot run reports as failed, never as a silent green.
21
+ - New `ask_tools:` frontmatter — the third answer between `tools:` and `disallowed_tools:`. Those are static, which forces a bad choice for a tool that is usually fine and occasionally not: grant `bash` and hope, or withhold it and cripple the agent. `ask_tools:` names the tools whose calls need the user to agree first, shown with the actual arguments so there is something real to approve. The approver is the human, never a model — an LLM arbitrator exists upstream only because those subagents run headless in another process and cannot reach a person, and putting a model in that seat where a person is reachable is a security regression. It fails **closed**: with no interactive session the call is refused, with a reason naming the fix, because failing open would delete the rule exactly where it matters most. A decline tells the model not to retry; an approval lasts the run, since prompting on every call trains the user to approve without reading. Applies to `isolated:` agents too, and round-trips through eject.
22
+ - New `contact_supervisor` tool: a subagent can now ask its human a question and wait for the answer. `steer_subagent` only ever sent guidance downward, so a child that hit a real fork in the road could only guess and have the guess surface later in its result. The answer comes from the person, not a supervising model — our subagents share the parent's `ExtensionContext`, so the parent's UI is directly reachable, which is both cheaper and more correct than delegating the judgement, and is why this is one promise rather than a filesystem channel. Free-text or a picker when the child offers concrete options. It is injected only where a human can actually answer (never headless, never for `isolated:` agents) rather than stubbed, and an unanswered or undeliverable question tells the child to proceed on its own judgement and state its assumption — a dismissed dialog must never stall or fail a run. Question and option text is sanitized and bounded before it reaches the terminal. Toggle with `supervisorQuestions`.
23
+ - `/agents → <agent> → Refine with Claude`: model-assisted rewrite of an existing agent definition, with rollback. The generate wizard covered creation; this is the other half, under the same guarantees — the child runs with the symbol-keyed zero-tool policy so it cannot touch the file, returns the whole revised file as text, and the parent validates it and commits through `atomicReplaceFile` against the snapshot read before the run, so a concurrent editor can only make the commit fail rather than lose a write. A malformed or unchanged result writes nothing. After the write, the previous version is offered back in one keystroke; the restore is itself guarded against a third edit that arrived in between, and says so instead of discarding it.
24
+ - Agent name badges: an agent declaring `color:` (a Claude Code palette name, an Agency Agents alias, or `#RRGGBB`) renders its name as a background badge in FleetView and the conversation viewer. Two local differences from the upstream this is modelled on: the name passes through `sanitizeDisplayText` **before** the escape sequences wrap it — sanitizing afterwards would strip the badge's own codes, and not sanitizing would let a crafted `display_name` redraw the parent terminal — and badge colour composes with the existing status colouring through a `fallbackColor`, so an agent without a badge still reads as running/failed/done exactly as before. Text colour is chosen per badge by WCAG contrast rather than one fixed inverse, which keeps the light half of the palette readable, and the palette is quantized to xterm-256 when the theme reports that mode, with contrast judged against the colour the terminal will actually show. An invalid colour costs the badge, never the agent. `color:`, `max_tokens:`, and `max_tool_calls:` all round-trip through agent-file eject — an explicit `0` budget is written out too, since it is a real opt-out of a project default.
25
+ - Per-run resource budgets: `defaultMaxTokens` and `defaultMaxToolCalls` settings, with `max_tokens:` and `max_tool_calls:` frontmatter overriding them per agent. `maxTurns` bounds how many turns an agent takes and nothing about what a turn costs — a single turn can burn an arbitrary number of tokens or tool calls — so these are the missing bound, in the same soft/hard shape as the turn limit: a wrap-up steer at 80% of the budget, an abort at 100%. The steer deliberately arrives at 80% rather than 100%, since an agent told to produce its final answer needs allowance left to produce it. `0` means unlimited (matching `defaultMaxTurns`) and both ship off; tool calls are counted on completion, not on start.
26
+ - Nested delegation now has a **horizontal** bound: `maxSubagentSpawnsPerBranch` (default 64) caps the cumulative descendants any one top-level agent may start over its whole life. `maxSubagentDepth` only ever bounded how DEEP nesting goes, so with nesting on by default (depth 2) a single top-level agent could fan out without limit — its only cost per child is one of its own turns, and max turns is commonly unlimited. Enforced in the manager, so every path into a nested spawn is covered, and counted cumulatively rather than concurrently on purpose: a loop starting one child at a time, forever, is exactly the shape a concurrency limit does not catch. Grandchildren count against the same top-level root, so a new nesting level cannot mint a fresh allowance. `0` is refused rather than read as "unlimited" — turn nesting off with `maxSubagentDepth`, which says so. Configurable in `subagents.json` or `/agents → Settings`.
27
+ - `/agents → Diagnostics` gained a tier **liveness** probe and an agent-file accounting line, and two of its existing checks were wrong. A tier can name a model that resolves to an error string — no registry entry, or no auth configured — while every reference check still passes, so the failure used to surface at the first spawn that needed it, minutes into a task; it is now caught up front (`inherit` is exempt, having no model reference to probe). The new agent-file line reports files present but not loaded (malformed frontmatter, a reserved `:` in `name:`, or shadowing), which load-time warnings can no longer answer once they have scrolled away. Fixed: a *missing* agent directory was reported as unreadable, so a permissions failure was claimed on every healthy workspace; and the file count now excludes the built-in agents, which have no file behind them and made the denominator meaningless.
28
+ - New `/agents → Usage`: session-cumulative tokens per agent type, with run counts and an input/output/cache-write breakdown. Accumulated on each record's terminal callback rather than scanned from live records, which are evicted after they finish — the expensive agents are exactly the long-finished ones a scan would drop. Reported in tokens, not currency: the model registry carries no per-token price for text models, and a dollar figure would have to come from a hardcoded table that silently goes stale.
29
+ - Host peer ranges narrowed from `>=0.80.0` to `^0.84.0`, matching every other package in the repository. The old unbounded range promised compatibility with host versions that were never tested; `^0.84.0` names the one that is.
30
+ - Shipped default `agentTiers.profiles.fast` for read-only search: the built-in Explore agent now declares `tier: fast`, and a fresh install resolves it to a provider-neutral low-thinking profile instead of inheriting the parent session's most expensive model. User-defined `fast` overrides it; deleting it tombstones rather than silently returned; untouched shipped tiers are never materialized into `subagents.json`.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [bae8689]
35
+ - @signalridge/pi-subagents-protocol@1.3.0
36
+
3
37
  ## 1.5.0
4
38
  ### Minor Changes
5
39
 
package/README.md CHANGED
@@ -26,7 +26,7 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
26
26
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
27
27
  - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
28
28
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
29
- - **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Protocol v3 adds policy-free managed spawning plus owner-scoped stop/quiescence; workflow callers require the advertised `ownedStop` capability and never fall back to unowned stop. Standardized reply envelopes provide versioning. Emits `subagents:ready` on session start
29
+ - **Cross-extension RPC** — other Pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Protocol v3 adds managed spawning, optional model/thinking/toolset/denylist/thread/worktree hints, owner-scoped stop/quiescence, and standardized reply envelopes; pi-subagents remains the final policy owner. Emits `subagents:ready` on session start
30
30
  - **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs`
31
31
  - **Model tiers** — name a (model, thinking) pair once and let the orchestrator pick it by name; the `Agent` tool exposes `tier` and never `model`/`thinking`, so which model runs stays a config decision. Manage the catalogue in `/agents → Model tiers`, pick the default in `/agents → Settings → Default tier`, or set a plain `defaultModel` when one line beats a catalogue
32
32
  - **Model scope enforcement** — opt-in validation that subagent model choices stay within your pi `enabledModels` allowlist (sourced from `/scoped-models`, with both global and project-local pi settings honored). Caller-supplied out-of-scope → hard error to orchestrator; frontmatter-pinned out-of-scope → warning + runs anyway (frontmatter authoritative). Toggle via `/agents → Settings → Scope models`
@@ -712,7 +712,7 @@ pi.events.emit("subagents:rpc:ping", { requestId });
712
712
 
713
713
  ### Managed spawn (protocol v3)
714
714
 
715
- Workflow-owned orchestration uses the additive `subagents:rpc:spawn-managed` channel. Its request is:
715
+ Workflow-owned orchestration uses the additive `subagents:rpc:spawn-managed` channel. Its request may include the core identity fields plus optional `tier`, exact `model`, `thinking`, `toolset`, `excludeTools`, `thread`, and `isolation: "worktree"` hints:
716
716
 
717
717
  ```json
718
718
  {
@@ -721,12 +721,15 @@ Workflow-owned orchestration uses the additive `subagents:rpc:spawn-managed` cha
721
721
  "type": "Explore",
722
722
  "prompt": "Find the relevant files",
723
723
  "description": "Find relevant files",
724
+ "tier": "small",
725
+ "model": "provider/model:medium",
726
+ "excludeTools": ["workflow", "workflow_control"],
727
+ "isolation": "worktree",
724
728
  "owner": { "extension": "pi-workflows", "runId": "run-id", "nodeId": "node-id", "attemptId": "run-id/node-id/attempt-1" }
725
729
  }
726
730
  ```
727
731
 
728
- The managed contract rejects execution settings and arbitrary fields. `spawnKey` is idempotent within a root manager; the same normalized request returns the existing agent id and a conflicting request is rejected. Managed agents use the normal Agent execution path, queue, FleetView, activity, transcript, compaction, and lifecycle events. Only the automatic main-session completion nudge is suppressed for an owner-scoped record.
729
- Managed requests must carry an attempt-scoped owner. The manager validates the exact owner on `stop-owned` and `quiesce-owned`; callers cannot stop a different node or generation in the same run. `quiesce-owned` requires one exact owner per agent ID and fails closed when that metadata is missing. During branch replacement, timed-out records are detached and late callbacks are suppressed.
732
+ The manager validates and resolves every hint against its own model scope, agent configuration, queue, tool, session, and worktree policy. `spawnKey` is idempotent within a root manager; the same normalized request returns the existing agent id and a conflicting request is rejected. A named managed `thread` re-enters one sequential session only while its effective model, thinking, toolset, denylist, isolation, and agent policy fingerprint remain unchanged; a policy change or concurrent call is rejected rather than silently reusing the old session. Managed agents use the normal Agent execution path, queue, FleetView, activity, transcript, compaction, and lifecycle events. Only the automatic main-session completion nudge is suppressed for an owner-scoped record. Managed requests must carry an attempt-scoped owner, and `stop-owned`/`quiesce-owned` fail closed when exact node/generation metadata is missing. During branch replacement, timed-out records are detached and late callbacks are suppressed.
730
733
 
731
734
  ### Spawn
732
735
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -25,14 +25,14 @@
25
25
  "autonomous"
26
26
  ],
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-ai": ">=0.80.0",
29
- "@earendil-works/pi-coding-agent": ">=0.80.0",
30
- "@earendil-works/pi-tui": ">=0.80.0"
28
+ "@earendil-works/pi-ai": "^0.84.0",
29
+ "@earendil-works/pi-coding-agent": "^0.84.0",
30
+ "@earendil-works/pi-tui": "^0.84.0"
31
31
  },
32
32
  "dependencies": {
33
- "@sinclair/typebox": "^0.34.49",
33
+ "@sinclair/typebox": "^0.34.50",
34
34
  "croner": "^10.0.1",
35
- "@signalridge/pi-subagents-protocol": "^1.0.0",
35
+ "@signalridge/pi-subagents-protocol": "^1.3.1",
36
36
  "nanoid": "^5.0.0"
37
37
  },
38
38
  "scripts": {
@@ -0,0 +1,188 @@
1
+ /**
2
+ * agent-color.ts — Claude Code-compatible agent name badges.
3
+ *
4
+ * Claude Code renders a subagent's name as a badge: the configured color is the
5
+ * background, the text an inverse foreground. Its eight named colors are
6
+ * reproduced here, along with six-digit hex and the extra palette names Agency
7
+ * Agents uses, so those definitions render as written.
8
+ *
9
+ * Both inputs come from a `.pi/agents/*.md` file this extension did not write,
10
+ * so both are treated as untrusted:
11
+ *
12
+ * - the NAME is passed through `sanitizeDisplayText` before any escape
13
+ * sequence is wrapped around it. Sanitizing afterwards would strip the
14
+ * badge's own SGR codes; not sanitizing at all would let a crafted
15
+ * `display_name` redraw the parent terminal.
16
+ * - the COLOR is only ever accepted as a named entry or `#RRGGBB`, and the
17
+ * numbers reaching the escape sequence are parsed from that hex rather than
18
+ * interpolated from the file, so nothing else can ride along inside it.
19
+ */
20
+
21
+ import { getConfig } from "./agent-types.js";
22
+ import { sanitizeDisplayText } from "./ui/safe-text.js";
23
+
24
+ const NAMED_AGENT_COLORS: Readonly<Record<string, string>> = {
25
+ // Claude Code's eight subagent colors, as its default theme renders them.
26
+ red: "#DC2626",
27
+ blue: "#6A9BCC",
28
+ green: "#16A34A",
29
+ yellow: "#CA8A04",
30
+ purple: "#827DBD",
31
+ orange: "#D97757",
32
+ pink: "#C46686",
33
+ cyan: "#0891B2",
34
+ // Agency Agents palette aliases.
35
+ amber: "#F59E0B",
36
+ teal: "#008080",
37
+ indigo: "#6366F1",
38
+ gold: "#EAB308",
39
+ "neon-green": "#10B981",
40
+ "neon-cyan": "#06B6D4",
41
+ "metallic-blue": "#3B82F6",
42
+ violet: "#8B5CF6",
43
+ rose: "#F43F5E",
44
+ lime: "#84CC16",
45
+ gray: "#6B7280",
46
+ grey: "#6B7280",
47
+ fuchsia: "#D946EF",
48
+ slate: "#64748B",
49
+ navy: "#1E3A8A",
50
+ };
51
+
52
+ const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
53
+ const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
54
+ const BLACK = { r: 0, g: 0, b: 0 };
55
+ const WHITE = { r: 255, g: 255, b: 255 };
56
+
57
+ type Rgb = { r: number; g: number; b: number };
58
+ type ColorMode = "truecolor" | "256color";
59
+
60
+ export interface AgentNameTheme {
61
+ fg(color: string, text: string): string;
62
+ bold(text: string): string;
63
+ getColorMode?(): ColorMode;
64
+ }
65
+
66
+ export interface AgentNameStyle {
67
+ /** Existing theme foreground used when no valid agent color is configured. */
68
+ fallbackColor?: string;
69
+ /** Reapply an enclosing background after the badge instead of resetting it. */
70
+ restoreBackground?: string;
71
+ bold?: boolean;
72
+ }
73
+
74
+ /** Resolve Claude Code/Agency Agents color syntax to normalized #RRGGBB. */
75
+ export function resolveAgentColor(value: string | undefined): string | undefined {
76
+ if (!value) return undefined;
77
+ const normalized = value.trim().toLowerCase();
78
+ const resolved = NAMED_AGENT_COLORS[normalized] ?? normalized;
79
+ return /^#[0-9a-f]{6}$/i.test(resolved) ? resolved.toUpperCase() : undefined;
80
+ }
81
+
82
+ function parseHex(hex: string): Rgb {
83
+ return {
84
+ r: Number.parseInt(hex.slice(1, 3), 16),
85
+ g: Number.parseInt(hex.slice(3, 5), 16),
86
+ b: Number.parseInt(hex.slice(5, 7), 16),
87
+ };
88
+ }
89
+
90
+ /** Index of the entry in `values` closest to `value`. */
91
+ function nearest(values: readonly number[], value: number): number {
92
+ return values.reduce((best, v, i) => (Math.abs(value - v) < Math.abs(value - values[best]) ? i : best), 0);
93
+ }
94
+
95
+ /**
96
+ * Quantize to the xterm-256 palette the way pi's own theme does, returning both
97
+ * the index to emit and the color the terminal will actually show — badge
98
+ * contrast is judged against the latter.
99
+ */
100
+ function rgbTo256({ r, g, b }: Rgb): { index: number; rgb: Rgb } {
101
+ const [rIndex, gIndex, bIndex] = [r, g, b].map((channel) => nearest(CUBE_VALUES, channel));
102
+ const distance = ({ r: cr, g: cg, b: cb }: Rgb) =>
103
+ 0.299 * (r - cr) ** 2 + 0.587 * (g - cg) ** 2 + 0.114 * (b - cb) ** 2;
104
+ const grayIndex = nearest(GRAY_VALUES, Math.round(0.299 * r + 0.587 * g + 0.114 * b));
105
+ const gray = { r: GRAY_VALUES[grayIndex], g: GRAY_VALUES[grayIndex], b: GRAY_VALUES[grayIndex] };
106
+ const cube = { r: CUBE_VALUES[rIndex], g: CUBE_VALUES[gIndex], b: CUBE_VALUES[bIndex] };
107
+ // Only near-neutral colors may take the gray ramp; anything else keeps its tint.
108
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 10 && distance(gray) < distance(cube)) {
109
+ return { index: 232 + grayIndex, rgb: gray };
110
+ }
111
+ return { index: 16 + 36 * rIndex + 6 * gIndex + bIndex, rgb: cube };
112
+ }
113
+
114
+ function ansiColor(layer: "foreground" | "background", color: Rgb | number): string {
115
+ const code = layer === "foreground" ? 38 : 48;
116
+ return typeof color === "number"
117
+ ? `[${code};5;${color}m`
118
+ : `[${code};2;${color.r};${color.g};${color.b}m`;
119
+ }
120
+
121
+ function relativeLuminance({ r, g, b }: Rgb): number {
122
+ const linear = (value: number) => {
123
+ const channel = value / 255;
124
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
125
+ };
126
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
127
+ }
128
+
129
+ /**
130
+ * Render one name as a padded background badge when `color` is valid. Claude
131
+ * Code uses one inverse color for every badge's text; black or white is picked
132
+ * by WCAG contrast here instead, so each palette entry stays readable. Invalid
133
+ * or omitted colors preserve the caller's existing theme styling.
134
+ */
135
+ export function renderAgentNameLabel(
136
+ name: string,
137
+ color: string | undefined,
138
+ theme: AgentNameTheme,
139
+ style: AgentNameStyle = {},
140
+ ): string {
141
+ // Before anything else, and before any escape sequence is wrapped around it:
142
+ // this string came from an agent definition file, and it is about to be
143
+ // written straight to the user's terminal.
144
+ const safeName = sanitizeDisplayText(name);
145
+ const resolved = resolveAgentColor(color);
146
+ if (!resolved) {
147
+ const text = style.bold ? theme.bold(safeName) : safeName;
148
+ return style.fallbackColor ? theme.fg(style.fallbackColor, text) : text;
149
+ }
150
+
151
+ const rgb = parseHex(resolved);
152
+ const quantized = (theme.getColorMode?.() ?? "truecolor") === "256color" ? rgbTo256(rgb) : undefined;
153
+ const shown = quantized?.rgb ?? rgb;
154
+ const contrasting = relativeLuminance(shown) > 0.179 ? BLACK : WHITE;
155
+ const label = style.bold ? theme.bold(` ${safeName} `) : ` ${safeName} `;
156
+
157
+ return (
158
+ ansiColor("background", quantized?.index ?? rgb) +
159
+ ansiColor("foreground", quantized ? rgbTo256(contrasting).index : contrasting) +
160
+ label +
161
+ "" +
162
+ (style.restoreBackground ?? "")
163
+ );
164
+ }
165
+
166
+ /** Whether an agent renders as a badge — i.e. it has a valid configured color. */
167
+ export function hasAgentBadge(type: string | undefined): boolean {
168
+ return type !== undefined && resolveAgentColor(getConfig(type).color) !== undefined;
169
+ }
170
+
171
+ /**
172
+ * Render a registered agent's display name with its configured color.
173
+ *
174
+ * `style.fallbackColor` is where this composes with `ui/status-label.ts`: a
175
+ * caller that has a status in hand passes `getAgentStatusColor(status)`, so an
176
+ * agent WITHOUT a badge still reads as running/failed/done exactly as it did
177
+ * before badges existed, and an agent WITH one shows its identity instead. The
178
+ * two never fight over the same cells.
179
+ */
180
+ export function renderAgentName(
181
+ type: string | undefined,
182
+ theme: AgentNameTheme,
183
+ style: AgentNameStyle = {},
184
+ ): string {
185
+ if (!type) return renderAgentNameLabel("Agent", undefined, theme, style);
186
+ const config = getConfig(type);
187
+ return renderAgentNameLabel(config.displayName, config.color, theme, style);
188
+ }
@@ -690,11 +690,17 @@ export function serializeAgentFile(cfg: AgentConfig): string {
690
690
  const fmFields: string[] = [];
691
691
  fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
692
692
  if (cfg.displayName) fmFields.push(`display_name: ${JSON.stringify(cfg.displayName)}`);
693
+ if (cfg.color) fmFields.push(`color: ${formatYamlScalar(cfg.color)}`);
693
694
  fmFields.push(`tools: ${formatYamlScalar(formatToolsField(cfg))}`);
694
695
  // Never model:/thinking: — the loader ignores them, so writing them back
695
696
  // would recreate a pin that looks effective and is not.
696
697
  if (cfg.agentTier) fmFields.push(`tier: ${formatYamlScalar(cfg.agentTier)}`);
697
698
  if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
699
+ // Written when explicitly set, INCLUDING an explicit 0: for the budgets, 0
700
+ // means "unlimited" and is a real opt-out of a project default, so dropping
701
+ // it on eject would silently re-impose that default on the ejected copy.
702
+ if (cfg.maxTokens !== undefined) fmFields.push(`max_tokens: ${cfg.maxTokens}`);
703
+ if (cfg.maxToolCalls !== undefined) fmFields.push(`max_tool_calls: ${cfg.maxToolCalls}`);
698
704
  if (cfg.persistSession) fmFields.push("persist_session: true");
699
705
  if (cfg.sessionDir) fmFields.push(`session_dir: ${JSON.stringify(cfg.sessionDir)}`);
700
706
  if (cfg.allowedSubagents !== undefined) {
@@ -707,6 +713,8 @@ export function serializeAgentFile(cfg: AgentConfig): string {
707
713
  if (cfg.skills === false) fmFields.push("skills: false");
708
714
  else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${formatYamlScalar(cfg.skills.join(", "))}`);
709
715
  if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${formatYamlScalar(cfg.disallowedTools.join(", "))}`);
716
+ if (cfg.askTools?.length) fmFields.push(`ask_tools: ${formatYamlScalar(cfg.askTools.join(", "))}`);
717
+ if (cfg.gate) fmFields.push(`gate: ${formatYamlScalar(cfg.gate)}`);
710
718
  if (cfg.inheritContext) fmFields.push("inherit_context: true");
711
719
  if (cfg.runInBackground) fmFields.push("run_in_background: true");
712
720
  if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");