@mystilleef/pi-subagent 0.5.0 → 0.7.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.
@@ -7,56 +7,37 @@ export function normalizeSummaryValue(value: string): string {
7
7
  return normalized;
8
8
  }
9
9
 
10
+ const SECRET_KEY_RE = /secret|token|password|passwd|credential|auth/i;
11
+ const JWT_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
12
+
10
13
  export function extractSemanticToolTarget(
11
- toolName: string,
12
14
  args: Record<string, unknown>,
13
15
  forceJson = false,
14
16
  ): string {
15
17
  if (forceJson) return JSON.stringify(args);
16
- if (toolName === "bash" && typeof args.command === "string")
17
- return args.command;
18
- if (
19
- ["read", "write", "edit", "file_search"].includes(toolName) &&
20
- typeof args.path === "string"
21
- )
22
- return args.path;
23
- if (toolName === "subagent") {
24
- const parts = [];
25
- if (typeof args.agent === "string") parts.push(args.agent);
26
- if (typeof args.task === "string")
27
- parts.push(normalizeSummaryValue(args.task));
28
- if (typeof args.agentScope === "string") parts.push(`[${args.agentScope}]`);
29
- if (parts.length) return parts.join(" ");
30
- return JSON.stringify(args);
18
+ const semanticKeys = [
19
+ "command",
20
+ "path",
21
+ "agent",
22
+ "query",
23
+ "url",
24
+ "action",
25
+ "name",
26
+ ];
27
+ for (const key of semanticKeys) {
28
+ const value = args[key];
29
+ if (typeof value === "string" && value.trim()) return value;
30
+ }
31
+ for (const key of Object.keys(args)) {
32
+ const value = args[key];
33
+ if (typeof value !== "string" || !value.trim()) continue;
34
+ if (SECRET_KEY_RE.test(key)) continue;
35
+ if (value.length > 60 || JWT_RE.test(value)) continue;
36
+ return value;
31
37
  }
32
38
  return "";
33
39
  }
34
40
 
35
- export function isTranscriptNoiseLine(line: string): boolean {
36
- return /^(?:(?:hello|hi|hey)(?:[!,.?:;]+|\s|$)|reasoning:|raw log:|apolog(?:y|ies)|sorry\b)/i.test(
37
- line,
38
- );
39
- }
40
-
41
- export function isFailureDiagnosticLine(line: string): boolean {
42
- return /^(?:at\s+|error:|failed:|failure:|exception:|traceback\b|caused by:)/i.test(
43
- line,
44
- );
45
- }
46
-
47
- export function filterOutputLines(output: string): string[] {
48
- return output
49
- .split(/\r?\n/)
50
- .map((l) => l.trim())
51
- .filter(
52
- (l) => l && !isTranscriptNoiseLine(l) && !isFailureDiagnosticLine(l),
53
- );
54
- }
55
-
56
- export function stripTerminalStatusPrefixes(value: string): string {
57
- return value.replace(/^(?:(?:success|failure):\s*)+/i, "");
58
- }
59
-
60
41
  export function truncateText(text: string, limit: number): string {
61
42
  if (text.length <= limit) return text;
62
43
  return `${text.slice(0, limit - 1)}…`;
@@ -72,7 +53,10 @@ export function normalizeTerminalSentence(
72
53
  .replace(/^\s*`{1,3}([^`]+)`{1,3}\s*$/, "$1")
73
54
  .replace(/^\s*\*\*([^*]+)\*\*\s*$/, "$1")
74
55
  .replace(/^\s*__([^_]+)__\s*$/, "$1");
75
- const withoutStatusPrefix = stripTerminalStatusPrefixes(unwrapped);
56
+ const withoutStatusPrefix = unwrapped.replace(
57
+ /^(?:(?:success|failure):\s*)+/i,
58
+ "",
59
+ );
76
60
  const withoutLabel = withoutStatusPrefix.replace(
77
61
  /^\s*(?:status|summary|result|output|message|error|check|outcome|project summary):\s+/i,
78
62
  "",
@@ -85,19 +69,21 @@ export function normalizeTerminalSentence(
85
69
 
86
70
  export const TOOL_PREVIEW_MAX_CHARS = 120;
87
71
 
72
+ export function normalizeAndTruncate(
73
+ text: string,
74
+ limit = TOOL_PREVIEW_MAX_CHARS,
75
+ ): string {
76
+ return truncateText(normalizeSummaryValue(text), limit);
77
+ }
78
+
88
79
  export function makeToolPreview(
89
80
  toolName: string,
90
81
  args: Record<string, unknown> | undefined,
91
82
  ): string {
92
83
  if (!args || Object.keys(args).length === 0) return toolName;
93
- const target = normalizeSummaryValue(
94
- extractSemanticToolTarget(toolName, args),
95
- );
84
+ const target = normalizeSummaryValue(extractSemanticToolTarget(args));
96
85
  if (!target) return toolName;
97
- return truncateText(
98
- normalizeSummaryValue(`${toolName}: ${target}`),
99
- TOOL_PREVIEW_MAX_CHARS,
100
- );
86
+ return normalizeAndTruncate(`${toolName}: ${target}`);
101
87
  }
102
88
 
103
89
  export function isStatusOnlySuccess(value: string): boolean {
@@ -1,5 +1,5 @@
1
+ import type { SingleResult } from "../shared/types.js";
1
2
  import { normalizeTerminalSentence } from "./normalize.js";
2
- import type { SingleResult } from "./types.js";
3
3
 
4
4
  export const FEEDBACK_UI_SUMMARY_MAX_CHARS = 120;
5
5
 
@@ -20,7 +20,9 @@ const FEEDBACK_UI_LABEL_PATTERN =
20
20
  /^\s*(outcome|project summary|result|summary|status|output|message|error|check):\s*/i;
21
21
 
22
22
  export function formatSubagentResultForParent(result: SingleResult): string {
23
- return result.finalOutput;
23
+ return result.thinkingWarning
24
+ ? `[thinking] ${result.thinkingWarning}\n\n${result.finalOutput}`
25
+ : result.finalOutput;
24
26
  }
25
27
 
26
28
  export function summarizeFeedbackUiFinalOutput(finalOutput: string): string {
@@ -7,11 +7,7 @@ import {
7
7
  type MarkdownTheme,
8
8
  Text,
9
9
  } from "@earendil-works/pi-tui";
10
- import type { AgentScope } from "./agents.js";
11
- import {
12
- extractSemanticToolTarget,
13
- normalizeSummaryValue,
14
- } from "./normalize.js";
10
+ import type { AgentScope } from "../agent/agents.js";
15
11
  import {
16
12
  formatContextPercent,
17
13
  formatElapsed,
@@ -21,11 +17,13 @@ import {
21
17
  STATUS_ICON,
22
18
  type SubagentProgressState,
23
19
  type ThemeBg,
24
- } from "./progress-state.js";
25
- import { hasSubagentFailed } from "./result-details.js";
26
- import type { SubagentDetails, UsageStats } from "./types.js";
27
-
28
- export type { ThemeBg };
20
+ } from "../progress/progress-state.js";
21
+ import { hasSubagentFailed } from "../progress/result-details.js";
22
+ import type { SubagentDetails, UsageStats } from "../shared/types.js";
23
+ import {
24
+ extractSemanticToolTarget,
25
+ normalizeSummaryValue,
26
+ } from "./normalize.js";
29
27
 
30
28
  /**
31
29
  * Abstraction for theme-aware text formatting.
@@ -103,32 +101,16 @@ export function formatUsageStats(
103
101
  return parts.join(" · ");
104
102
  }
105
103
 
106
- /**
107
- * Formats millisecond durations into human-readable time strings.
108
- */
109
- export function formatDuration(ms: number): string {
110
- if (ms < 1000) return `${Math.floor(ms)}ms`;
111
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
112
- const minutes = Math.floor(ms / 60000);
113
- const seconds = Math.floor((ms % 60000) / 1000);
114
- return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
115
- }
116
-
117
104
  /**
118
105
  * Formats the footer for subagent result cards, including model, context, turns, and cost.
119
106
  */
120
- export function formatResultFooter(
121
- usage: UsageStats,
122
- model?: string,
123
- durationMs?: number,
124
- ): string {
107
+ export function formatResultFooter(usage: UsageStats, model?: string): string {
125
108
  const parts: string[] = [];
126
109
  if (model) parts.push(model);
127
- if (usage.contextTokens && usage.contextTokens > 0)
128
- parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
129
110
  if (usage.turns)
130
111
  parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
131
- if (typeof durationMs === "number") parts.push(formatDuration(durationMs));
112
+ if (usage.contextTokens && usage.contextTokens > 0)
113
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
132
114
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
133
115
  return parts.join(" · ");
134
116
  }
@@ -143,7 +125,7 @@ export function formatToolCall(
143
125
  forceJson = false,
144
126
  ): string {
145
127
  const target = normalizeSummaryValue(
146
- extractSemanticToolTarget(toolName, args, forceJson),
128
+ extractSemanticToolTarget(args, forceJson),
147
129
  );
148
130
  if (!target) return themeFg("accent", toolName);
149
131
  return themeFg("accent", toolName) + themeFg("dim", ` ${target}`);
@@ -199,7 +181,8 @@ export function renderSubagentCall(
199
181
  ): Text {
200
182
  const scope: AgentScope = args.agentScope ?? "both";
201
183
  const agentName = args.agent || "...";
202
- const target = extractSemanticToolTarget("subagent", args);
184
+ // Parser-owned preview suppresses task text: show agent + scope only
185
+ const target = args.agent ? `[${scope}]` : JSON.stringify(args);
203
186
  let text =
204
187
  theme.fg("toolTitle", theme.bold("subagent ")) +
205
188
  theme.fg("accent", agentName) +
@@ -208,15 +191,17 @@ export function renderSubagentCall(
208
191
  return new Text(text, 0, 0, (line) => theme.bg("toolPendingBg", line));
209
192
  }
210
193
 
211
- /**
212
- * Renders the subagent result box.
213
- *
214
- * Invariants:
215
- * - Red background indicates failure (exit code, error reason, or message error).
216
- * - Green background indicates success.
217
- * - Trims redundant "Outcome:" lines from the body.
218
- * - Displays usage stats and duration in the footer.
219
- */
194
+ export function renderSubagentToolResult(
195
+ result: { content: { type: string; text?: string }[]; details?: unknown },
196
+ theme: SubagentTheme,
197
+ display?: { isPartial?: boolean },
198
+ ): Component {
199
+ const details = result.details as SubagentDetails | undefined;
200
+ if (details?.renderedByMessage) return new Text("", 0, 0);
201
+ return renderSubagentResult(result, theme, display);
202
+ }
203
+
204
+ // Invariants: Red background = failure, green = success. Trims redundant "Outcome:" lines. Shows usage stats + duration in footer.
220
205
  export function renderSubagentResult(
221
206
  result: { content: { type: string; text?: string }[]; details?: unknown },
222
207
  theme: SubagentTheme,
@@ -242,13 +227,29 @@ export function renderSubagentResult(
242
227
  : "success";
243
228
  const finalOutput = r.finalOutput ?? getFinalOutput(r.messages ?? []);
244
229
  const title = formatSubagentTitle(r.agent, r.instanceName, theme);
245
- const bodyText = stripOutcomeLineForResultUi(bodyOverride ?? finalOutput);
246
- const usageStr = formatResultFooter(r.usage, r.model, r.durationMs);
230
+ let effectiveBody = bodyOverride ?? finalOutput;
231
+ if (_display?.isPartial && !finalOutput?.trim() && !bodyOverride) {
232
+ effectiveBody =
233
+ result.content[0]?.text ||
234
+ r.progress?.activityText ||
235
+ r.progress?.lastToolPreview ||
236
+ "(running...)";
237
+ }
238
+ const bodyText = stripOutcomeLineForResultUi(effectiveBody);
239
+ const toolCount = r.progress?.toolCalls?.length ?? 0;
240
+ const toolLabel = `${toolCount} ${toolCount === 1 ? "tool" : "tools"}`;
241
+ const ctxPercent = formatContextPercent({
242
+ contextTokens: r.usage.contextTokens,
243
+ contextWindowTokens: r.usage.contextWindowTokens,
244
+ } as SubagentProgressState);
245
+ const metadata = `${toolLabel} · ${ctxPercent} ctx · ${formatElapsed(r.durationMs ?? 0)}`;
246
+ const usageStr = formatResultFooter(r.usage, r.model);
247
247
  return renderStatusCard(
248
248
  {
249
249
  status: resultStatus,
250
250
  title,
251
251
  variant: "full",
252
+ metadata,
252
253
  body: bodyText,
253
254
  footer: usageStr,
254
255
  },
@@ -347,7 +348,6 @@ function renderJobCard(
347
348
  );
348
349
  }
349
350
 
350
- /** Sort states by startTime descending (newest first). */
351
351
  function sortByStartTimeDesc(
352
352
  a: SubagentProgressState,
353
353
  b: SubagentProgressState,
@@ -363,11 +363,8 @@ const BOARD_SECTIONS: [string, ProgressStatus][] = [
363
363
  ["SUCCEEDED", "success"],
364
364
  ];
365
365
 
366
- /**
367
- * Renders a unified job board for the `/jobs` command.
368
- * Jobs render in status-specific sections, each sorted by `startTime` descending.
369
- * Status icons preserve the existing /jobs contract for running and cancelled jobs.
370
- */
366
+ // Jobs render in status-specific sections, each sorted by `startTime` descending.
367
+ // Status icons preserve the existing /jobs contract for running and cancelled jobs.
371
368
  export function renderRunsBoard(
372
369
  states: SubagentProgressState[],
373
370
  theme: SubagentTheme,
@@ -3,12 +3,18 @@ import {
3
3
  isStatusOnlyFailure,
4
4
  isStatusOnlySuccess,
5
5
  makeToolPreview,
6
+ normalizeAndTruncate,
6
7
  normalizeSummaryValue,
7
8
  normalizeTerminalSentence,
8
- TOOL_PREVIEW_MAX_CHARS,
9
9
  truncateText,
10
- } from "./normalize.js";
11
- import type { SubagentDetails } from "./types.js";
10
+ } from "../output/normalize.js";
11
+ import type {
12
+ SingleResult,
13
+ SubagentDetails,
14
+ ToolActivity,
15
+ } from "../shared/types.js";
16
+
17
+ export const SENSITIVE_PATTERN = /secret|token|password/i;
12
18
 
13
19
  export type ThemeBg = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
14
20
 
@@ -43,7 +49,9 @@ export interface SubagentProgressState {
43
49
  status: ProgressStatus;
44
50
  startTime: number;
45
51
  durationMs?: number;
52
+ activeToolActivity?: ToolActivity;
46
53
  lastToolPreview?: string;
54
+ toolResultCompleted?: boolean;
47
55
  toolCount: number;
48
56
  inputTokens?: number;
49
57
  outputTokens?: number;
@@ -59,7 +67,7 @@ export function createProgressState(
59
67
  requestId: string,
60
68
  agent: string,
61
69
  task: string,
62
- instanceName = requestId,
70
+ instanceName?: string,
63
71
  ): void {
64
72
  store.set(requestId, {
65
73
  requestId,
@@ -91,7 +99,9 @@ export function patchProgressState(
91
99
  store.set(requestId, {
92
100
  ...state,
93
101
  ...patch,
102
+ activeToolActivity: undefined,
94
103
  lastToolPreview: undefined,
104
+ toolResultCompleted: undefined,
95
105
  });
96
106
  return;
97
107
  }
@@ -115,7 +125,9 @@ export function finalizeProgressState(
115
125
  storeTerminalProgressState(requestId, {
116
126
  status: "success",
117
127
  finalOutput: makeProgressFinalOutput(finalOutput),
128
+ activeToolActivity: undefined,
118
129
  lastToolPreview: undefined,
130
+ toolResultCompleted: undefined,
119
131
  });
120
132
  }
121
133
 
@@ -124,14 +136,18 @@ export function failProgressState(requestId: string, errorText: string): void {
124
136
  storeTerminalProgressState(requestId, {
125
137
  status: "error",
126
138
  errorText: sentence,
139
+ activeToolActivity: undefined,
127
140
  lastToolPreview: undefined,
141
+ toolResultCompleted: undefined,
128
142
  });
129
143
  }
130
144
 
131
145
  export function cancelProgressState(requestId: string, reason?: string): void {
132
146
  storeTerminalProgressState(requestId, {
133
147
  status: "cancelled",
148
+ activeToolActivity: undefined,
134
149
  lastToolPreview: undefined,
150
+ toolResultCompleted: undefined,
135
151
  ...(reason !== undefined
136
152
  ? { errorText: normalizeTerminalSentence(reason) }
137
153
  : {}),
@@ -198,53 +214,110 @@ function isMeaningfulProgressErrorLine(line: string): boolean {
198
214
 
199
215
  export interface DetailsProgress {
200
216
  lastToolPreview?: string;
217
+ activityText?: string;
218
+ activeToolActivity?: ToolActivity;
219
+ progressLastToolPreview?: string;
220
+ toolResultCompleted?: boolean;
201
221
  newToolCallIds: string[];
202
222
  }
203
223
 
224
+ function trackNewToolCall(
225
+ id: string,
226
+ preview: string,
227
+ seenToolCallIds: Set<string>,
228
+ state: DetailsProgress,
229
+ ): void {
230
+ if (seenToolCallIds.has(id)) return;
231
+ seenToolCallIds.add(id);
232
+ state.newToolCallIds.push(id);
233
+ state.lastToolPreview = preview;
234
+ }
235
+
236
+ function extractProgressFromExistingProgress(
237
+ progress: {
238
+ activityText?: string;
239
+ activeToolActivity?: ToolActivity;
240
+ lastToolPreview?: string;
241
+ toolCalls: { id: string; preview: string }[];
242
+ toolResultCompleted?: boolean;
243
+ },
244
+ seenToolCallIds: Set<string>,
245
+ state: DetailsProgress,
246
+ ): void {
247
+ if (
248
+ typeof progress.activityText === "string" &&
249
+ progress.activityText.trim()
250
+ ) {
251
+ state.activityText = normalizeAndTruncate(progress.activityText);
252
+ }
253
+ if (progress.activeToolActivity) {
254
+ state.activeToolActivity = progress.activeToolActivity;
255
+ }
256
+ if (
257
+ typeof progress.lastToolPreview === "string" &&
258
+ progress.lastToolPreview.trim()
259
+ ) {
260
+ state.progressLastToolPreview = normalizeAndTruncate(
261
+ progress.lastToolPreview,
262
+ );
263
+ }
264
+ if (progress.toolResultCompleted) {
265
+ state.toolResultCompleted = true;
266
+ }
267
+ for (const toolCall of progress.toolCalls) {
268
+ if (!isDerivedToolCall(toolCall)) continue;
269
+ const preview = normalizeAndTruncate(toolCall.preview);
270
+ trackNewToolCall(toolCall.id, preview, seenToolCallIds, state);
271
+ }
272
+ }
273
+
274
+ function extractProgressFromMessages(
275
+ messages: SingleResult["messages"] = [],
276
+ seenToolCallIds: Set<string>,
277
+ state: DetailsProgress,
278
+ ): void {
279
+ for (const msg of messages) {
280
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
281
+ for (const part of msg.content) {
282
+ if (isToolCallPart(part)) {
283
+ const preview = makeToolPreview(part.name, part.arguments);
284
+ trackNewToolCall(part.id, preview, seenToolCallIds, state);
285
+ }
286
+ }
287
+ }
288
+ }
289
+
204
290
  export function extractProgressFromDetails(
205
291
  details: SubagentDetails,
206
292
  seenToolCallIds: Set<string>,
207
293
  ): DetailsProgress {
208
- const newToolCallIds: string[] = [];
209
- let lastToolPreview: string | undefined;
294
+ const state: DetailsProgress = { newToolCallIds: [] };
210
295
  const results = Array.isArray(details.results) ? details.results : [];
211
296
  for (const result of results) {
212
297
  if (result.progress) {
213
- for (const toolCall of result.progress.toolCalls) {
214
- if (!isDerivedToolCall(toolCall)) continue;
215
- lastToolPreview = truncateText(
216
- normalizeSummaryValue(toolCall.preview),
217
- TOOL_PREVIEW_MAX_CHARS,
218
- );
219
- if (seenToolCallIds.has(toolCall.id)) continue;
220
- seenToolCallIds.add(toolCall.id);
221
- newToolCallIds.push(toolCall.id);
222
- }
298
+ extractProgressFromExistingProgress(
299
+ result.progress,
300
+ seenToolCallIds,
301
+ state,
302
+ );
223
303
  continue;
224
304
  }
225
305
  const messages = Array.isArray(result.messages) ? result.messages : [];
226
- for (const msg of messages) {
227
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
228
- for (const part of msg.content) {
229
- if (isToolCallPart(part)) {
230
- lastToolPreview = makeToolPreview(part.name, part.arguments);
231
- if (seenToolCallIds.has(part.id)) continue;
232
- seenToolCallIds.add(part.id);
233
- newToolCallIds.push(part.id);
234
- }
235
- }
236
- }
306
+ extractProgressFromMessages(messages, seenToolCallIds, state);
237
307
  }
238
- return { lastToolPreview, newToolCallIds };
308
+ return state;
309
+ }
310
+
311
+ function isObjectWith(part: unknown): part is Record<string, unknown> {
312
+ return typeof part === "object" && part !== null;
239
313
  }
240
314
 
241
315
  function isDerivedToolCall(part: unknown): part is {
242
316
  id: string;
243
317
  preview: string;
244
318
  } {
245
- if (typeof part !== "object" || part === null) return false;
246
- const maybe = part as { id?: unknown; preview?: unknown };
247
- return typeof maybe.id === "string" && typeof maybe.preview === "string";
319
+ if (!isObjectWith(part)) return false;
320
+ return typeof part.id === "string" && typeof part.preview === "string";
248
321
  }
249
322
 
250
323
  export function isToolCallPart(part: unknown): part is {
@@ -253,12 +326,11 @@ export function isToolCallPart(part: unknown): part is {
253
326
  name: string;
254
327
  arguments?: Record<string, unknown>;
255
328
  } {
256
- if (typeof part !== "object" || part === null) return false;
257
- const maybe = part as { type?: unknown; id?: unknown; name?: unknown };
329
+ if (!isObjectWith(part)) return false;
258
330
  return (
259
- maybe.type === "toolCall" &&
260
- typeof maybe.id === "string" &&
261
- typeof maybe.name === "string"
331
+ part.type === "toolCall" &&
332
+ typeof part.id === "string" &&
333
+ typeof part.name === "string"
262
334
  );
263
335
  }
264
336
 
@@ -274,22 +346,6 @@ export function formatElapsed(ms: number): string {
274
346
  return `${mins}m ${secs}s`;
275
347
  }
276
348
 
277
- /**
278
- * Format a raw token count for compact inline display.
279
- * Values below 1000 rendered as-is. Larger counts use `k`
280
- * or `M` suffixes with one decimal place, stripping trailing `.0`.
281
- */
282
- export function formatTokenCount(count: number): string {
283
- if (count < 1000) return String(count);
284
- const unit = count >= 1_000_000 ? "M" : "k";
285
- const divisor = count >= 1_000_000 ? 1_000_000 : 1000;
286
- return `${trimTrailingZero((count / divisor).toFixed(1))}${unit}`;
287
- }
288
-
289
- function trimTrailingZero(value: string): string {
290
- return value.endsWith(".0") ? value.slice(0, -2) : value;
291
- }
292
-
293
349
  export function formatContextPercent(state: SubagentProgressState): string {
294
350
  const d = state.contextWindowTokens;
295
351
  if (!d || d <= 0 || !Number.isFinite(d)) return "--%";
@@ -311,3 +367,59 @@ export function formatHeaderStats(state: SubagentProgressState): string {
311
367
  const toolLabel = state.toolCount === 1 ? "tool" : "tools";
312
368
  return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}\n`;
313
369
  }
370
+
371
+ const REDACTED_PLACEHOLDER = "(running...)";
372
+ const REDACTED_PLACEHOLDER_LENGTH = REDACTED_PLACEHOLDER.length;
373
+
374
+ function redactOrTruncate(text: string, maxChars: number): string {
375
+ if (SENSITIVE_PATTERN.test(text))
376
+ return maxChars >= REDACTED_PLACEHOLDER_LENGTH ? REDACTED_PLACEHOLDER : "";
377
+ return truncateText(text, maxChars);
378
+ }
379
+
380
+ function walkActivityTree(activity: ToolActivity): string[] {
381
+ const parts: string[] = [];
382
+ let current: ToolActivity | undefined = activity;
383
+ while (current) {
384
+ if (current.inputSummary) {
385
+ const annotated = current.instanceName
386
+ ? `${current.inputSummary} [${current.instanceName}]`
387
+ : current.inputSummary;
388
+ parts.push(annotated);
389
+ }
390
+ current = current.child;
391
+ }
392
+ return parts;
393
+ }
394
+
395
+ /**
396
+ * Renders a ToolActivity tree for storage. Each segment is
397
+ * independently normalized and truncated to TOOL_PREVIEW_MAX_CHARS (120).
398
+ */
399
+ export function renderToolActivity(
400
+ activity: ToolActivity | undefined,
401
+ ): string | undefined {
402
+ if (!activity) return undefined;
403
+ const parts = walkActivityTree(activity);
404
+ if (parts.length === 0) return activity.toolName;
405
+ const result = parts.map((p) => normalizeAndTruncate(p)).join(" - ");
406
+ if (SENSITIVE_PATTERN.test(result)) return REDACTED_PLACEHOLDER;
407
+ return result;
408
+ }
409
+
410
+ /**
411
+ * Renders a ToolActivity tree for display with a caller-provided truncation
412
+ * budget. Segments are normalized without individual truncation so the
413
+ * joined result shares one post-join display budget.
414
+ */
415
+ export function renderToolActivityForDisplay(
416
+ activity: ToolActivity | undefined,
417
+ maxChars: number,
418
+ ): string | undefined {
419
+ if (!activity) return undefined;
420
+ if (maxChars <= 0) return "";
421
+ const parts = walkActivityTree(activity);
422
+ if (parts.length === 0) return redactOrTruncate(activity.toolName, maxChars);
423
+ const joined = parts.map((p) => normalizeSummaryValue(p)).join(" - ");
424
+ return redactOrTruncate(joined, maxChars);
425
+ }