@tintinweb/pi-subagents 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { defineTool, getAgentDir, getSettingsListTheme } from "@earendil-works/p
15
15
  import { Container, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
16
  import { Type } from "@sinclair/typebox";
17
17
  import { AgentManager } from "./agent-manager.js";
18
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
18
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
19
19
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
20
20
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
21
21
  import { loadCustomAgents } from "./custom-agents.js";
@@ -27,6 +27,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
27
27
  import { SubagentScheduler } from "./schedule.js";
28
28
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
29
29
  import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
30
+ import { getStatusNote } from "./status-note.js";
30
31
  import { AgentWidget, buildInvocationTags, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
31
32
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
32
33
  import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
@@ -98,15 +99,6 @@ function getStatusLabel(status, error) {
98
99
  default: return "Done";
99
100
  }
100
101
  }
101
- /** Parenthetical status note for completed agent result text. */
102
- function getStatusNote(status) {
103
- switch (status) {
104
- case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
105
- case "steered": return " (wrapped up — reached turn limit)";
106
- case "stopped": return " (stopped by user)";
107
- default: return "";
108
- }
109
- }
110
102
  /** Escape XML special characters to prevent injection in structured notifications. */
111
103
  function escapeXml(s) {
112
104
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -130,7 +122,7 @@ function formatTaskNotification(record, resultMaxLen) {
130
122
  record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
131
123
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
132
124
  `<status>${escapeXml(status)}</status>`,
133
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
125
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
134
126
  `<result>${escapeXml(resultPreview)}</result>`,
135
127
  `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
136
128
  `</task-notification>`,
@@ -573,7 +565,7 @@ export default function (pi) {
573
565
  ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
574
566
  : "";
575
567
  pi.registerTool(defineTool({
576
- name: "Agent",
568
+ name: SUBAGENT_TOOL_NAMES.AGENT,
577
569
  label: "Agent",
578
570
  description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
579
571
 
@@ -617,6 +609,13 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
617
609
  Terse command-style prompts produce shallow, generic work.
618
610
 
619
611
  **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`,
612
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
613
+ promptGuidelines: [
614
+ "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
615
+ "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
616
+ "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
617
+ "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
618
+ ],
620
619
  parameters: Type.Object({
621
620
  prompt: Type.String({
622
621
  description: "The task for the agent to perform.",
@@ -666,7 +665,7 @@ Terse command-style prompts produce shallow, generic work.
666
665
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
667
666
  return new Text(text, 0, 0);
668
667
  }
669
- // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
668
+ // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
670
669
  const stats = (d) => {
671
670
  const parts = [];
672
671
  if (d.modelName)
@@ -1035,9 +1034,10 @@ Terse command-style prompts produce shallow, generic work.
1035
1034
  }));
1036
1035
  // ---- get_subagent_result tool ----
1037
1036
  pi.registerTool(defineTool({
1038
- name: "get_subagent_result",
1037
+ name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1039
1038
  label: "Get Agent Result",
1040
1039
  description: "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1040
+ promptSnippet: "Check status and retrieve results from a background agent",
1041
1041
  parameters: Type.Object({
1042
1042
  agent_id: Type.String({
1043
1043
  description: "The agent ID to check.",
@@ -1076,7 +1076,7 @@ Terse command-style prompts produce shallow, generic work.
1076
1076
  statsParts.push(`Compactions: ${record.compactionCount}`);
1077
1077
  statsParts.push(`Duration: ${duration}`);
1078
1078
  let output = `Agent: ${record.id}\n` +
1079
- `Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
1079
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1080
1080
  `Description: ${record.description}\n\n`;
1081
1081
  if (record.status === "running") {
1082
1082
  output += "Agent is still running. Use wait: true or check back later.";
@@ -1104,10 +1104,11 @@ Terse command-style prompts produce shallow, generic work.
1104
1104
  }));
1105
1105
  // ---- steer_subagent tool ----
1106
1106
  pi.registerTool(defineTool({
1107
- name: "steer_subagent",
1107
+ name: SUBAGENT_TOOL_NAMES.STEER,
1108
1108
  label: "Steer Agent",
1109
1109
  description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
1110
1110
  "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
1111
+ promptSnippet: "Send a steering message to redirect a running background agent",
1111
1112
  parameters: Type.Object({
1112
1113
  agent_id: Type.String({
1113
1114
  description: "The agent ID to steer (must be currently running).",
@@ -1313,7 +1314,11 @@ Terse command-style prompts produce shallow, generic work.
1313
1314
  const session = record.session;
1314
1315
  const activity = agentActivity.get(record.id);
1315
1316
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
1316
- return new ConversationViewer(tui, session, record, activity, theme, done);
1317
+ return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1318
+ if (manager.abort(record.id)) {
1319
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
1320
+ }
1321
+ });
1317
1322
  }, {
1318
1323
  overlay: true,
1319
1324
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
package/dist/prompts.d.ts CHANGED
@@ -16,12 +16,15 @@ export interface PromptExtras {
16
16
  * Build the system prompt for an agent from its config.
17
17
  *
18
18
  * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
19
- * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
19
+ * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
20
20
  * - "append" with empty systemPrompt: pure parent clone
21
21
  *
22
- * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
22
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
23
23
  * extensions (e.g. permission/policy systems) can resolve per-agent policy
24
- * inside the child session by parsing the system prompt.
24
+ * inside the child session by parsing the system prompt. In replace mode the tag
25
+ * is prepended; in append mode it follows the shared inherited content so the
26
+ * parent prompt forms an identical, cacheable byte prefix with the parent
27
+ * session (the LLM's KV cache can then reuse those tokens across every spawn).
25
28
  *
26
29
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
27
30
  * @param extras Optional extra sections to inject (memory, preloaded skills).
package/dist/prompts.js CHANGED
@@ -5,12 +5,15 @@
5
5
  * Build the system prompt for an agent from its config.
6
6
  *
7
7
  * - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
8
- * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
8
+ * - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
9
9
  * - "append" with empty systemPrompt: pure parent clone
10
10
  *
11
- * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
11
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
12
12
  * extensions (e.g. permission/policy systems) can resolve per-agent policy
13
- * inside the child session by parsing the system prompt.
13
+ * inside the child session by parsing the system prompt. In replace mode the tag
14
+ * is prepended; in append mode it follows the shared inherited content so the
15
+ * parent prompt forms an identical, cacheable byte prefix with the parent
16
+ * session (the LLM's KV cache can then reuse those tokens across every spawn).
14
17
  *
15
18
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
16
19
  * @param extras Optional extra sections to inject (memory, preloaded skills).
@@ -49,7 +52,12 @@ You are operating as a sub-agent invoked to handle a specific task.
49
52
  const customSection = config.systemPrompt?.trim()
50
53
  ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
51
54
  : "";
52
- return activeAgentTag + envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
55
+ // Place shared/stable content first so the LLM's KV cache can reuse the
56
+ // inherited prefix across all subagent invocations. The parent prompt is
57
+ // placed verbatim (no wrapper tag) so it forms an identical byte prefix
58
+ // with the parent session, maximising KV cache hits. The <active_agent>
59
+ // tag and env block vary per call and are placed after the cached prefix.
60
+ return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection + extrasSuffix;
53
61
  }
54
62
  // "replace" mode — env header + the config's full system prompt
55
63
  const replaceHeader = `You are a pi coding agent sub-agent.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * status-note.ts — Parenthetical status note appended to agent result text.
3
+ */
4
+ /**
5
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
6
+ * agent can't mistake partial output for a completed result. Empty string for a
7
+ * clean completion (and any unknown/non-terminal status).
8
+ *
9
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
10
+ * turn limit was hit) — the parent should treat human intervention differently
11
+ * from a budget cutoff.
12
+ */
13
+ export declare function getStatusNote(status: string): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * status-note.ts — Parenthetical status note appended to agent result text.
3
+ */
4
+ /**
5
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
6
+ * agent can't mistake partial output for a completed result. Empty string for a
7
+ * clean completion (and any unknown/non-terminal status).
8
+ *
9
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
10
+ * turn limit was hit) — the parent should treat human intervention differently
11
+ * from a budget cutoff.
12
+ */
13
+ export function getStatusNote(status) {
14
+ switch (status) {
15
+ case "stopped":
16
+ return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
17
+ case "aborted":
18
+ return " (aborted — hit the turn limit before completion; output may be incomplete)";
19
+ case "steered":
20
+ return " (wrapped up at the turn limit — output may be partial)";
21
+ default:
22
+ return "";
23
+ }
24
+ }
package/dist/types.d.ts CHANGED
@@ -19,6 +19,9 @@ export interface AgentConfig {
19
19
  displayName?: string;
20
20
  description: string;
21
21
  builtinToolNames?: string[];
22
+ /** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
23
+ * Presence of any entry flips extension tools to an explicit allowlist. */
24
+ extSelectors?: string[];
22
25
  /** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
23
26
  disallowedTools?: string[];
24
27
  /** true = inherit all, string[] = only listed, false = none */
@@ -66,15 +66,15 @@ export declare function formatTokens(count: number): string;
66
66
  /**
67
67
  * Token count with optional context-fill % and compaction-count annotations.
68
68
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
69
- * Compaction count rendered as `↻N` in dim.
69
+ * Compaction count rendered as `⇊N` in dim.
70
70
  *
71
71
  * "12.3k token" — no annotations
72
72
  * "12.3k token (45%)" — percent only
73
- * "12.3k token (2)" — compactions only (e.g. right after compact)
74
- * "12.3k token (45% · 2)" — both
73
+ * "12.3k token (2)" — compactions only (e.g. right after compact)
74
+ * "12.3k token (45% · 2)" — both
75
75
  */
76
76
  export declare function formatSessionTokens(tokens: number, percent: number | null, theme: Theme, compactions?: number): string;
77
- /** Format turn count with optional max limit: "5≤30" or "5". */
77
+ /** Format turn count with optional max limit: "5≤30" or "5". */
78
78
  export declare function formatTurns(turnCount: number, maxTurns?: number | null): string;
79
79
  /** Format milliseconds as human-readable duration. */
80
80
  export declare function formatMs(ms: number): string;
@@ -36,12 +36,12 @@ export function formatTokens(count) {
36
36
  /**
37
37
  * Token count with optional context-fill % and compaction-count annotations.
38
38
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
39
- * Compaction count rendered as `↻N` in dim.
39
+ * Compaction count rendered as `⇊N` in dim.
40
40
  *
41
41
  * "12.3k token" — no annotations
42
42
  * "12.3k token (45%)" — percent only
43
- * "12.3k token (2)" — compactions only (e.g. right after compact)
44
- * "12.3k token (45% · 2)" — both
43
+ * "12.3k token (2)" — compactions only (e.g. right after compact)
44
+ * "12.3k token (45% · 2)" — both
45
45
  */
46
46
  export function formatSessionTokens(tokens, percent, theme, compactions = 0) {
47
47
  const tokenStr = formatTokens(tokens);
@@ -51,15 +51,15 @@ export function formatSessionTokens(tokens, percent, theme, compactions = 0) {
51
51
  annot.push(theme.fg(color, `${Math.round(percent)}%`));
52
52
  }
53
53
  if (compactions > 0) {
54
- annot.push(theme.fg("dim", `↻${compactions}`));
54
+ annot.push(theme.fg("dim", `⇊${compactions}`));
55
55
  }
56
56
  if (annot.length === 0)
57
57
  return tokenStr;
58
58
  return `${tokenStr} (${annot.join(" · ")})`;
59
59
  }
60
- /** Format turn count with optional max limit: "5≤30" or "5". */
60
+ /** Format turn count with optional max limit: "5≤30" or "5". */
61
61
  export function formatTurns(turnCount, maxTurns) {
62
- return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
62
+ return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
63
63
  }
64
64
  /** Format milliseconds as human-readable duration. */
65
65
  export function formatMs(ms) {
@@ -18,14 +18,22 @@ export declare class ConversationViewer implements Component {
18
18
  private activity;
19
19
  private theme;
20
20
  private done;
21
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
22
+ private onStop?;
21
23
  private scrollOffset;
22
24
  private autoScroll;
23
25
  private unsubscribe;
24
26
  private lastInnerW;
25
27
  private closed;
26
- constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void);
28
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
29
+ private stopArmed;
30
+ constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
31
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
32
+ onStop?: (() => void) | undefined);
27
33
  handleInput(data: string): void;
28
34
  render(width: number): string[];
35
+ /** Stoppable only when a stop handler exists and the agent is still active. */
36
+ private isStoppable;
29
37
  invalidate(): void;
30
38
  dispose(): void;
31
39
  private viewportHeight;
@@ -20,18 +20,24 @@ export class ConversationViewer {
20
20
  activity;
21
21
  theme;
22
22
  done;
23
+ onStop;
23
24
  scrollOffset = 0;
24
25
  autoScroll = true;
25
26
  unsubscribe;
26
27
  lastInnerW = 0;
27
28
  closed = false;
28
- constructor(tui, session, record, activity, theme, done) {
29
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
30
+ stopArmed = false;
31
+ constructor(tui, session, record, activity, theme, done,
32
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
33
+ onStop) {
29
34
  this.tui = tui;
30
35
  this.session = session;
31
36
  this.record = record;
32
37
  this.activity = activity;
33
38
  this.theme = theme;
34
39
  this.done = done;
40
+ this.onStop = onStop;
35
41
  this.unsubscribe = session.subscribe(() => {
36
42
  if (this.closed)
37
43
  return;
@@ -44,6 +50,23 @@ export class ConversationViewer {
44
50
  this.done(undefined);
45
51
  return;
46
52
  }
53
+ // Stop/abort the agent (only while it can still be stopped). Two-press:
54
+ // first "x" arms, second confirms — any other key disarms.
55
+ if (matchesKey(data, "x")) {
56
+ if (this.isStoppable()) {
57
+ if (this.stopArmed) {
58
+ this.stopArmed = false;
59
+ this.onStop?.();
60
+ }
61
+ else {
62
+ this.stopArmed = true;
63
+ }
64
+ this.tui.requestRender();
65
+ }
66
+ return;
67
+ }
68
+ if (this.stopArmed)
69
+ this.stopArmed = false;
47
70
  const totalLines = this.buildContentLines(this.lastInnerW).length;
48
71
  const viewportHeight = this.viewportHeight();
49
72
  const maxScroll = Math.max(0, totalLines - viewportHeight);
@@ -132,12 +155,22 @@ export class ConversationViewer {
132
155
  ? "100%"
133
156
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
134
157
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
135
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
158
+ const scrollHint = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
159
+ // Stop hint goes first in the right group so it survives right-edge
160
+ // truncation on narrow terminals (the scroll hint is the expendable part).
161
+ const footerRight = this.isStoppable()
162
+ ? (this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")) +
163
+ th.fg("dim", " · ") + scrollHint
164
+ : scrollHint;
136
165
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
137
166
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
138
167
  lines.push(hrBot);
139
168
  return lines;
140
169
  }
170
+ /** Stoppable only when a stop handler exists and the agent is still active. */
171
+ isStoppable() {
172
+ return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
173
+ }
141
174
  invalidate() { }
142
175
  dispose() {
143
176
  this.closed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
36
36
  "test": "vitest run",
37
37
  "test:watch": "vitest",
38
+ "test:e2e": "vitest run e2e --reporter=verbose",
38
39
  "typecheck": "tsc --noEmit",
39
40
  "lint": "biome check src/ test/",
40
41
  "lint:fix": "biome check --fix src/ test/"