@tintinweb/pi-subagents 0.9.1 → 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
 
@@ -673,7 +665,7 @@ Terse command-style prompts produce shallow, generic work.
673
665
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
674
666
  return new Text(text, 0, 0);
675
667
  }
676
- // 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
677
669
  const stats = (d) => {
678
670
  const parts = [];
679
671
  if (d.modelName)
@@ -1042,7 +1034,7 @@ Terse command-style prompts produce shallow, generic work.
1042
1034
  }));
1043
1035
  // ---- get_subagent_result tool ----
1044
1036
  pi.registerTool(defineTool({
1045
- name: "get_subagent_result",
1037
+ name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1046
1038
  label: "Get Agent Result",
1047
1039
  description: "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1048
1040
  promptSnippet: "Check status and retrieve results from a background agent",
@@ -1084,7 +1076,7 @@ Terse command-style prompts produce shallow, generic work.
1084
1076
  statsParts.push(`Compactions: ${record.compactionCount}`);
1085
1077
  statsParts.push(`Duration: ${duration}`);
1086
1078
  let output = `Agent: ${record.id}\n` +
1087
- `Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
1079
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1088
1080
  `Description: ${record.description}\n\n`;
1089
1081
  if (record.status === "running") {
1090
1082
  output += "Agent is still running. Use wait: true or check back later.";
@@ -1112,7 +1104,7 @@ Terse command-style prompts produce shallow, generic work.
1112
1104
  }));
1113
1105
  // ---- steer_subagent tool ----
1114
1106
  pi.registerTool(defineTool({
1115
- name: "steer_subagent",
1107
+ name: SUBAGENT_TOOL_NAMES.STEER,
1116
1108
  label: "Steer Agent",
1117
1109
  description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
1118
1110
  "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
@@ -1322,7 +1314,11 @@ Terse command-style prompts produce shallow, generic work.
1322
1314
  const session = record.session;
1323
1315
  const activity = agentActivity.get(record.id);
1324
1316
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
1325
- 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
+ });
1326
1322
  }, {
1327
1323
  overlay: true,
1328
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.1",
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/"