@yandy0725/pi-subagents 0.1.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 (61) hide show
  1. package/README.md +155 -0
  2. package/README.zh.md +155 -0
  3. package/index.ts +1 -0
  4. package/package.json +49 -0
  5. package/src/config/agent-types.ts +127 -0
  6. package/src/config/custom-agents.ts +109 -0
  7. package/src/config/default-agents.ts +117 -0
  8. package/src/config/invocation-config.ts +30 -0
  9. package/src/debug.ts +14 -0
  10. package/src/handlers/index.ts +3 -0
  11. package/src/handlers/interrupt.ts +49 -0
  12. package/src/handlers/lifecycle.ts +63 -0
  13. package/src/handlers/tool-start.ts +32 -0
  14. package/src/index.ts +186 -0
  15. package/src/layered-settings.ts +105 -0
  16. package/src/lifecycle/child-lifecycle.ts +88 -0
  17. package/src/lifecycle/concurrency-limiter.ts +55 -0
  18. package/src/lifecycle/create-subagent-session.ts +240 -0
  19. package/src/lifecycle/parent-snapshot.ts +45 -0
  20. package/src/lifecycle/run-listeners.ts +37 -0
  21. package/src/lifecycle/subagent-manager.ts +353 -0
  22. package/src/lifecycle/subagent-session.ts +232 -0
  23. package/src/lifecycle/subagent-state.ts +216 -0
  24. package/src/lifecycle/subagent.ts +498 -0
  25. package/src/lifecycle/turn-limits.ts +13 -0
  26. package/src/lifecycle/usage.ts +65 -0
  27. package/src/lifecycle/workspace-bracket.ts +59 -0
  28. package/src/lifecycle/workspace.ts +47 -0
  29. package/src/observation/composite-subagent-observer.ts +49 -0
  30. package/src/observation/notification-state.ts +27 -0
  31. package/src/observation/notification.ts +186 -0
  32. package/src/observation/record-observer.ts +75 -0
  33. package/src/observation/renderer.ts +63 -0
  34. package/src/observation/subagent-events-observer.ts +94 -0
  35. package/src/runtime.ts +77 -0
  36. package/src/service/service-adapter.ts +131 -0
  37. package/src/service/service.ts +123 -0
  38. package/src/session/content-items.ts +51 -0
  39. package/src/session/context.ts +78 -0
  40. package/src/session/conversation.ts +44 -0
  41. package/src/session/env.ts +40 -0
  42. package/src/session/model-resolver.ts +121 -0
  43. package/src/session/prompts.ts +83 -0
  44. package/src/session/session-config.ts +172 -0
  45. package/src/session/session-dir.ts +38 -0
  46. package/src/settings.ts +227 -0
  47. package/src/tools/agent-tool.ts +220 -0
  48. package/src/tools/background-spawner.ts +66 -0
  49. package/src/tools/foreground-runner.ts +114 -0
  50. package/src/tools/get-result-tool.ts +120 -0
  51. package/src/tools/helpers.ts +105 -0
  52. package/src/tools/result-renderer.ts +109 -0
  53. package/src/tools/spawn-config.ts +150 -0
  54. package/src/tools/steer-tool.ts +90 -0
  55. package/src/types.ts +115 -0
  56. package/src/ui/agent-widget.ts +311 -0
  57. package/src/ui/display.ts +174 -0
  58. package/src/ui/session-navigation.ts +147 -0
  59. package/src/ui/session-navigator.ts +406 -0
  60. package/src/ui/subagents-settings.ts +77 -0
  61. package/src/ui/widget-renderer.ts +296 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * result-renderer.ts — Pure per-status rendering functions for Agent tool results.
