@ilya-lesikov/pi-pi 0.9.0 → 0.11.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.
Files changed (108) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +12 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
  30. package/extensions/orchestrator/agents/constraints.test.ts +26 -0
  31. package/extensions/orchestrator/agents/constraints.ts +12 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -85
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ast-search.test.ts +124 -0
  46. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  47. package/extensions/orchestrator/billing-spoof.ts +95 -0
  48. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  49. package/extensions/orchestrator/command-handlers.ts +6 -0
  50. package/extensions/orchestrator/commands.test.ts +15 -2
  51. package/extensions/orchestrator/commands.ts +1 -1
  52. package/extensions/orchestrator/config.test.ts +89 -1
  53. package/extensions/orchestrator/config.ts +102 -19
  54. package/extensions/orchestrator/context.test.ts +46 -0
  55. package/extensions/orchestrator/context.ts +18 -5
  56. package/extensions/orchestrator/custom-footer.test.ts +24 -10
  57. package/extensions/orchestrator/custom-footer.ts +4 -2
  58. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  59. package/extensions/orchestrator/doctor.ts +6 -2
  60. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  61. package/extensions/orchestrator/event-handlers.test.ts +96 -9
  62. package/extensions/orchestrator/event-handlers.ts +344 -151
  63. package/extensions/orchestrator/exa.more.test.ts +118 -0
  64. package/extensions/orchestrator/flant-infra.more.test.ts +381 -0
  65. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  66. package/extensions/orchestrator/flant-infra.ts +126 -41
  67. package/extensions/orchestrator/index.test.ts +76 -0
  68. package/extensions/orchestrator/index.ts +2 -0
  69. package/extensions/orchestrator/integration.test.ts +183 -65
  70. package/extensions/orchestrator/model-registry.test.ts +2 -1
  71. package/extensions/orchestrator/model-registry.ts +12 -2
  72. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  73. package/extensions/orchestrator/orchestrator.ts +119 -27
  74. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  75. package/extensions/orchestrator/phases/brainstorm.ts +43 -6
  76. package/extensions/orchestrator/phases/implementation.ts +2 -0
  77. package/extensions/orchestrator/phases/machine.test.ts +17 -1
  78. package/extensions/orchestrator/phases/machine.ts +4 -1
  79. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  80. package/extensions/orchestrator/phases/planning.ts +18 -3
  81. package/extensions/orchestrator/phases/review-task.ts +5 -0
  82. package/extensions/orchestrator/phases/review.test.ts +10 -0
  83. package/extensions/orchestrator/phases/review.ts +22 -7
  84. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  85. package/extensions/orchestrator/phases/verdict.ts +6 -5
  86. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  87. package/extensions/orchestrator/plannotator.test.ts +38 -1
  88. package/extensions/orchestrator/plannotator.ts +50 -3
  89. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  90. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  91. package/extensions/orchestrator/pp-menu.test.ts +114 -7
  92. package/extensions/orchestrator/pp-menu.ts +580 -91
  93. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  94. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  95. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  96. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  97. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  98. package/extensions/orchestrator/review-files.test.ts +26 -0
  99. package/extensions/orchestrator/review-files.ts +3 -0
  100. package/extensions/orchestrator/state.test.ts +73 -1
  101. package/extensions/orchestrator/state.ts +95 -23
  102. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  103. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  104. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  105. package/package.json +9 -2
  106. package/scripts/lib/smoke-resolve.mjs +99 -0
  107. package/scripts/test-3p.sh +52 -0
  108. package/scripts/test-package.sh +62 -0
@@ -10,26 +10,31 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
- import { homedir } from "node:os";
13
+ import { existsSync, readFileSync } from "node:fs";
15
14
  import { join } from "node:path";
16
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import { Text } from "@earendil-works/pi-tui";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import { Container, Text } from "@earendil-works/pi-tui";
18
17
  import { Type } from "@sinclair/typebox";
19
18
  import { AgentManager } from "./agent-manager.js";
20
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
21
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, registerExtensionAgents, unregisterExtensionAgents, unregisterExtensionAgentsByPrefix, clearExtensionAgents, setExtensionOnlyMode, resolveType } from "./agent-types.js";
19
+ import { getAgentConversation, getDefaultMaxTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, steerAgent } from "./agent-runner.js";
20
+ import { BUILTIN_TOOL_NAMES, clearExtensionAgents, getAgentConfig, getAvailableTypes, registerAgents, registerExtensionAgents, resolveType, setExtensionOnlyMode, unregisterExtensionAgents, unregisterExtensionAgentsByPrefix } from "./agent-types.js";
22
21
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
23
22
  import { loadCustomAgents } from "./custom-agents.js";
23
+ import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
24
24
  import { GroupJoinManager } from "./group-join.js";
25
25
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
26
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
26
+ import { resolveModel } from "./model-resolver.js";
27
27
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
28
- import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
28
+ import { SubagentScheduler } from "./schedule.js";
29
+ import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
30
+ import { type ToolDescriptionMode } from "./settings.js";
31
+ import { getStatusNote } from "./status-note.js";
32
+ import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type WidgetMode } from "./types.js";
29
33
  import {
30
34
  type AgentActivity,
31
35
  type AgentDetails,
32
36
  AgentWidget,
37
+ buildInvocationTags,
33
38
  describeActivity,
34
39
  formatDuration,
35
40
  formatMs,
@@ -38,8 +43,10 @@ import {
38
43
  getDisplayName,
39
44
  getPromptModeLabel,
40
45
  SPINNER,
46
+ type Theme,
41
47
  type UICtx,
42
48
  } from "./ui/agent-widget.js";
49
+ import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
43
50
 
44
51
  // ---- Shared helpers ----
45
52
 
@@ -48,28 +55,22 @@ function textResult(msg: string, details?: AgentDetails) {
48
55
  return { content: [{ type: "text" as const, text: msg }], details: details as any };
49
56
  }
50
57
 
51
- function appendParentFlowTrace(cwd: string | undefined, event: string, detail: Record<string, unknown>) {
52
- if (!cwd) return;
53
- try {
54
- mkdirSync(join(cwd, ".pp"), { recursive: true });
55
- const line = JSON.stringify({ timestamp: new Date().toISOString(), event, ...detail }) + "\n";
56
- require("node:fs").appendFileSync(join(cwd, ".pp", "subagent-parent-flow-trace.jsonl"), line, "utf-8");
57
- } catch {}
58
+ export function renderRunningAgentStatus(
59
+ frame: string,
60
+ statsText: string,
61
+ activity: string,
62
+ theme: Pick<Theme, "fg">,
63
+ ): Container {
64
+ const container = new Container();
65
+ container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
66
+ container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
67
+ return container;
58
68
  }
59
69
 
60
- function appendSubagentDebugTrace(cwd: string | undefined, event: string, detail: Record<string, unknown>) {
61
- if (!cwd) return;
62
- try {
63
- mkdirSync(join(cwd, ".pp"), { recursive: true });
64
- const line = JSON.stringify({ timestamp: new Date().toISOString(), event, ...detail }) + "\n";
65
- require("node:fs").appendFileSync(join(cwd, ".pp", "subagent-interactive-trace.jsonl"), line, "utf-8");
66
- } catch {}
67
- }
68
-
69
- /** Safe token formatting — wraps session.getSessionStats() in try-catch. */
70
- function safeFormatTokens(session: { getSessionStats(): { tokens: { total: number } } } | undefined): string {
71
- if (!session) return "";
72
- try { return formatTokens(session.getSessionStats().tokens.total); } catch { return ""; }
70
+ /** Format an agent's lifetime token total, or "" when zero. */
71
+ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
72
+ const t = getLifetimeTotal(o.lifetimeUsage);
73
+ return t > 0 ? formatTokens(t) : "";
73
74
  }
74
75
 
75
76
  /**
@@ -77,7 +78,15 @@ function safeFormatTokens(session: { getSessionStats(): { tokens: { total: numbe
77
78
  * Used by both foreground and background paths to avoid duplication.
78
79
  */
79
80
  function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
80
- const state: AgentActivity = { activeTools: new Map(), toolUses: 0, turnCount: 1, maxTurns, tokens: "", responseText: "", session: undefined };
81
+ const state: AgentActivity = {
82
+ activeTools: new Map(),
83
+ toolUses: 0,
84
+ turnCount: 1,
85
+ maxTurns,
86
+ responseText: "",
87
+ session: undefined,
88
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
89
+ };
81
90
 
82
91
  const callbacks = {
83
92
  onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
@@ -89,7 +98,6 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
89
98
  }
90
99
  state.toolUses++;
91
100
  }
92
- state.tokens = safeFormatTokens(state.session);
93
101
  onStreamUpdate?.();
94
102
  },
95
103
  onTextDelta: (_delta: string, fullText: string) => {
@@ -103,6 +111,10 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
103
111
  onSessionCreated: (session: any) => {
104
112
  state.session = session;
105
113
  },
114
+ onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
115
+ addUsage(state.lifetimeUsage, usage);
116
+ onStreamUpdate?.();
117
+ },
106
118
  };
107
119
 
108
120
  return { state, callbacks };
@@ -119,16 +131,6 @@ function getStatusLabel(status: string, error?: string): string {
119
131
  }
120
132
  }
121
133
 
122
- /** Parenthetical status note for completed agent result text. */
123
- function getStatusNote(status: string): string {
124
- switch (status) {
125
- case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
126
- case "steered": return " (wrapped up — reached turn limit)";
127
- case "stopped": return " (stopped by user)";
128
- default: return "";
129
- }
130
- }
131
-
132
134
  /** Escape XML special characters to prevent injection in structured notifications. */
