@signalridge/pi-subagents 1.7.0 → 1.8.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.1
4
+ ### Patch Changes
5
+
6
+ - 28c8aa1: Remove non-functional references to external product names from package descriptions, examples, and comments. Provider identifiers required for runtime compatibility remain unchanged.
7
+
8
+ ## 1.8.0
9
+ ### Minor Changes
10
+
11
+ - 3c6a02e: Improve the subagent conversation viewer with a full Pi-style box border, upstream-aligned layout, and height-safe chat controls. Running agents can chat in the viewer, while `steer_subagent` and `@handle message` queue messages safely for queued runs; `/agents` can stop a queued run before it has a session, and cancelled queued resumes no longer leak messages or transcript subscriptions into later resumes.
12
+
3
13
  ## 1.7.0
4
14
  ### Minor Changes
5
15
 
package/README.md CHANGED
@@ -11,10 +11,10 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
11
11
  - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
12
12
  - **Live FleetView UI** — one navigable list below the editor with a status mark per row, live tool activity, token counts, and text status labels. Enter opens the selected agent's conversation. Toggle via `/agents → Settings → Fleet view`
13
13
  - **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
14
- - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
14
+ - **Conversation viewer** — select any retained agent with a session in `/agents` to open a live-scrolling, four-sided bordered overlay of its full conversation (auto-follows new content, scroll up to pause). Press `Enter` to chat: type a message, then `Enter` to send (`Esc` or an empty submit returns); it appears as a user message and redirects the agent after its current tool. Press `x` (then `x` again to confirm) to stop a running agent this works for background agents too
15
15
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` or `.agents/agents/<name>.md` (project) or globally, with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
16
16
  - **Nested subagents** — opt-in, default-off delegation: a custom agent that sets `allowed_subagents` gets its own ownership-scoped `Agent`, `get_subagent_result`, and `steer_subagent` tools, depth-capped from the main session (default 2). It can control only its own children, they are stopped when it finishes, and their transcripts and token spend roll up to it. The allowlist is a privilege boundary — a child runs with its own tools, so pick it as carefully as `tools:` itself
17
- - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
17
+ - **Mid-run chat** — inject messages into running or queued agents to redirect their work without restarting. From the main prompt, use `@handle message` (with `@` autocomplete); configure this under `/agents → Settings → Agent mentions`.
18
18
  - **Session resume** — pick up where an agent left off, preserving full conversation context
19
19
  - **Graceful turn limits** — agents get a "wrap up" warning before hard abort, producing clean partial results instead of cut-off output
20
20
  - **Case-insensitive agent types** — `"explore"`, `"Explore"`, `"EXPLORE"` all work. Unknown types fall back to general-purpose with a note
@@ -135,7 +135,7 @@ The mark is a geometric shape rather than an emoji on purpose: an emoji invites
135
135
  font fallback, and a fallback glyph is usually double-width, which would push
136
136
  the rest of the row out of alignment on some terminals and not others.
137
137
 
138
- The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press down or left to move focus from the prompt into the list. Up and down move the selection, Enter opens the selected agent's live conversation overlay (it auto-updates as the agent works), and Esc (or up above `main`) returns to the prompt. Selecting `main` returns to the normal view. Inside the overlay, press Enter to steer the running agent — type a message and Enter to send it (Esc or an empty submit returns), and it redirects the agent the same way the `steer_subagent` tool does. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures navigation keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`.
138
+ The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press down or left to move focus from the prompt into the list. Up and down move the selection, Enter opens the selected agent's live conversation overlay (it auto-updates as the agent works), and Esc (or up above `main`) returns to the prompt. Selecting `main` returns to the normal view. The conversation overlay uses a complete `╭─╮` / `│` / `╰─╯` border. Press Enter to chat with a running agent — type a message and Enter to send it (Esc or an empty submit returns). For a queued agent that has no session yet, type `@handle message` in the main prompt; the message is delivered when it starts. Press `x` twice to stop a running agent. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures navigation keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`.
139
139
 
140
140
  Individual agent results use restrained text status labels:
