@jopqior/pi-subagents 1.0.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 (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,305 @@
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 "@sinclair/typebox";
5
+ import { AgentTypeRegistry } from "#src/config/agent-types";
6
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
7
+ import type {
8
+ AgentSpawnConfig,
9
+ ResumeCallOptions,
10
+ ResumeOutcome,
11
+ ResumeRefusalReason,
12
+ } from "#src/lifecycle/subagent-manager";
13
+ import {
14
+ renderOutcomeAddenda,
15
+ renderOutcomeBody,
16
+ renderStatusNote,
17
+ } from "#src/observation/outcome-delivery";
18
+ import { spawnBackground } from "#src/tools/background-spawner";
19
+ import { runForeground } from "#src/tools/foreground-runner";
20
+ import { buildAgentGuidelines, buildDetails, buildTypeListText, textResult } from "#src/tools/helpers";
21
+ import { renderAgentResult } from "#src/tools/result-renderer";
22
+ import { type ModelInfo, resolveSpawnConfig, type SpawnPresentation } from "#src/tools/spawn-config";
23
+ import type { ParentSessionInfo, Subagent } from "#src/types";
24
+ import { type AgentDetails, getDisplayName, type Theme } from "#src/ui/display";
25
+ import { GLYPHS } from "#src/ui/glyphs";
26
+
27
+ // ---- Deps interfaces ----
28
+
29
+ /** Narrow manager interface — only the methods the Agent tool calls. */
30
+ export interface AgentToolManager {
31
+ spawn: (snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig) => string;
32
+ spawnAndWait: (snapshot: ParentSnapshot, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "background">) => Promise<Subagent>;
33
+ resume: (id: string, prompt: string, options: ResumeCallOptions) => Promise<ResumeOutcome>;
34
+ getRecord: (id: string) => Subagent | undefined;
35
+ }
36
+
37
+ /** Narrow runtime interface — the Agent tool's slice of SubagentRuntime. */
38
+ export interface AgentToolRuntime {
39
+ buildSnapshot(inheritContext: boolean): ParentSnapshot;
40
+ getModelInfo(): ModelInfo;
41
+ getSessionInfo(): { parentSessionFile: string; parentSessionId: string };
42
+ }
43
+
44
+ /** Narrow settings accessor — only the fields the Agent tool reads. */
45
+ export type AgentToolSettings = {
46
+ readonly defaultMaxTurns: number | undefined;
47
+ readonly maxConcurrent: number;
48
+ };
49
+
50
+ // ---- Class ----
51
+
52
+ export class AgentTool {
53
+ private readonly typeListText: string;
54
+ private readonly availableTypesText: string;
55
+ private readonly agentGuidelines: string[];
56
+
57
+ constructor(
58
+ private readonly manager: AgentToolManager,
59
+ private readonly runtime: AgentToolRuntime,
60
+ private readonly settings: AgentToolSettings,
61
+ private readonly registry: AgentTypeRegistry,
62
+ private readonly agentDir: string,
63
+ ) {
64
+ this.typeListText = buildTypeListText(registry, agentDir);
65
+ this.availableTypesText = registry.getAvailableTypes().join(", ");
66
+ this.agentGuidelines = buildAgentGuidelines(registry);
67
+ }
68
+
69
+ async execute(
70
+ toolCallId: string,
71
+ params: Record<string, unknown>,
72
+ signal: AbortSignal | undefined,
73
+ onUpdate: ((update: AgentToolResult<AgentDetails>) => void) | undefined,
74
+ _ctx: ExtensionContext,
75
+ ) {
76
+ // Reload custom agents so new .pi/agents/*.md files are picked up without restart
77
+ this.registry.reload();
78
+
79
+ // ---- Config resolution (pure) ----
80
+ const config = resolveSpawnConfig(
81
+ 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) {
95
+ return this.resumeExisting(
96
+ params.resume as string,
97
+ params.prompt as string,
98
+ signal,
99
+ config.presentation.detailBase,
100
+ );
101
+ }
102
+
103
+ // ---- Background execution ----
104
+ if (config.execution.runInBackground) {
105
+ return spawnBackground(
106
+ this.manager,
107
+ { config, snapshot, parentSession, settings: this.settings },
108
+ );
109
+ }
110
+
111
+ // ---- Foreground execution — stream progress via onUpdate ----
112
+ return runForeground(
113
+ this.manager,
114
+ { config, snapshot, parentSession },
115
+ signal,
116
+ onUpdate,
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Continue an existing agent's session with a new prompt, returning its
122
+ * resumed outcome directly to the parent.
123
+ */
124
+ private async resumeExisting(
125
+ id: string,
126
+ prompt: string,
127
+ signal: AbortSignal | undefined,
128
+ detailBase: SpawnPresentation["detailBase"],
129
+ ) {
130
+ // The manager owns whether a resume happens; this door owns only how the
131
+ // answer is worded. Resuming commits this call to delivering the outcome,
132
+ // so it claims it — nothing else announces what is already being returned.
133
+ const outcome = await this.manager.resume(id, prompt, {
134
+ signal: signal ?? new AbortController().signal,
135
+ claimOutcome: true,
136
+ });
137
+ if (outcome.kind === "refused") {
138
+ return textResult(resumeRefusalMessage(outcome.reason, id));
139
+ }
140
+ const record = outcome.record;
141
+ // Resume-return delivery edge: the resumed outcome is returned directly.
142
+ record.markConsumed();
143
+ return textResult(
144
+ `Agent ID: ${record.id}${renderStatusNote(record.status)}\n\n` +
145
+ renderOutcomeBody(record) +
146
+ renderOutcomeAddenda(record),
147
+ buildDetails(detailBase, record),
148
+ );
149
+ }
150
+
151
+ toToolDefinition() {
152
+ const typeListText = this.typeListText;
153
+ const availableTypesText = this.availableTypesText;
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
+ ...this.agentGuidelines,
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, or to answer an agent that ended its turn with a question.",
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
+ "- When a spawn-selection provider is registered, the subagent tool still accepts model and thinking, but the operator's choice after the call overrides those arguments and any agent locks for those two fields.",
168
+ "- Use inherit_context if the agent needs the parent conversation history.",
169
+ ].join("\n");
170
+
171
+ return defineTool({
172
+ name: "subagent" as const,
173
+ label: "Subagent",
174
+ promptSnippet: "Launch a specialized agent for complex, multi-step tasks.",
175
+ description: `Launch a new agent to handle complex, multi-step tasks autonomously.
176
+
177
+ The subagent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
178
+
179
+ Available agent types:
180
+ ${typeListText}
181
+
182
+ Guidelines:
183
+ ${guidelines}
184
+ `,
185
+ parameters: Type.Object({
186
+ prompt: Type.String({
187
+ description: "The task for the agent to perform.",
188
+ }),
189
+ description: Type.String({
190
+ description: "A short (3-5 word) description of the task (shown in UI).",
191
+ }),
192
+ subagent_type: Type.String({
193
+ 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.`,
194
+ }),
195
+ model: Type.Optional(
196
+ Type.String({
197
+ description:
198
+ 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default. An agent that locks this field keeps its own model and says so in the result.',
199
+ }),
200
+ ),
201
+ thinking: Type.Optional(
202
+ Type.String({
203
+ description:
204
+ "Thinking level: off, minimal, low, medium, high, xhigh, max. Overrides the agent's default unless the agent locks this field.",
205
+ }),
206
+ ),
207
+ max_turns: Type.Optional(
208
+ Type.Number({
209
+ description:
210
+ "Maximum number of agentic turns before stopping. Omit to use the agent's own limit, or unlimited when it declares none.",
211
+ minimum: 1,
212
+ }),
213
+ ),
214
+ run_in_background: Type.Optional(
215
+ Type.Boolean({
216
+ description:
217
+ "Set to true to run in background. Returns agent ID immediately. You will be notified when it completes. Omit to use the agent's own default.",
218
+ }),
219
+ ),
220
+ resume: Type.Optional(
221
+ Type.String({
222
+ description: "Optional agent ID to resume from. Continues from previous context.",
223
+ }),
224
+ ),
225
+ inherit_context: Type.Optional(
226
+ Type.Boolean({
227
+ description:
228
+ "If true, fork parent conversation into the agent. Omit to use the agent's own default, which is fresh context unless it declares otherwise.",
229
+ }),
230
+ ),
231
+ }),
232
+
233
+ // ---- Custom rendering: inline subagent results ----
234
+
235
+ renderCall(args: Record<string, unknown>, theme: Theme) {
236
+ const displayName = args.subagent_type
237
+ ? getDisplayName(args.subagent_type as string, registry)
238
+ : "Subagent";
239
+ const desc = (args.description as string | undefined) ?? "";
240
+ return new Text(
241
+ `${GLYPHS.toolCall} ` +
242
+ theme.fg("toolTitle", theme.bold(displayName)) +
243
+ (desc ? " " + theme.fg("muted", desc) : ""),
244
+ 0,
245
+ 0,
246
+ );
247
+ },
248
+
249
+ renderResult(
250
+ result: AgentToolResult<AgentDetails | undefined>,
251
+ { expanded, isPartial }: ToolRenderResultOptions,
252
+ theme: Theme,
253
+ ) {
254
+ const details = result.details;
255
+ if (!details) {
256
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
257
+ return new Text(text, 0, 0);
258
+ }
259
+ const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
260
+ return new Text(
261
+ renderAgentResult(details, resultText, expanded, isPartial, theme),
262
+ 0,
263
+ 0,
264
+ );
265
+ },
266
+
267
+ execute: (
268
+ toolCallId: string,
269
+ params: Record<string, unknown>,
270
+ signal: AbortSignal | undefined,
271
+ onUpdate: ((update: AgentToolResult<AgentDetails>) => void) | undefined,
272
+ ctx: ExtensionContext,
273
+ ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
274
+ });
275
+ }
276
+ }
277
+
278
+ /**
279
+ * The operator-facing sentence for each reason a resume is refused.
280
+ *
281
+ * Exhaustive over `ResumeRefusalReason`, so a reason added later fails to
282
+ * compile here rather than falling through to an attempted resume.
283
+ */
284
+ function resumeRefusalMessage(refusal: ResumeRefusalReason, id: string): string {
285
+ switch (refusal) {
286
+ case "unknown-agent":
287
+ return `Agent not found: "${id}". Records are cleared at session start/switch, so it may be from a previous session.`;
288
+ case "still-running":
289
+ return (
290
+ `Agent "${id}" is still running; wait for it to finish before resuming. ` +
291
+ "Use steer_subagent to send it a message while it runs."
292
+ );
293
+ case "session-released":
294
+ return `Agent "${id}" had its session released after its retention window; resume is unavailable, but its result is still retrievable via get_subagent_result.`;
295
+ case "no-session":
296
+ return `Agent "${id}" has no active session to resume.`;
297
+ case "workspace-disposed":
298
+ return (
299
+ `Agent "${id}" ran in an isolated workspace that no longer ` +
300
+ "exists; resume is unavailable because the agent would re-enter a directory that " +
301
+ "has been removed. Spawn a new agent instead — the agent's result records where " +
302
+ "any work was saved."
303
+ );
304
+ }
305
+ }
@@ -0,0 +1,83 @@
1
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
2
+ import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
3
+ import { renderSpawnNotes, textResult } from "#src/tools/helpers";
4
+ import type { ResolvedSpawnConfig } from "#src/tools/spawn-config";
5
+ import type { ParentSessionInfo, Subagent } from "#src/types";
6
+ import type { AgentDetails } from "#src/ui/display";
7
+
8
+ /** Narrow manager interface for the background spawner. */
9
+ export interface BackgroundManagerDeps {
10
+ spawn(snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig): string;
11
+ getRecord(id: string): Subagent | undefined;
12
+ }
13
+
14
+ /** All values the background spawner needs beyond the resolved config. */
15
+ export interface BackgroundParams {
16
+ config: ResolvedSpawnConfig;
17
+ snapshot: ParentSnapshot;
18
+ parentSession: ParentSessionInfo;
19
+ settings: { readonly maxConcurrent: number };
20
+ }
21
+
22
+ /**
23
+ * Spawn a background agent and return the tool result immediately.
24
+ * Owns: launch message formatting.
25
+ */
26
+ export function spawnBackground(
27
+ manager: BackgroundManagerDeps,
28
+ params: BackgroundParams,
29
+ ) {
30
+ const { identity, execution, presentation, notes } = params.config;
31
+
32
+ let id: string;
33
+ try {
34
+ id = manager.spawn(params.snapshot, identity.subagentType, execution.prompt, {
35
+ parentSession: params.parentSession,
36
+ description: execution.description,
37
+ model: execution.model,
38
+ maxTurns: execution.effectiveMaxTurns,
39
+ inheritContext: execution.inheritContext,
40
+ thinkingLevel: execution.thinking,
41
+ // resolveSpawnConfig already merged the agent's frontmatter and AgentTool
42
+ // routed here on the result, so this door has committed.
43
+ background: { kind: "explicit", isBackground: true },
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?.status === "queued";
52
+ const isAwaitingSelection = record?.awaitingSelection === true;
53
+ const launchVerb = isQueued ? "queued" : isAwaitingSelection ? "submitted" : "started";
54
+ // Annotated rather than inlined into the call: `textResult` is generic over its
55
+ // details, so an inline literal would define the type instead of being checked
56
+ // against it.
57
+ const details: AgentDetails = {
58
+ ...presentation.detailBase,
59
+ toolUses: 0,
60
+ tokens: "",
61
+ durationMs: 0,
62
+ status: "background",
63
+ agentId: id,
64
+ };
65
+ return textResult(
66
+ renderSpawnNotes(notes) +
67
+ `Agent ${launchVerb} in background.\n` +
68
+ `Agent ID: ${id}\n` +
69
+ `Type: ${identity.displayName}\n` +
70
+ `Description: ${execution.description}\n` +
71
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
72
+ (isQueued
73
+ ? `Position: queued (max ${params.settings.maxConcurrent} concurrent)\n`
74
+ : "") +
75
+ (isAwaitingSelection && !isQueued
76
+ ? `Awaiting model/thinking selection.\n`
77
+ : "") +
78
+ `\nYou will be notified when this agent completes.\n` +
79
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
80
+ `Do not duplicate this agent's work.`,
81
+ details,
82
+ );
83
+ }
@@ -0,0 +1,159 @@
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
+ renderOutcomeAddenda,
6
+ renderOutcomeBody,
7
+ renderRunUpdates,
8
+ renderStatusNote,
9
+ renderWorkspaceNotice,
10
+ } from "#src/observation/outcome-delivery";
11
+ import {
12
+ buildDetails,
13
+ formatLifetimeTokens,
14
+ renderSpawnNotes,
15
+ textResult,
16
+ } from "#src/tools/helpers";
17
+ import type { ResolvedSpawnConfig } from "#src/tools/spawn-config";
18
+ import type { ParentSessionInfo, Subagent } from "#src/types";
19
+ import { type AgentDetails, describeActivity, formatMs, PENDING_SELECTION_ACTIVITY } from "#src/ui/display";
20
+ import { SPINNER } from "#src/ui/glyphs";
21
+
22
+ /** Narrow manager interface for the foreground runner. */
23
+ export interface ForegroundManagerDeps {
24
+ spawnAndWait(
25
+ snapshot: ParentSnapshot,
26
+ type: string,
27
+ prompt: string,
28
+ opts: Omit<AgentSpawnConfig, "background">,
29
+ ): Promise<Subagent>;
30
+ }
31
+
32
+ /** All values the foreground runner needs beyond the resolved config. */
33
+ export interface ForegroundParams {
34
+ config: ResolvedSpawnConfig;
35
+ snapshot: ParentSnapshot;
36
+ parentSession: ParentSessionInfo;
37
+ }
38
+
39
+ /**
40
+ * Run an agent synchronously in the foreground, streaming spinner updates.
41
+ * Owns: spinner interval, streaming onUpdate callbacks, cleanup, and result formatting.
42
+ */
43
+ export async function runForeground(
44
+ manager: ForegroundManagerDeps,
45
+ params: ForegroundParams,
46
+ signal: AbortSignal | undefined,
47
+ onUpdate: ((update: AgentToolResult<AgentDetails>) => void) | undefined,
48
+ ) {
49
+ const { identity, execution, presentation } = params.config;
50
+ let spinnerFrame = 0;
51
+ const startedAt = Date.now();
52
+
53
+ let recordRef: Subagent | undefined;
54
+
55
+ const streamUpdate = () => {
56
+ const toolUses = recordRef?.toolUses ?? 0;
57
+ const details: AgentDetails = {
58
+ ...presentation.detailBase,
59
+ toolUses,
60
+ tokens: recordRef ? formatLifetimeTokens(recordRef) : "",
61
+ // Read activity off the record; fall back to safe defaults before session creation.
62
+ // onStarted fires at admission, so pending selection is visible before onSessionCreated.
63
+ turnCount: recordRef?.turnCount ?? 1,
64
+ maxTurns: recordRef?.maxTurns ?? execution.effectiveMaxTurns,
65
+ durationMs: Date.now() - startedAt,
66
+ status: "running",
67
+ activity: recordRef?.awaitingSelection
68
+ ? PENDING_SELECTION_ACTIVITY
69
+ : describeActivity(
70
+ recordRef?.activeTools ?? new Map(),
71
+ recordRef?.responseText ?? "",
72
+ ),
73
+ spinnerFrame: spinnerFrame % SPINNER.length,
74
+ };
75
+ onUpdate?.({
76
+ content: [{ type: "text", text: `${toolUses} tool uses...` }],
77
+ details,
78
+ });
79
+ };
80
+
81
+ // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
82
+ const spinnerInterval = setInterval(() => {
83
+ spinnerFrame++;
84
+ streamUpdate();
85
+ }, 80);
86
+
87
+ streamUpdate();
88
+
89
+ let record: Subagent;
90
+ try {
91
+ record = await manager.spawnAndWait(
92
+ params.snapshot,
93
+ identity.subagentType,
94
+ execution.prompt,
95
+ {
96
+ description: execution.description,
97
+ model: execution.model,
98
+ maxTurns: execution.effectiveMaxTurns,
99
+ inheritContext: execution.inheritContext,
100
+ thinkingLevel: execution.thinking,
101
+ signal,
102
+ parentSession: params.parentSession,
103
+ observer: {
104
+ onStarted: (agent) => {
105
+ recordRef = agent;
106
+ },
107
+ onSessionCreated: (agent) => {
108
+ recordRef = agent;
109
+ },
110
+ },
111
+ },
112
+ );
113
+ } catch (err) {
114
+ clearInterval(spinnerInterval);
115
+ return textResult(err instanceof Error ? err.message : String(err));
116
+ }
117
+
118
+ clearInterval(spinnerInterval);
119
+
120
+ // Foreground-return delivery edge: the result is handed back in this tool
121
+ // result, so the outcome is collected. Mark it consumed.
122
+ record.markConsumed();
123
+
124
+ const tokenText = formatLifetimeTokens(record);
125
+ const details = buildDetails(presentation.detailBase, record, { tokens: tokenText });
126
+
127
+ const noteText = renderSpawnNotes(params.config.notes);
128
+
129
+ if (record.status === "error") {
130
+ // A failed run has no result text, so this return is the only carrier that
131
+ // can say where its workspace saved the work — or what the child flagged
132
+ // before the failure, which is the one place those findings survive.
133
+ // The transcript pointer rides here for the same reason: with no result to
134
+ // read, it is the parent's only route to what the child did before it died.
135
+ // The success branch omits it deliberately — that return carries the result.
136
+ // The ask-back affordance is not composed here: a failed run answers no
137
+ // question, so this is the addenda tail minus the one that cannot apply.
138
+ const transcriptLine = record.outputFile
139
+ ? `\nFull transcript available at: ${record.outputFile}`
140
+ : "";
141
+ return textResult(
142
+ `${noteText}Agent failed: ${record.error}\nAgent ID: ${record.id}${transcriptLine}` +
143
+ renderRunUpdates(record.runUpdates) +
144
+ renderWorkspaceNotice(record.workspaceNotice),
145
+ details,
146
+ );
147
+ }
148
+
149
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
150
+ const statsParts = [`${record.toolUses} tool uses`];
151
+ if (tokenText) statsParts.push(tokenText);
152
+ return textResult(
153
+ `${noteText}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${renderStatusNote(record.status)}.\n` +
154
+ `Agent ID: ${record.id}\n\n` +
155
+ renderOutcomeBody(record) +
156
+ renderOutcomeAddenda(record),
157
+ details,
158
+ );
159
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * get-result-renderer.ts — Pure line assembly for the get_subagent_result TUI view.
3
+ *
4
+ * All functions are stateless: they receive GetResultDetails, the report text,
5
+ * and a Theme, returning the pre-themed lines a BoundedLines component spends
6
+ * one terminal row on each. No SDK types, no timers, no side effects.
7
+ * Consumed by the renderResult hook in get-result-tool.ts. Mirrors the
8
+ * result-renderer.ts pattern used by the subagent tool's renderer.
9
+ */
10
+
11
+ import type { SubagentStatus } from "#src/lifecycle/subagent-state";
12
+ import { renderStatusIcon } from "#src/tools/result-renderer";
13
+ import type { Theme } from "#src/ui/display";
14
+ import { GLYPHS } from "#src/ui/glyphs";
15
+
16
+ /** How many report lines the expanded view draws before it defers to the transcript. */
17
+ export const MAX_EXPANDED_LINES = 50;
18
+
19
+ /** How much of the result body the collapsed preview may carry into `details`. */
20
+ export const PREVIEW_CHARS = 200;
21
+
22
+ /**
23
+ * Compact presentation metadata for a get_subagent_result result.
24
+ *
25
+ * Deliberately excludes the result body and the conversation: `details` is
26
+ * serialized verbatim into the session JSONL and is never sent to the model, so
27
+ * carrying either would duplicate on disk what `content` already holds. The
28
+ * renderer reads the report text from the result's own content instead, and
29
+ * `preview` is bounded at `PREVIEW_CHARS` by its producer.
30
+ */
31
+ export interface GetResultDetails {
32
+ agentId: string;
33
+ displayName: string;
34
+ status: SubagentStatus;
35
+ description: string;
36
+ toolUses: number;
37
+ /** Pre-formatted lifetime token total; "" when zero. */
38
+ tokens: string;
39
+ contextPercent: number | null;
40
+ compactionCount: number;
41
+ /** Pre-formatted duration string. */
42
+ duration: string;
43
+ /** First non-empty line of the result body, clipped. Never the whole result. */
44
+ preview?: string;
45
+ error?: string;
46
+ /** Whether the conversation was requested, so the expanded view can say where it went. */
47
+ verbose: boolean;
48
+ transcriptPath?: string;
49
+ }
50
+
51
+ /**
52
+ * The lines for one get_subagent_result row, in its current expansion state.
53
+ *
54
+ * Collapsed is a fixed summary built from `details` alone; expanded is the
55
+ * report text, capped. Neither grows with the size of the result.
56
+ */
57
+ export function renderGetResultLines(
58
+ details: GetResultDetails,
59
+ reportText: string,
60
+ expanded: boolean,
61
+ theme: Theme,
62
+ ): string[] {
63
+ return expanded
64
+ ? renderExpandedReport(details, reportText, theme)
65
+ : renderCollapsedSummary(details, theme);
66
+ }
67
+
68
+ /** Status glyph and stats, then the description and either the preview or the error. */
69
+ function renderCollapsedSummary(details: GetResultDetails, theme: Theme): string[] {
70
+ const lines = [`${renderStatusIcon(details.status, theme)} ${renderStats(details, theme)}`];
71
+ if (details.description) lines.push(subLine(details.description, "dim", theme));
72
+ if (details.error) {
73
+ lines.push(subLine(details.error, "error", theme));
74
+ } else if (details.preview) {
75
+ lines.push(subLine(details.preview, "dim", theme));
76
+ }
77
+ return lines;
78
+ }
79
+
80
+ /** The report text capped at MAX_EXPANDED_LINES, with an indicator when lines were dropped. */
81
+ function renderExpandedReport(
82
+ details: GetResultDetails,
83
+ reportText: string,
84
+ theme: Theme,
85
+ ): string[] {
86
+ const reportLines = reportText.split("\n");
87
+ const shown = reportLines.slice(0, MAX_EXPANDED_LINES);
88
+ const lines = shown.map((line) => theme.fg("dim", ` ${line}`));
89
+ const dropped = reportLines.length - shown.length;
90
+ if (dropped > 0) lines.push(theme.fg("muted", ` ${renderOverflowNotice(details, dropped)}`));
91
+ return lines;
92
+ }
93
+
94
+ /** Say how much was withheld and where the whole of it can still be read. */
95
+ function renderOverflowNotice(details: GetResultDetails, dropped: number): string {
96
+ const withheld = details.verbose ? `${dropped} more lines, including the conversation` : `${dropped} more lines`;
97
+ const where = details.transcriptPath ? ` — full transcript at ${details.transcriptPath}` : "";
98
+ return `… (${withheld}${where})`;
99
+ }
100
+
101
+ /** The dim continuation line beneath the status line. */
102
+ function subLine(text: string, color: string, theme: Theme): string {
103
+ return theme.fg(color, ` ${GLYPHS.subLine} ${text}`);
104
+ }
105
+
106
+ /** Build the stats string: "Explore · 44 tool uses · 95.9k token · 9% · ⇊3 · 213.0s". */
107
+ function renderStats(details: GetResultDetails, theme: Theme): string {
108
+ const parts = [details.displayName];
109
+ if (details.toolUses > 0) {
110
+ parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
111
+ }
112
+ if (details.tokens) parts.push(details.tokens);
113
+ if (details.contextPercent !== null) parts.push(`${Math.round(details.contextPercent)}%`);
114
+ if (details.compactionCount > 0) parts.push(`${GLYPHS.compactions}${details.compactionCount}`);
115
+ parts.push(details.duration);
116
+ return parts
117
+ .map((part) => theme.fg("dim", part))
118
+ .join(" " + theme.fg("dim", "\u00B7") + " ");
119
+ }