133
135
  function escapeXml(s: string): string {
134
136
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -138,13 +140,10 @@ function escapeXml(s: string): string {
138
140
  function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
139
141
  const status = getStatusLabel(record.status, record.error);
140
142
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
141
- let totalTokens = 0;
142
- try {
143
- if (record.session) {
144
- const stats = record.session.getSessionStats();
145
- totalTokens = stats.tokens?.total ?? 0;
146
- }
147
- } catch { /* session stats unavailable */ }
143
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
144
+ const contextPercent = getSessionContextPercent(record.session);
145
+ const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
146
+ const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
148
147
 
149
148
  const resultPreview = record.result
150
149
  ? record.result.length > resultMaxLen
@@ -158,9 +157,9 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
158
157
  record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
159
158
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
160
159
  `<status>${escapeXml(status)}</status>`,
161
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
160
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
162
161
  `<result>${escapeXml(resultPreview)}</result>`,
163
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
162
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
164
163
  `</task-notification>`,
165
164
  ].filter(Boolean).join('\n');
166
165
  }
@@ -168,14 +167,14 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
168
167
  /** Build AgentDetails from a base + record-specific fields. */
169
168
  function buildDetails(
170
169
  base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
171
- record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any },
170
+ record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; lifetimeUsage: LifetimeUsage },
172
171
  activity?: AgentActivity,
173
172
  overrides?: Partial<AgentDetails>,
174
173
  ): AgentDetails {
175
174
  return {
176
175
  ...base,
177
176
  toolUses: record.toolUses,
178
- tokens: safeFormatTokens(record.session),
177
+ tokens: formatLifetimeTokens(record),
179
178
  turnCount: activity?.turnCount,
180
179
  maxTurns: activity?.maxTurns,
181
180
  durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
@@ -188,10 +187,7 @@ function buildDetails(
188
187
 
189
188
  /** Build notification details for the custom message renderer. */
190
189
  function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
191
- let totalTokens = 0;
192
- try {
193
- if (record.session) totalTokens = record.session.getSessionStats().tokens?.total ?? 0;
194
- } catch {}
190
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
195
191
 
196
192
  return {
197
193
  id: record.id,
@@ -262,16 +258,21 @@ export default function (pi: ExtensionAPI) {
262
258
  }
263
259
  );
264
260
 
265
- let currentProjectCwd = process.cwd?.() ?? undefined;
261
+ // The Agent tool's `subagent_type` description enumerates the currently
262
+ // available agent types. It is built once at tool registration, but sibling
263
+ // extensions (e.g. pi-pi) register dynamic agents LATER via
264
+ // `subagents:register-agents`. The host reads `parameters.…description` by
265
+ // reference fresh on each request, so mutating this field in place refreshes
266
+ // the advertised list without re-registering the tool.
267
+ let subagentTypeSchema: { description?: string } | undefined;
268
+ const buildSubagentTypeDesc = (): string =>
269
+ `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`;
266
270
 
267
271
  /** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
268
- const getProjectCwd = () => currentProjectCwd;
269
-
270
272
  const reloadCustomAgents = () => {
271
- const cwd = getProjectCwd();
272
- if (!cwd) return;
273
- const userAgents = loadCustomAgents(cwd);
273
+ const userAgents = loadCustomAgents(process.cwd());
274
274
  registerAgents(userAgents);
275
+ if (subagentTypeSchema) subagentTypeSchema.description = buildSubagentTypeDesc();
275
276
  };
276
277
 
277
278
  // Initial load
@@ -290,7 +291,7 @@ export default function (pi: ExtensionAPI) {
290
291
  cancelNudge(key);
291
292
  pendingNudges.set(key, setTimeout(() => {
292
293
  pendingNudges.delete(key);
293
- send();
294
+ try { send(); } catch { /* ignore stale completion side-effect errors */ }
294
295
  }, delay));
295
296
  }
296
297
 
@@ -306,17 +307,15 @@ export default function (pi: ExtensionAPI) {
306
307
  function emitIndividualNudge(record: AgentRecord) {
307
308
  if (record.resultConsumed) return; // re-check at send time
308
309
 
309
- const shouldTriggerTurn = !manager.hasRunning();
310
310
  const notification = formatTaskNotification(record, 500);
311
- const footer = record.outputFile ? `
312
- Full transcript available at: ${record.outputFile}` : '';
311
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
313
312
 
314
313
  pi.sendMessage<NotificationDetails>({
315
314
  customType: "subagent-notification",
316
315
  content: notification + footer,
317
316
  display: true,
318
317
  details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
319
- }, { deliverAs: "followUp", triggerTurn: shouldTriggerTurn });
318
+ }, { deliverAs: "followUp", triggerTurn: true });
320
319
  }
321
320
 
322
321
  function sendIndividualNudge(record: AgentRecord) {
@@ -348,17 +347,12 @@ Full transcript available at: ${record.outputFile}` : '';
348
347
  details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
349
348
  }
350
349
 
351
- const shouldTriggerTurn = !partial && !manager.hasRunning();
352
350
  pi.sendMessage<NotificationDetails>({
353
351
  customType: "subagent-notification",
354
- content: `Background agent group completed: ${label}
355
-
356
- ${notifications}
357
-
358
- Use get_subagent_result for full output.`,
352
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
359
353
  display: true,
360
354
  details,
361
- }, { deliverAs: "followUp", triggerTurn: shouldTriggerTurn });
355
+ }, { deliverAs: "followUp", triggerTurn: true });
362
356
  });
363
357
  widget.update();
364
358
  },
@@ -368,24 +362,15 @@ Use get_subagent_result for full output.`,
368
362
  /** Helper: build event data for lifecycle events from an AgentRecord. */
369
363
  function buildEventData(record: AgentRecord) {
370
364
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
371
- let tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number; cost: number } | undefined;
372
- try {
373
- if (record.session) {
374
- const stats = record.session.getSessionStats();
375
- tokens = {
376
- input: stats.tokens?.input ?? 0,
377
- output: stats.tokens?.output ?? 0,
378
- cacheRead: stats.tokens?.cacheRead ?? 0,
379
- cacheWrite: stats.tokens?.cacheWrite ?? 0,
380
- total: stats.tokens?.total ?? 0,
381
- cost: typeof stats.cost === "number" ? stats.cost : 0,
382
- };
383
- }
384
- } catch { /* session stats unavailable */ }
385
- let modelId: string | undefined;
386
- try {
387
- modelId = record.session?.model?.id;
388
- } catch {}
365
+ // All three fields are lifetime-accumulated over every assistant message_end),
366
+ // so they survive compaction together — input + output ≤ total always.
367
+ // tokens is omitted when nothing was ever produced (e.g. agent errored before
368
+ // any message_end fired), preserving prior payload shape.
369
+ const u = record.lifetimeUsage;
370
+ const total = getLifetimeTotal(u);
371
+ const tokens = total > 0
372
+ ? { input: u.input, output: u.output, total }
373
+ : undefined;
389
374
  return {
390
375
  id: record.id,
391
376
  type: record.type,
@@ -395,39 +380,27 @@ Use get_subagent_result for full output.`,
395
380
  status: record.status,
396
381
  toolUses: record.toolUses,
397
382
  durationMs,
398
- modelId,
399
383
  tokens,
400
- toolCallId: record.toolCallId,
401
384
  };
402
385
  }
403
386
 
404
387
  // Background completion: route through group join or send individual nudge
405
388
  const manager = new AgentManager((record) => {
406
- let eventData: ReturnType<typeof buildEventData> | undefined;
407
- try {
408
- eventData = buildEventData(record);
409
- } catch {}
410
-
411
- if (eventData) {
412
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
413
- try {
414
- if (isError) {
415
- pi.events.emit("subagents:failed", eventData);
416
- } else {
417
- pi.events.emit("subagents:completed", eventData);
418
- }
419
- } catch (error) {
420
- }
389
+ // Emit lifecycle event based on terminal status
390
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
391
+ const eventData = buildEventData(record);
392
+ if (isError) {
393
+ pi.events.emit("subagents:failed", eventData);
394
+ } else {
395
+ pi.events.emit("subagents:completed", eventData);
421
396
  }
422
397
 
423
- try {
424
- pi.appendEntry("subagents:record", {
425
- id: record.id, type: record.type, description: record.description,
426
- status: record.status, result: record.result, error: record.error,
427
- startedAt: record.startedAt, completedAt: record.completedAt,
428
- });
429
- } catch (error) {
430
- }
398
+ // Persist final record for cross-extension history reconstruction
399
+ pi.appendEntry("subagents:record", {
400
+ id: record.id, type: record.type, description: record.description,
401
+ status: record.status, result: record.result, error: record.error,
402
+ startedAt: record.startedAt, completedAt: record.completedAt,
403
+ });
431
404
 
432
405
  // Skip notification if result was already consumed via get_subagent_result
433
406
  if (record.resultConsumed) {
@@ -453,62 +426,113 @@ Use get_subagent_result for full output.`,
453
426
  widget.update();
454
427
  }, undefined, (record) => {
455
428
  // Emit started event when agent transitions to running (including from queue)
456
- try {
457
- pi.events.emit("subagents:started", {
458
- id: record.id,
459
- type: record.type,
460
- description: record.description,
461
- });
462
- } catch {}
429
+ pi.events.emit("subagents:started", {
430
+ id: record.id,
431
+ type: record.type,
432
+ description: record.description,
433
+ });
434
+ }, (record, info) => {
435
+ // Emit compacted event when agent's session compacts (preserves count on record).
436
+ pi.events.emit("subagents:compacted", {
437
+ id: record.id,
438
+ type: record.type,
439
+ description: record.description,
440
+ reason: info.reason,
441
+ tokensBefore: info.tokensBefore,
442
+ compactionCount: record.compactionCount,
443
+ });
463
444
  });
464
445
 
