@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/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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' | '
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
/**
|
|
121
|
-
* Initialized to 1 at spawn
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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"
|
|
218
|
+
/** Short display name for tight rows, e.g. "haiku 4.5". Always set once known. */
|
|
174
219
|
modelName?: string;
|
|
175
|
-
|
|
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
|
}
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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: "
|
|
75
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
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
|
}
|