@signalridge/pi-subagents 1.4.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 +68 -0
- package/README.md +69 -12
- package/package.json +6 -6
- package/src/agent-color.ts +188 -0
- package/src/agent-file-toggle.ts +8 -0
- package/src/agent-manager.ts +793 -62
- package/src/agent-runner.ts +361 -25
- package/src/agent-tiers.ts +189 -8
- package/src/agent-types.ts +3 -0
- package/src/ask-tools.ts +114 -0
- package/src/cross-extension-rpc.ts +16 -5
- package/src/custom-agents.ts +67 -2
- package/src/default-agents.ts +6 -5
- package/src/gate.ts +0 -0
- package/src/index.ts +3386 -1128
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/output-file.ts +23 -1
- package/src/settings.ts +208 -9
- package/src/supervisor.ts +115 -0
- package/src/types.ts +59 -2
- package/src/ui/agent-mention.ts +163 -0
- package/src/ui/conversation-viewer.ts +4 -3
- package/src/ui/fleet-list.ts +6 -5
- package/src/worktree.ts +128 -648
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
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
|
+
|
|
37
|
+
## 1.5.0
|
|
38
|
+
### Minor Changes
|
|
39
|
+
|
|
40
|
+
- a6cf423: Add a settings interface for the subagent default model and tier. Both were
|
|
41
|
+
previously reachable only by hand-editing `subagents.json`.
|
|
42
|
+
|
|
43
|
+
`/agents → Settings` gains two rows. **Default model** writes a new
|
|
44
|
+
`defaultModel` key: the model a subagent runs when no tier picked one, slotting
|
|
45
|
+
in ahead of the parent session at the end of resolution, so a workspace can say
|
|
46
|
+
"subagents run on the cheap model" without first defining a tier catalogue. It
|
|
47
|
+
decides only the model — thinking still comes from the parent, since a level
|
|
48
|
+
nobody chose for a specific model is what a tier exists to express — and any
|
|
49
|
+
tier that applies overrides it. **Default tier** selects `agentTiers.defaultTier`
|
|
50
|
+
from the defined tier keys.
|
|
51
|
+
|
|
52
|
+
`/agents → Model tiers` is a new menu for the catalogue itself: create a tier,
|
|
53
|
+
change its model, thinking or description, or delete it. The model picker
|
|
54
|
+
enumerates pi's own registry (narrowed to your scope when Scope models is on)
|
|
55
|
+
plus `inherit` and a typed escape hatch for a provider this machine has not
|
|
56
|
+
authed. The thinking picker offers only the levels the chosen model reports as
|
|
57
|
+
supported, rather than a fixed list whose extra entries `clampThinkingLevel`
|
|
58
|
+
would silently lower at spawn. A tier dropped as malformed stays listed as
|
|
59
|
+
`blocked` so redefining it retires the tombstone in one step, and deleting the
|
|
60
|
+
tier that `defaultTier` names clears the default in the same write rather than
|
|
61
|
+
leaving every untiered spawn to fail.
|
|
62
|
+
|
|
63
|
+
Unlike a tier, an unresolvable `defaultModel` falls back to the parent model
|
|
64
|
+
instead of failing the spawn — a tier is refused because someone named that
|
|
65
|
+
policy at the call site, while `defaultModel` is the value nobody named, so one
|
|
66
|
+
unauthed provider must not take every spawn on the machine down with it. The
|
|
67
|
+
Settings row flags it as `(unavailable, fallback: inherit)`. `defaultModel`
|
|
68
|
+
accepts the literal `"inherit"`, which is how a project cancels a global default;
|
|
69
|
+
omitting the key still inherits whatever the global file set.
|
|
70
|
+
|
|
3
71
|
## 1.4.0
|
|
4
72
|
### Minor Changes
|
|
5
73
|
|
package/README.md
CHANGED
|
@@ -26,8 +26,9 @@ 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
|
|
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
|
+
- **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
|
|
31
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`
|
|
32
33
|
- **Resilient agent files** — malformed custom `.md` files are skipped with a path-qualified warning so one bad file cannot prevent startup; enable `/agents → Settings → Strict agent files` when startup should fail closed instead
|
|
33
34
|
|
|
@@ -358,6 +359,7 @@ The `/agents` command opens an interactive menu:
|
|
|
358
359
|
```
|
|
359
360
|
Agent runs (2) · 1 running · 0 queued · 1 completed · 0 wrapped up · 0 stopped · 0 aborted · 0 failed
|
|
360
361
|
Agent types (6) · unified list of defaults and custom agents
|
|
362
|
+
Model tiers (3) · the (model, thinking) catalogue
|
|
361
363
|
Create new agent
|
|
362
364
|
Settings
|
|
363
365
|
```
|
|
@@ -371,7 +373,8 @@ Settings
|
|
|
371
373
|
- **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it
|
|
372
374
|
- **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list with a `disabled` label and can be re-enabled
|
|
373
375
|
- **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do; a sub-agent writes a unique same-directory staging file, and the parent parses and compare-commits it to the target). Any name is allowed, including default agent names (overrides them)
|
|
374
|
-
- **
|
|
376
|
+
- **Model tiers** — create, edit and delete the [tier](#model-tiers) profiles. Each row shows what the tier resolves to on this machine (`small — claude-haiku-4-5 · thinking max`), and a tier dropped as malformed is listed as `blocked` so it can be fixed rather than staying invisible. The model picker offers this machine's available models (narrowed to your scope when **Scope models** is on) plus `inherit` and a typed escape hatch; the thinking picker offers only the levels the chosen model actually supports, since the rest would be silently clamped at spawn. Deleting the tier that `defaultTier` names clears the default in the same step
|
|
377
|
+
- **Settings** — configure max concurrency, default max turns, grace turns, default model, default tier, and join mode at runtime
|
|
375
378
|
|
|
376
379
|
## Graceful Max Turns
|
|
377
380
|
|
|
@@ -464,13 +467,62 @@ the next pi session, since the description is built once at registration.
|
|
|
464
467
|
1. `tier` passed to the `Agent` call
|
|
465
468
|
2. `tier:` in the agent's frontmatter
|
|
466
469
|
3. `agentTiers.defaultTier`
|
|
467
|
-
4.
|
|
470
|
+
4. `defaultModel` — a model with no tier attached, for workspaces that want one
|
|
471
|
+
setting rather than a catalogue
|
|
472
|
+
5. the parent session's model and thinking
|
|
468
473
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
+
An agent cannot pin its own model at any step. `model:`/`thinking:` in
|
|
475
|
+
frontmatter are read only to warn that they are stale, and the built-in agents
|
|
476
|
+
pin nothing either. With nothing configured at all, a subagent runs on the
|
|
477
|
+
parent session's model.
|
|
478
|
+
|
|
479
|
+
### `defaultModel`
|
|
480
|
+
|
|
481
|
+
Steps 1–3 are a catalogue; step 4 is one line. Set it when the whole point is
|
|
482
|
+
"subagents run on the cheap model" and there is no second policy to name:
|
|
483
|
+
|
|
484
|
+
```json
|
|
485
|
+
{ "defaultModel": "anthropic/claude-haiku-4-5" }
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
It only decides the model — thinking still comes from the parent, because a
|
|
489
|
+
level nobody chose for a specific model is exactly what a tier exists to
|
|
490
|
+
express. Any tier that applies overrides it outright.
|
|
491
|
+
|
|
492
|
+
Set it from `/agents → Settings → Default model` (Enter opens the picker), or by
|
|
493
|
+
hand. It accepts the same references a tier's `model` does, plus the literal
|
|
494
|
+
`"inherit"`, which is how a project cancels a global `defaultModel` — omitting
|
|
495
|
+
the key inherits whatever the global file set.
|
|
496
|
+
|
|
497
|
+
Unlike a tier, an unresolvable `defaultModel` does **not** fail the spawn: it
|
|
498
|
+
falls back to the parent model, and the Settings row shows
|
|
499
|
+
`(unavailable, fallback: inherit)`. A tier is refused because someone named that
|
|
500
|
+
policy at the call site; `defaultModel` is the value nobody named, so one
|
|
501
|
+
unauthed provider must not take every spawn on the machine down with it.
|
|
502
|
+
|
|
503
|
+
### Editing tiers
|
|
504
|
+
|
|
505
|
+
`/agents → Model tiers` manages the catalogue — new tier, change a tier's model,
|
|
506
|
+
thinking or description, delete one. `defaultTier` lives with the other defaults
|
|
507
|
+
in `/agents → Settings → Default tier`. Both write the project file; the global
|
|
508
|
+
file is never written from the menu.
|
|
509
|
+
|
|
510
|
+
Two things the menu knows that a hand-edited file does not: the thinking picker
|
|
511
|
+
offers only levels the chosen model supports (the rest get clamped at spawn
|
|
512
|
+
anyway), and a tier dropped as malformed still appears in the list, marked
|
|
513
|
+
`blocked`, so redefining it is one selection rather than an archaeology
|
|
514
|
+
expedition through `subagents.json`.
|
|
515
|
+
|
|
516
|
+
The `Agent` tool description is built once at registration, so a tier edit
|
|
517
|
+
reaches the model on the next pi session. Resolution itself is live — a spawn
|
|
518
|
+
right after the edit already uses the new profile.
|
|
519
|
+
|
|
520
|
+
One thing the file format cannot express: a project *deleting* a tier that only
|
|
521
|
+
the global file defines. The menu writes the merged catalogue back to the
|
|
522
|
+
project file, so deleting one of several works, but deleting the last one — or
|
|
523
|
+
clearing a `defaultTier` that only global sets — leaves no `agentTiers` key
|
|
524
|
+
behind, and the global value is inherited again on the next start. Remove it
|
|
525
|
+
from `~/.pi/agent/subagents.json` instead. The same is true of `workflow.tiers`.
|
|
474
526
|
|
|
475
527
|
### Refusals
|
|
476
528
|
|
|
@@ -540,7 +592,7 @@ When on, each subagent spawn's effective model is validated against pi's own `en
|
|
|
540
592
|
|
|
541
593
|
## Persistent Settings
|
|
542
594
|
|
|
543
|
-
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default join mode, scheduling on/off, scope models on/off, strict agent files on/off, disable defaults on/off, output transcript on/off, tool description full/compact/custom, fleet view on/off) persist across pi restarts. Two files, merged on load:
|
|
595
|
+
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default model, default tier, default join mode, scheduling on/off, scope models on/off, strict agent files on/off, disable defaults on/off, output transcript on/off, tool description full/compact/custom, fleet view on/off) persist across pi restarts. Two files, merged on load:
|
|
544
596
|
|
|
545
597
|
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
|
|
546
598
|
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
|
|
@@ -564,6 +616,8 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
|
|
|
564
616
|
|
|
565
617
|
Profiles are complete `model` + `thinking` tuples. Each field may use `inherit`; a project profile replaces the whole matching global tier entry. Malformed or incomplete entries are retained as durable blocked-tier tombstones and fail closed rather than falling back to a built-in profile or merging field-by-field. An explicit `defaultTier` applies when a workflow task omits its tier. Without a task tier or configured default, the parent model and thinking level are inherited. Agent frontmatter remains authoritative for its explicit `model` and `thinking`; thinking is clamped to the selected model's native supported levels.
|
|
566
618
|
|
|
619
|
+
**Default model** (`defaultModel`, unset): the model a subagent runs when no tier picked one — see [`defaultModel`](#defaultmodel) for where it sits in precedence, why an unresolvable value falls back instead of failing, and how `"inherit"` lets a project cancel a global default. **Default tier** (`agentTiers.defaultTier`, unset) is the tier applied when neither the caller nor the agent names one; the profiles it selects from live under [`agentTiers`](#model-tiers).
|
|
620
|
+
|
|
567
621
|
**Strict agent files** (`strictAgentFiles`, default `false`): normal startup skips unreadable or malformed agent definitions with a warning that includes the file path. Enable it to fail closed during the first `session_start`, using that session's `ctx.cwd`, with the path in the error instead of silently running a surviving lower-priority override. A failed validation leaves no root manager or RPC responder behind. Reloads after startup remain lenient, so an accidental edit cannot terminate an active session; the setting applies on the next pi session.
|
|
568
622
|
|
|
569
623
|
**Nested depth** (`maxSubagentDepth`, default `2`): the hard ceiling on [nested delegation](#nested-subagents), counted from the main session (main = 0, its subagents = 1). `0` or `1` disables nesting project-wide regardless of any agent's `allowed_subagents`. Read when a subagent session is built, so a change applies to agents started after it.
|
|
@@ -658,7 +712,7 @@ pi.events.emit("subagents:rpc:ping", { requestId });
|
|
|
658
712
|
|
|
659
713
|
### Managed spawn (protocol v3)
|
|
660
714
|
|
|
661
|
-
Workflow-owned orchestration uses the additive `subagents:rpc:spawn-managed` channel. Its request
|
|
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:
|
|
662
716
|
|
|
663
717
|
```json
|
|
664
718
|
{
|
|
@@ -667,12 +721,15 @@ Workflow-owned orchestration uses the additive `subagents:rpc:spawn-managed` cha
|
|
|
667
721
|
"type": "Explore",
|
|
668
722
|
"prompt": "Find the relevant files",
|
|
669
723
|
"description": "Find relevant files",
|
|
724
|
+
"tier": "small",
|
|
725
|
+
"model": "provider/model:medium",
|
|
726
|
+
"excludeTools": ["workflow", "workflow_control"],
|
|
727
|
+
"isolation": "worktree",
|
|
670
728
|
"owner": { "extension": "pi-workflows", "runId": "run-id", "nodeId": "node-id", "attemptId": "run-id/node-id/attempt-1" }
|
|
671
729
|
}
|
|
672
730
|
```
|
|
673
731
|
|
|
674
|
-
The
|
|
675
|
-
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.
|
|
676
733
|
|
|
677
734
|
### Spawn
|
|
678
735
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-subagents",
|
|
3
|
-
"version": "1.
|
|
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": "
|
|
29
|
-
"@earendil-works/pi-coding-agent": "
|
|
30
|
-
"@earendil-works/pi-tui": "
|
|
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.
|
|
33
|
+
"@sinclair/typebox": "^0.34.50",
|
|
34
34
|
"croner": "^10.0.1",
|
|
35
|
-
"@signalridge/pi-subagents-protocol": "^1.
|
|
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
|
+
"[39m" +
|
|
162
|
+
(style.restoreBackground ?? "[49m")
|
|
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
|
+
}
|
package/src/agent-file-toggle.ts
CHANGED
|
@@ -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");
|