@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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.
package/src/types.ts CHANGED
@@ -17,8 +17,18 @@ export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as con
17
17
  /** Memory scope for persistent agent memory. */
18
18
  export type MemoryScope = "user" | "project" | "local";
19
19
 
20
- /** Isolation mode for agent execution. */
21
- export type IsolationMode = "worktree";
20
+ /**
21
+ * Isolation mode for agent execution.
22
+ *
23
+ * `"off"` exists for the caller's benefit, not the runtime's: models that fill
24
+ * every optional parameter had no legal way to decline a single-value
25
+ * `isolation` field and kept spawning worktrees they had just reasoned their
26
+ * way out of. It is an input spelling only — `resolveAgentInvocationConfig`
27
+ * collapses it to `undefined`, so nothing downstream sees a value other than
28
+ * `"worktree"`. In an agent file it is a genuine veto, since agent config
29
+ * outranks tool-call params.
30
+ */
31
+ export type IsolationMode = "worktree" | "off";
22
32
 
23
33
  /** Unified agent configuration — used for both default and user-defined agents. */
24
34
  export interface AgentConfig {
@@ -38,26 +48,44 @@ export interface AgentConfig {
38
48
  excludeExtensions?: string[];
39
49
  /** true = inherit all, string[] = only listed, false = none */
40
50
  skills: true | string[] | false;
51
+ /** Child-session model scope (`ctx.scopedModels`). true/undefined = inherit
52
+ * the parent session's resolved scope, string[] = only the listed
53
+ * `provider/modelId` entries, false = no scope. Controls which models the
54
+ * child session offers for cycling and to in-session extensions that read
55
+ * the scope (e.g. pi-fallback-provider's settle-boundary fallback). */
56
+ scopedModels?: true | string[] | false;
41
57
  model?: string;
42
58
  thinking?: ThinkingLevel;
43
59
  maxTurns?: number;
44
- /** Persist this subagent as a normal pi session instead of keeping it in memory only. */
60
+ /** Keep this subagent's pi session file after the run settles. Every run
61
+ * writes a session file while it runs (crash recovery); without this the file
62
+ * is deleted when the agent record is removed — the 10-minute eviction or a
63
+ * session switch. */
45
64
  persistSession?: boolean;
46
65
  /** Write the subagent's .output transcript. Defaults to true; false suppresses only that transcript. */
47
66
  outputTranscript?: boolean;
48
- /** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */
67
+ /** Optional directory for the run's pi session file. Omitted = pi's normal session location. */
49
68
  sessionDir?: string;
69
+ /**
70
+ * Nested delegation, off by default: undefined = no nested tools;
71
+ * "all" = any enabled agent; string[] = only those agent types.
72
+ */
73
+ allowedSubagents?: "all" | string[];
74
+ /** Per-agent nesting cap. Can only tighten the inherited
75
+ * `maxSubagentDepth`, never raise it. Omitted = inherit. */
76
+ maxSubagentDepth?: number;
50
77
  systemPrompt: string;
51
78
  promptMode: "replace" | "append";
52
79
  /** Default for spawn: fork parent conversation. undefined = caller decides. */
53
80
  inheritContext?: boolean;
54
- /** Default for spawn: run in background. undefined = caller decides. */
55
- runInBackground?: boolean;
56
81
  /** Default for spawn: no extension tools. undefined = caller decides. */
57
82
  isolated?: boolean;
58
83
  /** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
59
84
  memory?: MemoryScope;
60
- /** Isolation mode — "worktree" runs the agent in a temporary git worktree */
85
+ /**
86
+ * Isolation mode — "worktree" runs the agent in a temporary git worktree,
87
+ * "off" refuses one even when the caller asks (frontmatter outranks params).
88
+ */
61
89
  isolation?: IsolationMode;
62
90
  /** true = this is an embedded default agent (informational) */
63
91
  isDefault?: boolean;
@@ -65,20 +93,16 @@ export interface AgentConfig {
65
93
  enabled?: boolean;
66
94
  /** Where this agent was loaded from */
67
95
  source?: "default" | "project" | "global";
96
+ /** Path of the .md it was loaded from. Unset for embedded defaults. */
97
+ sourcePath?: string;
68
98
  }
69
99
 
70
- export type JoinMode = 'async' | 'group' | 'smart';
71
-
72
- export type ResultPreviewMode = "plain" | "markdown";
73
-
74
100
  /**
75
101
  * Display mode for the persistent above-editor agent widget.
76
- * - `all`: show every agent (foreground + background).
77
- * - `background`: hide foreground agents (they already render inline as the
78
- * Agent tool result, #118); show background/queued/scheduled/RPC.
102
+ * - `all`: show every agent.
79
103
  * - `off`: hide the widget entirely.
80
104
  */
81
- export type WidgetMode = 'all' | 'background' | 'off';
105
+ export type WidgetMode = 'all' | 'off';
82
106
 
83
107
  export interface AgentRecord {
84
108
  id: string;
@@ -91,10 +115,21 @@ export interface AgentRecord {
91
115
  startedAt: number;
92
116
  completedAt?: number;
93
117
  session?: AgentSession;
118
+ /** Resolved `persist_session` for this run, captured at spawn so the removal
119
+ * path never re-resolves the agent registry. A resume reuses the record and
120
+ * therefore its first run's decision. */
121
+ persistSession?: boolean;
94
122
  abortController?: AbortController;
95
123
  promise?: Promise<string>;
96
- groupId?: string;
97
- joinMode?: JoinMode;
124
+ /** Whether the run promise has fully unwound. Tri-state: `undefined` = no run
125
+ * began — a fresh queued record, or a start that failed in `drainQueue`
126
+ * before the counter/`settled` write; `false` = a run is in flight or a
127
+ * `stopped` run's promise is still settling; `true` = the completion tail
128
+ * finished, or a queued record was stopped before it ever started.
129
+ * `startAgent` and `resume` set `false`; `afterRun` and a queued stop set
130
+ * `true`. Resuming keys on `=== false` so a record that never ran is not
131
+ * blocked. */
132
+ settled?: boolean;
98
133
  /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
99
134
  resultConsumed?: boolean;
100
135
  /** Steering messages queued before the session was ready. */
@@ -103,8 +138,6 @@ export interface AgentRecord {
103
138
  worktree?: { path: string; branch: string; baseSha: string; workPath: string };
104
139
  /** Worktree cleanup result after agent completion. */
105
140
  worktreeResult?: { hasChanges: boolean; branch?: string };
106
- /** The tool_use_id from the original Agent tool call. */
107
- toolCallId?: string;
108
141
  /** Path to the streaming output transcript file. */
109
142
  outputFile?: string;
110
143
  /** Cleanup function for the output file stream subscription. */
@@ -117,14 +150,16 @@ export interface AgentRecord {
117
150
  lifetimeUsage: LifetimeUsage;
118
151
  /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
119
152
  compactionCount: number;
120
- /** Cumulative agentic turn count, incremented via the `onTurnEnd` callback.
121
- * Initialized to 1 at spawn (record construction) to match the activity
122
- * tracker's `turnCount: 1`, then stamped with the completed-turn count as
123
- * each turn ends. A second source exists in the `Agent` tool's execute
124
- * closure (the closure-local `AgentActivity.turnCount` used for the live
125
- * widget), which is unreachable from `get_subagent_result`'s separate execute
126
- * — hence the record needs its own field. `get_subagent_result` surfaces it
127
- * in its running header; the `checkpoint` tool reads it as the turn label. */
153
+ /** Run-local agentic turn count, stamped via the `onTurnEnd` callback.
154
+ * Initialized to 1 at spawn and reset to 1 when a resume starts, and zeroed
155
+ * when a record is stopped before its run starts (a never-started report
156
+ * carries no turn), so it describes the current run; the record's `toolUses`
157
+ * stays cumulative across resumes. A second source exists in the `Agent`
158
+ * tool's execute closure (the closure-local `AgentActivity.turnCount` used
159
+ * for the live widget), which is unreachable from `get_subagent_result`'s
160
+ * separate execute — hence the record needs its own field.
161
+ * `get_subagent_result` surfaces it in its running header;
162
+ * the `checkpoint` tool reads it as the turn label. */
128
163
  turnCount?: number;
129
164
  /** Latest checkpoint written by the subagent's `checkpoint` tool call.
130
165
  * Undefined until the subagent calls the tool. The full history lives in
@@ -146,19 +181,22 @@ export interface AgentRecord {
146
181
  /** The child session's ID, set at spawn. The `checkpoint` tool reads
147
182
  * ctx.sessionManager.getSessionId() to find its own record. */
148
183
  sessionId?: string;
149
- /**
150
- * Whether this agent was spawned to run in the background. Tri-state, set at
151
- * spawn from `SpawnOptions.isBackground`: `true` = background, `false` =
152
- * foreground (has an inline Agent tool-result surface), `undefined` = the
153
- * caller never declared it (e.g. a cross-extension RPC spawn, which is detached
154
- * and has no inline surface). The widget's background-only filter keys off this
155
- * — and excludes only explicit `false`, so `undefined` agents stay visible.
156
- * Reliable across ALL spawn paths, unlike the UI-only `invocation` snapshot,
157
- * which only the Agent-tool path populates.
158
- */
159
- isBackground?: boolean;
160
184
  /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
161
185
  invocation?: AgentInvocation;
186
+ /** Nesting depth: a top-level subagent is 1, its children 2, and so on. */
187
+ depth?: number;
188
+ /** Set on records spawned by a nested delegation tool: the owning agent's id.
189
+ * Nested records are reachable only through their parent's scoped tools, so
190
+ * every top-level surface (notification, widget, `/agents`, RPC, the Agent
191
+ * tool) filters them out. */
192
+ parentAgentId?: string;
193
+ /** Effective nesting cap inherited by this branch, so a child hands its own
194
+ * cap down instead of re-reading the process setting. */
195
+ maxSubagentDepth?: number;
196
+ /** Session id of the root (main) session this branch descends from. Nested
197
+ * spawns inherit it so their transcripts file under the same session
198
+ * directory as their ancestors' instead of the child session's own id. */
199
+ rootSessionId?: string;
162
200
  /** Effective max turns applied to the agent's run — caller-supplied value,
163
201
  * then the agent-config value, then the settings default
164
202
  * (`getDefaultMaxTurns()`), then normalized. Captured at spawn so
@@ -169,34 +207,33 @@ export interface AgentRecord {
169
207
  effectiveMaxTurns?: number;
170
208
  }
171
209
 
210
+ /**
211
+ * What a session reports as its level: pi's `ThinkingLevel` plus the `"off"` a
212
+ * model with thinking disabled reports. Display-only — spawning still takes a
213
+ * `ThinkingLevel`, so this widening cannot leak into an invocation.
214
+ */
215
+ export type EffectiveThinkingLevel = ThinkingLevel | "off";
216
+
172
217
  export interface AgentInvocation {
173
- /** Short display name, e.g. "haiku" — only set when different from parent. */
218
+ /** Short display name for tight rows, e.g. "haiku 4.5". Always set once known. */
174
219
  modelName?: string;
175
- thinking?: ThinkingLevel;
220
+ /** Canonical `provider/id`, for surfaces with room to disambiguate providers. */
221
+ modelId?: string;
222
+ /** The level actually in effect, once a session exists to report one. */
223
+ thinking?: EffectiveThinkingLevel;
224
+ /**
225
+ * What the caller asked for, kept only when they did not get it — pi clamped
226
+ * the level to the model's capabilities. The snapshot exists to answer "did
227
+ * the spawn honor my instructions?", which it cannot do if the request is
228
+ * lost, so it is never overwritten once set.
229
+ */
230
+ requestedThinking?: EffectiveThinkingLevel;
176
231
  maxTurns?: number;
177
232
  isolated?: boolean;
178
233
  inheritContext?: boolean;
179
- runInBackground?: boolean;
180
234
  isolation?: IsolationMode;
181
235
  }
182
236
 
183
- /** Details attached to custom notification messages for visual rendering. */
184
- export interface NotificationDetails {
185
- id: string;
186
- description: string;
187
- status: string;
188
- toolUses: number;
189
- turnCount: number;
190
- maxTurns?: number;
191
- totalTokens: number;
192
- durationMs: number;
193
- outputFile?: string;
194
- error?: string;
195
- resultPreview: string;
196
- /** Additional agents in a group notification. */
197
- others?: NotificationDetails[];
198
- }
199
-
200
237
  export interface EnvInfo {
201
238
  isGitRepo: boolean;
202
239
  branch: string;
@@ -244,4 +281,10 @@ export interface ScheduleStoreData {
244
281
  /** For future migrations. */
245
282
  version: 1;
246
283
  jobs: ScheduledSubagent[];
284
+ /**
285
+ * Records shadowed by an earlier duplicate with the same id. Written back
286
+ * verbatim, never promoted to live jobs; deleting an id purges its shadowed
287
+ * records too.
288
+ */
289
+ shadowed?: ScheduledSubagent[];
247
290
  }
@@ -8,8 +8,9 @@
8
8
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { getConfig } from "../agent-types.js";
11
+ import { safeTruncate, stripControlChars, toSingleLine } from "../text-safety.js";
11
12
  import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
12
- import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
14
 
14
15
  // ---- Constants ----
15
16
 
@@ -71,12 +72,8 @@ export interface AgentDetails {
71
72
  toolUses: number;
72
73
  tokens: string;
73
74
  durationMs: number;
74
- status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
75
- /** Human-readable description of what the agent is currently doing. */
76
- activity?: string;
77
- /** Current spinner frame index (for animated running indicator). */
78
- spinnerFrame?: number;
79
- /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
75
+ status: "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
76
+ /** Short label for the model the run used, e.g. "haiku 4.5". */
80
77
  modelName?: string;
81
78
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
82
79
  tags?: string[];
@@ -84,6 +81,8 @@ export interface AgentDetails {
84
81
  turnCount?: number;
85
82
  /** Effective max turns (undefined = unlimited). */
86
83
  maxTurns?: number;
84
+ /** Estimated cost in USD; 0 when the model has no pricing data. */
85
+ cost?: number;
87
86
  agentId?: string;
88
87
  error?: string;
89
88
  }
@@ -104,6 +103,33 @@ export function formatTokens(count: number): string {
104
103
  return `${count} token`;
105
104
  }
106
105
 
106
+ /**
107
+ * Format a cost as `~$0.0042`, or "" when there is nothing to show.
108
+ *
109
+ * The tilde is load-bearing: this is pi's own estimate from the model's listed
110
+ * rates, not a billed figure, and the surfaces that print it sit next to token
111
+ * counts that ARE exact.
112
+ *
113
+ * Nothing is printed for zero, which is also what a model with no pricing data
114
+ * reports: `$0.00` beside a local model's tokens would claim its cost was
115
+ * measured and found to be nothing, rather than never measured at all. For the
116
+ * same reason a real cost too small for four decimals reads `<$0.0001` — it was
117
+ * measured, and rounding it to `~$0.0000` would say the opposite.
118
+ */
119
+ export function formatCost(cost: number): string {
120
+ // Also catches NaN and Infinity: a non-finite total would otherwise render
121
+ // "~$Infinity" through toFixed.
122
+ if (!Number.isFinite(cost) || cost <= 0) return "";
123
+ if (cost < 0.0001) return "<$0.0001";
124
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
125
+ // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
126
+ // Most single runs land between a tenth of a cent and a dime, where rounding
127
+ // to cents would collapse a 4x difference in spend into the same figure.
128
+ const rounded = Number(cost.toFixed(4));
129
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
130
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
131
+ }
132
+
107
133
  /**
108
134
  * Token count with optional context-fill % and compaction-count annotations.
109
135
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
@@ -160,26 +186,37 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
160
186
  return config.promptMode === "append" ? "twin" : undefined;
161
187
  }
162
188
 
163
- /** Mode label is not included — callers add it where they want it. */
189
+ /**
190
+ * Mode label is not included — callers add it where they want it.
191
+ *
192
+ * Both model forms come back so each surface can pick by width; the
193
+ * "(asked X)" annotation is applied here rather than by callers, so a level the
194
+ * spawn did not honor cannot be rendered as though it had been.
195
+ */
164
196
  export function buildInvocationTags(
165
197
  invocation: AgentInvocation | undefined,
166
- ): { modelName?: string; tags: string[] } {
198
+ ): { modelName?: string; modelId?: string; tags: string[] } {
167
199
  const tags: string[] = [];
168
200
  if (!invocation) return { tags };
169
- if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
201
+ // The effective value with the request kept beside it, e.g. "low (asked max)":
202
+ // pi clamped the level to what the model supports.
203
+ const thinking = invocation.thinking && invocation.requestedThinking && invocation.requestedThinking !== invocation.thinking
204
+ ? `${invocation.thinking} (asked ${invocation.requestedThinking})`
205
+ : invocation.thinking;
206
+ if (thinking) tags.push(`thinking: ${thinking}`);
170
207
  if (invocation.isolated) tags.push("isolated");
171
208
  if (invocation.isolation === "worktree") tags.push("worktree");
172
209
  if (invocation.inheritContext) tags.push("inherit context");
173
- if (invocation.runInBackground) tags.push("background");
174
210
  if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
175
- return { modelName: invocation.modelName, tags };
211
+ return { modelName: invocation.modelName, modelId: invocation.modelId, tags };
176
212
  }
177
213
 
178
214
  /** Truncate text to a single line, max `len` chars. */
179
215
  function truncateLine(text: string, len = 60): string {
180
- const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
216
+ const raw = text.split("\n").find(l => l.trim())?.trim() ?? "";
217
+ const line = stripControlChars(raw);
181
218
  if (line.length <= len) return line;
182
- return line.slice(0, len) + "…";
219
+ return safeTruncate(line, len) + "…";
183
220
  }
184
221
 
185
222
  /** Build a human-readable activity string from currently-running tools or response text. */
@@ -233,30 +270,35 @@ export class AgentWidget {
233
270
  private agentActivity: Map<string, AgentActivity>,
234
271
  /**
235
272
  * Read live at render time. Selects which agents the widget shows — see
236
- * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
237
- * extension supplies one defaulting to `"background"`.
273
+ * `WidgetMode`. Defaults to `"all"`; the extension supplies the same default.
238
274
  */
239
275
  private mode: () => WidgetMode = () => "all",
276
+ /**
277
+ * Read live at render time, like `mode`. Whether running agents show an
278
+ * estimated cost beside their token count. Defaults to off — the extension
279
+ * supplies the user's `showCost` setting.
280
+ */
281
+ private showCost: () => boolean = () => false,
282
+ /**
283
+ * Read live at render time, like `mode`. Whether running agents name the
284
+ * model driving them and the thinking level it is running at. Defaults to
285
+ * off — the extension supplies the user's `showModel` setting — because the
286
+ * row is already dense and the same pair is on the tool result and in the
287
+ * conversation viewer unconditionally.
288
+ */
289
+ private showModel: () => boolean = () => false,
240
290
  ) {}
241
291
 
242
292
  /**
243
293
  * Agents eligible for the widget, per the current `WidgetMode`:
244
294
  * - `off`: none (the widget's existing empty-state path hides it entirely).
245
- * - `background`: drop only agents *known* to be foreground
246
- * (`isBackground === false`); keep everything else — background, queued,
247
- * scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground`
248
- * record flag rather than the UI-only `invocation` snapshot (which only the
249
- * Agent-tool path sets), and excluding rather than allow-listing, means
250
- * only proven-foreground runs drop out — nothing else silently vanishes.
251
295
  * - `all`: every agent.
252
296
  */
253
297
  private widgetAgents() {
254
- const all = this.manager.listAgents();
255
- switch (this.mode()) {
256
- case "off": return [];
257
- case "background": return all.filter(a => a.isBackground !== false);
258
- default: return all;
259
- }
298
+ if (this.mode() === "off") return [];
299
+ // Nested children are reachable only through their parent's scoped tools;
300
+ // showing them here would offer rows nothing in this session can act on.
301
+ return this.manager.listAgents().filter((a) => !a.parentAgentId);
260
302
  }
261
303
 
262
304
  /** Set the UI context (grabbed from first tool execution). */
@@ -305,9 +347,14 @@ export class AgentWidget {
305
347
  }
306
348
  }
307
349
 
350
+ /** Clear the finished-linger latch when a record starts a new run (resume). */
351
+ markRunning(agentId: string) {
352
+ this.finishedTurnAge.delete(agentId);
353
+ }
354
+
308
355
  /** Render a finished agent line. */
309
- private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
310
- const name = getDisplayName(a.type);
356
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
357
+ const name = toSingleLine(getDisplayName(a.type));
311
358
  const modeLabel = getPromptModeLabel(a.type);
312
359
  const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
313
360
 
@@ -324,7 +371,8 @@ export class AgentWidget {
324
371
  statusText = theme.fg("dim", " stopped");
325
372
  } else if (a.status === "error") {
326
373
  icon = theme.fg("error", "✗");
327
- const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
374
+ const err = a.error ? toSingleLine(a.error) : "";
375
+ const errMsg = err ? `: ${safeTruncate(err, 60)}` : "";
328
376
  statusText = theme.fg("error", ` error${errMsg}`);
329
377
  } else {
330
378
  // aborted
@@ -336,10 +384,15 @@ export class AgentWidget {
336
384
  const activity = this.agentActivity.get(a.id);
337
385
  if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
338
386
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
387
+ // From the record, not the activity tracker: that entry is deleted the
388
+ // moment an agent finishes, and "what did it cost" is a question asked
389
+ // about finished agents.
390
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
391
+ if (costText) parts.push(costText);
339
392
  parts.push(duration);
340
393
 
341
394
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
342
- return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
395
+ return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", toSingleLine(a.description))} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
343
396
  }
344
397
 
345
398
  /**
@@ -377,28 +430,43 @@ export class AgentWidget {
377
430
 
378
431
  const runningLines: string[][] = []; // each entry is [header, activity]
379
432
  for (const a of running) {
380
- const name = getDisplayName(a.type);
433
+ const name = toSingleLine(getDisplayName(a.type));
381
434
  const modeLabel = getPromptModeLabel(a.type);
382
435
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
383
436
  const elapsed = formatMs(Date.now() - a.startedAt);
384
437
 
385
438
  const bg = this.agentActivity.get(a.id);
386
439
  const toolUses = bg?.toolUses ?? a.toolUses;
387
- const tokens = getLifetimeTotal(bg?.lifetimeUsage);
440
+ // Falls back to the record: only Agent-tool spawns get an activity entry,
441
+ // so a scheduled or RPC-started agent would otherwise render with no
442
+ // stats at all — and, with the setting on, no cost.
443
+ const usage = bg?.lifetimeUsage ?? a.lifetimeUsage;
444
+ const tokens = getLifetimeTotal(usage);
388
445
  const contextPercent = getSessionContextPercent(bg?.session);
389
446
  const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
447
+ const costText = this.showCost() ? formatCost(getLifetimeCost(usage)) : "";
390
448
 
391
449
  const parts: string[] = [];
450
+ if (this.showModel()) {
451
+ // Leading, and paired: a thinking level means nothing without the model
452
+ // it applies to. The tag is taken from buildInvocationTags rather than
453
+ // rebuilt so the "(asked X)" annotation survives.
454
+ const { modelName, tags } = buildInvocationTags(a.invocation);
455
+ if (modelName) parts.push(modelName);
456
+ const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
457
+ if (thinkingTag) parts.push(thinkingTag);
458
+ }
392
459
  if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
393
460
  if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
394
461
  if (tokenText) parts.push(tokenText);
462
+ if (costText) parts.push(costText);
395
463
  parts.push(elapsed);
396
464
  const statsText = parts.join(" · ");
397
465
 
398
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
466
+ const activity = toSingleLine(bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…");
399
467
 
400
468
  runningLines.push([
401
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
469
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", toSingleLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
402
470
  truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
403
471
  ]);
404
472
  }
@@ -8,10 +8,11 @@
8
8
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
9
  import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
10
  import { extractText } from "../context.js";
11
+ import { safeTruncate, stripControlChars, toSingleLine } from "../text-safety.js";
11
12
  import type { AgentRecord } from "../types.js";
12
- import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
14
  import type { Theme } from "./agent-widget.js";
14
- import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
+ import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
16
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
16
17
 
17
18
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
@@ -45,6 +46,12 @@ export class ConversationViewer implements Component {
45
46
  keybindings?: ViewerKeybindings,
46
47
  /** Send a steering message to the agent. Omitted → no compose affordance. */
47
48
  private onSteer?: (message: string) => void,
49
+ /**
50
+ * Whether the header shows an estimated cost after the token count. Read
51
+ * once, at construction: the overlay is opened from a menu or the fleet
52
+ * list, so the setting cannot change while it is on screen.
53
+ */
54
+ private showCost = false,
48
55
  ) {
49
56
  this.keys = createViewerKeys(keybindings);
50
57
  this.unsubscribe = session.subscribe(() => {
@@ -137,7 +144,7 @@ export class ConversationViewer implements Component {
137
144
 
138
145
  // Header
139
146
  lines.push(hrTop);
140
- const name = getDisplayName(this.record.type);
147
+ const name = toSingleLine(getDisplayName(this.record.type));
141
148
  const modeLabel = getPromptModeLabel(this.record.type);
142
149
  const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
143
150
  const statusIcon = this.record.status === "running"
@@ -152,14 +159,19 @@ export class ConversationViewer implements Component {
152
159
  const headerParts: string[] = [duration];
153
160
  const toolUses = this.activity?.toolUses ?? this.record.toolUses;
154
161
  if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
155
- const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
162
+ // The viewer opens on finished agents too, whose activity entry is long
163
+ // gone — the record is the only place their totals survive.
164
+ const usage = this.activity?.lifetimeUsage ?? this.record.lifetimeUsage;
165
+ const tokens = getLifetimeTotal(usage);
156
166
  if (tokens > 0) {
157
167
  const percent = getSessionContextPercent(this.activity?.session);
158
168
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
159
169
  }
170
+ const cost = this.showCost ? formatCost(getLifetimeCost(usage)) : "";
171
+ if (cost) headerParts.push(cost);
160
172
 
161
173
  lines.push(row(
162
- `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
174
+ `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", toSingleLine(this.record.description))} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
163
175
  ));
164
176
  const invocationLine = this.invocationLine();
165
177
  if (invocationLine) lines.push(row(invocationLine));
@@ -274,8 +286,13 @@ export class ConversationViewer implements Component {
274
286
  }
275
287
 
276
288
  private invocationLine(): string | undefined {
277
- const { modelName, tags } = buildInvocationTags(this.record.invocation);
278
- const parts = modelName ? [modelName, ...tags] : tags;
289
+ // Canonical id here, short label everywhere else: this overlay is opened to
290
+ // inspect one agent and has the width for it, and two providers can serve
291
+ // models whose short names read alike.
292
+ const { modelName, modelId, tags } = buildInvocationTags(this.record.invocation);
293
+ const sanitizedTags = tags.map(t => toSingleLine(t));
294
+ const model = modelId ?? modelName;
295
+ const parts = model ? [toSingleLine(model), ...sanitizedTags] : sanitizedTags;
279
296
  if (parts.length === 0) return undefined;
280
297
  return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
281
298
  }
@@ -295,9 +312,9 @@ export class ConversationViewer implements Component {
295
312
  let needsSeparator = false;
296
313
  for (const msg of messages) {
297
314
  if (msg.role === "user") {
298
- const text = typeof msg.content === "string"
315
+ const text = stripControlChars(typeof msg.content === "string"
299
316
  ? msg.content
300
- : extractText(msg.content);
317
+ : extractText(msg.content));
301
318
  if (!text.trim()) continue;
302
319
  if (needsSeparator) lines.push(th.fg("dim", "───"));
303
320
  lines.push(th.fg("accent", "[User]"));
@@ -308,9 +325,9 @@ export class ConversationViewer implements Component {
308
325
  const textParts: string[] = [];
309
326
  const toolCalls: string[] = [];
310
327
  for (const c of msg.content) {
311
- if (c.type === "text" && c.text) textParts.push(c.text);
328
+ if (c.type === "text" && c.text) textParts.push(stripControlChars(c.text));
312
329
  else if (c.type === "toolCall") {
313
- toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
330
+ toolCalls.push(toSingleLine((c as any).name ?? (c as any).toolName) || "unknown");
314
331
  }
315
332
  }
316
333
  if (needsSeparator) lines.push(th.fg("dim", "───"));
@@ -324,8 +341,8 @@ export class ConversationViewer implements Component {
324
341
  lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
325
342
  }
326
343
  } else if (msg.role === "toolResult") {
327
- const text = extractText(msg.content);
328
- const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
344
+ const text = stripControlChars(extractText(msg.content));
345
+ const truncated = text.length > 500 ? safeTruncate(text, 500) + "... (truncated)" : text;
329
346
  if (!truncated.trim()) continue;
330
347
  if (needsSeparator) lines.push(th.fg("dim", "───"));
331
348
  lines.push(th.fg("dim", "[Result]"));
@@ -335,11 +352,12 @@ export class ConversationViewer implements Component {
335
352
  } else if ((msg as any).role === "bashExecution") {
336
353
  const bash = msg as any;
337
354
  if (needsSeparator) lines.push(th.fg("dim", "───"));
338
- lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
355
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${toSingleLine(bash.command)}`), width));
339
356
  if (bash.output?.trim()) {
340
- const out = bash.output.length > 500
341
- ? bash.output.slice(0, 500) + "... (truncated)"
342
- : bash.output;
357
+ const rawOut = stripControlChars(bash.output);
358
+ const out = rawOut.length > 500
359
+ ? safeTruncate(rawOut, 500) + "... (truncated)"
360
+ : rawOut;
343
361
  for (const line of wrapTextWithAnsi(out.trim(), width)) {
344
362
  lines.push(th.fg("dim", line));
345
363
  }
@@ -352,7 +370,7 @@ export class ConversationViewer implements Component {
352
370
 
353
371
  // Streaming indicator for running agents
354
372
  if (this.record.status === "running" && this.activity) {
355
- const act = describeActivity(this.activity.activeTools, this.activity.responseText);
373
+ const act = toSingleLine(describeActivity(this.activity.activeTools, this.activity.responseText));
356
374
  lines.push("");
357
375
  lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
358
376
  }