465
- // Expose manager via Symbol.for() global registry for cross-package access.
466
- // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
446
+ // Expose manager + running-agents menu via Symbol.for() global registry for
447
+ // cross-package access. Standard Node.js pattern for cross-package singletons
448
+ // (used by OpenTelemetry, etc.).
467
449
  const MANAGER_KEY = Symbol.for("pi-subagents:manager");
468
- (globalThis as any)[MANAGER_KEY] = {
469
- waitForAll: () => manager.waitForAll(),
470
- hasRunning: () => manager.hasRunning(),
471
- spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
472
- manager.spawn(piRef, ctx, type, prompt, options),
473
- getRecord: (id: string) => manager.getRecord(id),
474
- refreshWidget: (uiCtx?: any) => {
475
- if (uiCtx) widget.setUICtx(uiCtx as UICtx);
476
- widget.update();
477
- },
478
- };
450
+ const MENU_KEY = Symbol.for("pi-subagents:menu");
451
+ // Nested in-process subagent sessions re-run this factory (the resource loader
452
+ // disables module caching, so every session's reload() re-executes extension
453
+ // factories). Only the first/owning invocation may publish these globals: a
454
+ // nested re-run must leave them pointing at the root session's manager. If it
455
+ // overwrote them with its own fresh, empty manager, `/pp > Subagents` (which
456
+ // reads globalThis[MENU_KEY]) would bind to that empty manager while the real
457
+ // agents live in the original one — the "background agents never show" bug.
458
+ const ownsGlobals = (globalThis as any)[MANAGER_KEY] === undefined;
459
+ if (ownsGlobals) {
460
+ (globalThis as any)[MANAGER_KEY] = {
461
+ waitForAll: () => manager.waitForAll(),
462
+ hasRunning: () => manager.hasRunning(),
463
+ spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
464
+ manager.spawn(piRef, ctx, type, prompt, options),
465
+ getRecord: (id: string) => manager.getRecord(id),
466
+ // Refresh the above-editor widget from outside an LLM turn. Agents spawned
467
+ // via cross-extension RPC (e.g. pi-pi orchestrator planners/reviewers)
468
+ // start while the main agent is only waiting, so no tool_execution_start
469
+ // fires to hand the widget a UICtx. Callers pass their own ctx.ui so the
470
+ // widget can register and render. Restored after the v0.13.0 subtree
471
+ // update dropped this local patch.
472
+ refreshWidget: (uiCtx?: any) => {
473
+ if (uiCtx) widget.setUICtx(uiCtx as UICtx);
474
+ widget.ensureTimer();
475
+ widget.update();
476
+ },
477
+ // Runtime control of the background-concurrency limit from outside this
478
+ // package (pi-pi orchestrator applies its configured
479
+ // agents.maxConcurrentSubagents here). setMaxConcurrent clamps >=1 and
480
+ // drains the queue, so raising it mid-session starts queued agents
481
+ // immediately. Local patch — restore after a subtree update drops it.
482
+ setMaxConcurrent: (n: number) => manager.setMaxConcurrent(n),
483
+ getMaxConcurrent: () => manager.getMaxConcurrent(),
484
+ };
485
+
486
+ (globalThis as any)[MENU_KEY] = {
487
+ showFleet: (ctx: any) => showRunningAgents(ctx),
488
+ };
489
+ }
479
490
 
480
491
  // --- Cross-extension RPC via pi.events ---
481
492
  let currentCtx: ExtensionContext | undefined;
482
493
 
483
- // Capture ctx from session_start for RPC spawn handler
484
- pi.on("session_start", async (_event, ctx) => {
485
- currentCtx = ctx;
486
- currentProjectCwd = ctx.cwd ?? currentProjectCwd;
487
- if ((globalThis as any)[Symbol.for("pi-pi:subagent-session")]) {
488
- pi.appendEntry("subagent-session", { active: true });
494
+ // ---- Subagent scheduler ----
495
+ // Session-scoped: store is constructed inside session_start once sessionId
496
+ // is available. Mirrors pi-chonky-tasks's session-scoped task store —
497
+ // schedules reset on /new, restore on /resume.
498
+ const scheduler = new SubagentScheduler();
499
+
500
+ function startScheduler(ctx: ExtensionContext) {
501
+ try {
502
+ const sessionId = ctx.sessionManager?.getSessionId?.();
503
+ if (!sessionId) return; // sessionId not yet available — try again on next event
504
+ const path = resolveStorePath(ctx.cwd, sessionId);
505
+ const store = new ScheduleStore(path);
506
+ scheduler.start(pi, ctx, manager, store);
507
+ pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length });
508
+ } catch (err) {
509
+ // Scheduling is non-essential — log and move on so the rest of the
510
+ // extension keeps working if e.g. .pi/ is unwritable.
511
+ console.warn("[pi-subagents] Failed to start scheduler:", err);
489
512
  }
490
- manager.clearCompleted(); // preserve existing behavior
491
- });
513
+ }
492
514
 