141
141
 
@@ -341,7 +341,7 @@ Cancelling a `wait: true` call (for example, with `Esc`) stops only the wait. Th
341
341
 
342
342
  ### `steer_subagent`
343
343
 
344
- Send a steering message to a running agent. The message interrupts after the current tool execution.
344
+ Send a chat message to a running or queued agent. A running agent receives it after the current tool execution; a queued agent receives it when its session starts.
345
345
 
346
346
  | Parameter | Type | Required | Description |
347
347
  |-----------|------|----------|-------------|
@@ -364,7 +364,7 @@ Create new agent
364
364
  Settings
365
365
  ```
366
366
 
367
- - **Agent runs** — select any retained run to open its conversation viewer. The summary reports running, queued, completed, wrapped up, stopped, aborted, and failed buckets separately. While a run is still active, press Enter to open the steering composer, then Enter again to send a message that redirects the agent (same mechanism as the `steer_subagent` tool; Esc or an empty submit returns), or press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion.
367
+ - **Agent runs** — select any retained run with a session to open its conversation viewer. Queued runs without a session remain listed but are not openable until they start; selecting one offers a stop confirmation, and `@handle message` queues a chat message. The summary reports running, queued, completed, wrapped up, stopped, aborted, and failed buckets separately. While a run is still active, press Enter to open the chat composer, then Enter again to send a message that redirects the agent (same mechanism as the `steer_subagent` tool; Esc or an empty submit returns), or press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion.
368
368
  - **Agent types** — unified list with textual source labels (`project`, `global`, and `disabled`). Each row shows the agent's model, and the highlighted agent's full description appears below the list. The model column flags `(unavailable, fallback: inherit)` when a configured model can't be resolved (it would silently inherit the parent model), and shows `(resolved: provider/id)` when it resolves to a different provider or version than configured. Select an agent to manage it:
369
369
  - **Default agents** (no override): Eject (export as `.md`), Disable
370
370
  - **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete
@@ -420,17 +420,18 @@ by name and nothing else: the `Agent` tool exposes `tier` and does **not** expos
420
420
  `subagents.json`, not by the orchestrator improvising per call.
421
421
 
422
422
  Names are yours. `small`/`medium`/`large` below are only an example — `research`,
423
- `cheap`, `nightly` are equally valid keys.
423
+ `cheap`, `nightly` are equally valid keys. Replace the illustrative provider/model
424
+ values with models available in your environment.
424
425
 
425
426
  ```json
426
427
  {
427
428
  "agentTiers": {
428
429
  "defaultTier": "medium",
429
430
  "profiles": {
430
- "small": { "description": "Fast, cheap exploration", "model": "deepseek/deepseek-v4-flash", "thinking": "max" },
431
- "medium": { "description": "Ordinary planning and review", "model": "openai-codex/gpt-5.6-luna", "thinking": "max" },
432
- "large": { "description": "Architecture and risky review", "model": "openai-codex/gpt-5.6-sol", "thinking": "xhigh" },
433
- "research": { "description": "Long-context research", "model": "kimi/k3", "thinking": "max" }
431
+ "small": { "description": "Fast, cheap exploration", "model": "provider/fast-model", "thinking": "max" },
432
+ "medium": { "description": "Ordinary planning and review", "model": "provider/reasoning-model", "thinking": "max" },
433
+ "large": { "description": "Architecture and risky review", "model": "provider/architecture-model", "thinking": "xhigh" },
434
+ "research": { "description": "Long-context research", "model": "provider/long-context-model", "thinking": "max" }
434
435
  }
435
436
  }
436
437
  }
@@ -450,7 +451,7 @@ remember. It sees:
450
451
  Available agent tiers:
451
452
 
452
453
  - small: Fast, cheap exploration
453
- model: deepseek/deepseek-v4-flash
454
+ model: provider/fast-model
454
455
  thinking: max
455
456
  ...
456
457
  Default tier: medium
