pi-subagents-lite 1.0.2 → 1.2.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/README.md +47 -5
- package/package.json +1 -1
- package/src/agent-manager.ts +88 -62
- package/src/agent-runner.ts +194 -167
- package/src/agent-types.ts +21 -1
- package/src/config-io.ts +9 -1
- package/src/config-mutator.ts +183 -0
- package/src/context.ts +1 -1
- package/src/format.ts +173 -0
- package/src/index.ts +127 -177
- package/src/menus.ts +586 -137
- package/src/model-precedence.ts +5 -0
- package/src/output-file.ts +1 -68
- package/src/renderer.ts +163 -0
- package/src/result-viewer.ts +2 -1
- package/src/state.ts +83 -0
- package/src/tool-execution.ts +179 -56
- package/src/types.ts +104 -31
- package/src/ui/agent-widget.ts +159 -146
- package/src/usage.ts +5 -0
- package/src/worktree-validator.ts +199 -0
- package/src/stop-agent-tool.ts +0 -77
package/src/ui/agent-widget.ts
CHANGED
|
@@ -4,20 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
import type { AgentManager } from "../agent-manager.js";
|
|
7
|
-
import {
|
|
8
|
-
import type { AgentRecord, SubagentType } from "../types.js";
|
|
7
|
+
import type { AgentRecord, Theme } from "../types.js";
|
|
9
8
|
import {
|
|
10
|
-
|
|
9
|
+
formatCost,
|
|
11
10
|
getLifetimeTotal,
|
|
12
11
|
getSessionContextPercent,
|
|
13
12
|
type LifetimeUsage,
|
|
14
13
|
type SessionLike,
|
|
15
14
|
} from "../usage.js";
|
|
15
|
+
import { formatMs, buildStatsParts, getDisplayName } from "../format.js";
|
|
16
|
+
|
|
17
|
+
// Re-export Theme so existing consumers (model-selector, result-viewer) don't break
|
|
18
|
+
export type { Theme } from "../types.js";
|
|
16
19
|
|
|
17
20
|
// ---- Constants ----
|
|
18
21
|
|
|
19
22
|
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
20
|
-
const
|
|
23
|
+
const DEFAULT_MAX_WIDGET_LINES = 12;
|
|
21
24
|
|
|
22
25
|
/** Braille spinner frames for animated running indicator. */
|
|
23
26
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -58,11 +61,6 @@ const TOOL_DISPLAY: Record<string, string> = {
|
|
|
58
61
|
|
|
59
62
|
// ---- Types ----
|
|
60
63
|
|
|
61
|
-
export type Theme = {
|
|
62
|
-
fg(color: string, text: string): string;
|
|
63
|
-
bold(text: string): string;
|
|
64
|
-
italic?: (text: string) => string;
|
|
65
|
-
};
|
|
66
64
|
|
|
67
65
|
export type UICtx = {
|
|
68
66
|
setStatus(key: string, text: string | undefined): void;
|
|
@@ -99,95 +97,10 @@ export interface AgentActivity {
|
|
|
99
97
|
lifetimeUsage: LifetimeUsage;
|
|
100
98
|
}
|
|
101
99
|
|
|
100
|
+
// ---- Re-exports from format.ts (backward compatibility) ----
|
|
101
|
+
export { formatMs, buildStatsParts, getDisplayName } from "../format.js";
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
// ---- Formatting helpers ----
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Token count with optional context-fill % and compaction-count annotations.
|
|
108
|
-
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
109
|
-
* Compaction count rendered as `↻ N` in dim.
|
|
110
|
-
*
|
|
111
|
-
* "12.3k" — no annotations
|
|
112
|
-
* "12.3k(45%)" — percent only
|
|
113
|
-
* "12.3k(↻ 2)" — compactions only (e.g. right after compact)
|
|
114
|
-
* "12.3k(45%·↻ 2)" — both
|
|
115
|
-
*/
|
|
116
|
-
function formatSessionTokens(
|
|
117
|
-
tokens: number,
|
|
118
|
-
percent: number | null,
|
|
119
|
-
theme: Theme,
|
|
120
|
-
compactions = 0,
|
|
121
|
-
): string {
|
|
122
|
-
const tokenStr = formatTokens(tokens);
|
|
123
|
-
const annot: string[] = [];
|
|
124
|
-
if (percent !== null) {
|
|
125
|
-
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
|
|
126
|
-
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
127
|
-
}
|
|
128
|
-
if (compactions > 0) {
|
|
129
|
-
annot.push(theme.fg("dim", `↻ ${compactions}`));
|
|
130
|
-
}
|
|
131
|
-
if (annot.length === 0) return tokenStr;
|
|
132
|
-
// Include closing paren in the last annotation's color span to prevent
|
|
133
|
-
// ANSI reset from leaving `)` in default color when wrapped in outer dim.
|
|
134
|
-
const lastIdx = annot.length - 1;
|
|
135
|
-
annot[lastIdx] += ")";
|
|
136
|
-
return `${tokenStr}(${annot.join("·")}`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
|
|
140
|
-
function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
141
|
-
return maxTurns != null ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Format milliseconds as a compact human-readable duration: "1h 1m 1s", "5m 37s", "10s", "<1s". */
|
|
145
|
-
export function formatMs(ms: number): string {
|
|
146
|
-
if (!Number.isFinite(ms) || ms < 1000) return "<1s";
|
|
147
|
-
|
|
148
|
-
const totalSeconds = Math.floor(ms / 1000);
|
|
149
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
150
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
151
|
-
const seconds = totalSeconds % 60;
|
|
152
|
-
|
|
153
|
-
const parts: string[] = [];
|
|
154
|
-
if (hours > 0) parts.push(`${hours}h`);
|
|
155
|
-
if (minutes > 0) parts.push(`${minutes}m`);
|
|
156
|
-
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
|
157
|
-
|
|
158
|
-
return parts.join(" ");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Build common stats parts: toolUses · turns · tokens with context %.
|
|
163
|
-
* Shared by AgentWidget and index.ts for consistent stats display.
|
|
164
|
-
*/
|
|
165
|
-
export function buildStatsParts(
|
|
166
|
-
args: {
|
|
167
|
-
toolUses: number;
|
|
168
|
-
turnCount?: number;
|
|
169
|
-
maxTurns?: number;
|
|
170
|
-
tokens: number;
|
|
171
|
-
contextPercent: number | null;
|
|
172
|
-
compactions: number;
|
|
173
|
-
},
|
|
174
|
-
theme: Theme,
|
|
175
|
-
): string[] {
|
|
176
|
-
const parts: string[] = [];
|
|
177
|
-
if (args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
|
|
178
|
-
if (args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns));
|
|
179
|
-
if (args.tokens > 0) {
|
|
180
|
-
parts.push(formatSessionTokens(
|
|
181
|
-
args.tokens, args.contextPercent, theme, args.compactions,
|
|
182
|
-
));
|
|
183
|
-
}
|
|
184
|
-
return parts;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Get display name for any agent type (built-in or custom). */
|
|
188
|
-
export function getDisplayName(type: SubagentType): string {
|
|
189
|
-
return getConfig(type).displayName;
|
|
190
|
-
}
|
|
103
|
+
// ---- Widget-internal helpers ----
|
|
191
104
|
|
|
192
105
|
/**
|
|
193
106
|
* Wrap a stats line in dim ANSI codes, re-applying dim after any inner
|
|
@@ -245,6 +158,8 @@ export class AgentWidget {
|
|
|
245
158
|
/** Finished agents: agent ID → turns since finished. */
|
|
246
159
|
private finishedTurnAge = new Map<string, number>();
|
|
247
160
|
|
|
161
|
+
/** Whether to show cost in stats and status bar. */
|
|
162
|
+
private showCost = false;
|
|
248
163
|
|
|
249
164
|
/** Whether the widget callback is currently registered with the TUI. */
|
|
250
165
|
private widgetRegistered = false;
|
|
@@ -252,12 +167,67 @@ export class AgentWidget {
|
|
|
252
167
|
private tui: TUI | undefined;
|
|
253
168
|
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
254
169
|
private lastStatusText: string | undefined;
|
|
170
|
+
/** Pending tool expansion state from onTerminalInput (push-based, no polling). */
|
|
171
|
+
private pendingToolsExpanded: boolean | undefined;
|
|
172
|
+
|
|
173
|
+
/** Whether to use compact mode (1-line per agent). */
|
|
174
|
+
private compactMode = false;
|
|
175
|
+
|
|
176
|
+
/** Whether "force compact" mode is ON — overrides ctrl+o shortcut. */
|
|
177
|
+
private forceCompact = false;
|
|
178
|
+
|
|
179
|
+
/** Whether ctrl+o shortcut is enabled (syncs compact with toolsExpanded). */
|
|
180
|
+
private widgetShortcut = false;
|
|
181
|
+
|
|
182
|
+
/** Maximum lines for full mode. */
|
|
183
|
+
private maxLines = DEFAULT_MAX_WIDGET_LINES;
|
|
184
|
+
|
|
185
|
+
/** Maximum lines for compact mode. */
|
|
186
|
+
private maxLinesCompact = Math.floor(DEFAULT_MAX_WIDGET_LINES / 2);
|
|
255
187
|
|
|
256
188
|
constructor(
|
|
257
189
|
private manager: AgentManager,
|
|
258
190
|
private agentActivity: Map<string, AgentActivity>,
|
|
259
191
|
) {}
|
|
260
192
|
|
|
193
|
+
/** Set whether to show cost in stats and status bar. */
|
|
194
|
+
setShowCost(enabled: boolean) {
|
|
195
|
+
this.showCost = enabled;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Set compact mode (internal, for sync from ctrl+o). */
|
|
199
|
+
setCompactMode(enabled: boolean) {
|
|
200
|
+
if (this.compactMode === enabled) return;
|
|
201
|
+
this.compactMode = enabled;
|
|
202
|
+
this.update();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Set force compact mode — overrides ctrl+o shortcut. */
|
|
206
|
+
setForceCompact(enabled: boolean) {
|
|
207
|
+
this.forceCompact = enabled;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Set whether ctrl+o shortcut is enabled. */
|
|
211
|
+
setWidgetShortcut(enabled: boolean) {
|
|
212
|
+
this.widgetShortcut = enabled;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Notify widget that tool expansion state changed (push-based, no polling). */
|
|
216
|
+
notifyToolsExpansionChanged(expanded: boolean) {
|
|
217
|
+
this.pendingToolsExpanded = expanded;
|
|
218
|
+
this.update();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Set max lines for full mode. */
|
|
222
|
+
setMaxLines(lines: number) {
|
|
223
|
+
this.maxLines = lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Set max lines for compact mode. */
|
|
227
|
+
setMaxLinesCompact(lines: number) {
|
|
228
|
+
this.maxLinesCompact = lines;
|
|
229
|
+
}
|
|
230
|
+
|
|
261
231
|
/** Set the UI context (grabbed from first tool execution). */
|
|
262
232
|
setUICtx(ctx: UICtx) {
|
|
263
233
|
if (ctx !== this.uiCtx) {
|
|
@@ -297,9 +267,9 @@ export class AgentWidget {
|
|
|
297
267
|
const queued: AgentRecord[] = [];
|
|
298
268
|
const finished: AgentRecord[] = [];
|
|
299
269
|
for (const a of allAgents) {
|
|
300
|
-
if (a.status === "running") running.push(a);
|
|
301
|
-
else if (a.status === "queued") queued.push(a);
|
|
302
|
-
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) finished.push(a);
|
|
270
|
+
if (a.lifecycle.status === "running") running.push(a);
|
|
271
|
+
else if (a.lifecycle.status === "queued") queued.push(a);
|
|
272
|
+
else if (a.lifecycle.completedAt && this.shouldShowFinished(a.id, a.lifecycle.status)) finished.push(a);
|
|
303
273
|
}
|
|
304
274
|
return { running, queued, finished };
|
|
305
275
|
}
|
|
@@ -342,47 +312,44 @@ export class AgentWidget {
|
|
|
342
312
|
}
|
|
343
313
|
|
|
344
314
|
/** Render a finished agent line. */
|
|
345
|
-
private renderFinishedLine(a: {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
turnCount?: number; maxTurns?: number; session?: SessionLike;
|
|
350
|
-
outputFile?: string;
|
|
351
|
-
}, theme: Theme): string {
|
|
352
|
-
const name = getDisplayName(a.type);
|
|
353
|
-
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
|
|
354
|
-
const { icon, statusText } = this.finishedIconAndStatus(a.status, a.error, theme);
|
|
315
|
+
private renderFinishedLine(a: AgentRecord, theme: Theme): string {
|
|
316
|
+
const name = getDisplayName(a.display.type);
|
|
317
|
+
const duration = formatMs((a.lifecycle.completedAt ?? Date.now()) - a.lifecycle.startedAt);
|
|
318
|
+
const { icon, statusText } = this.finishedIconAndStatus(a.lifecycle.status, a.error, theme);
|
|
355
319
|
|
|
356
320
|
const activity = this.agentActivity.get(a.id);
|
|
321
|
+
const usage = activity?.lifetimeUsage ?? a.stats.lifetimeUsage;
|
|
357
322
|
const statsParts = buildStatsParts({
|
|
358
|
-
toolUses: a.toolUses,
|
|
359
|
-
turnCount: activity?.turnCount ?? a.turnCount,
|
|
360
|
-
maxTurns: activity?.maxTurns ?? a.maxTurns,
|
|
361
|
-
tokens: getLifetimeTotal(
|
|
362
|
-
contextPercent: getSessionContextPercent(activity
|
|
363
|
-
compactions: a.compactionCount,
|
|
323
|
+
toolUses: a.stats.toolUses,
|
|
324
|
+
turnCount: activity?.turnCount ?? a.stats.turnCount,
|
|
325
|
+
maxTurns: activity?.maxTurns ?? a.stats.maxTurns,
|
|
326
|
+
tokens: getLifetimeTotal(usage),
|
|
327
|
+
contextPercent: activity?.session ? getSessionContextPercent(activity.session) : a.stats.contextPercent ?? null,
|
|
328
|
+
compactions: a.stats.compactionCount,
|
|
329
|
+
cost: this.showCost ? usage.cost : undefined,
|
|
364
330
|
}, theme);
|
|
365
331
|
statsParts.push(duration);
|
|
366
332
|
|
|
367
333
|
const statsLine = statsParts.join("·");
|
|
368
|
-
return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
|
|
334
|
+
return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.display.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
|
|
369
335
|
}
|
|
370
336
|
|
|
371
|
-
/** Build the stats line (toolUses · turns · tokens · elapsed) for a running agent. */
|
|
337
|
+
/** Build the stats line (toolUses · turns · tokens · cost · elapsed) for a running agent. */
|
|
372
338
|
private buildStatsLine(
|
|
373
|
-
agent:
|
|
339
|
+
agent: AgentRecord,
|
|
374
340
|
activity: AgentActivity | undefined,
|
|
375
341
|
theme: Theme,
|
|
376
342
|
): string {
|
|
377
343
|
const parts = buildStatsParts({
|
|
378
|
-
toolUses: activity?.toolUses ?? agent.toolUses,
|
|
344
|
+
toolUses: activity?.toolUses ?? agent.stats.toolUses,
|
|
379
345
|
turnCount: activity?.turnCount,
|
|
380
346
|
maxTurns: activity?.maxTurns,
|
|
381
347
|
tokens: getLifetimeTotal(activity?.lifetimeUsage),
|
|
382
348
|
contextPercent: getSessionContextPercent(activity?.session),
|
|
383
|
-
compactions: agent.compactionCount,
|
|
349
|
+
compactions: agent.stats.compactionCount,
|
|
350
|
+
cost: this.showCost ? activity?.lifetimeUsage?.cost : undefined,
|
|
384
351
|
}, theme);
|
|
385
|
-
parts.push(formatMs(Date.now() - agent.startedAt));
|
|
352
|
+
parts.push(formatMs(Date.now() - agent.lifecycle.startedAt));
|
|
386
353
|
return parts.join("·");
|
|
387
354
|
}
|
|
388
355
|
|
|
@@ -395,11 +362,18 @@ export class AgentWidget {
|
|
|
395
362
|
const truncate = (line: string) => truncateToWidth(line, w);
|
|
396
363
|
const blocks: RenderBlock[] = [];
|
|
397
364
|
for (const a of finished) {
|
|
365
|
+
const continuations: string[] = [];
|
|
366
|
+
if (!this.isCompact()) {
|
|
367
|
+
if (a.display.outputFile || a.display.worktreeLabel) {
|
|
368
|
+
const parts: string[] = [];
|
|
369
|
+
if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
|
|
370
|
+
if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
|
|
371
|
+
continuations.push(truncate(theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
398
374
|
blocks.push({
|
|
399
375
|
header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
|
|
400
|
-
continuations
|
|
401
|
-
? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
|
|
402
|
-
: [],
|
|
376
|
+
continuations,
|
|
403
377
|
});
|
|
404
378
|
}
|
|
405
379
|
return blocks;
|
|
@@ -415,21 +389,35 @@ export class AgentWidget {
|
|
|
415
389
|
const truncate = (line: string) => truncateToWidth(line, w);
|
|
416
390
|
const blocks: RenderBlock[] = [];
|
|
417
391
|
for (const a of running) {
|
|
418
|
-
const name = getDisplayName(a.type);
|
|
392
|
+
const name = getDisplayName(a.display.type);
|
|
419
393
|
const bg = this.agentActivity.get(a.id);
|
|
420
394
|
const statsLine = this.buildStatsLine(a, bg, theme);
|
|
421
395
|
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
|
|
422
396
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
397
|
+
if (this.isCompact()) {
|
|
398
|
+
// Compact: single line with activity inline, truncated description
|
|
399
|
+
const desc = a.display.description.length > 30 ? a.display.description.slice(0, 27) + "..." : a.display.description;
|
|
400
|
+
const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${desc} ${statsLine} ${theme.fg("dim", activity)}`;
|
|
401
|
+
blocks.push({
|
|
402
|
+
header: truncate(headerLine),
|
|
403
|
+
continuations: [],
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
// Full: header + continuation lines
|
|
407
|
+
const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.display.description} ${statsLine}`;
|
|
408
|
+
const continuations: string[] = [];
|
|
409
|
+
if (a.display.outputFile || a.display.worktreeLabel) {
|
|
410
|
+
const parts: string[] = [];
|
|
411
|
+
if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
|
|
412
|
+
if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
|
|
413
|
+
continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
|
|
414
|
+
}
|
|
415
|
+
continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)));
|
|
416
|
+
blocks.push({
|
|
417
|
+
header: truncate(headerLine),
|
|
418
|
+
continuations,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
433
421
|
}
|
|
434
422
|
return blocks;
|
|
435
423
|
}
|
|
@@ -455,6 +443,11 @@ export class AgentWidget {
|
|
|
455
443
|
* connectors in a single pass. Last visible block gets CORNER + spaces, all others
|
|
456
444
|
* keep BRANCH + VLINE.
|
|
457
445
|
*/
|
|
446
|
+
/** Whether the widget should render in compact mode. */
|
|
447
|
+
private isCompact(): boolean {
|
|
448
|
+
return this.forceCompact || (this.widgetShortcut && this.compactMode);
|
|
449
|
+
}
|
|
450
|
+
|
|
458
451
|
private renderWidget(tui: TUI, theme: Theme): string[] {
|
|
459
452
|
const { running, queued, finished } = this.categorizeAgents();
|
|
460
453
|
|
|
@@ -485,7 +478,8 @@ export class AgentWidget {
|
|
|
485
478
|
|
|
486
479
|
// ---- Overflow logic (works with blocks, not lines) ----
|
|
487
480
|
|
|
488
|
-
const
|
|
481
|
+
const maxBodyLines = this.isCompact() ? this.maxLinesCompact : this.maxLines;
|
|
482
|
+
const maxBody = maxBodyLines - 1; // heading takes 1 line
|
|
489
483
|
const totalBody = blocks.reduce((sum, b) => sum + 1 + b.continuations.length, 0);
|
|
490
484
|
|
|
491
485
|
const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`;
|
|
@@ -601,21 +595,40 @@ export class AgentWidget {
|
|
|
601
595
|
}
|
|
602
596
|
|
|
603
597
|
/** Update the status bar text, only if it changed. */
|
|
604
|
-
private updateStatusBar(runningCount: number, queuedCount: number) {
|
|
605
|
-
const statusParts: string[] = [];
|
|
606
|
-
if (runningCount > 0) statusParts.push(`${runningCount} running`);
|
|
607
|
-
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
|
|
598
|
+
private updateStatusBar(runningCount: number, queuedCount: number, running: AgentRecord[]) {
|
|
608
599
|
const total = runningCount + queuedCount;
|
|
609
|
-
|
|
610
|
-
if (
|
|
611
|
-
this.
|
|
612
|
-
|
|
600
|
+
let statusText = total > 0 ? `${total} agent${total === 1 ? "" : "s"}` : `agents`;
|
|
601
|
+
if (this.showCost) {
|
|
602
|
+
const sessionCost = this.manager.getTotalAgentCost();
|
|
603
|
+
// Also include in-flight running agents (not yet completed, so not in accumulator)
|
|
604
|
+
const runningCost = running.reduce((sum, a) => sum + a.stats.lifetimeUsage.cost, 0);
|
|
605
|
+
const totalCost = sessionCost + runningCost;
|
|
606
|
+
if (totalCost > 0) statusText += `: ${formatCost(totalCost)}`;
|
|
607
|
+
}
|
|
608
|
+
if (statusText !== this.lastStatusText) {
|
|
609
|
+
this.uiCtx?.setStatus(STATUS_KEY, statusText);
|
|
610
|
+
this.lastStatusText = statusText;
|
|
613
611
|
}
|
|
614
612
|
}
|
|
615
613
|
|
|
616
614
|
/** Force an immediate widget update. */
|
|
617
615
|
update() {
|
|
616
|
+
if (!this.manager) {
|
|
617
|
+
// Widget lost its manager reference (e.g., after session shutdown)
|
|
618
|
+
clearInterval(this.widgetInterval);
|
|
619
|
+
this.widgetInterval = undefined;
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
618
622
|
if (!this.uiCtx) return;
|
|
623
|
+
|
|
624
|
+
// Sync compact mode with tool expansion state (ctrl+o)
|
|
625
|
+
// Tools expanded → widget full, tools collapsed → widget compact
|
|
626
|
+
// Note: sync is triggered by onTerminalInput detecting ctrl+o, not polling
|
|
627
|
+
if (this.widgetShortcut && !this.forceCompact && this.pendingToolsExpanded !== undefined) {
|
|
628
|
+
this.compactMode = !this.pendingToolsExpanded;
|
|
629
|
+
this.pendingToolsExpanded = undefined;
|
|
630
|
+
}
|
|
631
|
+
|
|
619
632
|
const { running, queued, finished } = this.categorizeAgents();
|
|
620
633
|
|
|
621
634
|
const hasActive = running.length > 0 || queued.length > 0;
|
|
@@ -628,7 +641,7 @@ export class AgentWidget {
|
|
|
628
641
|
}
|
|
629
642
|
|
|
630
643
|
// Status bar — only call setStatus when the text actually changes
|
|
631
|
-
this.updateStatusBar(running.length, queued.length);
|
|
644
|
+
this.updateStatusBar(running.length, queued.length, running);
|
|
632
645
|
|
|
633
646
|
this.widgetFrame++;
|
|
634
647
|
|
package/src/usage.ts
CHANGED
|
@@ -36,6 +36,11 @@ export function formatTokens(count: number): string {
|
|
|
36
36
|
return `${count}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Format cost as a dollar amount: "$0.00", "$0.01", "$1.23". */
|
|
40
|
+
export function formatCost(cost: number): string {
|
|
41
|
+
return `$${cost.toFixed(2)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Context-window utilization (0–100), or null when unavailable
|
|
41
46
|
* (no model contextWindow, or post-compaction before the next response).
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-validator.ts — Validate, resolve, and label a worktree path.
|
|
3
|
+
*
|
|
4
|
+
* Pure async functions that validate a `worktree_path` value against the parent's
|
|
5
|
+
* git repository. Depends on `pi.exec` for git commands.
|
|
6
|
+
*
|
|
7
|
+
* Validation strategy: compare `git-common-dir` of the parent and target paths.
|
|
8
|
+
* If they share the same common dir, the target is a worktree of the parent's repo.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { existsSync, statSync, realpathSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
/** Timeout for git commands (ms). */
|
|
15
|
+
const GIT_EXEC_TIMEOUT_MS = 5000;
|
|
16
|
+
|
|
17
|
+
/** Specific error messages returned to the LLM for self-correction. */
|
|
18
|
+
export const WORKTREE_VALIDATION_ERRORS = {
|
|
19
|
+
PATH_DOES_NOT_EXIST: "worktree_path does not exist: the specified path was not found on disk",
|
|
20
|
+
NOT_A_DIRECTORY: "worktree_path is not a directory: the specified path exists but is not a directory",
|
|
21
|
+
PARENT_NOT_IN_GIT_REPO: "worktree_path validation failed: the parent session is not inside a git repository",
|
|
22
|
+
NOT_IN_GIT_REPO: "worktree_path is not inside a git repository",
|
|
23
|
+
DIFFERENT_REPO: "worktree_path is not a worktree of the parent's repository",
|
|
24
|
+
GIT_NOT_FOUND: "worktree_path validation failed: git executable not found on this host",
|
|
25
|
+
GIT_TIMEOUT: "worktree_path validation failed: git command timed out",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Successful validation result. */
|
|
29
|
+
export interface WorktreeValidationSuccess {
|
|
30
|
+
ok: true;
|
|
31
|
+
/** Resolved absolute path (symlinks followed, relative resolved). Undefined when path is empty/omitted. */
|
|
32
|
+
resolvedPath?: string;
|
|
33
|
+
/** Worktree root directory. */
|
|
34
|
+
worktreeRoot?: string;
|
|
35
|
+
/** Short display label for the widget. */
|
|
36
|
+
label?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Failed validation result. */
|
|
40
|
+
export interface WorktreeValidationFailure {
|
|
41
|
+
ok: false;
|
|
42
|
+
/** Human-readable error describing the specific failure reason. */
|
|
43
|
+
error: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type WorktreeValidationResult = WorktreeValidationSuccess | WorktreeValidationFailure;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Minimal interface for the pi exec function — only what the validator needs.
|
|
50
|
+
*/
|
|
51
|
+
interface PiExec {
|
|
52
|
+
exec(cmd: string, args: string[], opts?: { cwd?: string; timeout?: number }): Promise<{ code: number; stdout: string; stderr: string }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run `git rev-parse --git-common-dir` and return the trimmed result.
|
|
57
|
+
* Returns a failure result if the command fails or git is unavailable.
|
|
58
|
+
*/
|
|
59
|
+
async function getGitCommonDir(
|
|
60
|
+
pi: PiExec,
|
|
61
|
+
cwd: string,
|
|
62
|
+
notInRepoError: string,
|
|
63
|
+
): Promise<{ ok: true; commonDir: string } | { ok: false; error: string }> {
|
|
64
|
+
try {
|
|
65
|
+
const result = await pi.exec("git", ["rev-parse", "--git-common-dir"], { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
|
|
66
|
+
if (result.code !== 0) return { ok: false, error: notInRepoError };
|
|
67
|
+
const commonDir = result.stdout.trim();
|
|
68
|
+
if (!commonDir) return { ok: false, error: notInRepoError };
|
|
69
|
+
return { ok: true, commonDir };
|
|
70
|
+
} catch (err: unknown) {
|
|
71
|
+
const msg = String(err instanceof Error ? err.message : err);
|
|
72
|
+
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
73
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_NOT_FOUND };
|
|
74
|
+
}
|
|
75
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a worktree path against the parent's git repository.
|
|
81
|
+
*
|
|
82
|
+
* Resolution order:
|
|
83
|
+
* 1. Empty/whitespace → treated as omitted (return ok with no path)
|
|
84
|
+
* 2. Resolve relative against parent cwd
|
|
85
|
+
* 3. Resolve symlinks (realpath)
|
|
86
|
+
* 4. Check exists + is directory
|
|
87
|
+
* 5. Get and compare git-common-dir for parent and target
|
|
88
|
+
* 6. Get worktree root via --show-toplevel
|
|
89
|
+
* 7. Normalize and compute display label
|
|
90
|
+
*
|
|
91
|
+
* @param pi - Minimal exec interface (pi.exec)
|
|
92
|
+
* @param worktreePath - The raw worktree_path value from the LLM
|
|
93
|
+
* @param parentCwd - The parent session's working directory
|
|
94
|
+
* @returns Validation result with resolved path + label, or error
|
|
95
|
+
*/
|
|
96
|
+
export async function validateWorktreePath(
|
|
97
|
+
pi: PiExec,
|
|
98
|
+
worktreePath: string,
|
|
99
|
+
parentCwd: string,
|
|
100
|
+
): Promise<WorktreeValidationResult> {
|
|
101
|
+
// Step 1: Empty / whitespace → treat as omitted
|
|
102
|
+
if (!worktreePath || worktreePath.trim() === "") {
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Step 2: Resolve relative paths against parent cwd
|
|
107
|
+
const resolved = path.isAbsolute(worktreePath)
|
|
108
|
+
? worktreePath
|
|
109
|
+
: path.resolve(parentCwd, worktreePath);
|
|
110
|
+
|
|
111
|
+
// Step 3: Check existence
|
|
112
|
+
if (!existsSync(resolved)) {
|
|
113
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.PATH_DOES_NOT_EXIST };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 4: Check is directory (resolve symlinks first via stat)
|
|
117
|
+
let realPath: string;
|
|
118
|
+
try {
|
|
119
|
+
const stat = statSync(resolved);
|
|
120
|
+
if (!stat.isDirectory()) {
|
|
121
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.NOT_A_DIRECTORY };
|
|
122
|
+
}
|
|
123
|
+
// Resolve symlinks — use realpathSync to get the canonical path
|
|
124
|
+
realPath = realpathSync(resolved);
|
|
125
|
+
} catch {
|
|
126
|
+
// stat failed — likely a broken symlink or permission issue
|
|
127
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.PATH_DOES_NOT_EXIST };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Step 5: Get and compare git-common-dir for parent and target
|
|
131
|
+
const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO);
|
|
132
|
+
if (!parentResult.ok) return parentResult;
|
|
133
|
+
|
|
134
|
+
const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO);
|
|
135
|
+
if (!targetResult.ok) return targetResult;
|
|
136
|
+
|
|
137
|
+
// Compare common dirs — must share the same repo
|
|
138
|
+
const parentCommonAbs = path.isAbsolute(parentResult.commonDir)
|
|
139
|
+
? parentResult.commonDir
|
|
140
|
+
: path.resolve(parentCwd, parentResult.commonDir);
|
|
141
|
+
const targetCommonAbs = path.isAbsolute(targetResult.commonDir)
|
|
142
|
+
? targetResult.commonDir
|
|
143
|
+
: path.resolve(realPath, targetResult.commonDir);
|
|
144
|
+
|
|
145
|
+
if (parentCommonAbs !== targetCommonAbs) {
|
|
146
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.DIFFERENT_REPO };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Step 6: Get the worktree root via git rev-parse --show-toplevel
|
|
150
|
+
let worktreeRoot: string;
|
|
151
|
+
try {
|
|
152
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: realPath, timeout: GIT_EXEC_TIMEOUT_MS });
|
|
153
|
+
if (result.code !== 0) {
|
|
154
|
+
worktreeRoot = realPath;
|
|
155
|
+
} else {
|
|
156
|
+
const raw = result.stdout.trim();
|
|
157
|
+
worktreeRoot = raw ? (path.isAbsolute(raw) ? raw : path.resolve(realPath, raw)) : realPath;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
worktreeRoot = realPath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Step 7: Normalize and compute display label
|
|
164
|
+
const normalizedRealPath = realPath.replace(/\\/g, "/");
|
|
165
|
+
const normalizedRoot = worktreeRoot.replace(/\\/g, "/");
|
|
166
|
+
const label = computeLabel(normalizedRealPath, normalizedRoot);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
resolvedPath: normalizedRealPath,
|
|
171
|
+
worktreeRoot: normalizedRoot,
|
|
172
|
+
label,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute a short display label for the worktree path.
|
|
178
|
+
*
|
|
179
|
+
* Rules:
|
|
180
|
+
* - Root of worktree → basename (e.g., "/wt/feature" → "feature")
|
|
181
|
+
* - Subdirectory → basename/relative (e.g., "/wt/feature/packages/web" → "feature/packages/web")
|
|
182
|
+
* - Always forward slashes regardless of host OS
|
|
183
|
+
*/
|
|
184
|
+
export function computeLabel(resolvedPath: string, worktreeRoot: string): string {
|
|
185
|
+
// Normalize both paths to forward slashes for cross-platform comparison
|
|
186
|
+
const normalizedResolved = resolvedPath.replace(/\\/g, "/");
|
|
187
|
+
const normalizedRoot = worktreeRoot.replace(/\\/g, "/");
|
|
188
|
+
|
|
189
|
+
const rootBasename = normalizedRoot.split("/").filter(Boolean).pop() ?? "";
|
|
190
|
+
|
|
191
|
+
if (normalizedResolved === normalizedRoot) {
|
|
192
|
+
return rootBasename;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Compute relative path using posix separator
|
|
196
|
+
const relative = path.posix.relative(normalizedRoot, normalizedResolved);
|
|
197
|
+
|
|
198
|
+
return `${rootBasename}/${relative}`;
|
|
199
|
+
}
|