493
- let managedSession = false;
494
- pi.events.on("subagents:set-managed", (data: any) => {
495
- managedSession = data?.managed === true;
515
+ // Capture ctx from session_start for RPC spawn handler + start the scheduler.
516
+ pi.on("session_start", async (_event, ctx) => {
517
+ currentCtx = ctx;
518
+ manager.clearCompleted(true);
519
+ if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
496
520
  });
497
521
 
498
- pi.on("session_switch", (event: any) => {
499
- if (!managedSession) manager.clearCompleted();
522
+ pi.on("session_before_switch", () => {
523
+ manager.clearCompleted(true);
524
+ scheduler.stop();
500
525
  });
501
526
 
502
527
  const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
503
528
  events: pi.events,
504
529
  pi,
505
530
  getCtx: () => currentCtx,
506
- isSubagentSession: (ctx: unknown) => Boolean((ctx as any)?.sessionManager?.getEntries?.().some?.((entry: any) => entry?.type === "custom" && entry?.customType === "subagent-session")),
507
531
  manager,
508
532
  });
509
533
 
510
- // Allow other extensions to take full control of agent registry
511
- pi.events.on("subagents:set-extension-only", (data: any) => {
534
+ // Allow other extensions to take full control of the agent registry
535
+ const unsubExtensionOnly = pi.events.on("subagents:set-extension-only", (data: any) => {
512
536
  if (typeof data?.enabled === "boolean") {
513
537
  setExtensionOnlyMode(data.enabled);
514
538
  reloadCustomAgents();
@@ -516,21 +540,21 @@ Use get_subagent_result for full output.`,
516
540
  });
517
541
 
518
542
  // Allow other extensions to register in-memory agent types
519
- pi.events.on("subagents:register-agents", (data: any) => {
520
- if (data?.agents && data.agents instanceof Map) {
543
+ const unsubRegisterAgents = pi.events.on("subagents:register-agents", (data: any) => {
544
+ if (data?.agents instanceof Map) {
521
545
  registerExtensionAgents(data.agents);
522
546
  reloadCustomAgents();
523
547
  }
524
548
  });
525
549
 
526
- pi.events.on("subagents:unregister-agents", (data: any) => {
550
+ const unsubUnregisterAgents = pi.events.on("subagents:unregister-agents", (data: any) => {
527
551
  if (data?.all === true) {
528
552
  clearExtensionAgents();
529
553
  reloadCustomAgents();
530
- } else if (data?.names && Array.isArray(data.names)) {
554
+ } else if (Array.isArray(data?.names)) {
531
555
  unregisterExtensionAgents(data.names);
532
556
  reloadCustomAgents();
533
- } else if (data?.prefix && typeof data.prefix === "string") {
557
+ } else if (typeof data?.prefix === "string") {
534
558
  unregisterExtensionAgentsByPrefix(data.prefix);
535
559
  reloadCustomAgents();
536
560
  }
@@ -539,20 +563,57 @@ Use get_subagent_result for full output.`,
539
563
  // Broadcast readiness so extensions loaded after us can discover us
540
564
  pi.events.emit("subagents:ready", {});
541
565
 
542
- // Interactive session transitions can emit session_shutdown while background
543
- // agents are still alive. Keep the subagent manager alive across shutdown so
544
- // background sessions can finish and still be surfaced in the next parent session.
566
+ // On shutdown, abort all agents immediately and clean up.
567
+ // If the session is going down, there's nothing left to consume agent results.
545
568
  pi.on("session_shutdown", async () => {
546
- return;
569
+ unsubSpawnRpc();
570
+ unsubStopRpc();
571
+ unsubPingRpc();
572
+ unsubExtensionOnly();
573
+ unsubRegisterAgents();
574
+ unsubUnregisterAgents();
575
+ currentCtx = undefined;
576
+ // Only the owning (root) invocation may retract the shared globals; a nested
577
+ // subagent session shutting down must not delete the root session's manager.
578
+ if (ownsGlobals) {
579
+ delete (globalThis as any)[MANAGER_KEY];
580
+ delete (globalThis as any)[MENU_KEY];
581
+ }
582
+ scheduler.stop();
583
+ manager.abortAll();
584
+ for (const timer of pendingNudges.values()) clearTimeout(timer);
585
+ pendingNudges.clear();
586
+ manager.dispose();
547
587
  });
548
588
 
549
- // Live widget: show running agents above editor
550
- const widget = new AgentWidget(manager, agentActivity);
589
+ // Live widget: show running agents above editor.
590
+ // widgetMode (default "background") selects what the widget shows: "all" =
591
+ // every agent; "background" = hide foreground (they already render inline as
592
+ // the Agent tool result, so showing them here too is a duplicate, #118), keep
593
+ // everything else; "off" = hide the widget entirely. Read live at render time.
594
+ const widgetMode: WidgetMode = "background";
595
+ function getWidgetMode(): WidgetMode { return widgetMode; }
596
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
551
597
 
552
598
  // ---- Join mode configuration ----
553
- let defaultJoinMode: JoinMode = 'smart';
554
- function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
555
- function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
599
+ const defaultJoinMode: JoinMode = 'smart';
600
+
601
+ // Master switch for the schedule subagent feature.
602
+ const schedulingEnabled = true;
603
+ function isSchedulingEnabled(): boolean { return schedulingEnabled; }
604
+
605
+ // ---- Scope models configuration ----
606
+ // When enabled, subagent model choices are validated against `enabledModels`
607
+ // from pi's settings — both global `<agentDir>/settings.json` and
608
+ // project-local `<cwd>/.pi/settings.json` (project overrides global).
609
+ const scopeModelsEnabled = false;
610
+ function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
611
+
612
+ // ---- Agent tool description mode ----
613
+ // "full" keeps the rich Claude Code-style description; read once at tool
614
+ // registration.
615
+ const toolDescriptionMode: ToolDescriptionMode = "full";
616
+ function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
556
617
 
557
618
  // ---- Batch tracking for smart join mode ----
558
619
  // Collects background agent IDs spawned in the current turn for smart grouping.
@@ -599,43 +660,45 @@ Use get_subagent_result for full output.`,
599
660
 
600
661
  // Grab UI context from first tool execution + clear lingering widget on new turn
601
662
  pi.on("tool_execution_start", async (_event, ctx) => {
602
- const hadUiCtx = Boolean((widget as any).uiCtx);
603
663
  widget.setUICtx(ctx.ui as UICtx);
604
- if (!hadUiCtx) widget.update();
605
664
  widget.onTurnStart();
606
665
  });
607
666
 
608
- pi.on("before_agent_start", async (_event, ctx) => {
609
- const hadUiCtx = Boolean((widget as any).uiCtx);
610
- widget.setUICtx(ctx.ui as UICtx);
611
- if (!hadUiCtx) widget.update();
612
- });
667
+ /** Format an agent's tool scope: "*" when it has all built-ins, else a comma-separated list. */
668
+ const formatToolsSuffix = (cfg: AgentConfig | undefined): string => {
669
+ const tools = cfg?.builtinToolNames;
670
+ if (!tools || tools.length === 0) return "*";
671
+ const isFullSet =
672
+ tools.length === BUILTIN_TOOL_NAMES.length
673
+ && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
674
+ return isFullSet ? "*" : tools.join(", ");
675
+ };
613
676
 
614
- /** Build the full type list text dynamically from the unified registry. */
677
+ /** Build the full type list text dynamically from available agents only. */
615
678
  const buildTypeListText = () => {
616
- const defaultNames = getDefaultAgentNames();
617
- const userNames = getUserAgentNames();
679
+ const available = getAvailableTypes();
618
680
 
619
- const defaultDescs = defaultNames.map((name) => {
681
+ return available.map((name) => {
620
682
  const cfg = getAgentConfig(name);
621
683
  const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
622
- return `- ${name}: ${cfg?.description ?? name}${modelSuffix}`;
623
- });
624
-
625
- const customDescs = userNames.map((name) => {
626
- const cfg = getAgentConfig(name);
627
- return `- ${name}: ${cfg?.description ?? name}`;
628
- });
684
+ const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
685
+ return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
686
+ }).join("\n");
687
+ };
629
688
 
630
- return [
631
- "Default agents:",
632
- ...defaultDescs,
633
- ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
634
- "",
635
- "Custom agents can be defined in .pi/agents/<name>.md (project) or ~/.pi/agent/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.",
636
- ].join("\n");
689
+ /** First sentence of an agent description — for the compact type list. */
690
+ const firstSentence = (text: string): string => {
691
+ const match = text.match(/^.*?[.!?](?=\s|$)/s);
692
+ return (match ? match[0] : text).replace(/\s+/g, " ").trim();
637
693
  };
638
694
 
695
+ /** Compact type list: one line per agent, first sentence only. */
696
+ const buildCompactTypeListText = () =>
697
+ getAvailableTypes().map((name) => {
698
+ const cfg = getAgentConfig(name);
699
+ return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
700
+ }).join("\n");
701
+
639
702
  /** Derive a short model label from a model string. */
640
703
  function getModelLabelFromConfig(model: string): string {
641
704
  // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
@@ -644,34 +707,148 @@ Use get_subagent_result for full output.`,
644
707
  return name.replace(/-\d{8}$/, "");
645
708
  }
646
709
 
647
- const typeListText = buildTypeListText();
648
-
649
710
  // ---- Agent tool ----
650
711
 
651
- pi.registerTool<any, AgentDetails>({
652
- name: "Agent",
653
- label: "Agent",
654
- description: `Launch a new agent to handle complex, multi-step tasks autonomously.
655
-
656
- The Agent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
657
-
658
- Available agent types:
659
- ${typeListText}
660
-
661
- Guidelines:
662
- - For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.
663
- - Use Explore for codebase searches and code understanding.
664
- - Use Plan for architecture and implementation planning.
665
- - Use general-purpose for complex tasks that need file editing.
666
- - Provide clear, detailed prompts so the agent can work autonomously.
667
- - Agent results are returned as text — summarize them for the user.
668
- - Use run_in_background for work you don't need immediately. You will be notified when it completes.
669
- - Use resume with an agent ID to continue a previous agent's work.
712
+ // Schedule param + its guideline are gated on `schedulingEnabled` (read once
713
+ // at registration; flipping the setting later requires next pi session for
714
+ // the schema to update). Defining the shape once and spreading it via Partial
715
+ // preserves Type.Object's inference when present and produces a
716
+ // `schedule`-free schema when absent — zero LLM-context cost in disabled mode.
717
+ const scheduleParamShape = {
718
+ schedule: Type.Optional(
719
+ Type.String({
720
+ description:
721
+ 'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
722
+ 'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
723
+ 'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.',
724
+ }),
725
+ ),
726
+ };
727
+ const scheduleParam: Partial<typeof scheduleParamShape> =
728
+ isSchedulingEnabled() ? scheduleParamShape : {};
729
+
730
+ const scheduleGuideline = isSchedulingEnabled()
731
+ ? `\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.`
732
+ : "";
733
+
734
+ // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
735
+ // the same load-bearing facts as the full version at ~75% fewer tokens, for
736
+ // small/local models. Per-option details live in the param descriptions.
737
+ const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
738
+ ${buildCompactTypeListText()}
739
+
740
+ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
741
+
742
+ Notes:
743
+ - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
744
+ - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
745
+ - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
746
+ - resume continues a previous agent by ID; steer_subagent messages a running one.
747
+ - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
748
+
749
+ const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
750
+
751
+ Available agent types and the tools they have access to:
752
+ ${buildTypeListText()}
753
+
754
+ Additional agent types may be registered dynamically at runtime — see the subagent_type parameter for the authoritative, always-current list.
755
+
756
+ Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
757
+
758
+ When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
759
+
760
+ ## When not to use
761
+
762
+ If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
763
+
764
+ ## Usage notes
765
+
766
+ - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
767
+ - When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
768
+ - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
769
+ - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
770
+ - 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.
771
+ - 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.
772
+ - 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.
670
773
  - Use steer_subagent to send mid-run messages to a running background agent.
774
+ - 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.
775
+ - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
671
776
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
672
777
  - Use thinking to control extended thinking level.
673
778
  - Use inherit_context if the agent needs the parent conversation history.
674
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).`,
779
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
780
+
781
+ ## Writing the prompt
782
+
783
+ Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
784
+ - Explain what you're trying to accomplish and why.
785
+ - Describe what you've already learned or ruled out.
786
+ - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
787
+ - If you need a short response, say so ("report in under 200 words").
788
+ - Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
789
+
790
+ Terse command-style prompts produce shallow, generic work.
791
+
792
+ **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.`;
793
+
794
+ // `toolDescriptionMode: "custom"` — user-authored description with live
795
+ // dynamic parts. Project file wins over global; missing/empty falls back to
796
+ // "full" (a stale fallback beats a blank tool description). Only the prose
797
+ // is customizable — the parameter schema stays code-owned.
798
+ const renderToolDescriptionTemplate = (template: string): string => {
799
+ const vars: Record<string, () => string> = {
800
+ typeList: buildTypeListText,
801
+ compactTypeList: buildCompactTypeListText,
802
+ agentDir: getAgentDir,
803
+ scheduleGuideline: () => scheduleGuideline,
804
+ };
805
+ // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
806
+ return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
807
+ if (vars[name]) return vars[name]();
808
+ console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
809
+ return raw;
810
+ });
811
+ };
812
+
813
+ const loadCustomToolDescription = (): string | undefined => {
814
+ for (const path of [
815
+ join(process.cwd(), ".pi", "agent-tool-description.md"),
816
+ join(getAgentDir(), "agent-tool-description.md"),
817
+ ]) {
818
+ try {
819
+ if (!existsSync(path)) continue;
820
+ const text = readFileSync(path, "utf-8").trim();
821
+ if (text) return renderToolDescriptionTemplate(text);
822
+ console.warn(`[pi-subagents] ${path} is empty — ignoring`);
823
+ } catch (err) {
824
+ console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
825
+ }
826
+ }
827
+ return undefined;
828
+ };
829
+
830
+ const agentToolDescription = (() => {
831
+ const mode = getToolDescriptionMode();
832
+ if (mode === "compact") return compactAgentToolDescription;
833
+ if (mode === "custom") {
834
+ const custom = loadCustomToolDescription();
835
+ if (custom) return custom;
836
+ console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
837
+ }
838
+ return fullAgentToolDescription;
839
+ })();
840
+
841
+ pi.registerTool(defineTool({
842
+ name: SUBAGENT_TOOL_NAMES.AGENT,
843
+ label: "Agent",
844
+ description: agentToolDescription,
845
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
846
+ promptGuidelines: [
847
+ "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.",
848
+ "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.",
849
+ "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.",
850
+ "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.",
851
+ ],
675
852
  parameters: Type.Object({
676
853
  prompt: Type.String({
677
854
  description: "The task for the agent to perform.",
@@ -679,9 +856,9 @@ Guidelines:
679
856
  description: Type.String({
680
857
  description: "A short (3-5 word) description of the task (shown in UI).",
681
858
  }),
682
- subagent_type: Type.String({
683
- description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ~/.pi/agent/agents/*.md (global) are also available.`,
684
- }),
859
+ subagent_type: (subagentTypeSchema = Type.String({
860
+ description: buildSubagentTypeDesc(),
861
+ })),
685
862
  model: Type.Optional(
686
863
  Type.String({
687
864
  description:
@@ -724,6 +901,7 @@ Guidelines:
724
901
  description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
725
902
  }),
726
903
  ),
904
+ ...scheduleParam,
727
905
  }),
728
906
 
729
907
  // ---- Custom rendering: Claude Code style ----
@@ -741,7 +919,7 @@ Guidelines:
741
919
  return new Text(text, 0, 0);
742
920
  }
743
921
 
744
- // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
922
+ // Helper: build "haiku · thinking: high · 5≤30 · 3 tool uses · 33.8k tokens" stats string
745
923
  const stats = (d: AgentDetails) => {
746
924
  const parts: string[] = [];
747
925
  if (d.modelName) parts.push(d.modelName);
@@ -758,9 +936,7 @@ Guidelines:
758
936
  if (isPartial || details.status === "running") {
759
937
  const frame = SPINNER[details.spinnerFrame ?? 0];
760
938
  const s = stats(details);
761
- let line = theme.fg("accent", frame) + (s ? " " + s : "");
762
- line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
763
- return new Text(line, 0, 0);
939
+ return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
764
940
  }
765
941
 
766
942
  // ---- Background agent launched ----
@@ -849,34 +1025,111 @@ Guidelines:
849
1025
  }
850
1026
  }
851
1027
 
1028
+ // Scope validation: the effective resolved model is checked against the
1029
+ // user's enabledModels list (read in `enabled-models.ts`).
1030
+ //
1031
+ // Design: scopeModels guards against *runtime* LLM choices, not user-level config.
1032
+ // - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
1033
+ // out-of-scope choice; surface it so it picks differently).
1034
+ // - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
1035
+ // user authored/installed this agent or chose the parent's model; trust it).
1036
+ // See SubagentsSettings.scopeModels docstring for the full policy.
1037
+ if (isScopeModelsEnabled() && model) {
1038
+ const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
1039
+ if (allowed && !isModelInScope(model, allowed)) {
1040
+ if (resolvedConfig.modelFromParams) {
1041
+ const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
1042
+ return textResult(
1043
+ `Model not in scope: "${resolvedConfig.modelInput}".\n\n` +
1044
+ `Allowed models (from enabledModels):\n${list}`,
1045
+ );
1046
+ }
1047
+ // Frontmatter-pinned or parent-inherited: warn + proceed.
1048
+ const agentLabel = customConfig?.displayName ?? subagentType;
1049
+ const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
1050
+ ctx.ui.notify(
1051
+ `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
1052
+ "warning",
1053
+ );
1054
+ }
1055
+ }
1056
+
852
1057
  const thinking = resolvedConfig.thinking;
853
1058
  const inheritContext = resolvedConfig.inheritContext;
854
1059
  const runInBackground = resolvedConfig.runInBackground;
855
1060
  const isolated = resolvedConfig.isolated;
856
1061
  const isolation = resolvedConfig.isolation;
857
1062
 
858
- // Build display tags for non-default config
859
1063
  const parentModelId = ctx.model?.id;
860
1064
  const effectiveModelId = model?.id;
861
- const agentModelName = effectiveModelId && effectiveModelId !== parentModelId
1065
+ const modelName = effectiveModelId && effectiveModelId !== parentModelId
862
1066
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
863
1067
  : undefined;
864
- const agentTags: string[] = [];
865
- const modeLabel = getPromptModeLabel(subagentType);
866
- if (modeLabel) agentTags.push(modeLabel);
867
- if (thinking) agentTags.push(`thinking: ${thinking}`);
868
- if (isolated) agentTags.push("isolated");
869
- if (isolation === "worktree") agentTags.push("worktree");
870
1068
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
871
- // Shared base fields for all AgentDetails in this call
1069
+ const agentInvocation: AgentInvocation = {
1070
+ modelName,
1071
+ thinking,
1072
+ // Explicit value only — the default fallback would just add noise.
1073
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1074
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
1075
+ isolated,
1076
+ inheritContext,
1077
+ runInBackground,
1078
+ isolation,
1079
+ };
1080
+ // Tool-result render shows the mode label too; viewer's header already does.
1081
+ const modeLabel = getPromptModeLabel(subagentType);
1082
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
1083
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
872
1084
  const detailBase = {
873
1085
  displayName,
874
1086
  description: params.description,
875
1087
  subagentType,
876
- modelName: agentModelName,
1088
+ modelName,
877
1089
  tags: agentTags.length > 0 ? agentTags : undefined,
878
1090
  };
879
1091
 
1092
+ // ---- Schedule: register a job, don't spawn now ----
1093
+ if (params.schedule) {
1094
+ if (!isSchedulingEnabled()) {
1095
+ return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.");
1096
+ }
1097
+ if (params.resume) {
1098
+ return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents.");
1099
+ }
1100
+ if (params.inherit_context) {
1101
+ return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
1102
+ }
1103
+ if (params.run_in_background === false) {
1104
+ return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.");
1105
+ }
1106
+ if (!scheduler.isActive()) {
1107
+ return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
1108
+ }
1109
+ try {
1110
+ const job = scheduler.addJob({
1111
+ name: params.description as string,
1112
+ description: params.description as string,
1113
+ schedule: params.schedule as string,
1114
+ subagent_type: subagentType,
1115
+ prompt: params.prompt as string,
1116
+ model: params.model as string | undefined,
1117
+ thinking: thinking,
1118
+ max_turns: effectiveMaxTurns,
1119
+ isolated: isolated,
1120
+ isolation: isolation,
1121
+ });
1122
+ const next = scheduler.getNextRun(job.id);
1123
+ return textResult(
1124
+ `Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1125
+ `Next run: ${next ?? "(unknown)"}. ` +
1126
+ `Manage via /agents → Scheduled jobs.`,
1127
+ );
1128
+ } catch (err) {
1129
+ return textResult(err instanceof Error ? err.message : String(err));
1130
+ }
1131
+ }
1132
+
880
1133
  // Resume existing agent
881
1134
  if (params.resume) {
882
1135
  const existing = manager.getRecord(params.resume);
@@ -900,14 +1153,10 @@ Guidelines:
900
1153
  if (runInBackground) {
901
1154
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
902
1155
 
903
- let id: string;
904
- // first_tool/first_turn are now emitted centrally by AgentManager.startAgent
905
- // (the single choke point for ALL spawn paths, including RPC-spawned panels),
906
- // so no per-branch wiring is needed here.
907
-
908
1156
  // Wrap onSessionCreated to wire output file streaming.
909
1157
  // The callback lazily reads record.outputFile (set right after spawn)
910
1158
  // rather than closing over a value that doesn't exist yet.
1159
+ let id: string;
911
1160
  const origBgOnSession = bgCallbacks.onSessionCreated;
912
1161
  bgCallbacks.onSessionCreated = (session: any) => {
913
1162
  origBgOnSession(session);
@@ -917,25 +1166,22 @@ Guidelines:
917
1166
  }
918
1167
  };
919
1168
 
920
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
921
- description: params.description,
922
- model,
923
- maxTurns: effectiveMaxTurns,
924
- isolated,
925
- inheritContext,
926
- thinkingLevel: thinking,
927
- isBackground: true,
928
- isolation,
929
- toolCallId,
930
- ...bgCallbacks,
931
- });
932
-
933
- pi.events.emit("subagents:created", {
934
- id,
935
- type: subagentType,
936
- description: params.description,
937
- isBackground: true,
938
- });
1169
+ try {
1170
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1171
+ description: params.description,
1172
+ model,
1173
+ maxTurns: effectiveMaxTurns,
1174
+ isolated,
1175
+ inheritContext,
1176
+ thinkingLevel: thinking,
1177
+ isBackground: true,
1178
+ isolation,
1179
+ invocation: agentInvocation,
1180
+ ...bgCallbacks,
1181
+ });
1182
+ } catch (err) {
1183
+ return textResult(err instanceof Error ? err.message : String(err));
1184
+ }
939
1185
 
940
1186
  // Set output file + join mode synchronously after spawn, before the
941
1187
  // event loop yields — onSessionCreated is async so this is safe.
@@ -944,11 +1190,8 @@ Guidelines:
944
1190
  if (record && joinMode) {
945
1191
  record.joinMode = joinMode;
946
1192
  record.toolCallId = toolCallId;
947
- const sessionId = ctx.sessionManager.getSessionId?.();
948
- if (ctx.cwd && sessionId) {
949
- record.outputFile = createOutputFilePath(ctx.cwd, id, sessionId);
950
- writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
951
- }
1193
+ record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1194
+ writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
952
1195
  }
953
1196
 
954
1197
  if (joinMode == null || joinMode === 'async') {
@@ -966,6 +1209,14 @@ Guidelines:
966
1209
  widget.ensureTimer();
967
1210
  widget.update();
968
1211
 
1212
+ // Emit created event
1213
+ pi.events.emit("subagents:created", {
1214
+ id,
1215
+ type: subagentType,
1216
+ description: params.description,
1217
+ isBackground: true,
1218
+ });
1219
+
969
1220
  const isQueued = record?.status === "queued";
970
1221
  return textResult(
971
1222
  `Agent ${isQueued ? "queued" : "started"} in background.\n` +
@@ -990,7 +1241,7 @@ Guidelines:
990
1241
  const details: AgentDetails = {
991
1242
  ...detailBase,
992
1243
  toolUses: fgState.toolUses,
993
- tokens: fgState.tokens,
1244
+ tokens: formatLifetimeTokens(fgState),
994
1245
  turnCount: fgState.turnCount,
995
1246
  maxTurns: fgState.maxTurns,
996
1247
  durationMs: Date.now() - startedAt,
@@ -1006,7 +1257,9 @@ Guidelines:
1006
1257
 
1007
1258
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1008
1259
 
1009
- // Wire session creation to register in widget
1260
+ // Wire session creation: register in widget + stream to output file.
1261
+ // The output file path is set synchronously after spawn (below),
1262
+ // before onSessionCreated fires — same pattern as background agents.
1010
1263
  const origOnSession = fgCallbacks.onSessionCreated;
1011
1264
  fgCallbacks.onSessionCreated = (session: any) => {
1012
1265
  origOnSession(session);
@@ -1018,6 +1271,13 @@ Guidelines:
1018
1271
  break;
1019
1272
  }
1020
1273
  }
1274
+ // Stream conversation to output file (foreground agent logging)
1275
+ if (fgId) {
1276
+ const rec = manager.getRecord(fgId);
1277
+ if (rec?.outputFile) {
1278
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1279
+ }
1280
+ }
1021
1281
  };
1022
1282
 
1023
1283
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
@@ -1028,17 +1288,33 @@ Guidelines:
1028
1288
 
1029
1289
  streamUpdate();
1030
1290
 
1031
- const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1032
- description: params.description,
1033
- model,
1034
- maxTurns: effectiveMaxTurns,
1035
- isolated,
1036
- inheritContext,
1037
- thinkingLevel: thinking,
1038
- isolation,
1039
- toolCallId,
1040
- ...fgCallbacks,
1041
- });
1291
+ let record: AgentRecord;
1292
+ try {
1293
+ const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1294
+ description: params.description,
1295
+ model,
1296
+ maxTurns: effectiveMaxTurns,
1297
+ isolated,
1298
+ inheritContext,
1299
+ thinkingLevel: thinking,
1300
+ isolation,
1301
+ invocation: agentInvocation,
1302
+ signal,
1303
+ ...fgCallbacks,
1304
+ }, (fgAgentId) => {
1305
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
1306
+ // Set up the output file so streamToOutputFile can pick it up.
1307
+ const fgRec = manager.getRecord(fgAgentId);
1308
+ if (fgRec) {
1309
+ fgRec.outputFile = createOutputFilePath(ctx.cwd, fgAgentId, ctx.sessionManager.getSessionId());
1310
+ writeInitialEntry(fgRec.outputFile, fgAgentId, params.prompt, ctx.cwd);
1311
+ }
1312
+ });
1313
+ record = fgResult.record;
1314
+ } catch (err) {
1315
+ clearInterval(spinnerInterval);
1316
+ return textResult(err instanceof Error ? err.message : String(err));
1317
+ }
1042
1318
 