@@ -608,12 +609,14 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
608
609
  "tiers": {
609
610
  "small": { "model": "inherit", "thinking": "low" },
610
611
  "medium": { "model": "inherit", "thinking": "medium" },
611
- "large": { "model": "openai-codex/gpt-5.6-luna", "thinking": "max" }
612
+ "large": { "model": "provider/architecture-model", "thinking": "max" }
612
613
  }
613
614
  }
614
615
  }
615
616
  ```
616
617
 
618
+ Replace the illustrative `provider/architecture-model` value with a model available in your environment.
619
+
617
620
  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.
618
621
 
619
622
  **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).
@@ -20,7 +20,7 @@ If the target is already known, use a direct tool — `read` for a known path, `
20
20
  - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
21
21
  - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
22
22
  - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
23
- - Use steer_subagent to send mid-run messages to a running background agent.
23
+ - Use steer_subagent to send mid-run messages to a running or queued background agent.
24
24
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
25
25
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
26
26
  - Use tier to pick the model profile for this spawn, by name. A tier overrides the agent's own default tier. Model and thinking are not callable parameters — they are what a tier resolves to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",
@@ -2193,6 +2193,8 @@ export class AgentManager {
2193
2193
  onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
2194
2194
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
2195
2195
  onCompaction?: (info: unknown) => void;
2196
+ /** Called once a queued background resume has acquired its run slot. */
2197
+ onStarted?: () => void;
2196
2198
  },