3
+ *
4
+ * All functions are stateless: they receive AgentDetails and a Theme, returning
5
+ * formatted strings. No SDK types, no timers, no side effects.
6
+ * Consumed by the renderResult hook in agent-tool.ts.
7
+ */
8
+
9
+ import type { AgentDetails, Theme } from "../ui/display";
10
+ import { formatMs, formatTurns, SPINNER } from "../ui/display";
11
+
12
+ // ---- Dispatcher ----
13
+
14
+ /** Dispatch to the per-status renderer based on details.status and isPartial. */
15
+ export function renderAgentResult(
16
+ details: AgentDetails,
17
+ resultText: string,
18
+ expanded: boolean,
19
+ isPartial: boolean,
20
+ theme: Theme,
21
+ ): string {
22
+ if (isPartial || details.status === "running") return renderRunning(details, theme);
23
+ if (details.status === "background") return renderBackground(details, theme);
24
+ if (details.status === "completed" || details.status === "steered")
25
+ return renderCompleted(details, resultText, expanded, theme);
26
+ if (details.status === "stopped") return renderStopped(details, theme);
27
+ return renderFailed(details, theme);
28
+ }
29
+
30
+ // ---- Per-status renderers ----
31
+
32
+ /** Render running/partial status: spinner + stats + activity line. */
33
+ export function renderRunning(details: AgentDetails, theme: Theme): string {
34
+ const frame = SPINNER[details.spinnerFrame ?? 0];
35
+ const s = renderStats(details, theme);
36
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
37
+ line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking\u2026"}`);
38
+ return line;
39
+ }
40
+
41
+ /** Render background launch status. */
42
+ export function renderBackground(details: AgentDetails, theme: Theme): string {
43
+ return theme.fg("dim", ` \u23BF Running in background (ID: ${details.agentId})`);
44
+ }
45
+
46
+ /** Render completed or steered status with optional expanded result text. */
47
+ export function renderCompleted(details: AgentDetails, resultText: string, expanded: boolean, theme: Theme): string {
48
+ const duration = formatMs(details.durationMs);
49
+ const isSteered = details.status === "steered";
50
+ const icon = isSteered ? theme.fg("warning", "\u2713") : theme.fg("success", "\u2713");
51
+ const s = renderStats(details, theme);
52
+ let line = icon + (s ? " " + s : "");
53
+ line += " " + theme.fg("dim", "\u00B7") + " " + theme.fg("dim", duration);
54
+
55
+ if (expanded) {
56
+ if (resultText) {
57
+ const lines = resultText.split("\n").slice(0, 50);
58
+ for (const l of lines) {
59
+ line += "\n" + theme.fg("dim", ` ${l}`);
60
+ }
61
+ if (resultText.split("\n").length > 50) {
62
+ line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
63
+ }
64
+ }
65
+ } else {
66
+ const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
67
+ line += "\n" + theme.fg("dim", ` \u23BF ${doneText}`);
68
+ }
69
+ return line;
70
+ }
71
+
72
+ /** Render stopped status: dim stop icon + stats + "Stopped". */
73
+ export function renderStopped(details: AgentDetails, theme: Theme): string {
74
+ const s = renderStats(details, theme);
75
+ let line = theme.fg("dim", "\u25A0") + (s ? " " + s : "");
76
+ line += "\n" + theme.fg("dim", " \u23BF Stopped");
77
+ return line;
78
+ }
79
+
80
+ /** Render error or aborted status: error icon + stats + status message. */
81
+ export function renderFailed(details: AgentDetails, theme: Theme): string {
82
+ const s = renderStats(details, theme);
83
+ let line = theme.fg("error", "\u2717") + (s ? " " + s : "");
84
+
85
+ if (details.status === "error") {
86
+ line += "\n" + theme.fg("error", ` \u23BF Error: ${details.error ?? "unknown"}`);
87
+ } else {
88
+ line += "\n" + theme.fg("warning", " \u23BF Aborted (max turns exceeded)");
89
+ }
90
+ return line;
91
+ }
92
+
93
+ // ---- Shared helper ----
94
+
95
+ /**
96
+ * Build the stats string: "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k token".
97
+ * Returns an empty string when all fields are absent or zero.
98
+ */
99
+ export function renderStats(details: AgentDetails, theme: Theme): string {
100
+ const parts: string[] = [];
101
+ if (details.modelName) parts.push(details.modelName);
102
+ if (details.tags) parts.push(...details.tags);
103
+ if (details.turnCount != null && details.turnCount > 0) {
104
+ parts.push(formatTurns(details.turnCount, details.maxTurns));
105
+ }
106
+ if (details.toolUses > 0) parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
107
+ if (details.tokens) parts.push(details.tokens);
108
+ return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "\u00B7") + " ");
109
+ }
@@ -0,0 +1,150 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
+ /**
3
+ * spawn-config.ts — Pure config resolution for the Agent tool.
4
+ *
5
+ * Extracts all config resolution logic from execute: type resolution,
6
+ * invocation config merge, model resolution, max-turns normalization,
7
+ * tag building, and detail-base construction.
8
+ */
9
+
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import type { AgentTypeRegistry } from "../config/agent-types";
12
+ import { resolveAgentInvocationConfig } from "../config/invocation-config";
13
+ import { normalizeMaxTurns } from "../lifecycle/turn-limits";
14
+ import { resolveInvocationModel } from "../session/model-resolver";
15
+ import type { AgentInvocation, SubagentType, ThinkingLevel } from "../types";
16
+ import { type AgentDetails, buildInvocationTags, getDisplayName, getPromptModeLabel } from "../ui/display";
17
+
18
+ /** Model info extracted from the parent session context. */
19
+ export interface ModelInfo {
20
+ parentModel: { id: string; name?: string } | undefined;
21
+ modelRegistry: unknown;
22
+ }
23
+
24
+ /** Identity: who is being spawned. */
25
+ export interface SpawnIdentity {
26
+ subagentType: string;
27
+ rawType: SubagentType;
28
+ fellBack: boolean;
29
+ displayName: string;
30
+ }
31
+
32
+ /** Execution: how the agent will run. */
33
+ export interface SpawnExecution {
34
+ prompt: string;
35
+ description: string;
36
+ model: Model<any> | undefined;
37
+ effectiveMaxTurns: number | undefined;
38
+ thinking: ThinkingLevel | undefined;
39
+ inheritContext: boolean;
40
+ runInBackground: boolean;
41
+ agentInvocation: AgentInvocation;
42
+ }
43
+
44
+ /** Presentation: display/UI values derived from identity and execution. */
45
+ export interface SpawnPresentation {
46
+ modelName: string | undefined;
47
+ agentTags: string[];
48
+ detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
49
+ }
50
+
51
+ /** Fully resolved config for spawning an agent — composed of domain-aligned sub-interfaces. */
52
+ export interface ResolvedSpawnConfig {
53
+ identity: SpawnIdentity;
54
+ execution: SpawnExecution;
55
+ presentation: SpawnPresentation;
56
+ }
57
+
58
+ /** Error result when model resolution fails. */
59
+ export interface SpawnConfigError {
60
+ error: string;
61
+ }
62
+
63
+ /**
64
+ * Resolve all config for an Agent tool invocation.
65
+ *
66
+ * Pure function — no SDK types, no side effects.
67
+ * Returns either a fully resolved config or an error.
68
+ */
69
+ export function resolveSpawnConfig(
70
+ params: Record<string, unknown>,
71
+ registry: AgentTypeRegistry,
72
+ modelInfo: ModelInfo,
73
+ settings: { readonly defaultMaxTurns: number | undefined },
74
+ ): ResolvedSpawnConfig | SpawnConfigError {
75
+ const rawType = params.subagent_type as SubagentType;
76
+ const resolved = registry.resolveType(rawType);
77
+
78
+ // A known-but-disabled type is an explicit error, not a silent unknown-type fallback.
79
+ if (resolved !== undefined && !registry.isValidType(resolved)) {
80
+ return { error: `Agent type "${resolved}" is disabled` };
81
+ }
82
+
83
+ const subagentType = resolved ?? "general-purpose";
84
+ const fellBack = resolved === undefined;
85
+
86
+ const displayName = getDisplayName(subagentType, registry);
87
+
88
+ // Merge agent config defaults with tool-call params
89
+ const customConfig = registry.resolveAgentConfig(subagentType);
90
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
91
+
92
+ // Resolve model
93
+ const resolution = resolveInvocationModel(
94
+ modelInfo.parentModel,
95
+ resolvedConfig.modelInput,
96
+ resolvedConfig.modelFromParams,
97
+ modelInfo.modelRegistry as any,
98
+ );
99
+ if (resolution.error) return { error: resolution.error };
100
+ const model = resolution.model;
101
+
102
+ const thinking = resolvedConfig.thinking;
103
+ const inheritContext = resolvedConfig.inheritContext;
104
+ const runInBackground = resolvedConfig.runInBackground;
105
+
106
+ // Compute display model name (only shown when different from parent)
107
+ const parentModelId = modelInfo.parentModel?.id;
108
+ const effectiveModelId = model?.id;
109
+ const modelName =
110
+ effectiveModelId && effectiveModelId !== parentModelId
111
+ ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
112
+ : undefined;
113
+
114
+ const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? settings.defaultMaxTurns);
115
+
116
+ const agentInvocation: AgentInvocation = {
117
+ modelName,
118
+ thinking,
119
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
120
+ inheritContext,
121
+ runInBackground,
122
+ };
123
+
124
+ const modeLabel = getPromptModeLabel(subagentType, registry);
125
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
126
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
127
+
128
+ const detailBase = {
129
+ displayName,
130
+ description: params.description as string,
131
+ subagentType,
132
+ modelName,
133
+ tags: agentTags.length > 0 ? agentTags : undefined,
134
+ };
135
+
136
+ return {
137
+ identity: { subagentType, rawType, fellBack, displayName },
138
+ execution: {
139
+ prompt: params.prompt as string,
140
+ description: params.description as string,
141
+ model,
142
+ effectiveMaxTurns,
143
+ thinking,
144
+ inheritContext,
145
+ runInBackground,
146
+ agentInvocation,
147
+ },
148
+ presentation: { modelName, agentTags, detailBase },
149
+ };
150
+ }
@@ -0,0 +1,90 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { formatLifetimeTokens, textResult } from "../tools/helpers";
4
+ import type { Subagent } from "../types";
5
+
6
+ // ---- Deps interfaces ----
7
+
8
+ export interface SteerToolManager {
9
+ getRecord(id: string): Subagent | undefined;
10
+ }
11
+
12
+ export interface SteerToolEvents {
13
+ emit(name: string, data: unknown): void;
14
+ }
15
+
16
+ // ---- Class ----
17
+
18
+ export class SteerTool {
19
+ constructor(
20
+ private readonly manager: SteerToolManager,
21
+ private readonly events: SteerToolEvents,
22
+ ) {}
23
+
24
+ async execute(
25
+ _toolCallId: string,
26
+ params: { agent_id: string; message: string },
27
+ _signal: AbortSignal,
28
+ _onUpdate: unknown,
29
+ _ctx: unknown,
30
+ ) {
31
+ const record = this.manager.getRecord(params.agent_id);
32
+ if (!record) {
33
+ return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
34
+ }
35
+ if (record.status !== "running") {
36
+ return textResult(
37
+ `Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
38
+ );
39
+ }
40
+ try {
41
+ const delivered = await record.steer(params.message);
42
+ this.events.emit("subagents:steered", { id: record.id, message: params.message });
43
+ if (!delivered) {
44
+ return textResult(
45
+ `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
46
+ );
47
+ }
48
+ const tokens = formatLifetimeTokens(record);
49
+ const contextPercent = record.getContextPercent();
50
+ const stateParts: string[] = [];
51
+ if (tokens) stateParts.push(tokens);
52
+ stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
53
+ if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
54
+ if (record.compactionCount)
55
+ stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
56
+ return textResult(
57
+ `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
58
+ `Current state: ${stateParts.join(" · ")}`,
59
+ );
60
+ } catch (err) {
61
+ return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ }
64
+
65
+ toToolDefinition() {
66
+ return defineTool({
67
+ name: "steer_subagent" as const,
68
+ label: "Steer Agent",
69
+ promptSnippet: "steer_subagent: Send a mid-run message to redirect a running background agent.",
70
+ description:
71
+ "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
72
+ "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
73
+ parameters: Type.Object({
74
+ agent_id: Type.String({
75
+ description: "The agent ID to steer (must be currently running).",
76
+ }),
77
+ message: Type.String({
78
+ description: "The steering message to send. This will appear as a user message in the agent's conversation.",
79
+ }),
80
+ }),
81
+ execute: (
82
+ toolCallId: string,
83
+ params: { agent_id: string; message: string },
84
+ signal: AbortSignal,
85
+ onUpdate: unknown,
86
+ ctx: unknown,
87
+ ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
88
+ });
89
+ }
90
+ }
package/src/types.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * types.ts — Type definitions for the subagent system.
3
+ */
4
+
5
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
+ import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { ModelRegistry } from "./session/model-resolver";
8
+
9
+ export { Subagent } from "./lifecycle/subagent";
10
+ export type { AgentSessionEvent, ThinkingLevel };
11
+
12
+ /**
13
+ * One message in a child session's history, typed from Pi's `SessionContext`.
14
+ *
15
+ * Derived from the barrel-exported `SessionContext` (whose `messages` field is
16
+ * `AgentMessage[]`) so the package needs no direct dependency on
17
+ * `@earendil-works/pi-agent-core`, which is not re-exported from the public barrel.
18
+ */
19
+ export type SessionMessage = SdkSessionContext["messages"][number];
20
+
21
+ /**
22
+ * Narrow session interface for event subscription.
23
+ * Used by record-observer — only the subscribe method is needed.
24
+ */
25
+ export interface SubscribableSession {
26
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void;
27
+ }
28
+
29
+ /** Agent type: any string name (built-in defaults or user-defined). */
30
+ export type SubagentType = string;
31
+
32
+ /** UI display and agent listing — name, display name, description, prompt mode. */
33
+ export interface AgentIdentity {
34
+ name: string;
35
+ displayName?: string;
36
+ description: string;
37
+ promptMode: "replace" | "append";
38
+ }
39
+
40
+ /** Prompt assembly — name, prompt mode, system prompt. */
41
+ export interface AgentPromptConfig {
42
+ name: string;
43
+ promptMode: "replace" | "append";
44
+ systemPrompt: string;
45
+ }
46
+
47
+ /** Unified agent configuration — used for both default and user-defined agents. */
48
+ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
49
+ builtinToolNames?: string[];
50
+ model?: string;
51
+ thinking?: ThinkingLevel;
52
+ maxTurns?: number;
53
+ /** Default for spawn: fork parent conversation. undefined = caller decides. */
54
+ inheritContext?: boolean;
55
+ /** Default for spawn: run in background. undefined = caller decides. */
56
+ runInBackground?: boolean;
57
+ /** true = this is an embedded default agent (informational) */
58
+ isDefault?: boolean;
59
+ /** false = agent is hidden from the registry */
60
+ enabled?: boolean;
61
+ /** Where this agent was loaded from */
62
+ source?: "default" | "project" | "global";
63
+ }
64
+
65
+ export interface AgentInvocation {
66
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
67
+ modelName?: string;
68
+ thinking?: ThinkingLevel;
69
+ maxTurns?: number;
70
+ inheritContext?: boolean;
71
+ runInBackground?: boolean;
72
+ }
73
+
74
+ /**
75
+ * Narrow shell-exec callback replacing `ExtensionAPI` in `detectEnv()`.
76
+ * Matches the shape of `pi.exec()` without carrying an SDK dependency.
77
+ */
78
+ /**
79
+ * Narrow interface capturing the ExtensionContext fields SubagentRuntime needs.
80
+ * Avoids coupling runtime to the full SDK ExtensionContext surface (ISP).
81
+ */
82
+ export interface SessionContext {
83
+ readonly cwd: string;
84
+ readonly model: unknown;
85
+ readonly modelRegistry: ModelRegistry | undefined;
86
+ getSystemPrompt(): string;
87
+ readonly sessionManager: {
88
+ getSessionFile(): string | undefined;
89
+ getSessionId(): string;
90
+ getBranch(): unknown[];
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Narrow shell-exec callback replacing `ExtensionAPI` in `detectEnv()`.
96
+ * Matches the shape of `pi.exec()` without carrying an SDK dependency.
97
+ */
98
+ export type ShellExec = (
99
+ command: string,
100
+ args: string[],
101
+ options?: { cwd?: string; timeout?: number },
102
+ ) => Promise<{ stdout: string; stderr: string; code: number }>;
103
+
104
+ /** Parent session identity — grouped fields that travel together from the tool boundary. */
105
+ export interface ParentSessionInfo {
106
+ /** Path to the parent session's JSONL file (for deriving the subagent session directory). */
107
+ parentSessionFile?: string;
108
+ /** Session ID of the parent agent (stored in the child session's parentSession header). */
109
+ parentSessionId?: string;
110
+ /** Tool call ID for background notification wiring. When set, spawn attaches NotificationState. */
111
+ toolCallId?: string;
112
+ }
113
+
114
+ /** Compaction event info passed through lifecycle observers. */
115
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };