@yefengr/pi-subagents 0.0.0-stage → 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 (103) hide show
  1. package/CHANGELOG.md +2411 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/UPSTREAM.md +48 -0
  5. package/dist/public.d.ts +201 -0
  6. package/dist/settings.d.ts +82 -0
  7. package/docs/architecture/architecture.md +833 -0
  8. package/docs/architecture/client-server-opportunities.md +127 -0
  9. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  10. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  11. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  12. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  13. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  14. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  15. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  16. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  17. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  18. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  19. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  20. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  21. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  22. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  23. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  24. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  25. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  26. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  27. package/docs/architecture/history/phase-8-testability.md +103 -0
  28. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  29. package/docs/comparison-with-upstream.md +79 -0
  30. package/docs/configuration.md +215 -0
  31. package/docs/decisions/0001-deferred-patches.md +80 -0
  32. package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
  33. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  34. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  35. package/index.ts +1 -0
  36. package/package.json +91 -4
  37. package/src/config/agent-enablement.ts +132 -0
  38. package/src/config/agent-types.ts +144 -0
  39. package/src/config/custom-agents.ts +114 -0
  40. package/src/config/default-agents.ts +120 -0
  41. package/src/config/invocation-config.ts +39 -0
  42. package/src/config/resource-limits.ts +26 -0
  43. package/src/config/settings-file.ts +174 -0
  44. package/src/debug.ts +14 -0
  45. package/src/handlers/index.ts +3 -0
  46. package/src/handlers/interrupt.ts +58 -0
  47. package/src/handlers/lifecycle.ts +65 -0
  48. package/src/handlers/tool-start.ts +32 -0
  49. package/src/index.ts +257 -0
  50. package/src/layered-settings.ts +105 -0
  51. package/src/lifecycle/child-lifecycle.ts +89 -0
  52. package/src/lifecycle/child-shutdown.ts +105 -0
  53. package/src/lifecycle/concurrency-limiter.ts +123 -0
  54. package/src/lifecycle/create-subagent-session.ts +265 -0
  55. package/src/lifecycle/invocation-control.ts +109 -0
  56. package/src/lifecycle/lifecycle-record.ts +159 -0
  57. package/src/lifecycle/parent-snapshot.ts +46 -0
  58. package/src/lifecycle/run-listeners.ts +41 -0
  59. package/src/lifecycle/subagent-manager.ts +392 -0
  60. package/src/lifecycle/subagent-session.ts +287 -0
  61. package/src/lifecycle/subagent-state.ts +307 -0
  62. package/src/lifecycle/subagent.ts +854 -0
  63. package/src/lifecycle/usage.ts +60 -0
  64. package/src/lifecycle/workspace-bracket.ts +59 -0
  65. package/src/lifecycle/workspace.ts +47 -0
  66. package/src/observation/composite-subagent-observer.ts +53 -0
  67. package/src/observation/notification.ts +233 -0
  68. package/src/observation/record-observer.ts +78 -0
  69. package/src/observation/renderer.ts +106 -0
  70. package/src/observation/subagent-events-observer.ts +75 -0
  71. package/src/runtime.ts +78 -0
  72. package/src/service/service-adapter.ts +159 -0
  73. package/src/service/service.ts +147 -0
  74. package/src/session/content-items.ts +53 -0
  75. package/src/session/context.ts +80 -0
  76. package/src/session/conversation.ts +49 -0
  77. package/src/session/env.ts +40 -0
  78. package/src/session/model-resolver.ts +135 -0
  79. package/src/session/package-exclusions.ts +75 -0
  80. package/src/session/prompts.ts +145 -0
  81. package/src/session/provider-inheritance.ts +38 -0
  82. package/src/session/session-config.ts +177 -0
  83. package/src/session/session-dir.ts +38 -0
  84. package/src/settings.ts +436 -0
  85. package/src/tools/agent-tool.ts +289 -0
  86. package/src/tools/background-spawner.ts +73 -0
  87. package/src/tools/foreground-runner.ts +135 -0
  88. package/src/tools/get-result-report.ts +73 -0
  89. package/src/tools/get-result-tool.ts +109 -0
  90. package/src/tools/helpers.ts +134 -0
  91. package/src/tools/result-renderer.ts +121 -0
  92. package/src/tools/spawn-config.ts +182 -0
  93. package/src/tools/steer-tool.ts +120 -0
  94. package/src/types.ts +145 -0
  95. package/src/ui/agent-widget.ts +313 -0
  96. package/src/ui/display.ts +214 -0
  97. package/src/ui/glyphs.ts +62 -0
  98. package/src/ui/session-navigation.ts +152 -0
  99. package/src/ui/session-navigator.ts +257 -0
  100. package/src/ui/subagents-agents.ts +116 -0
  101. package/src/ui/subagents-settings.ts +207 -0
  102. package/src/ui/transcript-content.ts +374 -0
  103. package/src/ui/widget-renderer.ts +289 -0
