simplified subagent tool schema, made name optional and agent type required, deduped names with unnamed in-flight subagents of same type
This commit is contained in:
@@ -122,7 +122,7 @@ The widget tracks each Pi-backed sub-agent from a child-written runtime snapshot
|
|||||||
|
|
||||||
These labels are no longer derived from session-file growth. Session JSONL is still used for transcript, resume, lineage, and result extraction, but Pi-backed liveness now comes from a small activity snapshot written by the child extension. A fixed internal watchdog marks a run as `stalled` when valid snapshots never appear, stop being readable, or stop matching the current child; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only.
|
These labels are no longer derived from session-file growth. Session JSONL is still used for transcript, resume, lineage, and result extraction, but Pi-backed liveness now comes from a small activity snapshot written by the child extension. A fixed internal watchdog marks a run as `stalled` when valid snapshots never appear, stop being readable, or stop matching the current child; valid long-running `active` or `waiting` states do not become `stalled` just because time passes. When a run enters `stalled` or recovers from it, the parent agent receives a steer message so it can react. All other status transitions stay in the widget only.
|
||||||
|
|
||||||
**Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `worker`, or any `fork: true` spawn) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter, or per-spawn with `interactive: true|false` on the tool call.
|
**Interactive subagents stay silent.** Long-running user-driven subagents (e.g. `worker`) do not wake the parent session on `stalled`/`recovered` transitions — the user is working directly in the subagent's pane, and a steer message there would just burn an orchestrator turn on a no-op "still waiting" ping. The widget still updates normally, and child snapshots are still recorded/classified regardless of the `interactive` setting. By default, agents with `auto-exit: true` are treated as autonomous and get stall pings; agents without it are treated as interactive and stay quiet. Override per-agent with `interactive: true|false` in frontmatter.
|
||||||
|
|
||||||
#### Configuration
|
#### Configuration
|
||||||
|
|
||||||
@@ -147,32 +147,26 @@ cp config.json.example config.json
|
|||||||
## Spawning Subagents
|
## Spawning Subagents
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Named agent with defaults from agent definition
|
// Spawn an agent by its profile name
|
||||||
subagent({ name: "Scout", agent: "scout", task: "Analyze the codebase..." });
|
subagent({ agent: "scout", task: "Analyze the codebase..." });
|
||||||
|
|
||||||
// Force a full-context fork for this spawn
|
// Cosmetic pane label (defaults to the agent name when omitted)
|
||||||
subagent({ name: "Fix", fork: true, task: "Fix the bug where..." });
|
subagent({ agent: "worker", name: "dark-mode", task: "Implement the dark mode toggle" });
|
||||||
|
|
||||||
// Interactive worker the user can drive in its own pane
|
|
||||||
subagent({ name: "Worker", agent: "worker", task: "Implement the dark mode toggle" });
|
|
||||||
|
|
||||||
// Custom working directory
|
// Custom working directory
|
||||||
subagent({ name: "Designer", agent: "game-designer", cwd: "agents/game-designer", task: "..." });
|
subagent({ agent: "game-designer", cwd: "agents/game-designer", task: "..." });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parameters
|
### Parameters
|
||||||
|
|
||||||
|
The schema is intentionally small: each agent has a fixed, well-defined loadout (model, tools, system prompt) baked into its definition, so per-spawn override knobs were removed. You pick an `agent` and give it a `task`.
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
| ---------------------- | ------- | -------------- | ------------------------------------------------------------------------------------------------- |
|
| --------- | ------ | -------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `name` | string | required | Display name (shown in widget and pane title) |
|
| `agent` | string | required | Which agent to spawn. Must be a known/permitted agent (see [`subagents_list`](#subagents_list)). Loads that agent's fixed profile. |
|
||||||
| `task` | string | required | Task prompt for the sub-agent |
|
| `task` | string | required | Task prompt for the sub-agent |
|
||||||
| `agent` | string | required¹ | Agent profile to spawn (must be a known/permitted agent). ¹Optional only for a top-level `fork: true` clone |
|
| `name` | string | _agent name_ | Optional cosmetic label for the pane and widget row. Defaults to the agent name. Does not select the agent. |
|
||||||
| `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
|
| `model` | string | — | Override the agent's default model for this spawn |
|
||||||
| `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. |
|
|
||||||
| `model` | string | — | Override agent's default model |
|
|
||||||
| `systemPrompt` | string | — | Append to system prompt |
|
|
||||||
| `skills` | string | — | Comma-separated skill names |
|
|
||||||
| `tools` | string | — | Comma-separated tool names |
|
|
||||||
| `cwd` | string | — | Working directory for the sub-agent (see [Role Folders](#role-folders)) |
|
| `cwd` | string | — | Working directory for the sub-agent (see [Role Folders](#role-folders)) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -285,9 +279,7 @@ Choose how a subagent session starts:
|
|||||||
- `lineage-only` — fresh blank child session with `parentSession` linkage, but no copied turns from the caller
|
- `lineage-only` — fresh blank child session with `parentSession` linkage, but no copied turns from the caller
|
||||||
- `fork` — linked child session seeded with the caller's prior conversation context
|
- `fork` — linked child session seeded with the caller's prior conversation context
|
||||||
|
|
||||||
`lineage-only` is useful when you want session discovery and fork lineage UX to show the relationship later, but you do **not** want the child to inherit the parent's turns.
|
`lineage-only` is useful when you want session discovery and fork lineage UX to show the relationship later, but you do **not** want the child to inherit the parent's turns. Set the mode per agent via the `session-mode` frontmatter field below.
|
||||||
|
|
||||||
`fork: true` on the tool call always forces the `fork` mode for that specific spawn.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -322,7 +314,7 @@ auto-exit: true
|
|||||||
|
|
||||||
Controls whether status transitions (`stalled`, `recovered`) wake the parent session with a steer message.
|
Controls whether status transitions (`stalled`, `recovered`) wake the parent session with a steer message.
|
||||||
|
|
||||||
**Default:** the inverse of `auto-exit`. Autonomous agents (`auto-exit: true`) are non-interactive and ping the parent on stall/recovery; agents without `auto-exit` are interactive and stay quiet. Bare spawns with no agent defs (e.g. a `fork: true` spawn) are treated as interactive.
|
**Default:** the inverse of `auto-exit`. Autonomous agents (`auto-exit: true`) are non-interactive and ping the parent on stall/recovery; agents without `auto-exit` are interactive and stay quiet.
|
||||||
|
|
||||||
**Why it exists:** Interactive agents can run for minutes or hours while the user thinks, types, and reads in the subagent's pane. Child snapshots still update the widget, but stalled/recovered supervision messages rarely need to wake the parent for user-driven sessions. Skipping the steer keeps the parent quiet until the child actually finishes.
|
**Why it exists:** Interactive agents can run for minutes or hours while the user thinks, types, and reads in the subagent's pane. Child snapshots still update the widget, but stalled/recovered supervision messages rarely need to wake the parent for user-driven sessions. Skipping the steer keeps the parent quiet until the child actually finishes.
|
||||||
|
|
||||||
@@ -338,12 +330,6 @@ name: worker
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Or per spawn:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
subagent({ name: "Scout", agent: "scout", interactive: true, task: "..." });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tool Access Control
|
## Tool Access Control
|
||||||
@@ -357,7 +343,7 @@ The agent whitelist is enforced at **every** depth, not just for restricted sub-
|
|||||||
- A **restricted sub-agent** (one launched with `PI_SUBAGENT_ALLOWED`) may only spawn the agents pinned in its `subagent_agents` list.
|
- A **restricted sub-agent** (one launched with `PI_SUBAGENT_ALLOWED`) may only spawn the agents pinned in its `subagent_agents` list.
|
||||||
- A **top-level session** may spawn any agent that appears in `subagents_list` (every discoverable definition).
|
- A **top-level session** may spawn any agent that appears in `subagents_list` (every discoverable definition).
|
||||||
|
|
||||||
Every `subagent` call must set `agent` to a name in the caller's permitted set. A missing `agent`, or one that doesn't resolve to a real definition (e.g. `agent: "wizard"`), is rejected — it no longer silently falls back to an unrestricted, full-toolset child. The only agentless spawn allowed is a top-level `fork: true` clone, which carries the caller's own already-trusted toolset.
|
Every `subagent` call must set `agent` to a name in the caller's permitted set. A missing `agent`, or one that doesn't resolve to a real definition (e.g. `agent: "wizard"`), is rejected — there is no agentless spawn route, so a child can never launch with an unrestricted, full-toolset profile by omitting its agent.
|
||||||
|
|
||||||
### Granting the ability to spawn: `subagent_agents`
|
### Granting the ability to spawn: `subagent_agents`
|
||||||
|
|
||||||
|
|||||||
@@ -87,55 +87,26 @@ function getModuleAbortSignal(): AbortSignal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SubagentParams = Type.Object({
|
const SubagentParams = Type.Object({
|
||||||
name: Type.String({
|
agent: Type.String({
|
||||||
description:
|
description:
|
||||||
"Display label for this subagent's pane only — it does NOT select an agent profile. " +
|
"Which agent to spawn (e.g. 'worker', 'scout', 'researcher'). This loads the agent's " +
|
||||||
"To run a specific agent (scout, researcher, etc.) you MUST set the `agent` field; `name` is purely cosmetic.",
|
"fixed profile — its model, tool loadout, and system prompt. Must be one of the available agents.",
|
||||||
}),
|
}),
|
||||||
task: Type.String({ description: "Task/prompt for the sub-agent" }),
|
task: Type.String({ description: "Task/prompt for the sub-agent" }),
|
||||||
agent: Type.Optional(
|
name: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description:
|
description:
|
||||||
"Which agent profile to spawn (e.g. 'worker', 'scout', 'researcher'). " +
|
"Optional cosmetic label for the subagent's pane and widget row. Defaults to the agent name. " +
|
||||||
"This is what loads the agent's model, tools, and system prompt from <name>.md. " +
|
"Has no effect on which agent runs — use `agent` for that.",
|
||||||
"Set this whenever you want a specific agent — do NOT put the agent name in `name` and leave this empty, " +
|
|
||||||
"or the child launches with NO restrictions (full default toolset) instead of the agent's profile.",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
systemPrompt: Type.Optional(
|
|
||||||
Type.String({ description: "Appended to system prompt (role instructions)" }),
|
|
||||||
),
|
|
||||||
model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })),
|
model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })),
|
||||||
skills: Type.Optional(
|
|
||||||
Type.String({ description: "Comma-separated skills (overrides agent default)" }),
|
|
||||||
),
|
|
||||||
tools: Type.Optional(
|
|
||||||
Type.String({ description: "Comma-separated tools (overrides agent default)" }),
|
|
||||||
),
|
|
||||||
cwd: Type.Optional(
|
cwd: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description:
|
description:
|
||||||
"Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.",
|
"Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
fork: Type.Optional(
|
|
||||||
Type.Boolean({
|
|
||||||
description:
|
|
||||||
"Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
interactive: Type.Optional(
|
|
||||||
Type.Boolean({
|
|
||||||
description:
|
|
||||||
"Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
resumeSessionId: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description:
|
|
||||||
"Resume a previous Claude Code session by its ID. Loads the conversation history and continues where it left off. The session ID is returned in details of every claude tool call. Use this to retry cancelled runs or ask follow-up questions.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
|
type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
|
||||||
@@ -346,7 +317,6 @@ function resolveEffectiveSessionMode(
|
|||||||
params: Static<typeof SubagentParams>,
|
params: Static<typeof SubagentParams>,
|
||||||
agentDefs: AgentDefaults | null,
|
agentDefs: AgentDefaults | null,
|
||||||
): SubagentSessionMode {
|
): SubagentSessionMode {
|
||||||
if (params.fork) return "fork";
|
|
||||||
return agentDefs?.sessionMode ?? "standalone";
|
return agentDefs?.sessionMode ?? "standalone";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,23 +343,16 @@ function resolveLaunchBehavior(
|
|||||||
* Decide whether a subagent is interactive (user-driven, long-running).
|
* Decide whether a subagent is interactive (user-driven, long-running).
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. Explicit `interactive` tool parameter wins.
|
* 1. Explicit `interactive` frontmatter field on the agent.
|
||||||
* 2. Explicit `interactive` frontmatter field on the agent.
|
* 2. Default: the inverse of `auto-exit`. Agents that auto-exit are
|
||||||
* 3. Default: the inverse of `auto-exit`. Agents that auto-exit are
|
* autonomous (scout, researcher) and the parent session should be
|
||||||
* autonomous (scout, worker, reviewer) and the parent session should be
|
|
||||||
* woken on stall/recovery transitions. Agents that don't auto-exit are
|
* woken on stall/recovery transitions. Agents that don't auto-exit are
|
||||||
* driven by the user in their own pane (planner, iterate/fork) and
|
* driven by the user in their own pane (worker) and stall pings are noise.
|
||||||
* stall pings are noise.
|
|
||||||
*
|
|
||||||
* When no agent defs exist at all (bare `subagent({ name, task })` call,
|
|
||||||
* typical for `/iterate` with `fork: true`), `autoExit` is undefined and the
|
|
||||||
* subagent is treated as interactive — matching the intent of iterate.
|
|
||||||
*/
|
*/
|
||||||
function resolveEffectiveInteractive(
|
function resolveEffectiveInteractive(
|
||||||
params: Static<typeof SubagentParams>,
|
_params: Static<typeof SubagentParams>,
|
||||||
agentDefs: AgentDefaults | null,
|
agentDefs: AgentDefaults | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (params.interactive != null) return params.interactive;
|
|
||||||
if (agentDefs?.interactive != null) return agentDefs.interactive;
|
if (agentDefs?.interactive != null) return agentDefs.interactive;
|
||||||
return !(agentDefs?.autoExit ?? false);
|
return !(agentDefs?.autoExit ?? false);
|
||||||
}
|
}
|
||||||
@@ -867,6 +830,30 @@ function observeRunningSubagent(running: RunningSubagent, observedAt = Date.now(
|
|||||||
}, observedAt);
|
}, observedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names claimed by spawns that are mid-launch but not yet registered in
|
||||||
|
* `runningSubagents`. Parallel `subagent` tool calls run their synchronous
|
||||||
|
* prefix (name defaulting) before any of them finishes `launchSubagent` and
|
||||||
|
* registers, so without this they'd all see an empty map and pick the same
|
||||||
|
* name. Reserved synchronously when a default name is chosen and released once
|
||||||
|
* the subagent registers (or its launch fails).
|
||||||
|
*/
|
||||||
|
const reservedNames = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return `base`, or `base-2`, `base-3`, … if a running subagent already uses
|
||||||
|
* that name (or a parallel spawn has reserved it). Keeps defaulted pane labels
|
||||||
|
* unique so `subagent_message({ name })` can address each one unambiguously.
|
||||||
|
*/
|
||||||
|
function uniqueRunningName(base: string): string {
|
||||||
|
const taken = new Set(Array.from(runningSubagents.values()).map((r) => r.name));
|
||||||
|
for (const reserved of reservedNames) taken.add(reserved);
|
||||||
|
if (!taken.has(base)) return base;
|
||||||
|
let n = 2;
|
||||||
|
while (taken.has(`${base}-${n}`)) n++;
|
||||||
|
return `${base}-${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRunningByName(name: string):
|
function resolveRunningByName(name: string):
|
||||||
| { running: RunningSubagent }
|
| { running: RunningSubagent }
|
||||||
| { error: string } {
|
| { error: string } {
|
||||||
@@ -1030,6 +1017,8 @@ export const __test__ = {
|
|||||||
observeRunningSubagent,
|
observeRunningSubagent,
|
||||||
getToolExtensionPath,
|
getToolExtensionPath,
|
||||||
resolveRunningByName,
|
resolveRunningByName,
|
||||||
|
uniqueRunningName,
|
||||||
|
reservedNames,
|
||||||
steerSubagent,
|
steerSubagent,
|
||||||
handleSubagentSteer,
|
handleSubagentSteer,
|
||||||
resolveResultPresentation,
|
resolveResultPresentation,
|
||||||
@@ -1068,8 +1057,8 @@ async function launchSubagent(
|
|||||||
|
|
||||||
const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
|
const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
|
||||||
const effectiveModel = params.model ?? agentDefs?.model;
|
const effectiveModel = params.model ?? agentDefs?.model;
|
||||||
const effectiveTools = params.tools ?? agentDefs?.tools;
|
const effectiveTools = agentDefs?.tools;
|
||||||
const effectiveSkills = params.skills ?? agentDefs?.skills;
|
const effectiveSkills = agentDefs?.skills;
|
||||||
const effectiveThinking = agentDefs?.thinking;
|
const effectiveThinking = agentDefs?.thinking;
|
||||||
const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
|
const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
|
||||||
|
|
||||||
@@ -1129,7 +1118,7 @@ async function launchSubagent(
|
|||||||
// An agent with a non-empty subagent_agents list is granted the spawning
|
// An agent with a non-empty subagent_agents list is granted the spawning
|
||||||
// toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED).
|
// toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED).
|
||||||
const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0);
|
const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0);
|
||||||
const identity = agentDefs?.body ?? params.systemPrompt ?? null;
|
const identity = agentDefs?.body ?? null;
|
||||||
const systemPromptMode = agentDefs?.systemPromptMode;
|
const systemPromptMode = agentDefs?.systemPromptMode;
|
||||||
const identityInSystemPrompt = systemPromptMode && identity;
|
const identityInSystemPrompt = systemPromptMode && identity;
|
||||||
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
|
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
|
||||||
@@ -1154,15 +1143,11 @@ async function launchSubagent(
|
|||||||
cmdParts.push("--model", shellEscape(effectiveModel));
|
cmdParts.push("--model", shellEscape(effectiveModel));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sp = params.systemPrompt ?? agentDefs.body;
|
const sp = agentDefs.body;
|
||||||
if (sp) {
|
if (sp) {
|
||||||
cmdParts.push("--append-system-prompt", shellEscape(sp));
|
cmdParts.push("--append-system-prompt", shellEscape(sp));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.resumeSessionId) {
|
|
||||||
cmdParts.push("--resume", shellEscape(params.resumeSessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always pass the task as the prompt — even for resumed sessions,
|
// Always pass the task as the prompt — even for resumed sessions,
|
||||||
// the caller's task is the follow-up instruction.
|
// the caller's task is the follow-up instruction.
|
||||||
cmdParts.push(shellEscape(params.task));
|
cmdParts.push(shellEscape(params.task));
|
||||||
@@ -1589,21 +1574,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
const permittedList = permittedAgents.join(", ") || "(none)";
|
const permittedList = permittedAgents.join(", ") || "(none)";
|
||||||
|
|
||||||
if (!params.agent) {
|
if (!params.agent) {
|
||||||
const forkAllowed = !SUBAGENT_ALLOWLIST && params.fork === true;
|
|
||||||
if (!forkAllowed) {
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text:
|
text:
|
||||||
`You must specify which agent to spawn via the "agent" field. ` +
|
`You must specify which agent to spawn via the "agent" field. ` +
|
||||||
`Available agents: ${permittedList}. ` +
|
`Available agents: ${permittedList}.`,
|
||||||
`(The "name" field is just a display label — set "agent" to one of the available agents.)`,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { error: "agent required" },
|
details: { error: "agent required" },
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} else if (!permittedSet.has(params.agent)) {
|
} else if (!permittedSet.has(params.agent)) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -1621,6 +1602,18 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default the cosmetic pane label to the agent name when omitted,
|
||||||
|
// disambiguating against any running subagent already using it so
|
||||||
|
// subagent_message({ name }) can target each one unambiguously. Reserve
|
||||||
|
// the chosen name synchronously (before any await) so parallel spawns
|
||||||
|
// don't all pick the same default.
|
||||||
|
let reservedName: string | null = null;
|
||||||
|
if (!params.name?.trim()) {
|
||||||
|
params.name = uniqueRunningName(params.agent);
|
||||||
|
reservedName = params.name;
|
||||||
|
reservedNames.add(reservedName);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate prerequisites
|
// Validate prerequisites
|
||||||
if (!isMuxAvailable()) {
|
if (!isMuxAvailable()) {
|
||||||
return muxUnavailableResult();
|
return muxUnavailableResult();
|
||||||
@@ -1638,8 +1631,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch the subagent (creates pane, sends command)
|
// Launch the subagent (creates pane, sends command). Release the name
|
||||||
const running = await launchSubagent(params, ctx);
|
// reservation once it registers in runningSubagents (or launch fails) —
|
||||||
|
// from then on uniqueRunningName tracks it via the running map.
|
||||||
|
let running;
|
||||||
|
try {
|
||||||
|
running = await launchSubagent(params, ctx);
|
||||||
|
} finally {
|
||||||
|
if (reservedName) reservedNames.delete(reservedName);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a separate AbortController for the watcher
|
// Create a separate AbortController for the watcher
|
||||||
// (the tool's signal completes when we return)
|
// (the tool's signal completes when we return)
|
||||||
@@ -1741,11 +1741,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
renderCall(args, theme) {
|
renderCall(args, theme) {
|
||||||
const partialArgs = args as Record<string, unknown>;
|
const partialArgs = args as Record<string, unknown>;
|
||||||
const name = typeof partialArgs.name === "string" && partialArgs.name ? partialArgs.name : "(unnamed)";
|
const agentName =
|
||||||
|
typeof partialArgs.agent === "string" && partialArgs.agent ? partialArgs.agent : "";
|
||||||
|
const name =
|
||||||
|
typeof partialArgs.name === "string" && partialArgs.name
|
||||||
|
? partialArgs.name
|
||||||
|
: agentName || "(unnamed)";
|
||||||
const task = typeof partialArgs.task === "string" ? partialArgs.task : "";
|
const task = typeof partialArgs.task === "string" ? partialArgs.task : "";
|
||||||
const agent = typeof partialArgs.agent === "string" && partialArgs.agent
|
// Only show the agent tag separately when a distinct cosmetic name was given.
|
||||||
? theme.fg("dim", ` (${partialArgs.agent})`)
|
const agent =
|
||||||
: "";
|
agentName && name !== agentName ? theme.fg("dim", ` (${agentName})`) : "";
|
||||||
const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd
|
const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd
|
||||||
? theme.fg("dim", ` in ${partialArgs.cwd}`)
|
? theme.fg("dim", ` in ${partialArgs.cwd}`)
|
||||||
: "";
|
: "";
|
||||||
|
|||||||
+62
-39
@@ -1076,23 +1076,6 @@ describe("subagent discovery", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolveEffectiveInteractive honors the explicit tool parameter over all else", () => {
|
|
||||||
assert.equal(
|
|
||||||
testApi.resolveEffectiveInteractive(
|
|
||||||
{ name: "A", task: "T", interactive: false },
|
|
||||||
{ autoExit: false, interactive: true },
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
testApi.resolveEffectiveInteractive(
|
|
||||||
{ name: "A", task: "T", interactive: true },
|
|
||||||
{ autoExit: true, interactive: false },
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bundled scout/researcher resolve as non-interactive; worker resolves as interactive", () => {
|
it("bundled scout/researcher resolve as non-interactive; worker resolves as interactive", () => {
|
||||||
for (const name of ["scout", "researcher"]) {
|
for (const name of ["scout", "researcher"]) {
|
||||||
const defs = testApi.loadAgentDefaults(name);
|
const defs = testApi.loadAgentDefaults(name);
|
||||||
@@ -1162,17 +1145,14 @@ describe("subagent discovery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves session mode with fork override precedence", () => {
|
it("resolves session mode from frontmatter (standalone default)", () => {
|
||||||
assert.equal(testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, null), "standalone");
|
assert.equal(testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, null), "standalone");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "lineage-only" }),
|
testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "lineage-only" }),
|
||||||
"lineage-only",
|
"lineage-only",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveSessionMode(
|
testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "fork" }),
|
||||||
{ name: "A", task: "T", fork: true },
|
|
||||||
{ sessionMode: "lineage-only" },
|
|
||||||
),
|
|
||||||
"fork",
|
"fork",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1202,18 +1182,6 @@ describe("subagent discovery", () => {
|
|||||||
taskDelivery: "direct",
|
taskDelivery: "direct",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
|
||||||
testApi.resolveLaunchBehavior(
|
|
||||||
{ name: "A", task: "T", fork: true },
|
|
||||||
{ sessionMode: "lineage-only" },
|
|
||||||
),
|
|
||||||
{
|
|
||||||
sessionMode: "fork",
|
|
||||||
seededSessionMode: "fork",
|
|
||||||
inheritsConversationContext: true,
|
|
||||||
taskDelivery: "direct",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("buildSubagentToolAllowlist preserves requested tools and adds child control tools", () => {
|
it("buildSubagentToolAllowlist preserves requested tools and adds child control tools", () => {
|
||||||
@@ -1546,7 +1514,7 @@ describe("tool registration", () => {
|
|||||||
assert.match(result.content[0].text, /not a known agent/i);
|
assert.match(result.content[0].text, /not a known agent/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("documents that `agent` (not `name`) selects the agent profile", () => {
|
it("exposes a debloated schema: agent+task required, name/model/cwd optional, no override knobs", () => {
|
||||||
const { api, registeredTools } = createMockExtensionApi();
|
const { api, registeredTools } = createMockExtensionApi();
|
||||||
(subagentsModule as any).default(api);
|
(subagentsModule as any).default(api);
|
||||||
|
|
||||||
@@ -1554,10 +1522,22 @@ describe("tool registration", () => {
|
|||||||
assert.ok(subagentTool, "expected subagent tool to be registered");
|
assert.ok(subagentTool, "expected subagent tool to be registered");
|
||||||
|
|
||||||
const props = subagentTool.parameters.properties;
|
const props = subagentTool.parameters.properties;
|
||||||
// `name` must clarify it is cosmetic and does NOT select a profile.
|
assert.deepEqual(
|
||||||
assert.match(props.name.description, /does NOT select/i);
|
Object.keys(props).sort(),
|
||||||
// `agent` must steer the model to set it for a specific profile.
|
["agent", "cwd", "model", "name", "task"],
|
||||||
assert.match(props.agent.description, /which agent profile to spawn/i);
|
"only agent/task/name/model/cwd should remain",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...(subagentTool.parameters.required ?? [])].sort(),
|
||||||
|
["agent", "task"],
|
||||||
|
"agent and task must be required",
|
||||||
|
);
|
||||||
|
// `name` is now optional and purely cosmetic.
|
||||||
|
assert.match(props.name.description, /cosmetic/i);
|
||||||
|
// The removed override knobs must be gone.
|
||||||
|
for (const gone of ["tools", "skills", "systemPrompt", "fork", "interactive", "resumeSessionId"]) {
|
||||||
|
assert.equal(props[gone], undefined, `expected ${gone} param to be removed`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders partial subagent tool-call args without throwing", () => {
|
it("renders partial subagent tool-call args without throwing", () => {
|
||||||
@@ -1827,6 +1807,49 @@ describe("subagent interruption", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uniqueRunningName suffixes defaulted names that collide with running subagents", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const runningMap = testApi.runningSubagents as Map<string, any>;
|
||||||
|
runningMap.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No collision: base name is returned untouched.
|
||||||
|
assert.equal(testApi.uniqueRunningName("worker"), "worker");
|
||||||
|
|
||||||
|
runningMap.set("a1", makeRunning({ id: "a1", name: "worker", surface: "a1" }));
|
||||||
|
assert.equal(testApi.uniqueRunningName("worker"), "worker-2");
|
||||||
|
|
||||||
|
runningMap.set("b2", makeRunning({ id: "b2", name: "worker-2", surface: "b2" }));
|
||||||
|
assert.equal(testApi.uniqueRunningName("worker"), "worker-3");
|
||||||
|
|
||||||
|
// A distinct base is unaffected by the worker collisions.
|
||||||
|
assert.equal(testApi.uniqueRunningName("scout"), "scout");
|
||||||
|
} finally {
|
||||||
|
runningMap.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uniqueRunningName also avoids names reserved by in-flight parallel spawns", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const runningMap = testApi.runningSubagents as Map<string, any>;
|
||||||
|
const reserved = testApi.reservedNames as Set<string>;
|
||||||
|
runningMap.clear();
|
||||||
|
reserved.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate the first parallel spawn reserving its default name before it
|
||||||
|
// has registered in runningSubagents.
|
||||||
|
reserved.add(testApi.uniqueRunningName("scout")); // "scout"
|
||||||
|
// The second spawn, running concurrently, must not reuse it.
|
||||||
|
assert.equal(testApi.uniqueRunningName("scout"), "scout-2");
|
||||||
|
reserved.add("scout-2");
|
||||||
|
assert.equal(testApi.uniqueRunningName("scout"), "scout-3");
|
||||||
|
} finally {
|
||||||
|
runningMap.clear();
|
||||||
|
reserved.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("steers a running subagent by typing into its pane (newlines flattened)", () => {
|
it("steers a running subagent by typing into its pane (newlines flattened)", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
let sentSurface = "";
|
let sentSurface = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user