@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,84 @@
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 { ResumeRefusal, SubagentStatus } from "#src/lifecycle/subagent";
11
+ import {
12
+ renderOutcomeAddenda,
13
+ renderOutcomeBody,
14
+ renderStatusNote,
15
+ } from "#src/observation/outcome-delivery";
16
+
17
+ /** The data a get_subagent_result report renders from — only what the formatter reads. */
18
+ export interface AgentReport {
19
+ id: string;
20
+ displayName: string;
21
+ status: SubagentStatus;
22
+ toolUses: number;
23
+ /** Pre-formatted lifetime token total; "" when zero. */
24
+ tokens: string;
25
+ contextPercent: number | null;
26
+ compactionCount: number;
27
+ /** Pre-formatted duration string. */
28
+ duration: string;
29
+ description: string;
30
+ result: string | undefined;
31
+ error: string | undefined;
32
+ /** Whether the agent was stopped before the limiter ever admitted it. */
33
+ stoppedWhileQueued: boolean;
34
+ /** Present only when verbose was requested and a conversation is available. */
35
+ conversation?: string;
36
+ /** Persisted transcript path; rendered as a pointer so the parent can read it directly. */
37
+ transcriptPath?: string;
38
+ /** The updates the agent sent while this call's carrier held the outcome. */
39
+ runUpdates?: readonly string[];
40
+ /** The question the agent ended its turn with, when it declared one. */
41
+ pendingQuestion?: string;
42
+ /**
43
+ * Why a resume would be refused; undefined when one would be accepted.
44
+ * Required, for the reason `OutcomeAddenda` gives.
45
+ */
46
+ resumeRefusal: ResumeRefusal | undefined;
47
+ /** Where a teardown with no result text to carry it saved the agent's work. */
48
+ workspaceNotice?: string;
49
+ }
50
+
51
+ /** Assemble the stats parts: Tool uses / tokens? / Context? / Compactions? / Duration. */
52
+ export function renderStatsParts(report: AgentReport): string[] {
53
+ const parts = [`Tool uses: ${report.toolUses}`];
54
+ if (report.tokens) parts.push(report.tokens);
55
+ if (report.contextPercent !== null) parts.push(`Context: ${Math.round(report.contextPercent)}%`);
56
+ if (report.compactionCount) parts.push(`Compactions: ${report.compactionCount}`);
57
+ parts.push(`Duration: ${report.duration}`);
58
+ return parts;
59
+ }
60
+
61
+ /**
62
+ * Select the per-status body. `AgentReport` structurally satisfies
63
+ * `OutcomeBody`, so this is the shared renderer under this carrier's name.
64
+ */
65
+ export function renderReportBody(report: AgentReport): string {
66
+ return renderOutcomeBody(report);
67
+ }
68
+
69
+ /** Assemble the full get_subagent_result report text. */
70
+ export function formatAgentReport(report: AgentReport): string {
71
+ let output =
72
+ `Agent: ${report.id}\n` +
73
+ `Type: ${report.displayName} | Status: ${report.status}${renderStatusNote(report.status)} | ${renderStatsParts(report).join(" | ")}\n` +
74
+ `Description: ${report.description}\n\n`;
75
+ output += renderReportBody(report);
76
+ output += renderOutcomeAddenda(report);
77
+ if (report.conversation) {
78
+ output += `\n\n--- Agent Conversation ---\n${report.conversation}`;
79
+ }
80
+ if (report.transcriptPath) {
81
+ output += `\n\nFull transcript available at: ${report.transcriptPath}`;
82
+ }
83
+ return output;
84
+ }
@@ -0,0 +1,192 @@
1
+ import type { AgentToolResult, 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 type { AgentConfigLookup } from "#src/config/agent-types";
6
+ import {
7
+ type GetResultDetails,
8
+ PREVIEW_CHARS,
9
+ renderGetResultLines,
10
+ } from "#src/tools/get-result-renderer";
11
+ import { type AgentReport, formatAgentReport } from "#src/tools/get-result-report";
12
+ import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
13
+ import type { Subagent } from "#src/types";
14
+ import { BoundedLines } from "#src/ui/bounded-lines";
15
+ import { formatDuration, getDisplayName, type Theme } from "#src/ui/display";
16
+ import { GLYPHS } from "#src/ui/glyphs";
17
+
18
+ // ---- Deps interfaces ----
19
+
20
+ export interface GetResultToolManager {
21
+ getRecord(id: string): Subagent | undefined;
22
+ }
23
+
24
+ // ---- Class ----
25
+
26
+ export class GetResultTool {
27
+ constructor(
28
+ private readonly manager: GetResultToolManager,
29
+ private readonly registry: AgentConfigLookup,
30
+ ) {}
31
+
32
+ async execute(
33
+ _toolCallId: string,
34
+ params: { agent_id: string; wait?: boolean; verbose?: boolean },
35
+ signal: AbortSignal,
36
+ _onUpdate: unknown,
37
+ _ctx: unknown,
38
+ ) {
39
+ const record = this.manager.getRecord(params.agent_id);
40
+ if (!record) {
41
+ return textResult<GetResultDetails>(`Agent not found: "${params.agent_id}". Records are cleared at session start/switch, so it may be from a previous session.`);
42
+ }
43
+
44
+ // Wait for completion if requested. The record owns the decision of whether
45
+ // it is still awaitable — a queued agent counts, because scheduleVia()
46
+ // captures its limiter promise at spawn. A parent interrupt ends the wait
47
+ // without cancelling the agent, leaving the outcome uncollected below.
48
+ const waited = params.wait === true;
49
+ if (waited) {
50
+ // Waiting commits this call to delivering the outcome, so claim it before
51
+ // the agent can settle and be announced by the nudge instead.
52
+ record.claim();
53
+ await record.waitUntilSettled(signal);
54
+ }
55
+
56
+ // Pull-delivery edge: the parent is collecting the settled outcome here, so
57
+ // mark it consumed. An agent still active after a wait means the wait was
58
+ // abandoned, so release the claim this call made and let the nudge announce.
59
+ // Only a wait that claimed may release, so a concurrent carrier's claim is
60
+ // never cleared by this call.
61
+ if (!record.isActive()) {
62
+ record.markConsumed();
63
+ } else if (waited) {
64
+ record.release();
65
+ }
66
+
67
+ const verbose = params.verbose === true;
68
+ return textResult<GetResultDetails>(
69
+ formatAgentReport(this.buildReport(record, verbose)),
70
+ this.buildGetResultDetails(record, verbose),
71
+ );
72
+ }
73
+
74
+ private buildReport(record: Subagent, verbose?: boolean): AgentReport {
75
+ return {
76
+ id: record.id,
77
+ displayName: getDisplayName(record.type, this.registry),
78
+ status: record.status,
79
+ toolUses: record.toolUses,
80
+ tokens: formatLifetimeTokens(record),
81
+ contextPercent: record.getContextPercent(),
82
+ compactionCount: record.compactionCount,
83
+ duration: formatDuration(record.startedAt, record.completedAt),
84
+ description: record.description,
85
+ result: record.result,
86
+ error: record.error,
87
+ stoppedWhileQueued: record.stoppedWhileQueued,
88
+ conversation: verbose ? record.getConversation() : undefined,
89
+ // Transcript pointer: lets the parent read the full session from disk,
90
+ // and covers verbose after the live session was released (no conversation).
91
+ transcriptPath: record.outputFile,
92
+ runUpdates: record.runUpdates,
93
+ pendingQuestion: record.pendingQuestion,
94
+ resumeRefusal: record.resumeRefusal,
95
+ workspaceNotice: record.workspaceNotice,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The compact metadata the TUI renders from.
101
+ *
102
+ * Named in full because `helpers.ts` exports a module-level `buildDetails`
103
+ * producing the structurally different `AgentDetails`.
104
+ */
105
+ private buildGetResultDetails(record: Subagent, verbose: boolean): GetResultDetails {
106
+ return {
107
+ agentId: record.id,
108
+ displayName: getDisplayName(record.type, this.registry),
109
+ status: record.status,
110
+ description: record.description,
111
+ toolUses: record.toolUses,
112
+ tokens: formatLifetimeTokens(record),
113
+ contextPercent: record.getContextPercent(),
114
+ compactionCount: record.compactionCount,
115
+ duration: formatDuration(record.startedAt, record.completedAt),
116
+ preview: buildPreview(record.result),
117
+ error: record.error,
118
+ verbose,
119
+ transcriptPath: record.outputFile,
120
+ };
121
+ }
122
+
123
+ toToolDefinition() {
124
+ return defineTool({
125
+ name: "get_subagent_result" as const,
126
+ label: "Get Agent Result",
127
+ promptSnippet:
128
+ "Check status and retrieve results from a background agent.",
129
+ description:
130
+ "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
131
+ parameters: Type.Object({
132
+ agent_id: Type.String({
133
+ description: "The agent ID to check.",
134
+ }),
135
+ wait: Type.Optional(
136
+ Type.Boolean({
137
+ description:
138
+ "If true, wait for the agent to complete before returning. Default: false.",
139
+ }),
140
+ ),
141
+ verbose: Type.Optional(
142
+ Type.Boolean({
143
+ description:
144
+ "If true, include the agent's full conversation (messages + tool calls). Default: false.",
145
+ }),
146
+ ),
147
+ }),
148
+ // ---- Custom rendering: a bounded, Ctrl+O-expandable retrieval row ----
149
+
150
+ renderCall(args: { agent_id: string; wait?: boolean; verbose?: boolean }, theme: Theme) {
151
+ const notes = [args.wait === true ? "waiting" : "", args.verbose === true ? "verbose" : ""]
152
+ .filter(Boolean)
153
+ .join(", ");
154
+ return new Text(
155
+ `${GLYPHS.toolCall} ` +
156
+ theme.fg("toolTitle", theme.bold("Get Agent Result")) +
157
+ " " +
158
+ theme.fg("muted", args.agent_id) +
159
+ (notes ? " " + theme.fg("muted", `(${notes})`) : ""),
160
+ 0,
161
+ 0,
162
+ );
163
+ },
164
+
165
+ renderResult(
166
+ result: AgentToolResult<GetResultDetails | undefined>,
167
+ { expanded }: ToolRenderResultOptions,
168
+ theme: Theme,
169
+ ) {
170
+ const reportText = result.content[0]?.type === "text" ? result.content[0].text : "";
171
+ const details = result.details;
172
+ if (!details) return new Text(reportText, 0, 0);
173
+ return new BoundedLines(renderGetResultLines(details, reportText, expanded, theme));
174
+ },
175
+
176
+ execute: (
177
+ toolCallId: string,
178
+ params: { agent_id: string; wait?: boolean; verbose?: boolean },
179
+ signal: AbortSignal,
180
+ onUpdate: unknown,
181
+ ctx: unknown,
182
+ ) => this.execute(toolCallId, params, signal, onUpdate, ctx),
183
+ });
184
+ }
185
+ }
186
+
187
+ /** The first non-empty line of a result body, clipped to the preview budget. */
188
+ function buildPreview(result: string | undefined): string | undefined {
189
+ const line = result?.split("\n").find((candidate) => candidate.trim())?.trim();
190
+ if (!line) return undefined;
191
+ return line.length > PREVIEW_CHARS ? line.slice(0, PREVIEW_CHARS - 1) + "\u2026" : line;
192
+ }
@@ -0,0 +1,118 @@
1
+ import type { AgentConfigLookup } from "#src/config/agent-types";
2
+ import { getLifetimeTotal, type LifetimeUsage } from "#src/lifecycle/usage";
3
+ import { type AgentDetails, formatTokens } from "#src/ui/display";
4
+
5
+ /** Build AgentDetails from a base + record-specific fields. */
6
+ export function buildDetails(
7
+ base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
8
+ record: {
9
+ toolUses: number;
10
+ startedAt: number;
11
+ completedAt?: number;
12
+ status: string;
13
+ error?: string;
14
+ id?: string;
15
+ lifetimeUsage: LifetimeUsage;
16
+ /** Live-activity counters — exposed as getters on Subagent (Phase 18 Step 2). */
17
+ turnCount?: number;
18
+ maxTurns?: number;
19
+ },
20
+ overrides?: Partial<AgentDetails>,
21
+ ): AgentDetails {
22
+ return {
23
+ ...base,
24
+ toolUses: record.toolUses,
25
+ tokens: formatLifetimeTokens(record),
26
+ turnCount: record.turnCount,
27
+ maxTurns: record.maxTurns,
28
+ durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
29
+ status: record.status as AgentDetails["status"],
30
+ agentId: record.id,
31
+ error: record.error,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ /** Render a spawn's advisories as the prefix a result's leading line follows, or "" when there are none. */
37
+ export function renderSpawnNotes(notes: readonly string[]): string {
38
+ return notes.length > 0 ? `${notes.join("\n")}\n\n` : "";
39
+ }
40
+
41
+ /**
42
+ * Tool execute return value for a text response.
43
+ *
44
+ * Generic over the details payload so a tool with its own presentation metadata
45
+ * can attach it; defaults to `AgentDetails`, which is what every subagent-tool
46
+ * call site passes.
47
+ */
48
+ export function textResult<T = AgentDetails>(msg: string, details?: T) {
49
+ return { content: [{ type: "text" as const, text: msg }], details };
50
+ }
51
+
52
+ /** Format an agent's lifetime token total, or "" when zero. */
53
+ export function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
54
+ const t = getLifetimeTotal(o.lifetimeUsage);
55
+ return t > 0 ? formatTokens(t) : "";
56
+ }
57
+
58
+ /**
59
+ * Narrow registry interface needed by buildTypeListText.
60
+ * Extends AgentConfigLookup with the two name-listing methods.
61
+ */
62
+ export interface TypeListRegistry extends AgentConfigLookup {
63
+ getDefaultAgentNames(): string[];
64
+ getUserAgentNames(): string[];
65
+ }
66
+
67
+ /**
68
+ * Build the full agent-type list text for the Agent tool description.
69
+ * Extracted from index.ts so it can be called inside createAgentTool.
70
+ */
71
+ export function buildTypeListText(registry: TypeListRegistry, agentDir: string): string {
72
+ const defaultNames = registry.getDefaultAgentNames().filter((name) => isEnabledAgent(registry, name));
73
+ const userNames = registry.getUserAgentNames().filter((name) => isEnabledAgent(registry, name));
74
+
75
+ const defaultDescs = defaultNames.map((name) => {
76
+ const cfg = registry.resolveAgentConfig(name);
77
+ const modelSuffix = cfg.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
78
+ return `- ${name}: ${cfg.description}${modelSuffix}`;
79
+ });
80
+
81
+ const customDescs = userNames.map((name) => {
82
+ const cfg = registry.resolveAgentConfig(name);
83
+ return `- ${name}: ${cfg.description}`;
84
+ });
85
+
86
+ return [
87
+ ...(defaultDescs.length > 0 ? ["Default agents:", ...defaultDescs] : []),
88
+ ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
89
+ "",
90
+ `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.`,
91
+ ].join("\n");
92
+ }
93
+
94
+ /** True when an agent config is present and not explicitly disabled. */
95
+ function isEnabledAgent(registry: AgentConfigLookup, name: string): boolean {
96
+ return registry.resolveAgentConfig(name).enabled !== false;
97
+ }
98
+
99
+ /**
100
+ * Collect the per-agent usage guidelines for the subagent tool's Guidelines: block.
101
+ * Sourced from each enabled default agent's `toolGuideline`, in registry order,
102
+ * so a disabled built-in drops its guideline automatically.
103
+ */
104
+ export function buildAgentGuidelines(registry: TypeListRegistry): string[] {
105
+ return registry
106
+ .getDefaultAgentNames()
107
+ .filter((name) => isEnabledAgent(registry, name))
108
+ .map((name) => registry.resolveAgentConfig(name).toolGuideline)
109
+ .filter((line): line is string => line !== undefined);
110
+ }
111
+
112
+ /** Derive a short model label from a model string. */
113
+ export function getModelLabelFromConfig(model: string): string {
114
+ // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
115
+ const name = model.includes("/") ? model.split("/").pop()! : model;
116
+ // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
117
+ return name.replace(/-\d{8}$/, "");
118
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * result-renderer.ts — Pure per-status rendering functions for Agent tool results.
3
+ *
4
+ * All functions are stateless: they receive AgentDetails and a Theme, returning
5
+ * formatted strings. No SDK types, no timers, no side effects.
6
+ * Consumed by the renderResult hook in agent-tool.ts.
7
+ */
8
+
9
+ import type { SubagentStatus } from "#src/lifecycle/subagent-state";
10
+ import type { AgentDetails, Theme } from "#src/ui/display";
11
+ import { formatMs, formatTurns } from "#src/ui/display";
12
+ import { GLYPHS, SPINNER } from "#src/ui/glyphs";
13
+
14
+ // ---- Dispatcher ----
15
+
16
+ /** Dispatch to the per-status renderer based on details.status and isPartial. */
17
+ export function renderAgentResult(
18
+ details: AgentDetails,
19
+ resultText: string,
20
+ expanded: boolean,
21
+ isPartial: boolean,
22
+ theme: Theme,
23
+ ): string {
24
+ if (isPartial || details.status === "running") return renderRunning(details, theme);
25
+ if (details.status === "background") return renderBackground(details, theme);
26
+ if (details.status === "completed" || details.status === "steered")
27
+ return renderCompleted(details, resultText, expanded, theme);
28
+ if (details.status === "stopped") return renderStopped(details, theme);
29
+ return renderFailed(details, theme);
30
+ }
31
+
32
+ // ---- Per-status renderers ----
33
+
34
+ /** Render running/partial status: spinner + stats + activity line. */
35
+ export function renderRunning(details: AgentDetails, theme: Theme): string {
36
+ const frame = SPINNER[details.spinnerFrame ?? 0];
37
+ const s = renderStats(details, theme);
38
+ let line = theme.fg("accent", frame) + (s ? " " + s : "");
39
+ line += "\n" + theme.fg("dim", ` ${GLYPHS.subLine} ${details.activity ?? "thinking\u2026"}`);
40
+ return line;
41
+ }
42
+
43
+ /** Render background launch status. */
44
+ export function renderBackground(details: AgentDetails, theme: Theme): string {
45
+ return theme.fg("dim", ` ${GLYPHS.subLine} Running in background (ID: ${details.agentId})`);
46
+ }
47
+
48
+ /** Render completed or steered status with optional expanded result text. */
49
+ export function renderCompleted(
50
+ details: AgentDetails,
51
+ resultText: string,
52
+ expanded: boolean,
53
+ theme: Theme,
54
+ ): string {
55
+ const duration = formatMs(details.durationMs);
56
+ const isSteered = details.status === "steered";
57
+ const icon = renderStatusIcon(isSteered ? "steered" : "completed", theme);
58
+ const s = renderStats(details, theme);
59
+ let line = icon + (s ? " " + s : "");
60
+ line += " " + theme.fg("dim", "\u00B7") + " " + theme.fg("dim", duration);
61
+
62
+ if (expanded) {
63
+ if (resultText) {
64
+ const lines = resultText.split("\n").slice(0, 50);
65
+ for (const l of lines) {
66
+ line += "\n" + theme.fg("dim", ` ${l}`);
67
+ }
68
+ if (resultText.split("\n").length > 50) {
69
+ line +=
70
+ "\n" +
71
+ theme.fg(
72
+ "muted",
73
+ " ... (use get_subagent_result with verbose for full output)",
74
+ );
75
+ }
76
+ }
77
+ } else {
78
+ const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
79
+ line += "\n" + theme.fg("dim", ` ${GLYPHS.subLine} ${doneText}`);
80
+ }
81
+ return line;
82
+ }
83
+
84
+ /** Render stopped status: dim stop icon + stats + "Stopped". */
85
+ export function renderStopped(details: AgentDetails, theme: Theme): string {
86
+ const s = renderStats(details, theme);
87
+ let line = renderStatusIcon("stopped", theme) + (s ? " " + s : "");
88
+ line += "\n" + theme.fg("dim", ` ${GLYPHS.subLine} Stopped`);
89
+ return line;
90
+ }
91
+
92
+ /** Render error or aborted status: error icon + stats + status message. */
93
+ export function renderFailed(details: AgentDetails, theme: Theme): string {
94
+ const s = renderStats(details, theme);
95
+ let line = renderStatusIcon(details.status === "error" ? "error" : "aborted", theme) + (s ? " " + s : "");
96
+
97
+ if (details.status === "error") {
98
+ line +=
99
+ "\n" +
100
+ theme.fg("error", ` ${GLYPHS.subLine} Error: ${details.error ?? "unknown"}`);
101
+ } else {
102
+ line +=
103
+ "\n" +
104
+ theme.fg("warning", ` ${GLYPHS.subLine} Aborted (max turns exceeded)`);
105
+ }
106
+ return line;
107
+ }
108
+
109
+ // ---- Shared helpers ----
110
+
111
+ /**
112
+ * The themed status glyph for a settled or pending agent.
113
+ *
114
+ * Exhaustive over `SubagentStatus`, so a status added later fails to compile
115
+ * here rather than falling through to an unmarked icon. Shared with
116
+ * `get-result-renderer.ts`, which draws the same vocabulary for the same enum.
117
+ */
118
+ export function renderStatusIcon(status: SubagentStatus, theme: Theme): string {
119
+ switch (status) {
120
+ case "completed":
121
+ return theme.fg("success", GLYPHS.success);
122
+ case "steered":
123
+ return theme.fg("warning", GLYPHS.success);
124
+ case "stopped":
125
+ return theme.fg("dim", GLYPHS.stopped);
126
+ case "error":
127
+ case "aborted":
128
+ return theme.fg("error", GLYPHS.failure);
129
+ case "queued":
130
+ return theme.fg("dim", GLYPHS.queued);
131
+ case "running":
132
+ return theme.fg("dim", GLYPHS.streaming);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Build the stats string: "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k token".
138
+ * Returns an empty string when all fields are absent or zero.
139
+ */
140
+ export function renderStats(details: AgentDetails, theme: Theme): string {
141
+ const parts: string[] = [];
142
+ if (details.modelName) parts.push(details.modelName);
143
+ if (details.tags) parts.push(...details.tags);
144
+ if (details.turnCount != null && details.turnCount > 0) {
145
+ parts.push(formatTurns(details.turnCount, details.maxTurns));
146
+ }
147
+ if (details.toolUses > 0)
148
+ parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
149
+ if (details.tokens) parts.push(details.tokens);
150
+ return parts
151
+ .map((p) => theme.fg("dim", p))
152
+ .join(" " + theme.fg("dim", "\u00B7") + " ");
153
+ }