@@ -0,0 +1,134 @@
1
+ import type { AgentConfigLookup } from "#src/config/agent-types";
2
+ import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent-state";
3
+ import { getLifetimeTotal, type LifetimeUsage } from "#src/lifecycle/usage";
4
+ import { type AgentDetails, formatTokens } from "#src/ui/display";
5
+
6
+ /** Parenthetical outcome note for completed agent result text. */
7
+ export function getOutcomeNote(
8
+ outcome: SubagentOutcome | undefined,
9
+ stoppedWhileQueued = false,
10
+ ): string {
11
+ if (stoppedWhileQueued && (outcome === "cancelled" || outcome === "queue_timeout")) {
12
+ return " (stopped before starting)";
13
+ }
14
+ switch (outcome) {
15
+ case "turn_limit":
16
+ return " (Turn limit reached)";
17
+ case "timeout":
18
+ return " (Timed out)";
19
+ case "queue_timeout":
20
+ return " (Queue timed out)";
21
+ case "cancelled":
22
+ return " (Cancelled)";
23
+ default:
24
+ return "";
25
+ }
26
+ }
27
+
28
+ /** Build AgentDetails from a base + record-specific fields. */
29
+ export function buildDetails(
30
+ base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
31
+ record: {
32
+ toolUses: number;
33
+ startedAt: number;
34
+ completedAt?: number;
35
+ phase: SubagentPhase;
36
+ outcome?: SubagentOutcome;
37
+ error?: string;
38
+ id?: string;
39
+ lifetimeUsage: LifetimeUsage;
40
+ /** Live-activity counters — exposed as getters on Subagent (Phase 18 Step 2). */
41
+ invocationTurnCount?: number;
42
+ lifetimeTurnCount?: number;
43
+ maxTurns?: number;
44
+ },
45
+ overrides?: Partial<AgentDetails>,
46
+ ): AgentDetails {
47
+ return {
48
+ ...base,
49
+ toolUses: record.toolUses,
50
+ tokens: formatLifetimeTokens(record),
51
+ invocationTurnCount: record.invocationTurnCount,
52
+ lifetimeTurnCount: record.lifetimeTurnCount,
53
+ maxTurns: record.maxTurns,
54
+ durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
55
+ phase: record.phase,
56
+ ...(record.outcome ? { outcome: record.outcome } : {}),
57
+ agentId: record.id,
58
+ error: record.error,
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ /** Tool execute return value for a text response. */
64
+ export function textResult(msg: string, details?: AgentDetails) {
65
+ return { content: [{ type: "text" as const, text: msg }], details };
66
+ }
67
+
68
+ /** Format an agent's lifetime token total, or "" when zero. */
69
+ export function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
70
+ const t = getLifetimeTotal(o.lifetimeUsage);
71
+ return t > 0 ? formatTokens(t) : "";
72
+ }
73
+
74
+ /**
75
+ * Narrow registry interface needed by buildTypeListText.
76
+ * Extends AgentConfigLookup with the two name-listing methods.
77
+ */
78
+ export interface TypeListRegistry extends AgentConfigLookup {
79
+ getDefaultAgentNames(): string[];
80
+ getUserAgentNames(): string[];
81
+ }
82
+
83
+ /**
84
+ * Build the full agent-type list text for the Agent tool description.
85
+ * Extracted from index.ts so it can be called inside createAgentTool.
86
+ */
87
+ export function buildTypeListText(registry: TypeListRegistry, agentDir: string): string {
88
+ const defaultNames = registry.getDefaultAgentNames().filter((name) => isEnabledAgent(registry, name));
89
+ const userNames = registry.getUserAgentNames().filter((name) => isEnabledAgent(registry, name));
90
+
91
+ const defaultDescs = defaultNames.map((name) => {
92
+ const cfg = registry.resolveAgentConfig(name);
93
+ const modelSuffix = cfg.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
94
+ return `- ${name}: ${cfg.description}${modelSuffix}`;
95
+ });
96
+
97
+ const customDescs = userNames.map((name) => {
98
+ const cfg = registry.resolveAgentConfig(name);
99
+ return `- ${name}: ${cfg.description}`;
100
+ });
101
+
102
+ return [
103
+ ...(defaultDescs.length > 0 ? ["Default agents:", ...defaultDescs] : []),
104
+ ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
105
+ "",
106
+ `Custom agents can be defined in .pi/agents/<name>.md (project) or ${agentDir}/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.`,
107
+ ].join("\n");
108
+ }
109
+
110
+ /** True when an agent config is present and not explicitly disabled. */
111
+ function isEnabledAgent(registry: AgentConfigLookup, name: string): boolean {
112
+ return registry.resolveAgentConfig(name).enabled !== false;
113
+ }
114
+
115
+ /**
116
+ * Collect the per-agent usage guidelines for the subagent tool's Guidelines: block.
117
+ * Sourced from each enabled default agent's `toolGuideline`, in registry order,
118
+ * so a disabled built-in drops its guideline automatically.
119
+ */
120
+ export function buildAgentGuidelines(registry: TypeListRegistry): string[] {
121
+ return registry
122
+ .getDefaultAgentNames()
123
+ .filter((name) => isEnabledAgent(registry, name))
124
+ .map((name) => registry.resolveAgentConfig(name).toolGuideline)
125
+ .filter((line): line is string => line !== undefined);
126
+ }
127
+
128
+ /** Derive a short model label from a model string. */
129
+ export function getModelLabelFromConfig(model: string): string {
130
+ // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
131
+ const name = model.includes("/") ? model.split("/").pop()! : model;
132
+ // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
133
+ return name.replace(/-\d{8}$/, "");
134
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * result-renderer.ts — Pure per-phase/outcome 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 "#src/ui/display";
10
+ import { formatMs, formatTurns, getOutcomeLabel } from "#src/ui/display";
11
+ import { GLYPHS, SPINNER } from "#src/ui/glyphs";
12
+
13
+ // ---- Dispatcher ----
14
+
15
+ /** Dispatch on details.phase/outcome; isPartial forces the running view. */
16
+ export function renderAgentResult(
17
+ details: AgentDetails,
18
+ resultText: string,
19
+ expanded: boolean,
20
+ isPartial: boolean,
21
+ theme: Theme,
22
+ ): string {
23
+ if (isPartial || details.phase === "running" || details.phase === "queued") {
24
+ return renderRunning(details, theme);
25
+ }
26
+ if (details.phase === "background") return renderBackground(details, theme);
27
+ if (details.phase === "terminal" && details.outcome === "completed") {
28
+ return renderCompleted(details, resultText, expanded, theme);
29
+ }
30
+ return renderFailed(details, theme);
31
+ }
32
+
33
+ // ---- Per-phase renderers ----
34
+
35
+ /** Render running/queued/partial status: spinner + stats + activity line. */
36
+ export function renderRunning(details: AgentDetails, theme: Theme): string {
37
+ const frame = SPINNER[details.spinnerFrame ?? 0];
38
+ const s = renderStats(details, theme);
39
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
40
+ line += "\n" + theme.fg("dim", ` ${GLYPHS.subLine} ${details.activity ?? "thinking\u2026"}`);
41
+ return line;
42
+ }
43
+
44
+ /** Render background launch status. */
45
+ export function renderBackground(details: AgentDetails, theme: Theme): string {
46
+ return theme.fg("dim", ` ${GLYPHS.subLine} Running in background (ID: ${details.agentId})`);
47
+ }
48
+
49
+ /** Render the completed outcome with optional expanded result text. */
50
+ export function renderCompleted(
51
+ details: AgentDetails,
52
+ resultText: string,
53
+ expanded: boolean,
54
+ theme: Theme,
55
+ ): string {
56
+ const duration = formatMs(details.durationMs);
57
+ const s = renderStats(details, theme);
58
+ let line = theme.fg("success", GLYPHS.success) + (s ? " " + s : "");
59
+ line += " " + theme.fg("dim", "\u00B7") + " " + theme.fg("dim", duration);
60
+
61
+ if (expanded) {
62
+ if (resultText) {
63
+ const lines = resultText.split("\n").slice(0, 50);
64
+ for (const l of lines) {
65
+ line += "\n" + theme.fg("dim", ` ${l}`);
66
+ }
67
+ if (resultText.split("\n").length > 50) {
68
+ line +=
69
+ "\n" +
70
+ theme.fg(
71
+ "muted",
72
+ " ... (use get_subagent_result with verbose for full output)",
73
+ );
74
+ }
75
+ }
76
+ } else {
77
+ line += "\n" + theme.fg("dim", ` ${GLYPHS.subLine} Done`);
78
+ }
79
+ return line;
80
+ }
81
+
82
+ /**
83
+ * Render every non-completed terminal outcome with failure styling.
84
+ * `error` carries the failure message; the other outcomes show their frozen label.
85
+ */
86
+ export function renderFailed(details: AgentDetails, theme: Theme): string {
87
+ const s = renderStats(details, theme);
88
+ let line = theme.fg("error", GLYPHS.failure) + (s ? " " + s : "");
89
+
90
+ if (details.outcome === "error") {
91
+ line +=
92
+ "\n" +
93
+ theme.fg("error", ` ${GLYPHS.subLine} Error: ${details.error ?? "unknown"}`);
94
+ } else {
95
+ line +=
96
+ "\n" +
97
+ theme.fg("warning", ` ${GLYPHS.subLine} ${getOutcomeLabel(details.outcome)}`);
98
+ }
99
+ return line;
100
+ }
101
+
102
+ // ---- Shared helper ----
103
+
104
+ /**
105
+ * Build the stats string: "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k token".
106
+ * Returns an empty string when all fields are absent or zero.
107
+ */
108
+ export function renderStats(details: AgentDetails, theme: Theme): string {
109
+ const parts: string[] = [];
110
+ if (details.modelName) parts.push(details.modelName);
111
+ if (details.tags) parts.push(...details.tags);
112
+ if (details.invocationTurnCount != null && details.invocationTurnCount > 0) {
113
+ parts.push(formatTurns(details.invocationTurnCount, details.maxTurns));
114
+ }
115
+ if (details.toolUses > 0)
116
+ parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
117
+ if (details.tokens) parts.push(details.tokens);
118
+ return parts
119
+ .map((p) => theme.fg("dim", p))
120
+ .join(" " + theme.fg("dim", "\u00B7") + " ");
121
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * spawn-config.ts — Pure config resolution for the Agent tool.
3
+ *
4
+ * Extracts all config resolution logic from execute: type resolution,
5
+ * invocation config merge, model resolution, runtime budget merging,
6
+ * tag building, and detail-base construction.
7
+ */
8
+
9
+ import type { Model } from "@earendil-works/pi-ai";
10
+ import type { AgentTypeRegistry } from "#src/config/agent-types";
11
+ import { resolveAgentInvocationConfig } from "#src/config/invocation-config";
12
+ import { clampWrapUpTurns, minPositive } from "#src/config/resource-limits";
13
+ import type { ModelRegistry } from "#src/session/model-resolver";
14
+ import { resolveInvocationModel } from "#src/session/model-resolver";
15
+ import type { AgentInvocation, SubagentType, ThinkingLevel } from "#src/types";
16
+ import {
17
+ type AgentDetails,
18
+ buildInvocationTags,
19
+ getDisplayName,
20
+ getPromptModeLabel,
21
+ } from "#src/ui/display";
22
+
23
+ /** Model info extracted from the parent session context. */
24
+ export interface ModelInfo {
25
+ parentModel: Model<any> | undefined;
26
+ modelRegistry: ModelRegistry | undefined;
27
+ }
28
+
29
+ /** Identity: who is being spawned. */
30
+ export interface SpawnIdentity {
31
+ subagentType: string;
32
+ rawType: SubagentType;
33
+ fellBack: boolean;
34
+ displayName: string;
35
+ }
36
+
37
+ /** Execution: how the agent will run. */
38
+ export interface SpawnExecution {
39
+ prompt: string;
40
+ description: string;
41
+ model: Model<any> | undefined;
42
+ /** Effective hard turn budget: minimum over global, profile, and tool call. */
43
+ effectiveMaxTurns: number;
44
+ /** Effective wrap-up window in turns, below effectiveMaxTurns. */
45
+ effectiveWrapUpTurns: number;
46
+ /** Effective run-duration budget in seconds: minimum over global, profile, and tool call. */
47
+ effectiveMaxDurationSeconds: number;
48
+ thinking: ThinkingLevel | undefined;
49
+ inheritContext: boolean;
50
+ runInBackground: boolean;
51
+ agentInvocation: AgentInvocation;
52
+ }
53
+
54
+ /** Presentation: display/UI values derived from identity and execution. */
55
+ export interface SpawnPresentation {
56
+ modelName: string | undefined;
57
+ agentTags: string[];
58
+ detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
59
+ }
60
+
61
+ /** Fully resolved config for spawning an agent — composed of domain-aligned sub-interfaces. */
62
+ export interface ResolvedSpawnConfig {
63
+ identity: SpawnIdentity;
64
+ execution: SpawnExecution;
65
+ presentation: SpawnPresentation;
66
+ }
67
+
68
+ /** Error result when model resolution fails. */
69
+ export interface SpawnConfigError {
70
+ error: string;
71
+ }
72
+
73
+ /** Runtime budgets read from the global settings layer. */
74
+ export interface SpawnSettings {
75
+ readonly maxTurns: number;
76
+ readonly wrapUpTurns: number;
77
+ readonly maxDurationSeconds: number;
78
+ }
79
+
80
+ /**
81
+ * Resolve all config for an Agent tool invocation.
82
+ *
83
+ * Pure function — no SDK types, no side effects.
84
+ * Returns either a fully resolved config or an error.
85
+ */
86
+ export function resolveSpawnConfig(
87
+ params: Record<string, unknown>,
88
+ registry: AgentTypeRegistry,
89
+ modelInfo: ModelInfo,
90
+ settings: SpawnSettings,
91
+ ): ResolvedSpawnConfig | SpawnConfigError {
92
+ const rawType = params.subagent_type as SubagentType;
93
+ const resolved = registry.resolveType(rawType);
94
+
95
+ let subagentType: string;
96
+ try {
97
+ subagentType = registry.resolveSpawnType(rawType);
98
+ } catch (error) {
99
+ return { error: error instanceof Error ? error.message : String(error) };
100
+ }
101
+ const fellBack = resolved === undefined;
102
+
103
+ const displayName = getDisplayName(subagentType, registry);
104
+
105
+ // Merge agent config defaults with tool-call params
106
+ const customConfig = registry.resolveAgentConfig(subagentType);
107
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
108
+
109
+ // Resolve model
110
+ const resolution = resolveInvocationModel(
111
+ modelInfo.parentModel,
112
+ resolvedConfig.modelInput,
113
+ resolvedConfig.modelFromParams,
114
+ modelInfo.modelRegistry,
115
+ );
116
+ if (resolution.error) return { error: resolution.error };
117
+ const model = resolution.model;
118
+
119
+ const thinking = resolvedConfig.thinking;
120
+ const inheritContext = resolvedConfig.inheritContext;
121
+ const runInBackground = resolvedConfig.runInBackground;
122
+
123
+ // Compute display model name (only shown when different from parent)
124
+ const parentModelId = modelInfo.parentModel?.id;
125
+ const effectiveModelId = model?.id;
126
+ const modelName =
127
+ effectiveModelId && effectiveModelId !== parentModelId
128
+ ? model.name.replace(/^Claude\s+/i, "").toLowerCase()
129
+ : undefined;
130
+
131
+ // Budgets: global values always exist; profile and tool-call values may only
132
+ // tighten them, so the effective value is the minimum of every defined layer.
133
+ const effectiveMaxTurns = minPositive(settings.maxTurns, resolvedConfig.maxTurns) ?? settings.maxTurns;
134
+ const effectiveMaxDurationSeconds =
135
+ minPositive(settings.maxDurationSeconds, resolvedConfig.maxDurationSeconds) ??
136
+ settings.maxDurationSeconds;
137
+ // Wrap-up precedence is invocation > profile > global, then clamped below the
138
+ // effective hard stop so the soft limit always fires before the hard stop.
139
+ const effectiveWrapUpTurns = clampWrapUpTurns(
140
+ resolvedConfig.wrapUpTurns ?? settings.wrapUpTurns,
141
+ effectiveMaxTurns,
142
+ );
143
+
144
+ const agentInvocation: AgentInvocation = {
145
+ modelName,
146
+ thinking,
147
+ maxTurns: resolvedConfig.maxTurns,
148
+ wrapUpTurns: resolvedConfig.wrapUpTurns,
149
+ maxDurationSeconds: resolvedConfig.maxDurationSeconds,
150
+ inheritContext,
151
+ runInBackground,
152
+ };
153
+
154
+ const modeLabel = getPromptModeLabel(subagentType, registry);
155
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
156
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
157
+
158
+ const detailBase = {
159
+ displayName,
160
+ description: params.description as string,
161
+ subagentType,
162
+ modelName,
163
+ tags: agentTags.length > 0 ? agentTags : undefined,
164
+ };
165
+
166
+ return {
167
+ identity: { subagentType, rawType, fellBack, displayName },
168
+ execution: {
169
+ prompt: params.prompt as string,
170
+ description: params.description as string,
171
+ model,
172
+ effectiveMaxTurns,
173
+ effectiveWrapUpTurns,
174
+ effectiveMaxDurationSeconds,
175
+ thinking,
176
+ inheritContext,
177
+ runInBackground,
178
+ agentInvocation,
179
+ },
180
+ presentation: { modelName, agentTags, detailBase },
181
+ };
182
+ }
@@ -0,0 +1,120 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { buildSteerLifecycleEvent } from "#src/lifecycle/lifecycle-record";
4
+ import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
5
+ import type { SteerOutcome, Subagent } from "#src/types";
6
+
7
+ // ---- Deps interfaces ----
8
+
9
+ export interface SteerToolManager {
10
+ getRecord(id: string): Subagent | undefined;
11
+ }
12
+
13
+ export interface SteerToolEvents {
14
+ emit(name: string, data: unknown): void;
15
+ }
16
+
17
+ // ---- Class ----
18
+
19
+ export class SteerTool {
20
+ constructor(
21
+ private readonly manager: SteerToolManager,
22
+ private readonly events: SteerToolEvents,
23
+ ) {}
24
+
25
+ async execute(
26
+ _toolCallId: string,
27
+ params: { agent_id: string; message: string },
28
+ _signal: AbortSignal,
29
+ _onUpdate: unknown,
30
+ _ctx: unknown,
31
+ ) {
32
+ const record = this.manager.getRecord(params.agent_id);
33
+ if (!record) {
34
+ return textResult(
35
+ `Agent not found: "${params.agent_id}". Records are cleared at session start/switch, so it may be from a previous session.`,
36
+ );
37
+ }
38
+
39
+ let outcome: SteerOutcome;
40
+ try {
41
+ outcome = await record.steer(params.message);
42
+ } catch (err) {
43
+ return textResult(
44
+ `Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`,
45
+ );
46
+ }
47
+
48
+ switch (outcome.kind) {
49
+ case "rejected": {
50
+ const state = outcome.outcome
51
+ ? `${outcome.phase}/${outcome.outcome}`
52
+ : outcome.phase;
53
+ return textResult(
54
+ `Agent "${params.agent_id}" is not running (state: ${state}). Cannot steer a non-running agent.`,
55
+ );
56
+ }
57
+ case "buffered":
58
+ this.events.emit(
59
+ "subagents:steered",
60
+ buildSteerLifecycleEvent(record, params.message),
61
+ );
62
+ return textResult(
63
+ `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
64
+ );
65
+ case "delivered":
66
+ this.events.emit(
67
+ "subagents:steered",
68
+ buildSteerLifecycleEvent(record, params.message),
69
+ );
70
+ return this.renderDelivered(record);
71
+ }
72
+ }
73
+
74
+ /** Render the success message with live state for a delivered steer. */
75
+ private renderDelivered(record: Subagent) {
76
+ const tokens = formatLifetimeTokens(record);
77
+ const contextPercent = record.getContextPercent();
78
+ const stateParts: string[] = [];
79
+ if (tokens) stateParts.push(tokens);
80
+ stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
81
+ if (contextPercent !== null)
82
+ stateParts.push(`context ${Math.round(contextPercent)}% full`);
83
+ if (record.compactionCount)
84
+ stateParts.push(
85
+ `${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`,
86
+ );
87
+ return textResult(
88
+ `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
89
+ `Current state: ${stateParts.join(" · ")}`,
90
+ );
91
+ }
92
+
93
+ toToolDefinition() {
94
+ return defineTool({
95
+ name: "steer_subagent" as const,
96
+ label: "Steer Agent",
97
+ promptSnippet:
98
+ "Send a mid-run message to redirect a running background agent.",
99
+ description:
100
+ "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
101
+ "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
102
+ parameters: Type.Object({
103
+ agent_id: Type.String({
104
+ description: "The agent ID to steer (must be currently running).",
105
+ }),
106
+ message: Type.String({
107
+ description:
108
+ "The steering message to send. This will appear as a user message in the agent's conversation.",
109
+ }),
110
+ }),
111
+ execute: (
112
+ toolCallId: string,
113
+ params: { agent_id: string; message: string },
114
+ signal: AbortSignal,
115
+ onUpdate: unknown,
116
+ ctx: unknown,
117
+ ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
118
+ });
119
+ }
120
+ }
package/src/types.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * types.ts — Type definitions for the subagent system.
3
+ */
4
+
5
+ import type { Model, ThinkingLevel } from "@earendil-works/pi-ai";
6
+ import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { ModelRegistry } from "#src/session/model-resolver";
8
+
9
+
10
+ export type { SteerOutcome } from "#src/lifecycle/subagent";
11
+ export { Subagent } from "#src/lifecycle/subagent";
12
+ export type { AgentSessionEvent, ThinkingLevel };
13
+
14
+ /**
15
+ * One message in a child session's history, typed from Pi's `SessionContext`.
16
+ *
17
+ * Derived from the barrel-exported `SessionContext` (whose `messages` field is
18
+ * `AgentMessage[]`) so the package needs no direct dependency on
19
+ * `@earendil-works/pi-agent-core`, which is not re-exported from the public barrel.
20
+ */
21
+ export type SessionMessage = SdkSessionContext["messages"][number];
22
+
23
+ /**
24
+ * Narrow session interface for event subscription.
25
+ * Used by record-observer — only the subscribe method is needed.
26
+ */
27
+ export interface SubscribableSession {
28
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void;
29
+ }
30
+
31
+ /** Agent type: any string name (built-in defaults or user-defined). */
32
+ export type SubagentType = string;
33
+
34
+ /** UI display and agent listing — name, display name, description, prompt mode. */
35
+ export interface AgentIdentity {
36
+ name: string;
37
+ displayName?: string;
38
+ description: string;
39
+ promptMode: "replace" | "append";
40
+ }
41
+
42
+ /** Prompt assembly — name, prompt mode, system prompt. */
43
+ export interface AgentPromptConfig {
44
+ name: string;
45
+ promptMode: "replace" | "append";
46
+ systemPrompt: string;
47
+ }
48
+
49
+ /** Unified agent configuration — used for both default and user-defined agents. */
50
+ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
51
+ /** The agent's tool allowlist. Entries name built-in or extension-registered tools; omitted means every built-in. */
52
+ toolNames?: string[];
53
+ model?: string;
54
+ thinking?: ThinkingLevel;
55
+ maxTurns?: number;
56
+ wrapUpTurns?: number;
57
+ maxDurationSeconds?: number;
58
+ /** Default for spawn: fork parent conversation. undefined = caller decides. */
59
+ inheritContext?: boolean;
60
+ /** Default for spawn: run in background. undefined = caller decides. */
61
+ runInBackground?: boolean;
62
+ /** One-line usage guideline for the subagent tool's Guidelines: block. Omitted — no guideline line. */
63
+ toolGuideline?: string;
64
+ /** true = this is an embedded default agent (informational) */
65
+ isDefault?: boolean;
66
+ /** Effective runtime state; populated by AgentTypeRegistry, never by frontmatter. */
67
+ enabled?: boolean;
68
+ /** Where this agent was loaded from */
69
+ source?: "default" | "project" | "global";
70
+ /** Definition path for operator inspection; absent for embedded defaults. */
71
+ filePath?: string;
72
+ }
73
+
74
+ export interface AgentInvocation {
75
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
76
+ modelName?: string;
77
+ thinking?: ThinkingLevel;
78
+ maxTurns?: number;
79
+ wrapUpTurns?: number;
80
+ maxDurationSeconds?: number;
81
+ inheritContext?: boolean;
82
+ runInBackground?: boolean;
83
+ }
84
+
85
+ /**
86
+ * Narrow shell-exec callback replacing `ExtensionAPI` in `detectEnv()`.
87
+ * Matches the shape of `pi.exec()` without carrying an SDK dependency.
88
+ */
89
+ /**
90
+ * Narrow interface capturing the ExtensionContext fields SubagentRuntime needs.
91
+ * Avoids coupling runtime to the full SDK ExtensionContext surface (ISP).
92
+ */
93
+ export interface SessionContext {
94
+ readonly cwd: string;
95
+ readonly model: Model<any> | undefined;
96
+ readonly modelRegistry: ModelRegistry;
97
+ getSystemPrompt(): string;
98
+ readonly sessionManager: {
99
+ getSessionFile(): string | undefined;
100
+ getSessionId(): string;
101
+ getBranch(): unknown[];
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Narrow shell-exec callback replacing `ExtensionAPI` in `detectEnv()`.
107
+ * Matches the shape of `pi.exec()` without carrying an SDK dependency.
108
+ */
109
+ export type ShellExec = (
110
+ command: string,
111
+ args: string[],
112
+ options?: { cwd?: string; timeout?: number },
113
+ ) => Promise<{ stdout: string; stderr: string; code: number }>;
114
+
115
+ /** Identifies the parent invocation currently driving a subagent turn. */
116
+ export type SubagentInvocationIdentity =
117
+ | {
118
+ kind: "fresh" | "resume";
119
+ source: "tool";
120
+ toolCallId: string;
121
+ }
122
+ | {
123
+ kind: "fresh" | "resume";
124
+ source: "service";
125
+ toolCallId?: never;
126
+ };
127
+
128
+ /** Stable identity assigned after the child Pi session is created. */
129
+ export interface SubagentSessionIdentity {
130
+ sessionId: string;
131
+ outputFile?: string;
132
+ }
133
+
134
+ /** Parent session identity — grouped fields that travel together from the tool boundary. */
135
+ export interface ParentSessionInfo {
136
+ /** Path to the parent session's JSONL file (for deriving the subagent session directory). */
137
+ parentSessionFile?: string;
138
+ /** Session ID of the parent agent (stored in the child session's parentSession header). */
139
+ parentSessionId?: string;
140
+ /** Parent tool call ID for the fresh invocation; lifecycle v2 requires it for tool-driven runs. */
141
+ toolCallId?: string;
142
+ }
143
+
144
+ /** Compaction event info passed through lifecycle observers. */
145
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };