pi-crew 0.10.1 → 0.10.2
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 +98 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2781 -949
- package/package.json +1 -1
- package/schema.json +492 -94
- package/src/config/config-validation.ts +7 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +19 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +52 -2
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- package/src/runtime/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/team-runner.ts +3 -2
- package/src/schema/config-schema.ts +1 -1
- package/src/state/stores/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +73 -3
- package/src/state/types.ts +2 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-list.ts — the run's plan, painted above the editor (pi-tasks /
|
|
3
|
+
* Claude Code style): a `● N tasks (…)` header, then one row per task in
|
|
4
|
+
* PLAN ORDER with task numbers (#1, #2, …) instead of technical ids.
|
|
5
|
+
* Completed rows are dimmed and struck through; the task actively executing
|
|
6
|
+
* carries a spinner frame with elapsed time and token counts; queued rows
|
|
7
|
+
* name the dependencies they wait on.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* ```
|
|
11
|
+
* ● 4 tasks (1 done, 1 in progress, 2 open)
|
|
12
|
+
* ✔ #1 Design the flux capacitor
|
|
13
|
+
* ⠙ #2 Wire the overlay scroll (2m 49s · ↑ 4.1k ↓ 1.2k)
|
|
14
|
+
* ◻ #3 Run the suite › blocked by #2
|
|
15
|
+
* ◻ #4 Report the result
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Pure plan surface: which worker/agent/model runs a task is the dock's
|
|
19
|
+
* story below the editor, never the plan's. Pure display — the interactive
|
|
20
|
+
* surface stays that dock. Rows come from the run snapshot's `tasks` slice
|
|
21
|
+
* (plan order), so the list repaints on task transitions only; the running
|
|
22
|
+
* row's spinner/elapsed/token suffix rides the render signature's spinner
|
|
23
|
+
* bucket while any worker is alive.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { TeamTaskState } from "../../state/types.ts";
|
|
27
|
+
import { truncate } from "../../utils/visual.ts";
|
|
28
|
+
import { spinnerFrame } from "../spinner.ts";
|
|
29
|
+
import { shortRunLabel } from "./widget-model.ts";
|
|
30
|
+
import type { WidgetRun } from "./widget-types.ts";
|
|
31
|
+
|
|
32
|
+
/** Task rows painted before the "… and N more" overflow line (pi-tasks
|
|
33
|
+
* defaults its `maxVisible` to 10). */
|
|
34
|
+
const MAX_TASK_ROWS = 10;
|
|
35
|
+
|
|
36
|
+
const ACTIVE_TASK_STATUSES = new Set(["running", "queued", "waiting", "needs_attention"]);
|
|
37
|
+
|
|
38
|
+
// pi-tasks-style strikethrough + dim for completed rows: raw SGR because the
|
|
39
|
+
// theme adapter has no strike; the truncate/colorize paths are ANSI-aware.
|
|
40
|
+
const DIM_ON = "\x1b[2m";
|
|
41
|
+
const DIM_OFF = "\x1b[22m";
|
|
42
|
+
const STRIKE_ON = "\x1b[9m";
|
|
43
|
+
const STRIKE_OFF = "\x1b[29m";
|
|
44
|
+
|
|
45
|
+
function isDoneStatus(status: string): boolean {
|
|
46
|
+
return status === "completed" || status === "failed" || status === "cancelled" || status === "skipped";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function taskTitle(task: TeamTaskState): string {
|
|
50
|
+
return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** pi-tasks status glyph per row: ✔ done (struck through), spinner for the
|
|
54
|
+
* actively executing task, ◼ parked/needs input, ◻ queued, ✗ dead. */
|
|
55
|
+
function taskStatusIcon(task: TeamTaskState): string {
|
|
56
|
+
switch (task.status) {
|
|
57
|
+
case "running":
|
|
58
|
+
return spinnerFrame("crew-task-list");
|
|
59
|
+
case "queued":
|
|
60
|
+
return "◻";
|
|
61
|
+
case "waiting":
|
|
62
|
+
case "needs_attention":
|
|
63
|
+
return "◼";
|
|
64
|
+
case "completed":
|
|
65
|
+
return "✔";
|
|
66
|
+
case "failed":
|
|
67
|
+
case "cancelled":
|
|
68
|
+
case "skipped":
|
|
69
|
+
return "✗";
|
|
70
|
+
default:
|
|
71
|
+
return "?";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `45s` / `2m 49s` / `1h 03m`. */
|
|
76
|
+
function formatDuration(ms: number): string {
|
|
77
|
+
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
78
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
79
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
80
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
81
|
+
if (minutes < 60) return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
|
|
82
|
+
const hours = Math.floor(minutes / 60);
|
|
83
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `4.1k` / `1.2M` / `918` — no unit suffix; the arrow pair carries it. */
|
|
87
|
+
function compactTokens(count: number): string {
|
|
88
|
+
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return "";
|
|
89
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
90
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
91
|
+
return `${count}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `(2m 49s · ↑ 4.1k ↓ 1.2k)` — elapsed plus tokens, pi-tasks style. Live
|
|
95
|
+
* totals come from agentProgress until the final usage lands. */
|
|
96
|
+
function runningSuffix(task: TeamTaskState): string {
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
const start = Date.parse(task.startedAt ?? "");
|
|
99
|
+
if (Number.isFinite(start) && start > 0) {
|
|
100
|
+
const end = task.finishedAt ? Date.parse(task.finishedAt) : Date.now();
|
|
101
|
+
const elapsed = formatDuration(end - start);
|
|
102
|
+
if (elapsed) parts.push(elapsed);
|
|
103
|
+
}
|
|
104
|
+
const input = task.usage?.input ?? 0;
|
|
105
|
+
const output = task.usage?.output ?? 0;
|
|
106
|
+
if (input > 0 || output > 0) {
|
|
107
|
+
parts.push(`↑ ${compactTokens(input) || "0"} ↓ ${compactTokens(output) || "0"}`);
|
|
108
|
+
} else {
|
|
109
|
+
const live = compactTokens(task.agentProgress?.tokens ?? 0);
|
|
110
|
+
if (live) parts.push(live);
|
|
111
|
+
}
|
|
112
|
+
return parts.length ? ` (${parts.join(" · ")})` : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `› blocked by #2, #3` for queued tasks — open (non-completed)
|
|
116
|
+
* dependencies only, named by task number (pi-tasks display rule). */
|
|
117
|
+
function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], numberById: Map<string, number>): string {
|
|
118
|
+
if (task.status !== "queued") return "";
|
|
119
|
+
const openDeps = (task.dependsOn ?? [])
|
|
120
|
+
.map((depId) => tasks.find((entry) => entry.id === depId))
|
|
121
|
+
.filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
|
|
122
|
+
if (openDeps.length === 0) return "";
|
|
123
|
+
const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
|
|
124
|
+
return ` › blocked by ${labels.join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One line per task: `#n title` behind its status glyph — pi-tasks style.
|
|
128
|
+
* Completed titles are dimmed and struck through. */
|
|
129
|
+
function taskRow(
|
|
130
|
+
task: TeamTaskState,
|
|
131
|
+
taskNumber: number,
|
|
132
|
+
tasks: readonly TeamTaskState[],
|
|
133
|
+
numberById: Map<string, number>,
|
|
134
|
+
width: number,
|
|
135
|
+
): string {
|
|
136
|
+
const title = taskTitle(task);
|
|
137
|
+
const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, tasks, numberById);
|
|
138
|
+
if (task.status === "completed") {
|
|
139
|
+
const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
|
|
140
|
+
return truncate(` ✔ ${struck}`.trimEnd(), width);
|
|
141
|
+
}
|
|
142
|
+
return truncate(` ${taskStatusIcon(task)} #${taskNumber} ${title}${suffix}`.trimEnd(), width);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The run whose plan is shown: the first with unfinished work, else the first. */
|
|
146
|
+
function primaryRun(runs: readonly WidgetRun[]): WidgetRun | undefined {
|
|
147
|
+
return runs.find((entry) => (entry.snapshot?.tasks ?? []).some((task) => ACTIVE_TASK_STATUSES.has(task.status))) ?? runs[0];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Which rows fit the cap: plan order, but never drop an unfinished task —
|
|
151
|
+
* if active work falls outside the first MAX_TASK_ROWS rows it stays and
|
|
152
|
+
* finished rows give way instead. */
|
|
153
|
+
function visibleTasks(tasks: readonly TeamTaskState[]): TeamTaskState[] {
|
|
154
|
+
if (tasks.length <= MAX_TASK_ROWS) return [...tasks];
|
|
155
|
+
const active = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(task.status));
|
|
156
|
+
if (active.length >= MAX_TASK_ROWS) return active.slice(0, MAX_TASK_ROWS);
|
|
157
|
+
const keep = new Set(active.map((task) => task.id));
|
|
158
|
+
for (const task of tasks) {
|
|
159
|
+
if (keep.size >= MAX_TASK_ROWS) break;
|
|
160
|
+
keep.add(task.id);
|
|
161
|
+
}
|
|
162
|
+
return tasks.filter((task) => keep.has(task.id));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the task-list lines for the current runs. Empty when no run carries a
|
|
167
|
+
* tasks slice (nothing to say — the dock alone stays).
|
|
168
|
+
*/
|
|
169
|
+
export function buildTaskListLines(runs: readonly WidgetRun[], width: number): string[] {
|
|
170
|
+
const entry = primaryRun(runs);
|
|
171
|
+
if (!entry) return [];
|
|
172
|
+
const tasks = entry.snapshot?.tasks ?? [];
|
|
173
|
+
if (tasks.length === 0) return [];
|
|
174
|
+
|
|
175
|
+
const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
|
|
176
|
+
const done = tasks.filter((task) => task.status === "completed").length;
|
|
177
|
+
const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
|
|
178
|
+
const inProgress = tasks.filter(
|
|
179
|
+
(task) => task.status === "running" || task.status === "waiting" || task.status === "needs_attention",
|
|
180
|
+
).length;
|
|
181
|
+
const open = tasks.filter((task) => task.status === "queued").length;
|
|
182
|
+
|
|
183
|
+
const counts = [`${done} done`];
|
|
184
|
+
if (dead) counts.push(`${dead} failed`);
|
|
185
|
+
counts.push(`${inProgress} in progress`, `${open} open`);
|
|
186
|
+
const headerParts = [`● ${tasks.length} tasks (${counts.join(", ")})`];
|
|
187
|
+
if (runs.length > 1) headerParts.push(shortRunLabel(entry.run));
|
|
188
|
+
const lines = [truncate(headerParts.join(" · "), width)];
|
|
189
|
+
|
|
190
|
+
const visible = visibleTasks(tasks);
|
|
191
|
+
for (const task of visible) {
|
|
192
|
+
lines.push(taskRow(task, numberById.get(task.id) ?? 0, tasks, numberById, width));
|
|
193
|
+
}
|
|
194
|
+
if (tasks.length > visible.length) {
|
|
195
|
+
lines.push(truncate(` … and ${tasks.length - visible.length} more`, width));
|
|
196
|
+
}
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
8
8
|
import type { LiveAgentHandle } from "../../runtime/live-session/live-agent-manager.ts";
|
|
9
9
|
import { getTaskUsage } from "../../runtime/usage-tracker.ts";
|
|
10
|
-
import { visibleWidth } from "../../utils/visual.ts";
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
|
|
11
11
|
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
12
12
|
|
|
13
13
|
// ── No-color mode (UI-10) ─────────────────────────────────────────────
|
|
@@ -75,6 +75,7 @@ const TOKENS_METRIC_WIDTH = 10; // "1.2k tok", "12.3M tok"
|
|
|
75
75
|
const TPS_METRIC_WIDTH = 9; // "411 tok/s"
|
|
76
76
|
const CTX_METRIC_WIDTH = 7; // "100% ctx"
|
|
77
77
|
const DURATION_METRIC_WIDTH = 6; // "120.0s"
|
|
78
|
+
const COST_METRIC_WIDTH = 9; // "$0.001234"
|
|
78
79
|
|
|
79
80
|
function alignMetric(value: string, width: number): string {
|
|
80
81
|
const pad = Math.max(0, width - visibleWidth(value));
|
|
@@ -82,6 +83,11 @@ function alignMetric(value: string, width: number): string {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export function formatTokensCompact(count: number): string {
|
|
86
|
+
// Display-layer guard: state records have at least once carried the
|
|
87
|
+
// literal "***" in a numeric field (redaction false-positive, fixed at
|
|
88
|
+
// the source). A string here would print `*** tok` verbatim, so non-
|
|
89
|
+
// numeric/undefined input renders as an empty metric instead.
|
|
90
|
+
if (typeof count !== "number" || !Number.isFinite(count)) return "";
|
|
85
91
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M tok`;
|
|
86
92
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k tok`;
|
|
87
93
|
return `${count} tok`;
|
|
@@ -99,6 +105,13 @@ export function elapsed(iso: string | undefined, now = Date.now()): string | und
|
|
|
99
105
|
return `${Math.floor(ms / 3_600_000)}h`;
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
/** pi-subtask's always-on elapsed tail: `0s` from the start (its fork rows
|
|
109
|
+
* flatten sub-second ages to 0 — not the legacy widget's "now"). */
|
|
110
|
+
export function dockElapsed(iso: string | undefined, now = Date.now()): string {
|
|
111
|
+
const value = elapsed(iso, now);
|
|
112
|
+
return value === undefined ? "" : value === "now" ? "0s" : value;
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
// ── Agent activity description ────────────────────────────────────────
|
|
103
116
|
|
|
104
117
|
const TOOL_LABELS: Record<string, string> = {
|
|
@@ -176,6 +189,218 @@ export function agentActivity(agent: CrewAgentRecord, liveHandle?: LiveAgentHand
|
|
|
176
189
|
return "done";
|
|
177
190
|
}
|
|
178
191
|
|
|
192
|
+
// ── Per-agent cost ────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compact per-agent spend for widget rows, or "" when there is nothing to
|
|
196
|
+
* show. `formatCost`'s 6-decimal sub-cent output (`$0.001000`) wastes a third
|
|
197
|
+
* of a one-line row, so the widget uses a short form: cent precision in
|
|
198
|
+
* dollars, milli-precision below, and a `< $0.001` floor.
|
|
199
|
+
*/
|
|
200
|
+
export function formatCostCompact(cost: number): string {
|
|
201
|
+
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
202
|
+
if (cost >= 0.01) return `$${cost.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
203
|
+
if (cost >= 0.001) return `$${cost.toFixed(4).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
204
|
+
return "< $0.001";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Formatted per-agent spend, or "" when there is nothing to show. The value
|
|
209
|
+
* already lives on the durable task record and the dashboard agents pane has
|
|
210
|
+
* shown it since Round 17; the widget omitted it only by oversight.
|
|
211
|
+
*/
|
|
212
|
+
export function agentCost(agent: CrewAgentRecord): string {
|
|
213
|
+
const cost = agent.usage?.cost;
|
|
214
|
+
if (typeof cost !== "number" || !Number.isFinite(cost) || cost <= 0) return "";
|
|
215
|
+
return formatCostCompact(cost);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── pi-subtask dock formatters ─────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* pi-subtask's FIXED per-status glyphs (`statusIcon` in source/pi-subtask:
|
|
222
|
+
* starting ○, running ✻, done ✓, failed ✗, stopped ■). The compact dock
|
|
223
|
+
* deliberately does NOT spin the running marker like the legacy widget —
|
|
224
|
+
* the row set is stable across ticks, which is what makes the keyboard
|
|
225
|
+
* cursor feel anchored.
|
|
226
|
+
*/
|
|
227
|
+
export function dockStatusIcon(status: string): string {
|
|
228
|
+
switch (status) {
|
|
229
|
+
case "running":
|
|
230
|
+
return "✻";
|
|
231
|
+
case "queued":
|
|
232
|
+
case "waiting":
|
|
233
|
+
return "○";
|
|
234
|
+
case "completed":
|
|
235
|
+
return "✓";
|
|
236
|
+
case "failed":
|
|
237
|
+
return "✗";
|
|
238
|
+
case "needs_attention":
|
|
239
|
+
return "⚠";
|
|
240
|
+
case "cancelled":
|
|
241
|
+
case "stopped":
|
|
242
|
+
return "■";
|
|
243
|
+
default:
|
|
244
|
+
return "?";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** pi-subtask uses the status word as a finished row's activity. */
|
|
249
|
+
export function dockStatusLabel(status: string): string {
|
|
250
|
+
switch (status) {
|
|
251
|
+
case "completed":
|
|
252
|
+
return "done";
|
|
253
|
+
case "failed":
|
|
254
|
+
return "failed";
|
|
255
|
+
case "cancelled":
|
|
256
|
+
case "stopped":
|
|
257
|
+
return "stopped";
|
|
258
|
+
case "needs_attention":
|
|
259
|
+
return "needs attention";
|
|
260
|
+
case "queued":
|
|
261
|
+
return "queued";
|
|
262
|
+
case "waiting":
|
|
263
|
+
return "waiting";
|
|
264
|
+
default:
|
|
265
|
+
return status;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** pi-subtask's formatTokens: raw under 1k, `Nk` under 1M, `N.M` above. */
|
|
270
|
+
export function tokenCountShort(count: number): string {
|
|
271
|
+
if (count < 1_000) return `${count}`;
|
|
272
|
+
if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
|
|
273
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface DockUsageOptions {
|
|
277
|
+
/** While the agent's pane is open: add tok/s + context % like pi-subtask. */
|
|
278
|
+
viewed?: boolean;
|
|
279
|
+
/** Model context window for the `P% / N` gauge; omitted when unknown. */
|
|
280
|
+
contextWindow?: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* pi-subtask's footer-style usage (formatUsage): `↑in ↓out Rcache CH% $cost`,
|
|
285
|
+
* plus `N tok/s` and `P% / window` while viewed. Live agents have split
|
|
286
|
+
* input/output/cacheWrite via the usage tracker; non-live ones show the
|
|
287
|
+
* durable token total + cost.
|
|
288
|
+
*/
|
|
289
|
+
export function dockUsageText(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle, options: DockUsageOptions = {}): string {
|
|
290
|
+
const parts: string[] = [];
|
|
291
|
+
if (liveHandle) {
|
|
292
|
+
const usage = getTaskUsage(liveHandle.taskId);
|
|
293
|
+
const input = usage.input ?? 0;
|
|
294
|
+
const output = usage.output ?? 0;
|
|
295
|
+
const cacheWrite = usage.cacheWrite ?? 0;
|
|
296
|
+
if (input > 0) parts.push(`↑${tokenCountShort(input)}`);
|
|
297
|
+
if (output > 0) parts.push(`↓${tokenCountShort(output)}`);
|
|
298
|
+
if (cacheWrite > 0) parts.push(`R${tokenCountShort(cacheWrite)}`);
|
|
299
|
+
const promptTokens = input + cacheWrite;
|
|
300
|
+
if (cacheWrite > 0 && promptTokens > 0) {
|
|
301
|
+
parts.push(`CH${((cacheWrite / promptTokens) * 100).toFixed(1)}%`);
|
|
302
|
+
}
|
|
303
|
+
const cost = agent.usage?.cost;
|
|
304
|
+
if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) parts.push(`$${cost.toFixed(4)}`);
|
|
305
|
+
if (options.viewed) {
|
|
306
|
+
const act = liveHandle.activity;
|
|
307
|
+
const ms = computeLiveDurationMs(act);
|
|
308
|
+
const totalTokens = input + output + cacheWrite;
|
|
309
|
+
if (totalTokens > 0 && ms > 1000) {
|
|
310
|
+
const tps = Math.round(totalTokens / (ms / 1000));
|
|
311
|
+
if (tps > 0) parts.push(`${tps} tok/s`);
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const ctxPct = liveHandle.session.getSessionStats?.()?.contextUsage?.percent;
|
|
315
|
+
if (ctxPct != null) {
|
|
316
|
+
const window =
|
|
317
|
+
options.contextWindow && options.contextWindow >= 1_000_000
|
|
318
|
+
? `${(options.contextWindow / 1_000_000).toFixed(1)}M`
|
|
319
|
+
: options.contextWindow
|
|
320
|
+
? tokenCountShort(options.contextWindow)
|
|
321
|
+
: "";
|
|
322
|
+
parts.push(`${Math.round(ctxPct)}%${window ? ` / ${window}` : ""}`);
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
/* ignore */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return parts.join(" ");
|
|
329
|
+
}
|
|
330
|
+
const tokens = agent.progress?.tokens;
|
|
331
|
+
const tokenCount = typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
332
|
+
if (tokenCount > 0) parts.push(`${tokenCountShort(tokenCount)} tok`);
|
|
333
|
+
const cost = agent.usage?.cost;
|
|
334
|
+
if (typeof cost === "number" && Number.isFinite(cost) && cost > 0) parts.push(`$${cost.toFixed(4)}`);
|
|
335
|
+
return parts.join(" ");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Adaptive single-line row ───────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
export interface BudgetedRowParts {
|
|
341
|
+
/** Marker + glyph prefix; never trimmed. */
|
|
342
|
+
lead: string;
|
|
343
|
+
/** Agent label; grows into whatever the activity leaves over. */
|
|
344
|
+
name: string;
|
|
345
|
+
/** Current activity; shrinks first but keeps a readable floor. */
|
|
346
|
+
activity: string;
|
|
347
|
+
/** Metrics tail; never trimmed, so numbers stay comparable across ticks. */
|
|
348
|
+
suffix: string;
|
|
349
|
+
separator?: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Smallest activity/name slice still worth showing before we drop the field. */
|
|
353
|
+
const MIN_FIELD_WIDTH = 12;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Assemble one row that fills `width` without wrapping.
|
|
357
|
+
*
|
|
358
|
+
* `lead` and `suffix` are fixed costs; the remaining budget is split between
|
|
359
|
+
* `name` and `activity`. The activity absorbs the trimming first (it changes
|
|
360
|
+
* every tick anyway) but keeps a MIN_FIELD_WIDTH floor, and the name expands
|
|
361
|
+
* into the rest up to its natural length — so a 200-column terminal shows the
|
|
362
|
+
* full description instead of the same clip an 80-column one gets.
|
|
363
|
+
*
|
|
364
|
+
* The closing truncate is a hard guard, not an optimisation: pi's renderer
|
|
365
|
+
* throws on a line wider than the terminal.
|
|
366
|
+
*/
|
|
367
|
+
export function budgetedRow(parts: BudgetedRowParts, width: number): string {
|
|
368
|
+
const sep = parts.separator ?? " · ";
|
|
369
|
+
const { lead, suffix } = parts;
|
|
370
|
+
const name = parts.name.replace(/\s+/g, " ").trim();
|
|
371
|
+
const activity = parts.activity.replace(/\s+/g, " ").trim();
|
|
372
|
+
if (!name && !activity) return truncateToWidth(lead + suffix, width);
|
|
373
|
+
if (!activity) return fitNameOnly(lead, name, suffix, width);
|
|
374
|
+
if (!name) return fitNameOnly(lead, activity, suffix, width);
|
|
375
|
+
|
|
376
|
+
const budget = width - visibleWidth(lead) - visibleWidth(suffix) - visibleWidth(sep);
|
|
377
|
+
// Too narrow to hold name + activity above their floors: keep the name
|
|
378
|
+
// only, and never let the metrics tail eat the clipping.
|
|
379
|
+
if (budget < MIN_FIELD_WIDTH * 2) return fitNameOnly(lead, name, suffix, width);
|
|
380
|
+
|
|
381
|
+
const nameNatural = visibleWidth(name);
|
|
382
|
+
const activityNatural = visibleWidth(activity);
|
|
383
|
+
const activityRoom = Math.min(activityNatural, Math.max(MIN_FIELD_WIDTH, budget - nameNatural));
|
|
384
|
+
const nameRoom = Math.max(MIN_FIELD_WIDTH, budget - activityRoom);
|
|
385
|
+
const assembled = lead + truncateToWidth(name, nameRoom) + sep + truncateToWidth(activity, activityRoom) + suffix;
|
|
386
|
+
// The room arithmetic keeps this ≤ width by construction (nameRoom +
|
|
387
|
+
// activityRoom ≤ budget in every branch); this guard is the last line of
|
|
388
|
+
// defense because pi's renderer throws on over-width lines.
|
|
389
|
+
if (visibleWidth(assembled) <= width) return assembled;
|
|
390
|
+
return fitNameOnly(lead, name, suffix, width);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Narrow-terminal fallback: fixed lead + suffix, the name absorbs every
|
|
395
|
+
* remaining column. The metrics tail is sacred — it must stay comparable
|
|
396
|
+
* across ticks — so clipping always happens in the middle fields.
|
|
397
|
+
*/
|
|
398
|
+
function fitNameOnly(lead: string, name: string, suffix: string, width: number): string {
|
|
399
|
+
const fixed = visibleWidth(lead) + visibleWidth(suffix);
|
|
400
|
+
if (fixed >= width) return truncateToWidth(lead + suffix, width);
|
|
401
|
+
return lead + truncateToWidth(name, width - fixed) + suffix;
|
|
402
|
+
}
|
|
403
|
+
|
|
179
404
|
// ── Agent stats line ──────────────────────────────────────────────────
|
|
180
405
|
|
|
181
406
|
export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle): string {
|
|
@@ -186,6 +411,10 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
|
|
|
186
411
|
const usage = getTaskUsage(liveHandle.taskId);
|
|
187
412
|
const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
|
|
188
413
|
if (total > 0) parts.push(alignMetric(formatTokensCompact(total), TOKENS_METRIC_WIDTH));
|
|
414
|
+
// The live usage tracker carries tokens only (LifetimeUsage has no cost
|
|
415
|
+
// field), so cost always comes off the durable task record.
|
|
416
|
+
const liveCost = agentCost(agent);
|
|
417
|
+
if (liveCost) parts.push(alignMetric(liveCost, COST_METRIC_WIDTH));
|
|
189
418
|
try {
|
|
190
419
|
const stats = liveHandle.session.getSessionStats?.();
|
|
191
420
|
const ctxPct = stats?.contextUsage?.percent;
|
|
@@ -200,11 +429,18 @@ export function agentStats(agent: CrewAgentRecord, liveHandle?: LiveAgentHandle)
|
|
|
200
429
|
}
|
|
201
430
|
parts.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
202
431
|
} else {
|
|
432
|
+
// Type-narrowed: state has carried the literal "***" (redaction
|
|
433
|
+
// false-positive) in progress.tokens, which is truthy but not a count.
|
|
434
|
+
// Only real numbers produce metrics; formatTokensCompact guards too.
|
|
435
|
+
const tokens = agent.progress?.tokens;
|
|
436
|
+
const tokenCount = typeof tokens === "number" ? tokens : undefined;
|
|
203
437
|
if (agent.toolUses) parts.push(alignMetric(`${agent.toolUses} tools`, TOOLS_METRIC_WIDTH));
|
|
204
|
-
if (
|
|
438
|
+
if (tokenCount && tokenCount > 0) parts.push(alignMetric(formatTokensCompact(tokenCount), TOKENS_METRIC_WIDTH));
|
|
439
|
+
const cost = agentCost(agent);
|
|
440
|
+
if (cost) parts.push(alignMetric(cost, COST_METRIC_WIDTH));
|
|
205
441
|
const ageMs = agent.startedAt ? Math.max(0, Date.now() - new Date(agent.startedAt).getTime()) : 0;
|
|
206
|
-
if (
|
|
207
|
-
const tps = Math.round(
|
|
442
|
+
if (tokenCount && tokenCount > 0 && ageMs > 1000) {
|
|
443
|
+
const tps = Math.round(tokenCount / (ageMs / 1000));
|
|
208
444
|
if (tps > 0) parts.push(alignMetric(`${formatTokensCompact(tps)}/s`, TPS_METRIC_WIDTH));
|
|
209
445
|
}
|
|
210
446
|
const age = elapsed(agent.completedAt ?? agent.startedAt);
|