@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,289 @@
1
+ import type { AgentToolResult, ExtensionContext, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { AgentTypeRegistry } from "#src/config/agent-types";
6
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
7
+ import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
8
+ import { spawnBackground } from "#src/tools/background-spawner";
9
+ import { runForeground } from "#src/tools/foreground-runner";
10
+ import { buildAgentGuidelines, buildDetails, buildTypeListText, textResult } from "#src/tools/helpers";
11
+ import { renderAgentResult } from "#src/tools/result-renderer";
12
+ import { type ModelInfo, resolveSpawnConfig } from "#src/tools/spawn-config";
13
+ import type { ParentSessionInfo, Subagent, SubagentInvocationIdentity } from "#src/types";
14
+ import { type AgentDetails, getDisplayName, type Theme } from "#src/ui/display";
15
+ import { GLYPHS } from "#src/ui/glyphs";
16
+
17
+ // ---- Deps interfaces ----
18
+
19
+ /** Narrow manager interface — only the methods the Agent tool calls. */
20
+ export interface AgentToolManager {
21
+ spawn: (snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig) => string;
22
+ spawnAndWait: (snapshot: ParentSnapshot, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<Subagent>;
23
+ resume: (
24
+ id: string,
25
+ prompt: string,
26
+ invocation: SubagentInvocationIdentity,
27
+ limits?: { maxTurns?: number; wrapUpTurns?: number; maxDurationSeconds?: number },
28
+ signal?: AbortSignal,
29
+ ) => Promise<Subagent | undefined>;
30
+ getRecord: (id: string) => Subagent | undefined;
31
+ }
32
+
33
+ /** Narrow runtime interface — the Agent tool's slice of SubagentRuntime. */
34
+ export interface AgentToolRuntime {
35
+ buildSnapshot(inheritContext: boolean): ParentSnapshot;
36
+ getModelInfo(): ModelInfo;
37
+ getSessionInfo(): { parentSessionFile: string; parentSessionId: string };
38
+ }
39
+
40
+ /** Narrow settings accessor — only the fields the Agent tool reads. */
41
+ export type AgentToolSettings = {
42
+ readonly maxTurns: number;
43
+ readonly wrapUpTurns: number;
44
+ readonly maxDurationSeconds: number;
45
+ readonly maxQueueWaitSeconds: number;
46
+ readonly abortGraceSeconds: number;
47
+ readonly maxConcurrent: number;
48
+ };
49
+
50
+ // ---- Class ----
51
+
52
+ export class AgentTool {
53
+ constructor(
54
+ private readonly manager: AgentToolManager,
55
+ private readonly runtime: AgentToolRuntime,
56
+ private readonly settings: AgentToolSettings,
57
+ private readonly registry: AgentTypeRegistry,
58
+ private readonly agentDir: string,
59
+ ) {}
60
+
61
+ async execute(
62
+ toolCallId: string,
63
+ params: Record<string, unknown>,
64
+ signal: AbortSignal | undefined,
65
+ onUpdate: ((update: AgentToolResult<AgentDetails>) => void) | undefined,
66
+ _ctx: ExtensionContext,
67
+ ) {
68
+ // Reload custom agents so new .pi/agents/*.md files are picked up without restart
69
+ this.registry.reload();
70
+
71
+ // 恢复按原任务的真实类型校验,不能用另一个已启用类型绕过禁用。
72
+ const existing = params.resume ? this.manager.getRecord(params.resume as string) : undefined;
73
+ if (params.resume && !existing) {
74
+ return textResult(
75
+ `Agent not found: "${params.resume as string}". Records are cleared at session start/switch, so it may be from a previous session.`,
76
+ );
77
+ }
78
+
79
+ // ---- Config resolution (pure) ----
80
+ const config = resolveSpawnConfig(
81
+ existing ? { ...params, subagent_type: existing.type } : params,
82
+ this.registry,
83
+ this.runtime.getModelInfo(),
84
+ this.settings,
85
+ );
86
+ if ("error" in config) return textResult(config.error);
87
+
88
+ // ---- Boundary extraction (after config so inheritContext is resolved) ----
89
+ const snapshot = this.runtime.buildSnapshot(config.execution.inheritContext);
90
+ const { parentSessionFile, parentSessionId } = this.runtime.getSessionInfo();
91
+ const parentSession: ParentSessionInfo = { parentSessionFile, parentSessionId, toolCallId };
92
+
93
+ // ---- Resume existing agent ----
94
+ if (params.resume && existing) {
95
+ if (!existing.isSessionReady()) {
96
+ if (existing.sessionReleased) {
97
+ return textResult(
98
+ `Agent "${params.resume as string}" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result.`,
99
+ );
100
+ }
101
+ return textResult(
102
+ `Agent "${params.resume as string}" has no active session to resume.`,
103
+ );
104
+ }
105
+ let record: Subagent | undefined;
106
+ try {
107
+ record = await this.manager.resume(
108
+ params.resume as string,
109
+ params.prompt as string,
110
+ { kind: "resume", source: "tool", toolCallId },
111
+ {
112
+ maxTurns: config.execution.effectiveMaxTurns,
113
+ wrapUpTurns: config.execution.effectiveWrapUpTurns,
114
+ maxDurationSeconds: config.execution.effectiveMaxDurationSeconds,
115
+ },
116
+ signal,
117
+ );
118
+ } catch (err) {
119
+ return textResult(
120
+ `Failed to resume agent "${params.resume as string}": ${err instanceof Error ? err.message : String(err)}`,
121
+ );
122
+ }
123
+ if (!record) {
124
+ return textResult(`Failed to resume agent "${params.resume as string}".`);
125
+ }
126
+ // Resume-return delivery edge: the resumed outcome is returned directly.
127
+ record.markConsumed();
128
+ return textResult(
129
+ record.result?.trim() ?? record.error?.trim() ?? "No output.",
130
+ buildDetails(config.presentation.detailBase, record),
131
+ );
132
+ }
133
+
134
+ // ---- Background execution ----
135
+ if (config.execution.runInBackground) {
136
+ return spawnBackground(
137
+ this.manager,
138
+ { config, snapshot, parentSession, settings: this.settings },
139
+ );
140
+ }
141
+
142
+ // ---- Foreground execution — stream progress via onUpdate ----
143
+ return runForeground(
144
+ this.manager,
145
+ { config, snapshot, parentSession },
146
+ signal,
147
+ onUpdate,
148
+ );
149
+ }
150
+
151
+ toToolDefinition() {
152
+ const typeListText = buildTypeListText(this.registry, this.agentDir);
153
+ const availableTypesText = this.registry.getAvailableTypes().join(", ");
154
+ const agentDir = this.agentDir;
155
+ const registry = this.registry;
156
+
157
+ const guidelines = [
158
+ "- For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.",
159
+ ...buildAgentGuidelines(this.registry),
160
+ "- Provide clear, detailed prompts so the agent can work autonomously.",
161
+ "- Subagent results are returned as text — summarize them for the user.",
162
+ "- Use run_in_background for work you don't need immediately. You will be notified when it completes.",
163
+ "- Use resume with an agent ID to continue a previous agent's work.",
164
+ "- Use steer_subagent to send mid-run messages to a running background agent.",
165
+ '- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").',
166
+ "- Use thinking to control extended thinking level.",
167
+ "- Use inherit_context if the agent needs the parent conversation history.",
168
+ ].join("\n");
169
+
170
+ return defineTool({
171
+ name: "subagent" as const,
172
+ label: "Subagent",
173
+ promptSnippet: "Launch a specialized agent for complex, multi-step tasks.",
174
+ description: `Launch a new agent to handle complex, multi-step tasks autonomously.
175
+
176
+ The subagent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
177
+
178
+ Available agent types:
179
+ ${typeListText}
180
+
181
+ Guidelines:
182
+ ${guidelines}
183
+ `,
184
+ parameters: Type.Object({
185
+ prompt: Type.String({
186
+ description: "The task for the agent to perform.",
187
+ }),
188
+ description: Type.String({
189
+ description: "A short (3-5 word) description of the task (shown in UI).",
190
+ }),
191
+ subagent_type: Type.String({
192
+ description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${agentDir}/agents/<name>.md (global) are also available.`,
193
+ }),
194
+ model: Type.Optional(
195
+ Type.String({
196
+ description:
197
+ 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
198
+ }),
199
+ ),
200
+ thinking: Type.Optional(
201
+ Type.String({
202
+ description:
203
+ "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
204
+ }),
205
+ ),
206
+ max_turns: Type.Optional(
207
+ Type.Integer({
208
+ description:
209
+ "Maximum number of agentic turns before the hard stop. Caps the configured default; cannot raise it.",
210
+ minimum: 1,
211
+ }),
212
+ ),
213
+ wrap_up_turns: Type.Optional(
214
+ Type.Integer({
215
+ description:
216
+ "Turns reserved for wrap-up before the hard stop. Overrides the configured wrap-up budget but stays below max_turns. Omit to use the agent/global value.",
217
+ minimum: 0,
218
+ }),
219
+ ),
220
+ max_duration_seconds: Type.Optional(
221
+ Type.Integer({
222
+ description:
223
+ "Maximum agent runtime in seconds. Caps the configured default; cannot raise it.",
224
+ minimum: 1,
225
+ }),
226
+ ),
227
+ run_in_background: Type.Optional(
228
+ Type.Boolean({
229
+ description:
230
+ "Set to true to run in background. Returns agent ID immediately. You will be notified when it completes.",
231
+ }),
232
+ ),
233
+ resume: Type.Optional(
234
+ Type.String({
235
+ description: "Optional agent ID to resume from. Continues from previous context.",
236
+ }),
237
+ ),
238
+ inherit_context: Type.Optional(
239
+ Type.Boolean({
240
+ description:
241
+ "If true, fork parent conversation into the agent. Default: false (fresh context).",
242
+ }),
243
+ ),
244
+ }),
245
+
246
+ // ---- Custom rendering: inline subagent results ----
247
+
248
+ renderCall(args: Record<string, unknown>, theme: Theme) {
249
+ const displayName = args.subagent_type
250
+ ? getDisplayName(args.subagent_type as string, registry)
251
+ : "Subagent";
252
+ const desc = (args.description as string | undefined) ?? "";
253
+ return new Text(
254
+ `${GLYPHS.toolCall} ` +
255
+ theme.fg("toolTitle", theme.bold(displayName)) +
256
+ (desc ? " " + theme.fg("muted", desc) : ""),
257
+ 0,
258
+ 0,
259
+ );
260
+ },
261
+
262
+ renderResult(
263
+ result: AgentToolResult<AgentDetails | undefined>,
264
+ { expanded, isPartial }: ToolRenderResultOptions,
265
+ theme: Theme,
266
+ ) {
267
+ const details = result.details;
268
+ if (!details) {
269
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
270
+ return new Text(text, 0, 0);
271
+ }
272
+ const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
273
+ return new Text(
274
+ renderAgentResult(details, resultText, expanded, isPartial, theme),
275
+ 0,
276
+ 0,
277
+ );
278
+ },
279
+
280
+ execute: (
281
+ toolCallId: string,
282
+ params: Record<string, unknown>,
283
+ signal: AbortSignal | undefined,
284
+ onUpdate: ((update: AgentToolResult<AgentDetails>) => void) | undefined,
285
+ ctx: ExtensionContext,
286
+ ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
287
+ });
288
+ }
289
+ }
@@ -0,0 +1,73 @@
1
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
2
+ import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
3
+ import { textResult } from "#src/tools/helpers";
4
+ import type { ResolvedSpawnConfig } from "#src/tools/spawn-config";
5
+ import type { ParentSessionInfo, Subagent } from "#src/types";
6
+
7
+ /** Narrow manager interface for the background spawner. */
8
+ export interface BackgroundManagerDeps {
9
+ spawn(snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig): string;
10
+ getRecord(id: string): Subagent | undefined;
11
+ }
12
+
13
+ /** All values the background spawner needs beyond the resolved config. */
14
+ export interface BackgroundParams {
15
+ config: ResolvedSpawnConfig;
16
+ snapshot: ParentSnapshot;
17
+ parentSession: ParentSessionInfo;
18
+ settings: { readonly maxConcurrent: number };
19
+ }
20
+
21
+ /**
22
+ * Spawn a background agent and return the tool result immediately.
23
+ * Owns: launch message formatting.
24
+ */
25
+ export function spawnBackground(
26
+ manager: BackgroundManagerDeps,
27
+ params: BackgroundParams,
28
+ ) {
29
+ const { identity, execution, presentation } = params.config;
30
+
31
+ let id: string;
32
+ try {
33
+ id = manager.spawn(params.snapshot, identity.subagentType, execution.prompt, {
34
+ parentSession: params.parentSession,
35
+ description: execution.description,
36
+ model: execution.model,
37
+ maxTurns: execution.effectiveMaxTurns,
38
+ wrapUpTurns: execution.effectiveWrapUpTurns,
39
+ maxDurationSeconds: execution.effectiveMaxDurationSeconds,
40
+ inheritContext: execution.inheritContext,
41
+ thinkingLevel: execution.thinking,
42
+ isBackground: true,
43
+ invocation: execution.agentInvocation,
44
+ });
45
+ } catch (err) {
46
+ return textResult(err instanceof Error ? err.message : String(err));
47
+ }
48
+
49
+ const record = manager.getRecord(id);
50
+
51
+ const isQueued = record?.phase === "queued";
52
+ return textResult(
53
+ `Agent ${isQueued ? "queued" : "started"} in background.\n` +
54
+ `Agent ID: ${id}\n` +
55
+ `Type: ${identity.displayName}\n` +
56
+ `Description: ${execution.description}\n` +
57
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
58
+ (isQueued
59
+ ? `Position: queued (max ${params.settings.maxConcurrent} concurrent)\n`
60
+ : "") +
61
+ `\nYou will be notified when this agent completes.\n` +
62
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
63
+ `Do not duplicate this agent's work.`,
64
+ {
65
+ ...presentation.detailBase,
66
+ toolUses: 0,
67
+ tokens: "",
68
+ durationMs: 0,
69
+ phase: "background" as const,
70
+ agentId: id,
71
+ },
72
+ );
73
+ }
@@ -0,0 +1,135 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
3
+ import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
4
+ import {
5
+ buildDetails,
6
+ formatLifetimeTokens,
7
+ getOutcomeNote,
8
+ textResult,
9
+ } from "#src/tools/helpers";
10
+ import type { ResolvedSpawnConfig } from "#src/tools/spawn-config";
11
+ import type { ParentSessionInfo, Subagent } from "#src/types";
12
+ import { type AgentDetails, describeActivity, formatMs } from "#src/ui/display";
13
+ import { SPINNER } from "#src/ui/glyphs";
14
+
15
+ /** Narrow manager interface for the foreground runner. */
16
+ export interface ForegroundManagerDeps {
17
+ spawnAndWait(
18
+ snapshot: ParentSnapshot,
19
+ type: string,
20
+ prompt: string,
21
+ opts: Omit<AgentSpawnConfig, "isBackground">,
22
+ ): Promise<Subagent>;
23
+ }
24
+
25
+ /** All values the foreground runner needs beyond the resolved config. */
26
+ export interface ForegroundParams {
27
+ config: ResolvedSpawnConfig;
28
+ snapshot: ParentSnapshot;
29
+ parentSession: ParentSessionInfo;
30
+ }
31
+
32
+ /**
33
+ * Run an agent synchronously in the foreground, streaming spinner updates.
34
+ * Owns: spinner interval, streaming onUpdate callbacks, cleanup, and result formatting.
35
+ */
36
+ export async function runForeground(
37
+ manager: ForegroundManagerDeps,
38
+ params: ForegroundParams,
39
+ signal: AbortSignal | undefined,
40
+ onUpdate: ((update: AgentToolResult<AgentDetails>) => void) | undefined,
41
+ ) {
42
+ const { identity, execution, presentation } = params.config;
43
+ let spinnerFrame = 0;
44
+ const startedAt = Date.now();
45
+
46
+ let recordRef: Subagent | undefined;
47
+
48
+ const streamUpdate = () => {
49
+ const toolUses = recordRef?.toolUses ?? 0;
50
+ const details: AgentDetails = {
51
+ ...presentation.detailBase,
52
+ toolUses,
53
+ tokens: recordRef ? formatLifetimeTokens(recordRef) : "",
54
+ // Read activity off the record; fall back to safe defaults before onSessionCreated fires
55
+ invocationTurnCount: recordRef?.invocationTurnCount ?? 0,
56
+ lifetimeTurnCount: recordRef?.lifetimeTurnCount ?? 0,
57
+ maxTurns: recordRef?.maxTurns ?? execution.effectiveMaxTurns,
58
+ durationMs: Date.now() - startedAt,
59
+ phase: "running",
60
+ activity: describeActivity(
61
+ recordRef?.activeTools ?? new Map(),
62
+ recordRef?.responseText ?? "",
63
+ ),
64
+ spinnerFrame: spinnerFrame % SPINNER.length,
65
+ };
66
+ onUpdate?.({
67
+ content: [{ type: "text", text: `${toolUses} tool uses...` }],
68
+ details,
69
+ });
70
+ };
71
+
72
+ // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
73
+ const spinnerInterval = setInterval(() => {
74
+ spinnerFrame++;
75
+ streamUpdate();
76
+ }, 80);
77
+
78
+ streamUpdate();
79
+
80
+ let record: Subagent;
81
+ try {
82
+ record = await manager.spawnAndWait(
83
+ params.snapshot,
84
+ identity.subagentType,
85
+ execution.prompt,
86
+ {
87
+ description: execution.description,
88
+ model: execution.model,
89
+ maxTurns: execution.effectiveMaxTurns,
90
+ wrapUpTurns: execution.effectiveWrapUpTurns,
91
+ maxDurationSeconds: execution.effectiveMaxDurationSeconds,
92
+ inheritContext: execution.inheritContext,
93
+ thinkingLevel: execution.thinking,
94
+ invocation: execution.agentInvocation,
95
+ signal,
96
+ parentSession: params.parentSession,
97
+ observer: {
98
+ onSessionCreated: (agent) => {
99
+ recordRef = agent;
100
+ },
101
+ },
102
+ },
103
+ );
104
+ } catch (err) {
105
+ clearInterval(spinnerInterval);
106
+ return textResult(err instanceof Error ? err.message : String(err));
107
+ }
108
+
109
+ clearInterval(spinnerInterval);
110
+
111
+ // Foreground-return delivery edge: the result is handed back in this tool
112
+ // result, so the outcome is collected. Mark it consumed.
113
+ record.markConsumed();
114
+
115
+ const tokenText = formatLifetimeTokens(record);
116
+ const details = buildDetails(presentation.detailBase, record, { tokens: tokenText });
117
+
118
+ const fallbackNote = identity.fellBack
119
+ ? `Note: Unknown agent type "${identity.rawType}" — using general-purpose.\n\n`
120
+ : "";
121
+ const agentIdentity = `Agent ID: ${record.id}\n`;
122
+
123
+ if (record.outcome === "error") {
124
+ return textResult(`${fallbackNote}${agentIdentity}Agent failed: ${record.error}`, details);
125
+ }
126
+
127
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
128
+ const statsParts = [`${record.toolUses} tool uses`];
129
+ if (tokenText) statsParts.push(tokenText);
130
+ return textResult(
131
+ `${fallbackNote}${agentIdentity}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getOutcomeNote(record.outcome, record.stoppedWhileQueued)}.\n\n` +
132
+ (record.result?.trim() ?? "No output."),
133
+ details,
134
+ );
135
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * get-result-report.ts — Pure report assembly for get_subagent_result.
3
+ *
4
+ * All functions are stateless: they receive an AgentReport, returning
5
+ * formatted strings. No SDK types, no timers, no side effects.
6
+ * Consumed by GetResultTool.execute in get-result-tool.ts. Mirrors the
7
+ * result-renderer.ts pattern used by the subagent tool's TUI renderer.
8
+ */
9
+
10
+ import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent";
11
+
12
+ /** The data a get_subagent_result report renders from — only what the formatter reads. */
13
+ export interface AgentReport {
14
+ id: string;
15
+ displayName: string;
16
+ phase: SubagentPhase;
17
+ outcome?: SubagentOutcome;
18
+ toolUses: number;
19
+ /** Pre-formatted lifetime token total; "" when zero. */
20
+ tokens: string;
21
+ contextPercent: number | null;
22
+ compactionCount: number;
23
+ /** Pre-formatted duration string. */
24
+ duration: string;
25
+ description: string;
26
+ result: string | undefined;
27
+ error: string | undefined;
28
+ /** Whether the agent was stopped before the limiter ever admitted it. */
29
+ stoppedWhileQueued: boolean;
30
+ /** Present only when verbose was requested and a conversation is available. */
31
+ conversation?: string;
32
+ /** Persisted transcript path; rendered as a pointer so the parent can read it directly. */
33
+ transcriptPath?: string;
34
+ }
35
+
36
+ /** Assemble the stats parts: Tool uses / tokens? / Context? / Compactions? / Duration. */
37
+ export function renderStatsParts(report: AgentReport): string[] {
38
+ const parts = [`Tool uses: ${report.toolUses}`];
39
+ if (report.tokens) parts.push(report.tokens);
40
+ if (report.contextPercent !== null) parts.push(`Context: ${Math.round(report.contextPercent)}%`);
41
+ if (report.compactionCount) parts.push(`Compactions: ${report.compactionCount}`);
42
+ parts.push(`Duration: ${report.duration}`);
43
+ return parts;
44
+ }
45
+
46
+ /** Select the per-phase/outcome body: running note, error line, or trimmed result. */
47
+ export function renderReportBody(report: AgentReport): string {
48
+ if (report.phase !== "terminal")
49
+ return "Agent is still running. Use wait: true or check back later.";
50
+ if (report.outcome === "error") return `Error: ${report.error}`;
51
+ if (
52
+ report.stoppedWhileQueued &&
53
+ (report.outcome === "cancelled" || report.outcome === "queue_timeout")
54
+ )
55
+ return "Agent was stopped while queued and never started. No work was performed.";
56
+ return report.result?.trim() ?? "No output.";
57
+ }
58
+
59
+ /** Assemble the full get_subagent_result report text. */
60
+ export function formatAgentReport(report: AgentReport): string {
61
+ let output =
62
+ `Agent: ${report.id}\n` +
63
+ `Type: ${report.displayName} | Status: ${report.outcome ?? report.phase} | ${renderStatsParts(report).join(" | ")}\n` +
64
+ `Description: ${report.description}\n\n`;
65
+ output += renderReportBody(report);
66
+ if (report.conversation) {
67
+ output += `\n\n--- Agent Conversation ---\n${report.conversation}`;
68
+ }
69
+ if (report.transcriptPath) {
70
+ output += `\n\nFull transcript available at: ${report.transcriptPath}`;
71
+ }
72
+ return output;
73
+ }
@@ -0,0 +1,109 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { AgentConfigLookup } from "#src/config/agent-types";
4
+ import { type AgentReport, formatAgentReport } from "#src/tools/get-result-report";
5
+ import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
6
+ import type { Subagent } from "#src/types";
7
+ import { formatDuration, getDisplayName } from "#src/ui/display";
8
+
9
+ // ---- Deps interfaces ----
10
+
11
+ export interface GetResultToolManager {
12
+ getRecord(id: string): Subagent | undefined;
13
+ }
14
+
15
+ // ---- Class ----
16
+
17
+ export class GetResultTool {
18
+ constructor(
19
+ private readonly manager: GetResultToolManager,
20
+ private readonly registry: AgentConfigLookup,
21
+ ) {}
22
+
23
+ async execute(
24
+ _toolCallId: string,
25
+ params: { agent_id: string; wait?: boolean; verbose?: boolean },
26
+ signal: AbortSignal,
27
+ _onUpdate: unknown,
28
+ _ctx: unknown,
29
+ ) {
30
+ const record = this.manager.getRecord(params.agent_id);
31
+ if (!record) {
32
+ return textResult(`Agent not found: "${params.agent_id}". Records are cleared at session start/switch, so it may be from a previous session.`);
33
+ }
34
+
35
+ // Wait for completion if requested. The record owns the decision of whether
36
+ // it is still awaitable — a queued agent counts, because scheduleVia()
37
+ // captures its limiter promise at spawn. A parent interrupt ends the wait
38
+ // without cancelling the agent, leaving the outcome uncollected below.
39
+ if (params.wait) {
40
+ await record.waitUntilSettled(signal);
41
+ }
42
+
43
+ // Pull-delivery edge: the parent is collecting the settled outcome here, so
44
+ // mark it consumed. The completion nudge scheduled by onSubagentCompleted
45
+ // re-reads record.consumed at fire time and suppresses itself.
46
+ if (!record.isActive()) {
47
+ record.markConsumed();
48
+ }
49
+
50
+ return textResult(formatAgentReport(this.buildReport(record, params.verbose)));
51
+ }
52
+
53
+ private buildReport(record: Subagent, verbose?: boolean): AgentReport {
54
+ return {
55
+ id: record.id,
56
+ displayName: getDisplayName(record.type, this.registry),
57
+ phase: record.phase,
58
+ ...(record.outcome ? { outcome: record.outcome } : {}),
59
+ toolUses: record.toolUses,
60
+ tokens: formatLifetimeTokens(record),
61
+ contextPercent: record.getContextPercent(),
62
+ compactionCount: record.compactionCount,
63
+ duration: formatDuration(record.startedAt, record.completedAt),
64
+ description: record.description,
65
+ result: record.result,
66
+ error: record.error,
67
+ stoppedWhileQueued: record.stoppedWhileQueued,
68
+ conversation: verbose ? record.getConversation() : undefined,
69
+ // Transcript pointer: lets the parent read the full session from disk,
70
+ // and covers verbose after the live session was released (no conversation).
71
+ transcriptPath: record.outputFile,
72
+ };
73
+ }
74
+
75
+ toToolDefinition() {
76
+ return defineTool({
77
+ name: "get_subagent_result" as const,
78
+ label: "Get Agent Result",
79
+ promptSnippet:
80
+ "Check status and retrieve results from a background agent.",
81
+ description:
82
+ "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
83
+ parameters: Type.Object({
84
+ agent_id: Type.String({
85
+ description: "The agent ID to check.",
86
+ }),
87
+ wait: Type.Optional(
88
+ Type.Boolean({
89
+ description:
90
+ "If true, wait for the agent to complete before returning. Default: false.",
91
+ }),
92
+ ),
93
+ verbose: Type.Optional(
94
+ Type.Boolean({
95
+ description:
96
+ "If true, include the agent's full conversation (messages + tool calls). Default: false.",
97
+ }),
98
+ ),
99
+ }),
100
+ execute: (
101
+ toolCallId: string,
102
+ params: { agent_id: string; wait?: boolean; verbose?: boolean },
103
+ signal: AbortSignal,
104
+ onUpdate: unknown,
105
+ ctx: unknown,
106
+ ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
107
+ });
108
+ }
109
+ }