@tintinweb/pi-subagents 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prompts.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * prompts.ts — System prompts per agent type.
3
+ */
4
+
5
+ import type { EnvInfo } from "./types.js";
6
+
7
+ // ---- Reusable prompt blocks ----
8
+
9
+ const READ_ONLY_PROHIBITION = `You are STRICTLY PROHIBITED from:
10
+ - Creating new files
11
+ - Modifying existing files
12
+ - Deleting files
13
+ - Moving or copying files
14
+ - Creating temporary files anywhere, including /tmp
15
+ - Using redirect operators (>, >>, |) or heredocs to write to files
16
+ - Running ANY commands that change system state`;
17
+
18
+ const READ_ONLY_TOOLS = `# Tool Usage
19
+ - Use the find tool for file pattern matching (NOT the bash find command)
20
+ - Use the grep tool for content search (NOT bash grep/rg command)
21
+ - Use the read tool for reading files (NOT bash cat/head/tail)
22
+ - Use Bash ONLY for read-only operations`;
23
+
24
+ const FULL_TOOL_USAGE = `# Tool Usage
25
+ - Use the read tool instead of cat/head/tail
26
+ - Use the edit tool instead of sed/awk
27
+ - Use the write tool instead of echo/heredoc
28
+ - Use the find tool instead of bash find/ls for file search
29
+ - Use the grep tool instead of bash grep/rg for content search
30
+ - Make independent tool calls in parallel`;
31
+
32
+ const GIT_SAFETY = `# Git Safety
33
+ - NEVER update git config
34
+ - NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) without explicit request
35
+ - NEVER skip hooks (--no-verify, --no-gpg-sign) unless explicitly asked
36
+ - NEVER force push to main/master — warn the user if they request it
37
+ - Always create NEW commits, never amend existing ones. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit. Fix the issue, re-stage, and create a NEW commit
38
+ - Stage specific files by name, not git add -A or git add .
39
+ - NEVER commit changes unless the user explicitly asks
40
+ - NEVER push unless the user explicitly asks
41
+ - NEVER use git commands with the -i flag (like git rebase -i or git add -i) — they require interactive input
42
+ - Do not use --no-edit with git rebase commands
43
+ - Do not commit files that likely contain secrets (.env, credentials.json, etc); warn the user if they request it`;
44
+
45
+ const OUTPUT_RULES = `# Output
46
+ - Use absolute file paths
47
+ - Do not use emojis
48
+ - Be concise but complete`;
49
+
50
+ export function buildSystemPrompt(type: string, cwd: string, env: EnvInfo): string {
51
+ const commonHeader = `You are a pi coding agent sub-agent.
52
+ You have been invoked to handle a specific task autonomously.
53
+
54
+ # Environment
55
+ Working directory: ${cwd}
56
+ ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
57
+ Platform: ${env.platform}`;
58
+
59
+ switch (type) {
60
+ case "Explore":
61
+ return `${commonHeader}
62
+
63
+ # CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
64
+ You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
65
+ Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
66
+
67
+ ${READ_ONLY_PROHIBITION}
68
+
69
+ Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
70
+
71
+ ${READ_ONLY_TOOLS}
72
+ - Make independent tool calls in parallel for efficiency
73
+ - Adapt search approach based on thoroughness level specified
74
+
75
+ # Output
76
+ - Use absolute file paths in all references
77
+ - Report findings as regular messages
78
+ - Do not use emojis
79
+ - Be thorough and precise`;
80
+
81
+ case "Plan":
82
+ return `${commonHeader}
83
+
84
+ # CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
85
+ You are a software architect and planning specialist.
86
+ Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
87
+ You do NOT have access to file editing tools — attempting to edit files will fail.
88
+
89
+ ${READ_ONLY_PROHIBITION}
90
+
91
+ # Planning Process
92
+ 1. Understand requirements
93
+ 2. Explore thoroughly (read files, find patterns, understand architecture)
94
+ 3. Design solution based on your assigned perspective
95
+ 4. Detail the plan with step-by-step implementation strategy
96
+
97
+ # Requirements
98
+ - Consider trade-offs and architectural decisions
99
+ - Identify dependencies and sequencing
100
+ - Anticipate potential challenges
101
+ - Follow existing patterns where appropriate
102
+
103
+ ${READ_ONLY_TOOLS}
104
+
105
+ # Output Format
106
+ - Use absolute file paths
107
+ - Do not use emojis
108
+ - End your response with:
109
+
110
+ ### Critical Files for Implementation
111
+ List 3-5 files most critical for implementing this plan:
112
+ - /absolute/path/to/file.ts - [Brief reason]`;
113
+
114
+ case "general-purpose":
115
+ return `${commonHeader}
116
+
117
+ # Role
118
+ You are a general-purpose coding agent for complex, multi-step tasks.
119
+ You have full access to read, write, edit files, and execute commands.
120
+ Do what has been asked; nothing more, nothing less.
121
+
122
+ ${FULL_TOOL_USAGE}
123
+
124
+ # File Operations
125
+ - NEVER create files unless absolutely necessary
126
+ - Prefer editing existing files over creating new ones
127
+ - NEVER create documentation files unless explicitly requested
128
+
129
+ ${GIT_SAFETY}
130
+
131
+ ${OUTPUT_RULES}`;
132
+
133
+ case "statusline-setup":
134
+ return `${commonHeader}
135
+
136
+ # Role
137
+ You configure settings. You can read and edit files only.
138
+ Focus on the specific configuration task requested.
139
+ Use absolute file paths.`;
140
+
141
+ case "claude-code-guide":
142
+ return `${commonHeader}
143
+
144
+ # Role
145
+ You help answer questions about the tool, its features, and capabilities.
146
+ Search documentation, read config files, and provide accurate answers.
147
+ You have read-only access to the codebase for reference.
148
+ Use absolute file paths.`;
149
+
150
+ default:
151
+ // Custom agents or unknown: general-purpose base without git safety / file ops
152
+ return `${commonHeader}
153
+
154
+ # Role
155
+ You are a general-purpose coding agent for complex, multi-step tasks.
156
+ You have full access to read, write, edit files, and execute commands.
157
+ Do what has been asked; nothing more, nothing less.
158
+
159
+ ${FULL_TOOL_USAGE}
160
+
161
+ ${OUTPUT_RULES}`;
162
+ }
163
+ }
package/src/types.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * types.ts — Type definitions for the subagent system.
3
+ */
4
+
5
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
6
+ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
7
+
8
+ export type { ThinkingLevel };
9
+
10
+ /** Built-in agent types. Custom agents use arbitrary string names. */
11
+ export type BuiltinSubagentType = "general-purpose" | "Explore" | "Plan" | "statusline-setup" | "claude-code-guide";
12
+
13
+ /** Agent type: built-in or custom (any string). */
14
+ export type SubagentType = BuiltinSubagentType | (string & {});
15
+
16
+ /** Display name mapping for built-in types. */
17
+ export const DISPLAY_NAMES: Record<BuiltinSubagentType, string> = {
18
+ "general-purpose": "Agent",
19
+ "Explore": "Explore",
20
+ "Plan": "Plan",
21
+ "statusline-setup": "Config",
22
+ "claude-code-guide": "Guide",
23
+ };
24
+
25
+ export const SUBAGENT_TYPES: BuiltinSubagentType[] = [
26
+ "general-purpose",
27
+ "Explore",
28
+ "Plan",
29
+ "statusline-setup",
30
+ "claude-code-guide",
31
+ ];
32
+
33
+ export interface SubagentTypeConfig {
34
+ displayName: string;
35
+ description: string;
36
+ builtinToolNames: string[];
37
+ /** true = inherit all, string[] = only listed, false = none */
38
+ extensions: true | string[] | false;
39
+ /** true = inherit all, string[] = only listed, false = none */
40
+ skills: true | string[] | false;
41
+ }
42
+
43
+ /** Configuration for a custom agent loaded from .pi/agents/<name>.md */
44
+ export interface CustomAgentConfig {
45
+ name: string;
46
+ description: string;
47
+ builtinToolNames: string[];
48
+ /** true = inherit all, string[] = only listed, false = none */
49
+ extensions: true | string[] | false;
50
+ /** true = inherit all, string[] = only listed, false = none */
51
+ skills: true | string[] | false;
52
+ model?: string;
53
+ thinking?: ThinkingLevel;
54
+ maxTurns?: number;
55
+ systemPrompt: string;
56
+ promptMode: "replace" | "append";
57
+ /** Default for spawn: fork parent conversation */
58
+ inheritContext: boolean;
59
+ /** Default for spawn: run in background */
60
+ runInBackground: boolean;
61
+ /** Default for spawn: no extension tools */
62
+ isolated: boolean;
63
+ }
64
+
65
+ export interface AgentRecord {
66
+ id: string;
67
+ type: SubagentType;
68
+ description: string;
69
+ status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
70
+ result?: string;
71
+ error?: string;
72
+ toolUses: number;
73
+ startedAt: number;
74
+ completedAt?: number;
75
+ session?: AgentSession;
76
+ abortController?: AbortController;
77
+ promise?: Promise<string>;
78
+ }
79
+
80
+ export interface EnvInfo {
81
+ isGitRepo: boolean;
82
+ branch: string;
83
+ platform: string;
84
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * agent-widget.ts — Persistent widget showing running/completed agents above the editor.
3
+ *
4
+ * Displays a tree of agents with animated spinners, live stats, and activity descriptions.
5
+ * Uses the callback form of setWidget for themed rendering.
6
+ */
7
+
8
+ import type { AgentManager } from "../agent-manager.js";
9
+ import type { SubagentType } from "../types.js";
10
+ import { getConfig } from "../agent-types.js";
11
+
12
+ // ---- Constants ----
13
+
14
+ /** Braille spinner frames for animated running indicator. */
15
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
+
17
+ /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
18
+ export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
19
+
20
+ /** Tool name → human-readable action for activity descriptions. */
21
+ const TOOL_DISPLAY: Record<string, string> = {
22
+ read: "reading",
23
+ bash: "running command",
24
+ edit: "editing",
25
+ write: "writing",
26
+ grep: "searching",
27
+ find: "finding files",
28
+ ls: "listing",
29
+ };
30
+
31
+ // ---- Types ----
32
+
33
+ export type Theme = {
34
+ fg(color: string, text: string): string;
35
+ bold(text: string): string;
36
+ };
37
+
38
+ export type UICtx = {
39
+ setStatus(key: string, text: string | undefined): void;
40
+ setWidget(
41
+ key: string,
42
+ content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
43
+ options?: { placement?: "aboveEditor" | "belowEditor" },
44
+ ): void;
45
+ };
46
+
47
+ /** Per-agent live activity state. */
48
+ export interface AgentActivity {
49
+ activeTools: Map<string, string>;
50
+ toolUses: number;
51
+ tokens: string;
52
+ responseText: string;
53
+ session?: { getSessionStats(): { tokens: { total: number } } };
54
+ }
55
+
56
+ /** Metadata attached to Agent tool results for custom rendering. */
57
+ export interface AgentDetails {
58
+ displayName: string;
59
+ description: string;
60
+ subagentType: string;
61
+ toolUses: number;
62
+ tokens: string;
63
+ durationMs: number;
64
+ status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
65
+ /** Human-readable description of what the agent is currently doing. */
66
+ activity?: string;
67
+ /** Current spinner frame index (for animated running indicator). */
68
+ spinnerFrame?: number;
69
+ /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
70
+ modelName?: string;
71
+ /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
72
+ tags?: string[];
73
+ agentId?: string;
74
+ error?: string;
75
+ }
76
+
77
+ // ---- Formatting helpers ----
78
+
79
+ /** Format a token count as "33.8k tokens" or "1.2M tokens". */
80
+ export function formatTokens(count: number): string {
81
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tokens`;
82
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tokens`;
83
+ return `${count} tokens`;
84
+ }
85
+
86
+ /** Format milliseconds as human-readable duration. */
87
+ export function formatMs(ms: number): string {
88
+ return `${(ms / 1000).toFixed(1)}s`;
89
+ }
90
+
91
+ /** Format duration from start/completed timestamps. */
92
+ export function formatDuration(startedAt: number, completedAt?: number): string {
93
+ if (completedAt) return formatMs(completedAt - startedAt);
94
+ return `${formatMs(Date.now() - startedAt)} (running)`;
95
+ }
96
+
97
+ /** Get display name for any agent type (built-in or custom). */
98
+ export function getDisplayName(type: SubagentType): string {
99
+ return getConfig(type).displayName;
100
+ }
101
+
102
+ /** Truncate text to a single line, max `len` chars. */
103
+ function truncateLine(text: string, len = 60): string {
104
+ const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
105
+ if (line.length <= len) return line;
106
+ return line.slice(0, len) + "…";
107
+ }
108
+
109
+ /** Build a human-readable activity string from currently-running tools or response text. */
110
+ export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
111
+ if (activeTools.size > 0) {
112
+ const groups = new Map<string, number>();
113
+ for (const toolName of activeTools.values()) {
114
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
115
+ groups.set(action, (groups.get(action) ?? 0) + 1);
116
+ }
117
+
118
+ const parts: string[] = [];
119
+ for (const [action, count] of groups) {
120
+ if (count > 1) {
121
+ parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
122
+ } else {
123
+ parts.push(action);
124
+ }
125
+ }
126
+ return parts.join(", ") + "…";
127
+ }
128
+
129
+ // No tools active — show truncated response text if available
130
+ if (responseText && responseText.trim().length > 0) {
131
+ return truncateLine(responseText);
132
+ }
133
+
134
+ return "thinking…";
135
+ }
136
+
137
+ // ---- Widget manager ----
138
+
139
+ export class AgentWidget {
140
+ private uiCtx: UICtx | undefined;
141
+ private widgetFrame = 0;
142
+ private widgetInterval: ReturnType<typeof setInterval> | undefined;
143
+ /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
144
+ private finishedTurnAge = new Map<string, number>();
145
+ /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
146
+ private static readonly ERROR_LINGER_TURNS = 2;
147
+
148
+ constructor(
149
+ private manager: AgentManager,
150
+ private agentActivity: Map<string, AgentActivity>,
151
+ ) {}
152
+
153
+ /** Set the UI context (grabbed from first tool execution). */
154
+ setUICtx(ctx: UICtx) {
155
+ this.uiCtx = ctx;
156
+ }
157
+
158
+ /**
159
+ * Called on each new turn (tool_execution_start).
160
+ * Ages finished agents and clears those that have lingered long enough.
161
+ */
162
+ onTurnStart() {
163
+ // Age all finished agents
164
+ for (const [id, age] of this.finishedTurnAge) {
165
+ this.finishedTurnAge.set(id, age + 1);
166
+ }
167
+ // Trigger a widget refresh (will filter out expired agents)
168
+ this.update();
169
+ }
170
+
171
+ /** Ensure the widget update timer is running. */
172
+ ensureTimer() {
173
+ if (!this.widgetInterval) {
174
+ this.widgetInterval = setInterval(() => this.update(), 80);
175
+ }
176
+ }
177
+
178
+ /** Check if a finished agent should still be shown in the widget. */
179
+ private shouldShowFinished(agentId: string, status: string): boolean {
180
+ const age = this.finishedTurnAge.get(agentId) ?? 0;
181
+ const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
182
+ return age < maxAge;
183
+ }
184
+
185
+ /** Record an agent as finished (call when agent completes). */
186
+ markFinished(agentId: string) {
187
+ if (!this.finishedTurnAge.has(agentId)) {
188
+ this.finishedTurnAge.set(agentId, 0);
189
+ }
190
+ }
191
+
192
+ /** Render a finished agent line. */
193
+ private renderFinishedLine(a: { type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
194
+ const name = getDisplayName(a.type);
195
+ const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
196
+
197
+ let icon: string;
198
+ let statusText: string;
199
+ if (a.status === "completed") {
200
+ icon = theme.fg("success", "✓");
201
+ statusText = "";
202
+ } else if (a.status === "steered") {
203
+ icon = theme.fg("warning", "✓");
204
+ statusText = theme.fg("warning", " (turn limit)");
205
+ } else if (a.status === "stopped") {
206
+ icon = theme.fg("dim", "■");
207
+ statusText = theme.fg("dim", " stopped");
208
+ } else if (a.status === "error") {
209
+ icon = theme.fg("error", "✗");
210
+ const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
211
+ statusText = theme.fg("error", ` error${errMsg}`);
212
+ } else {
213
+ // aborted
214
+ icon = theme.fg("error", "✗");
215
+ statusText = theme.fg("warning", " aborted");
216
+ }
217
+
218
+ const parts: string[] = [];
219
+ if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
220
+ parts.push(duration);
221
+
222
+ return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
223
+ }
224
+
225
+ /** Force an immediate widget update. */
226
+ update() {
227
+ if (!this.uiCtx) return;
228
+ const allAgents = this.manager.listAgents();
229
+ const running = allAgents.filter(a => a.status === "running");
230
+ const queued = allAgents.filter(a => a.status === "queued");
231
+ const finished = allAgents.filter(a =>
232
+ a.status !== "running" && a.status !== "queued" && a.completedAt
233
+ && this.shouldShowFinished(a.id, a.status),
234
+ );
235
+
236
+ const hasActive = running.length > 0 || queued.length > 0;
237
+ const hasFinished = finished.length > 0;
238
+
239
+ // Nothing to show — clear widget
240
+ if (!hasActive && !hasFinished) {
241
+ this.uiCtx.setWidget("agents", undefined);
242
+ this.uiCtx.setStatus("subagents", undefined);
243
+ if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
244
+ // Clean up stale entries
245
+ for (const [id] of this.finishedTurnAge) {
246
+ if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
247
+ }
248
+ return;
249
+ }
250
+
251
+ // Status bar
252
+ if (hasActive) {
253
+ const statusParts: string[] = [];
254
+ if (running.length > 0) statusParts.push(`${running.length} running`);
255
+ if (queued.length > 0) statusParts.push(`${queued.length} queued`);
256
+ const total = running.length + queued.length;
257
+ this.uiCtx.setStatus("subagents", `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`);
258
+ } else {
259
+ this.uiCtx.setStatus("subagents", undefined);
260
+ }
261
+
262
+ this.widgetFrame++;
263
+ const frame = SPINNER[this.widgetFrame % SPINNER.length];
264
+
265
+ this.uiCtx.setWidget("agents", (_tui, theme) => {
266
+ const headingColor = hasActive ? "accent" : "dim";
267
+ const headingIcon = hasActive ? "●" : "○";
268
+ const lines: string[] = [theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents")];
269
+
270
+ // --- Finished agents (shown first, dimmed) ---
271
+ for (let i = 0; i < finished.length; i++) {
272
+ const a = finished[i];
273
+ const isLast = !hasActive && i === finished.length - 1;
274
+ const connector = isLast ? "└─" : "├─";
275
+ lines.push(theme.fg("dim", connector) + " " + this.renderFinishedLine(a, theme));
276
+ }
277
+
278
+ // --- Running agents ---
279
+ const isLastSection = queued.length === 0;
280
+ for (let i = 0; i < running.length; i++) {
281
+ const a = running[i];
282
+ const isLast = isLastSection && i === running.length - 1;
283
+ const connector = isLast ? "└─" : "├─";
284
+ const name = getDisplayName(a.type);
285
+ const elapsed = formatMs(Date.now() - a.startedAt);
286
+
287
+ const bg = this.agentActivity.get(a.id);
288
+ const toolUses = bg?.toolUses ?? a.toolUses;
289
+ let tokenText = "";
290
+ if (bg?.session) {
291
+ try { tokenText = formatTokens(bg.session.getSessionStats().tokens.total); } catch { /* */ }
292
+ }
293
+
294
+ const parts: string[] = [];
295
+ if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
296
+ if (tokenText) parts.push(tokenText);
297
+ parts.push(elapsed);
298
+ const statsText = parts.join(" · ");
299
+
300
+ const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
301
+
302
+ lines.push(theme.fg("dim", connector) + ` ${theme.fg("accent", frame)} ${theme.bold(name)} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`);
303
+ const indent = isLast ? " " : "│ ";
304
+ lines.push(theme.fg("dim", indent) + theme.fg("dim", ` ⎿ ${activity}`));
305
+ }
306
+
307
+ // --- Queued agents (collapsed) ---
308
+ if (queued.length > 0) {
309
+ lines.push(theme.fg("dim", "└─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`);
310
+ }
311
+
312
+ return { render: () => lines, invalidate: () => {} };
313
+ }, { placement: "aboveEditor" });
314
+ }
315
+
316
+ dispose() {
317
+ if (this.widgetInterval) {
318
+ clearInterval(this.widgetInterval);
319
+ this.widgetInterval = undefined;
320
+ }
321
+ if (this.uiCtx) {
322
+ this.uiCtx.setWidget("agents", undefined);
323
+ this.uiCtx.setStatus("subagents", undefined);
324
+ }
325
+ }
326
+ }