2197
2199
  ): Promise<AgentRecord | undefined> {
2198
2200
  if (this.disposed) return undefined;
@@ -2311,6 +2313,8 @@ export class AgentManager {
2311
2313
  onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
2312
2314
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
2313
2315
  onCompaction?: (info: unknown) => void;
2316
+ /** Called once a queued background resume has acquired its run slot. */
2317
+ onStarted?: () => void;
2314
2318
  },
2315
2319
  ): Promise<string> {
2316
2320
  const snapshot = control.snapshot;
@@ -2340,6 +2344,28 @@ export class AgentManager {
2340
2344
  record.result = undefined;
2341
2345
  record.error = undefined;
2342
2346
  this.syncManagedRecord(record);
2347
+ try {
2348
+ options?.onStarted?.();
2349
+ } catch {
2350
+ // Observability hooks must not prevent the resumed agent from running.
2351
+ }
2352
+
2353
+ // A queued background resume reuses an existing session, so messages
2354
+ // sent while it waits must be flushed here rather than through the
2355
+ // session's idle steering queue. Otherwise cancelling this queued run
2356
+ // would leave those messages behind for a later resume.
2357
+ const pendingSteers = record.pendingSteers;
2358
+ record.pendingSteers = undefined;
2359
+ if (pendingSteers?.length) {
2360
+ for (const message of pendingSteers) {
2361
+ if (control.controller.signal.aborted) break;
2362
+ try {
2363
+ await record.session!.steer(message);
2364
+ } catch {
2365
+ // A malformed or stale queued message must not prevent the resume.
2366
+ }
2367
+ }
2368
+ }
2343
2369
 
2344
2370
  try {
2345
2371
  const { text, failure } = await resumeAgent(record.session!, prompt, {
@@ -2397,21 +2423,25 @@ export class AgentManager {
2397
2423
 
2398
2424
  /**
2399
2425
  * Send a steering message to an agent from the UI (mirrors the steer_subagent
2400
- * tool). A live session delivers it now — it interrupts the agent after its
2401
- * current tool execution and appears as a user message. If the session isn't
2402
- * ready yet, the message is queued on `pendingSteers` and flushed when the
2403
- * session is created. Returns false if the agent can't accept steering
2404
- * (unknown id, or no longer running/queued).
2426
+ * tool). A running session delivers it now — it interrupts the agent after
2427
+ * its current tool execution and appears as a user message. Queued runs keep
2428
+ * it on `pendingSteers` until their run actually starts, including background
2429
+ * resumes that already have an old session attached. Returns false if the
2430
+ * agent can't accept steering (unknown id, or no longer running/queued).
2405
2431
  */
2406
2432
  steer(id: string, message: string): boolean {
2407
2433
  const record = this.agents.get(id);
2408
2434
  if (!record) return false;
2409
2435
  if (record.status !== "running" && record.status !== "queued") return false;
2410
- if (record.session) {
2411
- record.session.steer(message).catch(() => {});
2412
- } else {
2436
+ // A queued background resume already has its old session attached. Keep
2437
+ // messages in the manager until that resume actually starts; putting them
2438
+ // into AgentSession's idle queue would survive cancellation into a future
2439
+ // resume of the same conversation.
2440
+ if (record.status === "queued" || !record.session) {
2413
2441
  if (!record.pendingSteers) record.pendingSteers = [];
2414
2442
  record.pendingSteers.push(message);
2443
+ } else {
2444
+ record.session.steer(message).catch(() => {});
2415
2445
  }
2416
2446
  return true;
2417
2447
  }
@@ -2461,6 +2491,9 @@ export class AgentManager {
2461
2491
  resumeControl?.controller.abort();
2462
2492
  resumeControl?.cleanup();
2463
2493
  resumeControl?.deferred?.resolve("");
2494
+ // Do not let chat messages queued for a cancelled queued run leak into
2495
+ // the session when the same agent is resumed later.
2496
+ record.pendingSteers = undefined;
2464
2497
  this.syncManagedRecord(record);
2465
2498
  // Queued agents have no run promise yet. Still use the normal terminal
2466
2499
  // callback so lifecycle consumers (including workflow waits) cannot hang.
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Tools:
5
5
  * Agent — LLM-callable: spawn a sub-agent
6
6
  * get_subagent_result — LLM-callable: check background agent status/result
7
- * steer_subagent — LLM-callable: send a steering message to a running agent
7
+ * steer_subagent — LLM-callable: chat with a running or queued agent
8
8
  *
9
9
  * Commands:
10
10
  * /agents — Interactive agent management menu
@@ -2168,7 +2168,7 @@ Notes:
2168
2168
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
2169
2169
  - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
2170
2170
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
2171
- - resume continues a previous agent by ID; steer_subagent messages a running one.
2171
+ - resume continues a previous agent by ID; steer_subagent messages a running or queued one.
2172
2172
  - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
2173
2173
 
2174
2174
  const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
@@ -2193,7 +2193,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
2193
2193
  - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
2194
2194
  - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
2195
2195
  - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
2196
- - Use steer_subagent to send mid-run messages to a running background agent.
2196
+ - Use steer_subagent to send mid-run messages to a running or queued background agent.
2197
2197
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
2198
2198
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
2199
2199
  - Use tier to pick the model profile for this spawn, by name. A tier overrides the agent's own default tier. Model and thinking are not callable parameters — they are what a tier resolves to.
@@ -2758,6 +2758,16 @@ Terse command-style prompts produce shallow, generic work.
2758
2758
  // run starts. The resumed prompt lands as an ordinary user message at
2759
2759
  // this index, so it is written exactly once.
2760
2760
  const transcriptAnchor = existing.session.messages.length ?? 0;
2761
+ const attachTranscript = (): void => {
2762
+ if (!existing.outputFile || existing.outputCleanup) return;
2763
+ existing.outputCleanup = streamToOutputFile(
2764
+ existing.session!,
2765
+ existing.outputFile,
2766
+ params.resume!,
2767
+ ctx.cwd,
2768
+ transcriptAnchor,
2769
+ );
2770
+ };
2761
2771
  const record = await manager.resume(
2762
2772
  params.resume,
2763
2773
  params.prompt,
@@ -2766,6 +2776,7 @@ Terse command-style prompts produce shallow, generic work.
2766
2776
  isBackground: true,
2767
2777
  onToolActivity: bgCallbacks.onToolActivity,
2768
2778
  onAssistantUsage: bgCallbacks.onAssistantUsage,
2779
+ onStarted: attachTranscript,
2769
2780
  },
2770
2781
  );
2771
2782
  if (!record) {
@@ -2774,17 +2785,6 @@ Terse command-style prompts produce shallow, generic work.
2774
2785
  "Wait for it to settle, or steer it with steer_subagent.",
2775
2786
  );
2776
2787
  }
2777
- // Wire streaming once the run actually starts (immediately, or on
2778
- // queue drain).
2779
- if (existing.outputFile) {
2780
- existing.outputCleanup = streamToOutputFile(
2781
- existing.session,
2782
- existing.outputFile,
2783
- params.resume,
2784
- ctx.cwd,
2785
- transcriptAnchor,
2786
- );
2787
- }
2788
2788
  agentActivity.set(params.resume, bgState);
2789
2789
  void bgCallbacks;
2790
2790
  return textResult(
@@ -3128,6 +3128,9 @@ Terse command-style prompts produce shallow, generic work.
3128
3128
  if (record.status === "running") {
3129
3129
  output +=
3130
3130
  "Agent is still running. Use wait: true or check back later.";
3131
+ } else if (record.status === "queued") {
3132
+ output +=
3133
+ "Agent is queued and has not started yet. Use wait: true or check back later.";
3131
3134
  } else if (record.status === "error") {
3132
3135
  output += `Error: ${record.error}${partialOutputSuffix(record)}`;
3133
3136
  } else {
@@ -3158,19 +3161,19 @@ Terse command-style prompts produce shallow, generic work.
3158
3161
  pi.registerTool(
3159
3162
  defineTool({
3160
3163
  name: SUBAGENT_TOOL_NAMES.STEER,
3161
- label: "Steer Agent",
3164
+ label: "Chat with Agent",
3162
3165
  description:
3163
- "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
3164
- "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
3166
+ "Send a chat message to a running or queued agent. A running agent receives it after its current tool execution; " +
3167
+ "a queued agent receives it when its session starts. The message is injected into the agent's conversation.",
3165
3168
  promptSnippet:
3166
- "Send a steering message to redirect a running background agent",
3169
+ "Chat with or redirect a running or queued background agent",
3167
3170
  parameters: Type.Object({
3168
3171
  agent_id: Type.String({
3169
- description: "The agent ID to steer (must be currently running).",
3172
+ description: "The agent ID to message (must be currently running or queued).",
3170
3173
  }),
3171
3174
  message: Type.String({
3172
3175
  description:
3173
- "The steering message to send. This will appear as a user message in the agent's conversation.",
3176
+ "The chat message to send. This will appear as a user message in the agent's conversation.",
3174
3177
  }),
3175
3178
  }),
3176
3179
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
@@ -3180,13 +3183,16 @@ Terse command-style prompts produce shallow, generic work.
3180
3183
  `Agent not found: "${params.agent_id}". It may have been cleaned up.`,
3181
3184
  );
3182
3185
  }
3183
- if (record.status !== "running") {
3186
+ if (record.status !== "running" && record.status !== "queued") {
3184
3187
  return textResult(
3185
- `Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
3188
+ `Agent "${params.agent_id}" is not running or queued (status: ${record.status}). Cannot send a chat message.`,
3186
3189
  );
3187
3190
  }
3188
- if (!record.session) {
3189
- // Session not ready yet queue the steer for delivery once initialized
3191
+ if (record.status === "queued" || !record.session) {
3192
+ // Keep queued messages in the manager record until the run starts.
3193
+ // This also covers a queued background resume whose old session is
3194
+ // already attached; sending directly to that session would survive
3195
+ // cancellation into a later resume.
3190
3196
  if (!record.pendingSteers) record.pendingSteers = [];
3191
3197
  record.pendingSteers.push(params.message);
3192
3198
  if (!record.detached)
@@ -3194,8 +3200,9 @@ Terse command-style prompts produce shallow, generic work.
3194
3200
  id: record.id,
3195
3201
  message: params.message,
3196
3202
  });
3203
+ const delivery = record.status === "queued" ? "when the agent starts" : "once the session initializes";
3197
3204
  return textResult(
3198
- `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
3205
+ `Chat message queued for agent ${record.id}. It will be delivered ${delivery}.`,
3199
3206
  );
3200
3207
  }
3201
3208
 
@@ -3220,7 +3227,7 @@ Terse command-style prompts produce shallow, generic work.
3220
3227
  `${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
3221
3228
  );
3222
3229
  return textResult(
3223
- `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
3230
+ `Chat message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
3224
3231
  `Current state: ${stateParts.join(" · ")}`,
3225
3232
  );
3226
3233
  } catch (err) {
@@ -3492,10 +3499,20 @@ Terse command-style prompts produce shallow, generic work.
3492
3499
  record: AgentRecord,
3493
3500
  ) {
3494
3501
  if (!record.session) {
3495
- ctx.ui.notify(
3496
- `Agent is ${record.status === "queued" ? "queued" : "expired"}; no session available.`,
3497
- "info",
3498
- );
3502
+ if (record.status === "queued") {
3503
+ const handleHint = record.handle
3504
+ ? ` Use @${record.handle} <message> to chat while it waits.`
3505
+ : " Use steer_subagent or wait until it starts to chat.";
3506
+ const stop = await ctx.ui.confirm(
3507
+ "Queued agent",
3508
+ `"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
3509
+ );
3510
+ if (stop && manager.abort(record.id)) {
3511
+ ctx.ui.notify(`Stopped queued agent "${sanitizeDisplayText(record.description)}".`, "info");
3512
+ }
3513
+ return;
3514
+ }
3515
+ ctx.ui.notify("Agent is expired; no session available.", "info");
3499
3516
  return;
3500
3517
  }
3501
3518
 
@@ -389,30 +389,32 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
389
389
 
390
390
  const steerTool = defineTool({
391
391
  name: NESTED_TOOL_NAMES[2],
392
- label: "Steer Nested Agent",
393
- description: "Send guidance to a running nested agent owned by this parent.",
392
+ label: "Chat with Nested Agent",
393
+ description: "Send guidance to a running or queued nested agent owned by this parent.",
394
394
  parameters: Type.Object({
395
395
  agent_id: Type.String(),
396
396
  message: Type.String(),
397
397
  }),
398
398
  execute: async (_toolCallId, params) => {
399
399
  const record = lookupRecord(context, params.agent_id);
400
- if (!ownsRecord(record, context.parentAgentId) || record.status !== "running") {
401
- return textResult(`Running nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
400
+ if (!ownsRecord(record, context.parentAgentId) ||
401
+ (record.status !== "running" && record.status !== "queued")) {
402
+ return textResult(`Running or queued nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
402
403
  }
403
- // Session not ready yet queue the steer. The manager flushes pending
404
- // steers when the session is created (same contract as the top-level tool).
405
- if (!record.session) {
404
+ // Queue chat for any queued run, even when a background resume already
405
+ // has its old session attached. The manager flushes it only when the run
406
+ // actually starts, so cancellation cannot leak it into a later resume.
407
+ if (record.status === "queued" || !record.session) {
406
408
  if (!record.pendingSteers) record.pendingSteers = [];
407
409
  record.pendingSteers.push(params.message);
408
- return textResult(`Steering message queued for nested agent ${params.agent_id}.`);
410
+ return textResult(`Chat message queued for nested agent ${params.agent_id}.`);
409
411
  }
410
412
  try {
411
413
  await record.session.steer(params.message);
412
414
  } catch (err) {
413
415
  return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
414
416
  }
415
- return textResult(`Steering message sent to nested agent ${params.agent_id}.`);
417
+ return textResult(`Chat message sent to nested agent ${params.agent_id}.`);
416
418
  },
417
419
  });
418
420
 
@@ -17,9 +17,9 @@ import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCode
17
17
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
18
18
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
19
19
 
20
- /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
20
+ /** Base lines consumed by chrome: top border + header + two separators + footer + bottom border. */
21
21
  const CHROME_LINES_BASE = 6;
22
- const MIN_VIEWPORT = 3;
22
+ const MIN_VIEWPORT = 1;
23
23
  /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
24
24
  export const VIEWPORT_HEIGHT_PCT = 70;
25
25
 
@@ -165,8 +165,14 @@ export class ConversationViewer implements Component {
165
165
  render(width: number): string[] {
166
166
  if (width < 6) return [];
167
167
  this.refreshRecord();
168
+ // A custom overlay is clipped by pi after rendering. At very low heights
169
+ // there is no room for the complete header/footer chrome, so render nothing
170
+ // rather than showing a misleading half-box with a missing bottom border.
171
+ if (this.maxOverlayRows() < this.chromeLines() + MIN_VIEWPORT) return [];
168
172
  const th = this.theme;
169
- const innerW = width - 2;
173
+ // Match pi's overlay style: a complete box with two columns reserved for
174
+ // the side borders and one padding column on each side of the content.
175
+ const innerW = width - 4;
170
176
  this.lastInnerW = innerW;
171
177
  const lines: string[] = [];
172
178
 
@@ -176,18 +182,20 @@ export class ConversationViewer implements Component {
176
182
  };
177
183
  const row = (content: string) => {
178
184
  const fitted = truncateToWidth(pad(content, innerW), innerW, "...", true);
179
- return ` ${fitted} `;
185
+ return `${th.fg("borderAccent", "│")} ${fitted} ${th.fg("borderAccent", "│")}`;
180
186
  };
181
- // Two full-width rules, top and bottom, and none in between. The overlay
182
- // floats over the transcript, so without them there is no telling where the
183
- // agent's conversation ends and the parent's resumes. An inner rule would be
184
- // a third horizontal line competing with the two that mark the boundary, and
185
- // a four-sided box would cost two columns on every row for the same job.
186
- const rule = () => th.fg("borderAccent", "".repeat(Math.max(0, width)));
187
- const hrMid = row("");
188
-
189
- lines.push(rule());
190
- lines.push(row(th.bold("Agent conversation")));
187
+ // Keep the border in the viewer rather than relying on the host overlay's
188
+ // transparent background. This makes the dialog boundary obvious and
189
+ // matches the reference pi-subagents viewer (╭─╮ / / ╰─╯).
190
+ const rule = (left: string, right: string) =>
191
+ th.fg("borderAccent", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`);
192
+ const topRule = rule("", "");
193
+ const bottomRule = rule("", "╯");
194
+ // Match the reference viewer's internal separator: it keeps the header,
195
+ // transcript, and controls visually distinct without adding another row.
196
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
197
+
198
+ lines.push(topRule);
191
199
  const name = renderAgentName(this.record.type, th, { bold: true });
192
200
  const modeLabel = getPromptModeLabel(this.record.type);
193
201
  const modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
@@ -238,13 +246,13 @@ export class ConversationViewer implements Component {
238
246
  const composerLine = renderedComposer.startsWith("> ") ? renderedComposer.slice(2) : renderedComposer;
239
247
  lines.push(row(composerLine));
240
248
  const composeHint = th.fg("dim", "Enter send · Esc cancel");
241
- const composeLeft = th.fg("accent", "steer");
249
+ const composeLeft = th.fg("accent", "chat");
242
250
  const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
243
251
  lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
244
252
  } else {
245
253
  const sep = th.fg("dim", " · ");
246
254
  const actions: string[] = [];
247
- if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
255
+ if (this.canSteer()) actions.push(th.fg("dim", "Enter chat"));
248
256
  if (this.isStoppable()) {
249
257
  actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
250
258
  }
@@ -262,7 +270,7 @@ export class ConversationViewer implements Component {
262
270
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
263
271
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
264
272
  }
265
- lines.push(rule());
273
+ lines.push(bottomRule);
266
274
 
267
275
  return lines;
268
276
  }
@@ -309,11 +317,14 @@ export class ConversationViewer implements Component {
309
317
 
310
318
  // ---- Private ----
311
319
 
320
+ private maxOverlayRows(): number {
321
+ return Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
322
+ }
323
+
312
324
  private viewportHeight(): number {
313
325
  // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
314
326
  // more lines than the overlay shows and clip the footer.
315
- const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
316
- return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
327
+ return Math.max(MIN_VIEWPORT, this.maxOverlayRows() - this.chromeLines());
317
328
  }
318
329
 
319
330
  private chromeLines(): number {