1043
1319
  clearInterval(spinnerInterval);
1044
1320
 
@@ -1049,12 +1325,14 @@ Guidelines:
1049
1325
  }
1050
1326
 
1051
1327
  // Get final token count
1052
- const tokenText = safeFormatTokens(fgState.session);
1328
+ const tokenText = formatLifetimeTokens(fgState);
1053
1329
 
1054
1330
  const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
1055
1331
 
1332
+ // "general-purpose" may itself be unregistered (defaults disabled, no
1333
+ // user override) — getConfig then uses the hardcoded fallback config.
1056
1334
  const fallbackNote = fellBack
1057
- ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
1335
+ ? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
1058
1336
  : "";
1059
1337
 
1060
1338
  if (record.status === "error") {
@@ -1070,15 +1348,16 @@ Guidelines:
1070
1348
  details,
1071
1349
  );
1072
1350
  },
1073
- });
1351
+ }));
1074
1352
 
1075
1353
  // ---- get_subagent_result tool ----
1076
1354
 
1077
- pi.registerTool({
1078
- name: "get_subagent_result",
1355
+ pi.registerTool(defineTool({
1356
+ name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1079
1357
  label: "Get Agent Result",
1080
1358
  description:
1081
1359
  "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1360
+ promptSnippet: "Check status and retrieve results from a background agent",
1082
1361
  parameters: Type.Object({
1083
1362
  agent_id: Type.String({
1084
1363
  description: "The agent ID to check.",
@@ -1112,12 +1391,17 @@ Guidelines:
1112
1391
 
1113
1392
  const displayName = getDisplayName(record.type);
1114
1393
  const duration = formatDuration(record.startedAt, record.completedAt);
1115
- const tokens = safeFormatTokens(record.session);
1116
- const toolStats = tokens ? `Tool uses: ${record.toolUses} | ${tokens}` : `Tool uses: ${record.toolUses}`;
1394
+ const tokens = formatLifetimeTokens(record);
1395
+ const contextPercent = getSessionContextPercent(record.session);
1396
+ const statsParts = [`Tool uses: ${record.toolUses}`];
1397
+ if (tokens) statsParts.push(tokens);
1398
+ if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
1399
+ if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
1400
+ statsParts.push(`Duration: ${duration}`);
1117
1401
 
1118
1402
  let output =
1119
1403
  `Agent: ${record.id}\n` +
1120
- `Type: ${displayName} | Status: ${record.status} | ${toolStats} | Duration: ${duration}\n` +
1404
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1121
1405
  `Description: ${record.description}\n\n`;
1122
1406
 
1123
1407
  if (record.status === "running") {
@@ -1144,16 +1428,17 @@ Guidelines:
1144
1428
 
1145
1429
  return textResult(output);
1146
1430
  },
1147
- });
1431
+ }));
1148
1432
 
1149
1433
  // ---- steer_subagent tool ----
1150
1434
 
1151
- pi.registerTool({
1152
- name: "steer_subagent",
1435
+ pi.registerTool(defineTool({
1436
+ name: SUBAGENT_TOOL_NAMES.STEER,
1153
1437
  label: "Steer Agent",
1154
1438
  description:
1155
1439
  "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
1156
1440
  "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
1441
+ promptSnippet: "Send a steering message to redirect a running background agent",
1157
1442
  parameters: Type.Object({
1158
1443
  agent_id: Type.String({
1159
1444
  description: "The agent ID to steer (must be currently running).",
@@ -1181,149 +1466,29 @@ Guidelines:
1181
1466
  try {
1182
1467
  await steerAgent(record.session, params.message);
1183
1468
  pi.events.emit("subagents:steered", { id: record.id, message: params.message });
1184
- return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
1469
+ const tokens = formatLifetimeTokens(record);
1470
+ const contextPercent = getSessionContextPercent(record.session);
1471
+ const stateParts: string[] = [];
1472
+ if (tokens) stateParts.push(tokens);
1473
+ stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
1474
+ if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
1475
+ if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
1476
+ return textResult(
1477
+ `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
1478
+ `Current state: ${stateParts.join(" · ")}`,
1479
+ );
1185
1480
  } catch (err) {
1186
1481
  return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
1187
1482
  }
1188
1483
  },
1189
- });
1484
+ }));
1190
1485
 
1191
- // ---- /agents interactive menu ----
1192
-
1193
- const projectAgentsDir = () => {
1194
- const cwd = getProjectCwd();
1195
- return cwd ? join(cwd, ".pi", "agents") : undefined;
1196
- };
1197
- const personalAgentsDir = () => join(homedir(), ".pi", "agent", "agents");
1198
-
1199
- /** Find the file path of a custom agent by name (project first, then global). */
1200
- function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined {
1201
- const projectDir = projectAgentsDir();
1202
- if (projectDir) {
1203
- const projectPath = join(projectDir, `${name}.md`);
1204
- if (existsSync(projectPath)) return { path: projectPath, location: "project" };
1205
- }
1206
- const personalPath = join(personalAgentsDir(), `${name}.md`);
1207
- if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
1208
- return undefined;
1209
- }
1210
-
1211
- function getModelLabel(type: string, registry?: ModelRegistry): string {
1212
- const cfg = getAgentConfig(type);
1213
- if (!cfg?.model) return "inherit";
1214
- // If registry provided, check if the model actually resolves
1215
- if (registry) {
1216
- const resolved = resolveModel(cfg.model, registry);
1217
- if (typeof resolved === "string") return "inherit"; // model not available
1218
- }
1219
- return getModelLabelFromConfig(cfg.model);
1220
- }
1221
-
1222
- async function showAgentsMenu(ctx: ExtensionCommandContext) {
1223
- reloadCustomAgents();
1224
- const allNames = getAllTypes();
1225
-
1226
- // Build select options
1227
- const options: string[] = [];
1228
-
1229
- // Running agents entry (only if there are active agents)
1230
- const agents = manager.listAgents();
1231
- if (agents.length > 0) {
1232
- const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
1233
- const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
1234
- options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
1235
- }
1236
-
1237
- // Agent types list
1238
- if (allNames.length > 0) {
1239
- options.push(`Agent types (${allNames.length})`);
1240
- }
1241
-
1242
- // Actions
1243
- options.push("Create new agent");
1244
- options.push("Settings");
1245
-
1246
- const noAgentsMsg = allNames.length === 0 && agents.length === 0
1247
- ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
1248
- "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
1249
- "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
1250
- : "";
1251
-
1252
- if (noAgentsMsg) {
1253
- ctx.ui.notify(noAgentsMsg, "info");
1254
- }
1255
-
1256
- const choice = await ctx.ui.select("Agents", options);
1257
- if (!choice) return;
1258
-
1259
- if (choice.startsWith("Running agents (")) {
1260
- await showRunningAgents(ctx);
1261
- await showAgentsMenu(ctx);
1262
- } else if (choice.startsWith("Agent types (")) {
1263
- await showAllAgentsList(ctx);
1264
- await showAgentsMenu(ctx);
1265
- } else if (choice === "Create new agent") {
1266
- await showCreateWizard(ctx);
1267
- } else if (choice === "Settings") {
1268
- await showSettings(ctx);
1269
- await showAgentsMenu(ctx);
1270
- }
1271
- }
1272
-
1273
- async function showAllAgentsList(ctx: ExtensionCommandContext) {
1274
- const allNames = getAllTypes();
1275
- if (allNames.length === 0) {
1276
- ctx.ui.notify("No agents.", "info");
1277
- return;
1278
- }
1279
-
1280
- // Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global)
1281
- // Disabled agents get ✕ prefix
1282
- const sourceIndicator = (cfg: AgentConfig | undefined) => {
1283
- const disabled = cfg?.enabled === false;
1284
- if (cfg?.source === "project") return disabled ? "✕• " : "• ";
1285
- if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ ";
1286
- if (disabled) return "✕ ";
1287
- return " ";
1288
- };
1289
-
1290
- const entries = allNames.map(name => {
1291
- const cfg = getAgentConfig(name);
1292
- const disabled = cfg?.enabled === false;
1293
- const model = getModelLabel(name, ctx.modelRegistry);
1294
- const indicator = sourceIndicator(cfg);
1295
- const prefix = `${indicator}${name} · ${model}`;
1296
- const desc = disabled ? "(disabled)" : (cfg?.description ?? name);
1297
- return { name, prefix, desc };
1298
- });
1299
- const maxPrefix = Math.max(...entries.map(e => e.prefix.length));
1300
-
1301
- const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; });
1302
- const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false);
1303
- const legendParts: string[] = [];
1304
- if (hasCustom) legendParts.push("• = project ◦ = global");
1305
- if (hasDisabled) legendParts.push("✕ = disabled");
1306
- const legend = legendParts.length ? "\n" + legendParts.join(" ") : "";
1307
-
1308
- const options = entries.map(({ prefix, desc }) =>
1309
- `${prefix.padEnd(maxPrefix)} — ${desc}`,
1310
- );
1311
- if (legend) options.push(legend);
1312
-
1313
- const choice = await ctx.ui.select("Agent types", options);
1314
- if (!choice) return;
1315
-
1316
- const agentName = choice.split(" · ")[0].replace(/^[•◦✕\s]+/, "").trim();
1317
- if (getAgentConfig(agentName)) {
1318
- await showAgentDetail(ctx, agentName);
1319
- await showAllAgentsList(ctx);
1320
- }
1321
- }
1486
+ // ---- Running-agents list (the /pp > Subagents entry) ----
1322
1487
 
1323
1488
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1324
1489
  const agents = manager.listAgents();
1325
1490
  if (agents.length === 0) {
1326
- ctx.ui.notify("No agents.", "info");
1491
+ await ctx.ui.select("Running agents", ["Back"]);
1327
1492
  return;
1328
1493
  }
1329
1494
 
@@ -1339,7 +1504,15 @@ Guidelines:
1339
1504
  // Find the selected agent by matching the option index
1340
1505
  const idx = options.indexOf(choice);
1341
1506
  if (idx < 0) return;
1342
- const record = agents[idx];
1507
+ // The list is a snapshot, so re-resolve by id at selection time: the agent
1508
+ // may have finished and been reaped while the menu was open. If it's gone,
1509
+ // just refresh the list instead of opening a dead viewer.
1510
+ const record = manager.getRecord(agents[idx].id);
1511
+ if (!record) {
1512
+ ctx.ui.notify("That agent is no longer available.", "info");
1513
+ await showRunningAgents(ctx);
1514
+ return;
1515
+ }
1343
1516
 
1344
1517
  await viewAgentConversation(ctx, record);
1345
1518
  // Back-navigation: re-show the list
@@ -1352,436 +1525,23 @@ Guidelines:
1352
1525
  return;
1353
1526
  }
1354
1527
 
1355
- const { ConversationViewer } = await import("./ui/conversation-viewer.js");
1528
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1356
1529
  const session = record.session;
1357
1530
  const activity = agentActivity.get(record.id);
1358
1531
 
1359
1532
  await ctx.ui.custom<undefined>(
1360
- (tui, theme, _keybindings, done) => {
1361
- return new ConversationViewer(tui, session, record, activity, theme, done);
1533
+ (tui, theme, keybindings, done) => {
1534
+ return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1535
+ if (manager.abort(record.id)) {
1536
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
1537
+ }
1538
+ }, keybindings, (message: string) => manager.steer(record.id, message));
1362
1539
  },
1363
1540
  {
1364
1541
  overlay: true,
1365
- overlayOptions: { anchor: "center", width: "90%" },
1542
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
1366
1543
  },
1367
1544
  );
1368
1545
  }
1369
1546
 
1370
- async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
1371
- const cfg = getAgentConfig(name);
1372
- if (!cfg) {
1373
- ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
1374
- return;
1375
- }
1376
-
1377
- const file = findAgentFile(name);
1378
- const isDefault = cfg.isDefault === true;
1379
- const disabled = cfg.enabled === false;
1380
-
1381
- let menuOptions: string[];
1382
- if (disabled && file) {
1383
- // Disabled agent with a file — offer Enable
1384
- menuOptions = isDefault
1385
- ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
1386
- : ["Enable", "Edit", "Delete", "Back"];
1387
- } else if (isDefault && !file) {
1388
- // Default agent with no .md override
1389
- menuOptions = ["Eject (export as .md)", "Disable", "Back"];
1390
- } else if (isDefault && file) {
1391
- // Default agent with .md override (ejected)
1392
- menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
1393
- } else {
1394
- // User-defined agent
1395
- menuOptions = ["Edit", "Disable", "Delete", "Back"];
1396
- }
1397
-
1398
- const choice = await ctx.ui.select(name, menuOptions);
1399
- if (!choice || choice === "Back") return;
1400
-
1401
- if (choice === "Edit" && file) {
1402
- const content = readFileSync(file.path, "utf-8");
1403
- const edited = await ctx.ui.editor(`Edit ${name}`, content);
1404
- if (edited !== undefined && edited !== content) {
1405
- const { writeFileSync } = await import("node:fs");
1406
- writeFileSync(file.path, edited, "utf-8");
1407
- reloadCustomAgents();
1408
- ctx.ui.notify(`Updated ${file.path}`, "info");
1409
- }
1410
- } else if (choice === "Delete") {
1411
- if (file) {
1412
- const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
1413
- if (confirmed) {
1414
- unlinkSync(file.path);
1415
- reloadCustomAgents();
1416
- ctx.ui.notify(`Deleted ${file.path}`, "info");
1417
- }
1418
- }
1419
- } else if (choice === "Reset to default" && file) {
1420
- const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
1421
- if (confirmed) {
1422
- unlinkSync(file.path);
1423
- reloadCustomAgents();
1424
- ctx.ui.notify(`Restored default ${name}`, "info");
1425
- }
1426
- } else if (choice.startsWith("Eject")) {
1427
- await ejectAgent(ctx, name, cfg);
1428
- } else if (choice === "Disable") {
1429
- await disableAgent(ctx, name);
1430
- } else if (choice === "Enable") {
1431
- await enableAgent(ctx, name);
1432
- }
1433
- }
1434
-
1435
- /** Eject a default agent: write its embedded config as a .md file. */
1436
- async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
1437
- const location = await ctx.ui.select("Choose location", [
1438
- "Project (.pi/agents/)",
1439
- "Personal (~/.pi/agent/agents/)",
1440
- ]);
1441
- if (!location) return;
1442
-
1443
- const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1444
- mkdirSync(targetDir, { recursive: true });
1445
-
1446
- const targetPath = join(targetDir, `${name}.md`);
1447
- if (existsSync(targetPath)) {
1448
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1449
- if (!overwrite) return;
1450
- }
1451
-
1452
- // Build the .md file content
1453
- const fmFields: string[] = [];
1454
- fmFields.push(`description: ${cfg.description}`);
1455
- if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
1456
- fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
1457
- if (cfg.model) fmFields.push(`model: ${cfg.model}`);
1458
- if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
1459
- if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
1460
- fmFields.push(`prompt_mode: ${cfg.promptMode}`);
1461
- if (cfg.extensions === false) fmFields.push("extensions: false");
1462
- else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
1463
- if (cfg.skills === false) fmFields.push("skills: false");
1464
- else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
1465
- if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
1466
- if (cfg.inheritContext) fmFields.push("inherit_context: true");
1467
- if (cfg.runInBackground) fmFields.push("run_in_background: true");
1468
- if (cfg.isolated) fmFields.push("isolated: true");
1469
- if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
1470
- if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
1471
-
1472
- const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
1473
-
1474
- const { writeFileSync } = await import("node:fs");
1475
- writeFileSync(targetPath, content, "utf-8");
1476
- reloadCustomAgents();
1477
- ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
1478
- }
1479
-
1480
- /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
1481
- async function disableAgent(ctx: ExtensionCommandContext, name: string) {
1482
- const file = findAgentFile(name);
1483
- if (file) {
1484
- // Existing file — set enabled: false in frontmatter (idempotent)
1485
- const content = readFileSync(file.path, "utf-8");
1486
- if (content.includes("\nenabled: false\n")) {
1487
- ctx.ui.notify(`${name} is already disabled.`, "info");
1488
- return;
1489
- }
1490
- const updated = content.replace(/^---\n/, "---\nenabled: false\n");
1491
- const { writeFileSync } = await import("node:fs");
1492
- writeFileSync(file.path, updated, "utf-8");
1493
- reloadCustomAgents();
1494
- ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
1495
- return;
1496
- }
1497
-
1498
- // No file (built-in default) — create a stub
1499
- const location = await ctx.ui.select("Choose location", [
1500
- "Project (.pi/agents/)",
1501
- "Personal (~/.pi/agent/agents/)",
1502
- ]);
1503
- if (!location) return;
1504
-
1505
- const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1506
- mkdirSync(targetDir, { recursive: true });
1507
-
1508
- const targetPath = join(targetDir, `${name}.md`);
1509
- const { writeFileSync } = await import("node:fs");
1510
- writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
1511
- reloadCustomAgents();
1512
- ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
1513
- }
1514
-
1515
- /** Enable a disabled agent by removing enabled: false from its frontmatter. */
1516
- async function enableAgent(ctx: ExtensionCommandContext, name: string) {
1517
- const file = findAgentFile(name);
1518
- if (!file) return;
1519
-
1520
- const content = readFileSync(file.path, "utf-8");
1521
- const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
1522
- const { writeFileSync } = await import("node:fs");
1523
-
1524
- // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
1525
- if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
1526
- unlinkSync(file.path);
1527
- reloadCustomAgents();
1528
- ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
1529
- } else {
1530
- writeFileSync(file.path, updated, "utf-8");
1531
- reloadCustomAgents();
1532
- ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
1533
- }
1534
- }
1535
-
1536
- async function showCreateWizard(ctx: ExtensionCommandContext) {
1537
- const location = await ctx.ui.select("Choose location", [
1538
- "Project (.pi/agents/)",
1539
- "Personal (~/.pi/agent/agents/)",
1540
- ]);
1541
- if (!location) return;
1542
-
1543
- const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1544
-
1545
- const method = await ctx.ui.select("Creation method", [
1546
- "Generate with Claude (recommended)",
1547
- "Manual configuration",
1548
- ]);
1549
- if (!method) return;
1550
-
1551
- if (method.startsWith("Generate")) {
1552
- await showGenerateWizard(ctx, targetDir);
1553
- } else {
1554
- await showManualWizard(ctx, targetDir);
1555
- }
1556
- }
1557
-
1558
- async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
1559
- const description = await ctx.ui.input("Describe what this agent should do");
1560
- if (!description) return;
1561
-
1562
- const name = await ctx.ui.input("Agent name (filename, no spaces)");
1563
- if (!name) return;
1564
-
1565
- mkdirSync(targetDir, { recursive: true });
1566
-
1567
- const targetPath = join(targetDir, `${name}.md`);
1568
- if (existsSync(targetPath)) {
1569
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1570
- if (!overwrite) return;
1571
- }
1572
-
1573
- ctx.ui.notify("Generating agent definition...", "info");
1574
-
1575
- const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
1576
-
1577
- Write a markdown file to: ${targetPath}
1578
-
1579
- The file format is a markdown file with YAML frontmatter and a system prompt body:
1580
-
1581
- \`\`\`markdown
1582
- ---
1583
- description: <one-line description shown in UI>
1584
- tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
1585
- model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
1586
- thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
1587
- max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
1588
- prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
1589
- extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
1590
- skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
1591
- disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
1592
- inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
1593
- run_in_background: <true to run in background by default. Default: false>
1594
- isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
1595
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
1596
- isolation: <"worktree" to run in isolated git worktree. Omit for normal>
1597
- ---
1598
-
1599
- <system prompt body — instructions for the agent>
1600
- \`\`\`
1601
-
1602
- Guidelines for choosing settings:
1603
- - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
1604
- - For code modification tasks: include edit, write
1605
- - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
1606
- - Use prompt_mode: replace for fully custom agents with their own personality/instructions
1607
- - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
1608
- - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
1609
- - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
1610
-
1611
- Write the file using the write tool. Only write the file, nothing else.`;
1612
-
1613
- const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1614
- description: `Generate ${name} agent`,
1615
- maxTurns: 5,
1616
- });
1617
-
1618
- if (record.status === "error") {
1619
- ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
1620
- return;
1621
- }
1622
-
1623
- reloadCustomAgents();
1624
-
1625
- if (existsSync(targetPath)) {
1626
- ctx.ui.notify(`Created ${targetPath}`, "info");
1627
- } else {
1628
- ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
1629
- }
1630
- }
1631
-
1632
- async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
1633
- // 1. Name
1634
- const name = await ctx.ui.input("Agent name (filename, no spaces)");
1635
- if (!name) return;
1636
-
1637
- // 2. Description
1638
- const description = await ctx.ui.input("Description (one line)");
1639
- if (!description) return;
1640
-
1641
- // 3. Tools
1642
- const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
1643
- if (!toolChoice) return;
1644
-
1645
- let tools: string;
1646
- if (toolChoice === "all") {
1647
- tools = BUILTIN_TOOL_NAMES.join(", ");
1648
- } else if (toolChoice === "none") {
1649
- tools = "none";
1650
- } else if (toolChoice.startsWith("read-only")) {
1651
- tools = "read, bash, grep, find, ls";
1652
- } else {
1653
- const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
1654
- if (!customTools) return;
1655
- tools = customTools;
1656
- }
1657
-
1658
- // 4. Model
1659
- const modelChoice = await ctx.ui.select("Model", [
1660
- "inherit (parent model)",
1661
- "haiku",
1662
- "sonnet",
1663
- "opus",
1664
- "custom...",
1665
- ]);
1666
- if (!modelChoice) return;
1667
-
1668
- let modelLine = "";
1669
- if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
1670
- else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
1671
- else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
1672
- else if (modelChoice === "custom...") {
1673
- const customModel = await ctx.ui.input("Model (provider/modelId)");
1674
- if (customModel) modelLine = `\nmodel: ${customModel}`;
1675
- }
1676
-
1677
- // 5. Thinking
1678
- const thinkingChoice = await ctx.ui.select("Thinking level", [
1679
- "inherit",
1680
- "off",
1681
- "minimal",
1682
- "low",
1683
- "medium",
1684
- "high",
1685
- "xhigh",
1686
- ]);
1687
- if (!thinkingChoice) return;
1688
-
1689
- let thinkingLine = "";
1690
- if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
1691
-
1692
- // 6. System prompt
1693
- const systemPrompt = await ctx.ui.editor("System prompt", "");
1694
- if (systemPrompt === undefined) return;
1695
-
1696
- // Build the file
1697
- const content = `---
1698
- description: ${description}
1699
- tools: ${tools}${modelLine}${thinkingLine}
1700
- prompt_mode: replace
1701
- ---
1702
-
1703
- ${systemPrompt}
1704
- `;
1705
-
1706
- mkdirSync(targetDir, { recursive: true });
1707
- const targetPath = join(targetDir, `${name}.md`);
1708
-
1709
- if (existsSync(targetPath)) {
1710
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1711
- if (!overwrite) return;
1712
- }
1713
-
1714
- const { writeFileSync } = await import("node:fs");
1715
- writeFileSync(targetPath, content, "utf-8");
1716
- reloadCustomAgents();
1717
- ctx.ui.notify(`Created ${targetPath}`, "info");
1718
- }
1719
-
1720
- async function showSettings(ctx: ExtensionCommandContext) {
1721
- const choice = await ctx.ui.select("Settings", [
1722
- `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1723
- `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
1724
- `Grace turns (current: ${getGraceTurns()})`,
1725
- `Join mode (current: ${getDefaultJoinMode()})`,
1726
- ]);
1727
- if (!choice) return;
1728
-
1729
- if (choice.startsWith("Max concurrency")) {
1730
- const val = await ctx.ui.input("Max concurrent background agents", String(manager.getMaxConcurrent()));
1731
- if (val) {
1732
- const n = parseInt(val, 10);
1733
- if (n >= 1) {
1734
- manager.setMaxConcurrent(n);
1735
- ctx.ui.notify(`Max concurrency set to ${n}`, "info");
1736
- } else {
1737
- ctx.ui.notify("Must be a positive integer.", "warning");
1738
- }
1739
- }
1740
- } else if (choice.startsWith("Default max turns")) {
1741
- const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
1742
- if (val) {
1743
- const n = parseInt(val, 10);
1744
- if (n === 0) {
1745
- setDefaultMaxTurns(undefined);
1746
- ctx.ui.notify("Default max turns set to unlimited", "info");
1747
- } else if (n >= 1) {
1748
- setDefaultMaxTurns(n);
1749
- ctx.ui.notify(`Default max turns set to ${n}`, "info");
1750
- } else {
1751
- ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
1752
- }
1753
- }
1754
- } else if (choice.startsWith("Grace turns")) {
1755
- const val = await ctx.ui.input("Grace turns after wrap-up steer", String(getGraceTurns()));
1756
- if (val) {
1757
- const n = parseInt(val, 10);
1758
- if (n >= 1) {
1759
- setGraceTurns(n);
1760
- ctx.ui.notify(`Grace turns set to ${n}`, "info");
1761
- } else {
1762
- ctx.ui.notify("Must be a positive integer.", "warning");
1763
- }
1764
- }
1765
- } else if (choice.startsWith("Join mode")) {
1766
- const val = await ctx.ui.select("Default join mode for background agents", [
1767
- "smart — auto-group 2+ agents in same turn (default)",
1768
- "async — always notify individually",
1769
- "group — always group background agents",
1770
- ]);
1771
- if (val) {
1772
- const mode = val.split(" ")[0] as JoinMode;
1773
- setDefaultJoinMode(mode);
1774
- ctx.ui.notify(`Default join mode set to ${mode}`, "info");
1775
- }
1776
- }
1777
- }
1778
-
1779
- pi.registerCommand("agents", {
1780
- description: "Manage agents",
1781
- handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
1782
- });
1783
-
1784
- (globalThis as any)[Symbol.for("pi-subagents:menu")] = {
1785
- showMenu: async (ctx: ExtensionCommandContext) => showAgentsMenu(ctx),
1786
- };
1787
1547
  }