pi-subagents-lite 1.0.2 → 1.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.
@@ -10,12 +10,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
12
12
  import { formatTokens } from "./usage.js";
13
-
14
- /** Max length for a truncated command in tool arg summaries. */
15
- const MAX_COMMAND_DISPLAY_LENGTH = 100;
16
-
17
- /** Max length for a truncated string value in default tool arg summaries. */
18
- const MAX_DEFAULT_STRING_DISPLAY_LENGTH = 200;
13
+ import { summarizeToolArgs } from "./format.js";
19
14
 
20
15
  /** Max content length for full tool result display — longer results get a summary line. */
21
16
  const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
@@ -68,68 +63,6 @@ function splitAndPrefix(text: string, role: string): string {
68
63
  .join("");
69
64
  }
70
65
 
71
- /**
72
- * Summarize tool arguments for log-friendly display.
73
- *
74
- * Heavy tools (read, write, edit, bash, grep, rg) get compact summaries.
75
- * Other tools fall back to the default JSON formatting.
76
- */
77
- export function summarizeToolArgs(name: string, rawArgs: Record<string, unknown> | undefined): string {
78
- if (!rawArgs || typeof rawArgs !== "object" || Object.keys(rawArgs).length === 0) return "";
79
-
80
- switch (name) {
81
- case "read": {
82
- // read("/path/to/file") — just the path
83
- const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
84
- return `(${JSON.stringify(path)})`;
85
- }
86
- case "write": {
87
- // write("/path/to/file", <N> chars) — path + content size
88
- const path = typeof rawArgs.file_path === "string" ? rawArgs.file_path : "";
89
- const content = rawArgs.content;
90
- const size = typeof content === "string" ? content.length : 0;
91
- return `(${JSON.stringify(path)}, ${size} chars)`;
92
- }
93
- case "edit": {
94
- // edit("/path/to/file", <N> edits) — path + edit count
95
- const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
96
- const edits = rawArgs.edits;
97
- const editCount = Array.isArray(edits) ? edits.length : 0;
98
- return `(${JSON.stringify(path)}, ${editCount} edits)`;
99
- }
100
- case "bash": {
101
- // bash("command") — just the command, strip heredoc, truncate long
102
- const cmd = typeof rawArgs.command === "string" ? rawArgs.command : "";
103
- // Strip heredoc: truncate at << followed by delimiter
104
- const heredocIdx = cmd.search(/<<\s*['"]?\w+['"]?/);
105
- const cleanCmd = heredocIdx >= 0 ? cmd.slice(0, heredocIdx).trim() : cmd.trim();
106
- // Truncate long commands
107
- const display = cleanCmd.length > MAX_COMMAND_DISPLAY_LENGTH
108
- ? cleanCmd.slice(0, MAX_COMMAND_DISPLAY_LENGTH) + "…" : cleanCmd;
109
- return `(${JSON.stringify(display)})`;
110
- }
111
- case "grep":
112
- case "rg": {
113
- // grep("pattern", "/path") — pattern + path
114
- const pattern = typeof rawArgs.pattern === "string" ? rawArgs.pattern : "";
115
- const path = typeof rawArgs.path === "string" ? rawArgs.path : "";
116
- return `(${JSON.stringify(pattern)}, ${JSON.stringify(path)})`;
117
- }
118
- default: {
119
- // Default behavior for other tools: single-arg shorthand or JSON dump
120
- const keys = Object.keys(rawArgs);
121
- if (keys.length === 1) {
122
- const val = rawArgs[keys[0]];
123
- const display = typeof val === "string" && val.length > MAX_DEFAULT_STRING_DISPLAY_LENGTH
124
- ? JSON.stringify(val.slice(0, MAX_DEFAULT_STRING_DISPLAY_LENGTH) + "...")
125
- : JSON.stringify(val);
126
- return `(${display})`;
127
- }
128
- return ` ${JSON.stringify(rawArgs)}`;
129
- }
130
- }
131
- }
132
-
133
66
  /** Format a toolUse/toolCall content item as a single log line. */
134
67
  function formatToolItem(item: Record<string, unknown>): string {
135
68
  const name = (item.name ?? item.toolName ?? "unknown") as string;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * renderer.ts — Rendering helpers for the Agent tool and subagent-result messages.
3
+ *
4
+ * Extracted from index.ts to separate display concerns from extension wiring.
5
+ */
6
+
7
+ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
8
+ import type { Theme } from "./types.js";
9
+ import { buildStatsParts, formatMs, getDisplayName } from "./format.js";
10
+ import { __config } from "./state.js";
11
+
12
+ // ============================================================================
13
+ // Stats rendering helpers
14
+ // ============================================================================
15
+
16
+ /** Format agent display name with optional model: "Agent (mimo-v2.5-pro)" or "Agent". */
17
+ export function agentNameLabel(d: Record<string, unknown>, theme: Theme): string {
18
+ const typeName = getDisplayName((d.type as string) || "");
19
+ const modelName = d.modelName as string | undefined;
20
+ return modelName ? `${theme.bold(typeName)} (${modelName})` : theme.bold(typeName);
21
+ }
22
+
23
+ /** Build the stats line for an agent result card. */
24
+ export function buildStatsLine(d: Record<string, unknown>, theme: Theme): string {
25
+ const showCost = __config.agent.showCost === true;
26
+ const parts = buildStatsParts({
27
+ toolUses: (d.toolUses as number) ?? 0,
28
+ turnCount: d.turnCount as number | undefined,
29
+ maxTurns: d.maxTurns as number | undefined,
30
+ tokens: (d.tokens as number) ?? 0,
31
+ contextPercent: d.contextPercent as number | null,
32
+ compactions: (d.compactions as number) ?? 0,
33
+ cost: showCost ? (d.cost as number | undefined) : undefined,
34
+ }, theme);
35
+ parts.push(formatMs(d.durationMs as number));
36
+ return parts.join("·");
37
+ }
38
+
39
+ // ============================================================================
40
+ // Agent tool renderers
41
+ // ============================================================================
42
+
43
+ /** Render the Agent tool call line (e.g., "▸ Agent (model)"). */
44
+ export function renderAgentToolCall(
45
+ args: Record<string, unknown>,
46
+ theme: Theme,
47
+ ): Text {
48
+ const typeName = getDisplayName((args.agent as string) || "");
49
+ const label = typeName || "Agent";
50
+ let text = `▸ ${theme.fg("accent", theme.bold(label))}`;
51
+
52
+ const modelOverride = args._modelOverride as string | undefined;
53
+ if (modelOverride) {
54
+ text += ` (${modelOverride})`;
55
+ }
56
+
57
+ return new Text(text, 0, 0);
58
+ }
59
+
60
+ /** Render the Agent tool result — compact or expanded. */
61
+ export function renderAgentToolResult(
62
+ result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
63
+ options: { expanded?: boolean },
64
+ theme: Theme,
65
+ ): Text {
66
+ const { expanded } = options;
67
+ const text = result.content[0]?.type === "text" ? result.content[0].text ?? "" : "";
68
+ const d = result.details;
69
+ const icon = result.isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
70
+ const desc = (d?.description as string) || "";
71
+
72
+ if (d && d.turnCount != null) {
73
+ const namePart = agentNameLabel(d, theme);
74
+ const statsLine = buildStatsLine(d, theme);
75
+ let lines = `${icon} ${namePart}·${statsLine}\n ${theme.fg("text", desc)}`;
76
+ if (expanded && text) {
77
+ lines += "\n" + text.split("\n").map(l => ` ${l}`).join("\n");
78
+ }
79
+ return new Text(lines, 0, 0);
80
+ }
81
+
82
+ // Minimal card — background spawns (no stats) use space placeholder
83
+ const isBackground = text.includes("running in background") || text.includes("queued");
84
+ const prefix = isBackground ? " " : `${icon} `;
85
+ if (desc) {
86
+ return new Text(`${prefix}${theme.fg("text", desc)}`, 0, 0);
87
+ }
88
+
89
+ return new Text(`${prefix}${theme.fg("dim", text)}`, 0, 0);
90
+ }
91
+
92
+ // ============================================================================
93
+ // Message renderer — subagent-result (background agent completion)
94
+ // ============================================================================
95
+
96
+ /** Render a subagent-result message injected after background agent completion. */
97
+ export function renderSubagentResult(
98
+ message: { content?: string; details?: Record<string, unknown> },
99
+ options: { expanded?: boolean },
100
+ theme: Theme,
101
+ ): Container {
102
+ const { expanded } = options;
103
+ const d = message.details;
104
+ const text = (message.content as string)?.trim() || "";
105
+
106
+ const inner = new Container();
107
+ inner.addChild(new Text(theme.fg("customMessageLabel", "Subagent Result"), 0, 0));
108
+ inner.addChild(new Spacer(1));
109
+
110
+ if (d && d.turnCount != null) {
111
+ const isError = d.status === "error" || d.status === "aborted" || d.status === "stopped";
112
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
113
+
114
+ const namePart = agentNameLabel(d, theme);
115
+ const statsLine = buildStatsLine(d, theme);
116
+ let headerLine = `${icon} ${namePart}·${statsLine}\n ${theme.fg("text", (d.description as string) || "")}`;
117
+ if (d.outputFile as string) {
118
+ headerLine += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
119
+ }
120
+ inner.addChild(new Text(headerLine, 0, 0));
121
+
122
+ if (expanded && text) {
123
+ inner.addChild(new Spacer(1));
124
+ inner.addChild(new Text(text.split("\n").map(l => ` ${l}`).join("\n"), 0, 0));
125
+ }
126
+ } else {
127
+ inner.addChild(new Text(buildFallbackResultLine(d, text, theme), 0, 0));
128
+ }
129
+
130
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
131
+ box.addChild(inner);
132
+
133
+ const outer = new Container();
134
+ outer.addChild(new Spacer(1));
135
+ outer.addChild(box);
136
+ outer.addChild(new Spacer(1));
137
+ return outer;
138
+ }
139
+
140
+ /** Build a fallback result line for subagent-result messages without stats. */
141
+ function buildFallbackResultLine(
142
+ d: Record<string, unknown> | undefined,
143
+ text: string,
144
+ theme: Theme,
145
+ ): string {
146
+ const icon = theme.fg("success", "✓");
147
+ let line = icon;
148
+ if (d?.type) {
149
+ line += ` ${agentNameLabel(d, theme)}`;
150
+ }
151
+ const desc = (d?.description as string) || "";
152
+ if (desc) line += `\n ${theme.fg("text", desc)}`;
153
+ if (d?.outputFile) {
154
+ line += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
155
+ }
156
+ return line;
157
+ }
@@ -17,7 +17,8 @@ import {
17
17
  } from "@earendil-works/pi-tui";
18
18
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
19
19
  import { type LifetimeUsage, formatTokens } from "./usage.js";
20
- import { formatMs, type Theme } from "./ui/agent-widget.js";
20
+ import type { Theme } from "./ui/agent-widget.js";
21
+ import { formatMs } from "./format.js";
21
22
 
22
23
  /* ------------------------------------------------------------------ */
23
24
  /* Types */
package/src/state.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * state.ts — Shared module state. Extracted from index.ts to break circular deps.
3
+ *
4
+ * manager and widget use holders because they're reassigned after import and the
5
+ * PI runtime doesn't propagate ESM live binding reassignments.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import type { SessionModelOverrides, SubagentsConfig } from "./model-precedence.js";
10
+ import { DEFAULT_CONFIG } from "./config-io.js";
11
+ import { AgentManager } from "./agent-manager.js";
12
+ import { AgentWidget, type AgentActivity } from "./ui/agent-widget.js";
13
+
14
+ export let sessionOverrides: SessionModelOverrides = { default: null };
15
+ export let __config: SubagentsConfig = { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
16
+ export const agentActivity = new Map<string, AgentActivity>();
17
+ export let piInstance: ExtensionAPI;
18
+
19
+ // Holder objects — PI runtime doesn't propagate ESM live binding reassignments
20
+ const managerHolder: { current?: AgentManager } = {};
21
+ const widgetHolder: { current?: AgentWidget } = {};
22
+
23
+ export function setConfig(config: SubagentsConfig): void { __config = config; }
24
+ export function resetSessionOverrides(): void { sessionOverrides = { default: null }; }
25
+ export function setManager(m: AgentManager): void { managerHolder.current = m; }
26
+ export function clearManager(): void { managerHolder.current = undefined; }
27
+ export function setWidget(w: AgentWidget | undefined): void { widgetHolder.current = w; }
28
+ export function setPiInstance(pi: ExtensionAPI): void { piInstance = pi; }
29
+ export function getManager(): AgentManager { return managerHolder.current!; }
30
+ export function getWidget(): AgentWidget | undefined { return widgetHolder.current; }
31
+
32
+ // State mutation helpers
33
+
34
+ /** Update the cost display toggle in config and sync to widget. */
35
+ export function setShowCostEnabled(enabled: boolean): void {
36
+ __config.agent.showCost = enabled;
37
+ getWidget()?.setShowCost(enabled);
38
+ }
39
+
40
+ /** Sync widget display settings from config to the widget instance. */
41
+ export function syncWidgetSettings(): void {
42
+ const w = getWidget();
43
+ if (!w) return;
44
+ w.setForceCompact(__config.agent.widgetCompact === true);
45
+ w.setWidgetShortcut(__config.agent.widgetShortcut === true);
46
+ w.setMaxLines(__config.agent.widgetMaxLines ?? 12);
47
+ w.setMaxLinesCompact(
48
+ __config.agent.widgetMaxLinesCompact ?? Math.floor((__config.agent.widgetMaxLines ?? 12) / 2),
49
+ );
50
+ }
51
+
52
+ /** Track previous tool expansion state to detect ctrl+o toggle. */
53
+ let lastToolsExpanded: boolean | undefined;
54
+
55
+ /** Reset lastToolsExpanded (called at session_start). */
56
+ export function resetLastToolsExpanded(): void {
57
+ lastToolsExpanded = undefined;
58
+ }
59
+
60
+ /** Sync compact mode with the tool expansion state (ctrl+o toggle).
61
+ * Only syncs when widgetShortcut is enabled in config (opt-in behavior).
62
+ * Only triggers on state change (not every tool_execution_start).
63
+ * When forceCompact (widgetCompact) is ON, ignores ctrl+o state changes.
64
+ */
65
+ export function syncCompactFromToolsExpanded(expanded: boolean): void {
66
+ if (__config.agent.widgetShortcut !== true) {
67
+ lastToolsExpanded = expanded;
68
+ return;
69
+ }
70
+ // When forceCompact is ON, ignore ctrl+o state changes
71
+ if (__config.agent.widgetCompact === true) {
72
+ lastToolsExpanded = expanded;
73
+ return;
74
+ }
75
+ // Tools expanded → widget full, tools collapsed → widget compact
76
+ if (lastToolsExpanded !== undefined && lastToolsExpanded !== expanded) {
77
+ getWidget()?.setCompactMode(!expanded);
78
+ }
79
+ lastToolsExpanded = expanded;
80
+ }
@@ -8,22 +8,23 @@
8
8
  import type { ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  import type { AgentRecord } from "./types.js";
11
+ import { SHORT_ID_LENGTH } from "./types.js";
11
12
  import type { SpawnOptions as AgentManagerSpawnOptions } from "./agent-manager.js";
12
13
  import type { AgentActivity } from "./ui/agent-widget.js";
13
14
  import { resolveType, getAgentConfig, discoverNewAgents } from "./agent-types.js";
14
15
  import { resolveModel } from "./model-precedence.js";
15
16
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
16
17
 
17
- // Shared state imported from index.ts
18
+ // Shared state imported from state.ts
18
19
  import { parseModelKey, findModelInRegistry, parseThinkingLevel } from "./utils.js";
19
20
  import {
20
21
  __config,
21
22
  sessionOverrides,
22
- manager,
23
23
  piInstance,
24
24
  agentActivity,
25
- widget,
26
- } from "./index.js";
25
+ getManager,
26
+ getWidget,
27
+ } from "./state.js";
27
28
 
28
29
  // ============================================================================
29
30
  // Module-level state
@@ -103,6 +104,61 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
103
104
  return { state, callbacks };
104
105
  }
105
106
 
107
+ // ============================================================================
108
+ // buildAgentDetails — consolidated stats/details construction
109
+ // ============================================================================
110
+
111
+ interface AgentDetailsOptions {
112
+ /** Include full stats (turns, tokens, context%, compactions, cost). Default: false. */
113
+ includeStats?: boolean;
114
+ /** Include status and outputFile. Default: false. */
115
+ includeStatus?: boolean;
116
+ /** Override the turnCount (e.g. from activity tracker). Default: record.turnCount. */
117
+ turnCount?: number;
118
+ }
119
+
120
+ /**
121
+ * Build a details Record from an AgentRecord, controlled by options.
122
+ *
123
+ * Always includes `type` and `description`. Optional groups:
124
+ * - `includeStatus`: adds `status`, `outputFile`
125
+ * - `includeStats`: adds turn/token/cost/context/compaction/model fields
126
+ *
127
+ * Consolidates the identical field-selection logic previously duplicated
128
+ * across emitIndividualNudge, executeSpawnForeground, and executeSpawnBackground.
129
+ */
130
+ export function buildAgentDetails(
131
+ record: AgentRecord,
132
+ options?: AgentDetailsOptions,
133
+ ): Record<string, unknown> {
134
+ const details: Record<string, unknown> = {
135
+ type: record.display.type,
136
+ description: record.display.description,
137
+ };
138
+
139
+ if (options?.includeStatus) {
140
+ details.status = record.lifecycle.status;
141
+ details.outputFile = record.display.outputFile;
142
+ }
143
+
144
+ if (options?.includeStats) {
145
+ const totalTokens = getLifetimeTotal(record.stats.lifetimeUsage);
146
+ const elapsedMs = record.lifecycle.completedAt ? record.lifecycle.completedAt - record.lifecycle.startedAt : 0;
147
+
148
+ details.turnCount = options.turnCount ?? record.stats.turnCount;
149
+ details.maxTurns = record.stats.maxTurns;
150
+ details.toolUses = record.stats.toolUses;
151
+ details.tokens = totalTokens;
152
+ details.contextPercent = getSessionContextPercent(record.execution.session);
153
+ details.durationMs = elapsedMs;
154
+ details.compactions = record.stats.compactionCount;
155
+ details.modelName = record.display.invocation?.modelName;
156
+ details.cost = record.stats.lifetimeUsage.cost;
157
+ }
158
+
159
+ return details;
160
+ }
161
+
106
162
  // ============================================================================
107
163
  // Nudge scheduling — batch completion notifications within the hold window
108
164
  // ============================================================================
@@ -118,7 +174,7 @@ export function scheduleNudge(agentId: string): void {
118
174
  pendingNudges.clear();
119
175
 
120
176
  for (const id of batch) {
121
- emitIndividualNudge(id, manager?.getRecord(id));
177
+ emitIndividualNudge(id, getManager()?.getRecord(id));
122
178
  }
123
179
  }, NUDGE_DELAY_MS);
124
180
  }
@@ -126,31 +182,16 @@ export function scheduleNudge(agentId: string): void {
126
182
  function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
127
183
  if (!record) return;
128
184
 
129
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
130
- const elapsedMs = record.completedAt
131
- ? record.completedAt - record.startedAt
132
- : 0;
133
-
134
- const details: Record<string, unknown> = {
135
- type: record.type,
136
- description: record.description,
137
- status: record.status,
138
- outputFile: record.outputFile,
139
- turnCount: record.turnCount ?? agentActivity.get(agentId)?.turnCount,
140
- maxTurns: record.maxTurns,
141
- toolUses: record.toolUses,
142
- tokens: totalTokens,
143
- cost: record.lifetimeUsage.cost,
144
- contextPercent: getSessionContextPercent(record.session),
145
- durationMs: elapsedMs,
146
- compactions: record.compactionCount,
147
- modelName: record.invocation?.modelName,
148
- };
185
+ const details = buildAgentDetails(record, {
186
+ includeStats: true,
187
+ includeStatus: true,
188
+ turnCount: record.stats.turnCount ?? agentActivity.get(agentId)?.turnCount,
189
+ });
149
190
 
150
191
  piInstance.sendMessage(
151
192
  {
152
193
  customType: "subagent-result",
153
- content: `[Subagent "${record.type}" completed]\n\n${record.result ?? ""}`,
194
+ content: `[Subagent "${record.display.type}" completed]\n\n${record.result ?? ""}`,
154
195
  details,
155
196
  display: true,
156
197
  },
@@ -225,20 +266,20 @@ async function executeSpawnBackground(
225
266
  spawnOptions.maxTurns,
226
267
  );
227
268
 
228
- const agentId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
269
+ const agentId = getManager().spawn(piInstance, ctx, resolvedType, prompt, {
229
270
  ...spawnOptions,
230
271
  isBackground: true,
231
272
  ...callbacks,
232
273
  });
233
274
  backgroundAgentIds.add(agentId);
234
275
  agentActivity.set(agentId, state);
235
- widget?.ensureTimer();
236
- widget?.update();
276
+ getWidget()?.ensureTimer();
277
+ getWidget()?.update();
237
278
 
238
- const record = manager.getRecord(agentId)!;
239
- const details: Record<string, unknown> = { type: resolvedType, description: spawnOptions.description };
279
+ const record = getManager().getRecord(agentId)!;
280
+ const details = buildAgentDetails(record);
240
281
  const suffix = `A notification will arrive when done - User asks you not to poll, check status or duplicate the delegated work.\n\nAgent ID: ${agentId}`;
241
- const label = record.status === "queued" ? "Agent queued" : "Agent running";
282
+ const label = record.lifecycle.status === "queued" ? "Agent queued" : "Agent running";
242
283
 
243
284
  return successResult(`[${label}] ${suffix}`, details);
244
285
  }
@@ -253,37 +294,27 @@ async function executeSpawnForeground(
253
294
  spawnOptions.maxTurns,
254
295
  );
255
296
 
256
- const fgId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
297
+ const fgId = getManager().spawn(piInstance, ctx, resolvedType, prompt, {
257
298
  ...spawnOptions,
258
299
  ...fgCallbacks,
259
300
  isBackground: false,
260
301
  });
261
302
  agentActivity.set(fgId, fgState);
262
- widget?.ensureTimer();
303
+ getWidget()?.ensureTimer();
263
304
 
264
- const record = manager.getRecord(fgId)!;
265
- await record.promise;
305
+ const record = getManager().getRecord(fgId)!;
306
+ await record.execution.promise;
266
307
 
267
308
  agentActivity.delete(fgId);
268
- widget?.markFinished(fgId);
269
- widget?.update();
309
+ getWidget()?.markFinished(fgId);
310
+ getWidget()?.update();
270
311
 
271
- const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt;
272
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
273
- const stats: Record<string, unknown> = {
274
- type: resolvedType,
312
+ const stats = buildAgentDetails(record, {
313
+ includeStats: true,
275
314
  turnCount: fgState.turnCount,
276
- maxTurns: fgState.maxTurns,
277
- toolUses: record.toolUses,
278
- tokens: totalTokens,
279
- contextPercent: getSessionContextPercent(fgState.session),
280
- durationMs: elapsedMs,
281
- description: spawnOptions.description,
282
- compactions: record.compactionCount,
283
- modelName: record.invocation?.modelName,
284
- };
315
+ });
285
316
 
286
- if (record.status === "error") {
317
+ if (record.lifecycle.status === "error") {
287
318
  return errorResult(`Agent failed: ${record.error || "unknown error"}`, stats);
288
319
  }
289
320
 
@@ -291,9 +322,70 @@ async function executeSpawnForeground(
291
322
  }
292
323
 
293
324
  // ============================================================================
294
- // Tool_call listener inject model into Agent tool calls
325
+ // Running agents list helper (used by executeStopAgentTool)
295
326
  // ============================================================================
296
327
 
328
+ /**
329
+ * Build a compact list of running (or queued) agents.
330
+ * Format: "type·short_id, type·short_id" — one line, easy for LLM to parse.
331
+ */
332
+ function formatRunningAgents(): string {
333
+ const agents = getManager().listAgents().filter(
334
+ (a) => a.lifecycle.status === "running" || a.lifecycle.status === "queued",
335
+ );
336
+
337
+ if (agents.length === 0) return "none";
338
+
339
+ return agents
340
+ .map((a) => `${a.display.type}·${a.id.slice(0, SHORT_ID_LENGTH)}`)
341
+ .join(", ");
342
+ }
343
+
344
+ // ============================================================================
345
+ // StopAgent execute handler
346
+ // ============================================================================
347
+
348
+ export async function executeStopAgentTool(
349
+ _toolCallId: string,
350
+ params: Record<string, unknown>,
351
+ _signal: AbortSignal | undefined,
352
+ _onUpdate: ((update: any) => void) | undefined,
353
+ _ctx: ExtensionContext,
354
+ ): Promise<any> {
355
+ const agentId = params.agent_id as string | undefined;
356
+
357
+ if (!agentId) {
358
+ return errorResult("agent_id is required");
359
+ }
360
+
361
+ const record = getManager().getRecord(agentId);
362
+
363
+ if (!record) {
364
+ // Agent not found → return error + list of running agents
365
+ return errorResult(
366
+ `Agent ${agentId} not found. Running agents: ${formatRunningAgents()}`,
367
+ );
368
+ }
369
+
370
+ // Check if already in a terminal state (not running or queued)
371
+ if (record.lifecycle.status !== "running" && record.lifecycle.status !== "queued") {
372
+ return successResult(
373
+ `Agent ${agentId} is already ${record.lifecycle.status}. Running agents: ${formatRunningAgents()}`,
374
+ );
375
+ }
376
+
377
+ // Attempt to stop the running/queued agent
378
+ if (getManager().abort(agentId)) {
379
+ return successResult(`Stopped agent ${agentId.slice(0, SHORT_ID_LENGTH)}`);
380
+ }
381
+
382
+ return errorResult(`Failed to stop agent ${agentId}`);
383
+ }
384
+
385
+ // ============================================================================
386
+ // Tool_call listener — inject model into Agent tool calls
387
+ // =============================================================================
388
+
297
389
  export async function toolCallListener(
298
390
  event: ToolCallEvent,
299
391
  ctx: ExtensionContext,