@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.8
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 +160 -43
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +418 -118
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +231 -107
- package/src/invocation-config.ts +8 -35
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +31 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +346 -0
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +41 -19
- package/src/settings.ts +111 -8
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +33 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
package/src/ui/fleet-list.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
|
|
3
3
|
*
|
|
4
|
+
* Starts as a one-line state/count summary; ctrl+shift+a toggles the full list.
|
|
4
5
|
* Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
|
|
5
6
|
* ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
|
|
6
7
|
* Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
|
|
@@ -11,16 +12,28 @@
|
|
|
11
12
|
* can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
import { isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { isKeyRelease, isKeyRepeat, Key, matchesKey, type ScrollViewScrollbar, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
16
|
import { hasAgentBadge, renderAgentName } from "../agent-color.js";
|
|
16
|
-
import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
|
|
17
|
+
import { type AgentManager, isTopLevelAgent, markConsumed } from "../agent-manager.js";
|
|
17
18
|
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
18
19
|
import type { RunningDetached } from "../detached-results.js";
|
|
19
20
|
import { describeModelCall } from "../model-call.js";
|
|
20
21
|
import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
|
|
21
22
|
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
22
|
-
import { type AgentActivity, buildInvocationTags, formatCost, type Theme } from "./agent-widget.js";
|
|
23
|
+
import { type AgentActivity, buildInvocationTags, fgPreservingNestedStyles, formatCost, type Theme } from "./agent-widget.js";
|
|
23
24
|
import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./conversation-viewer.js";
|
|
25
|
+
import { fallbackFlag, hasFallback, withLeadingFlag } from "./fallback-flag.js";
|
|
26
|
+
import { FleetJournal, type FleetJournalEntry, type FleetSnapshot, fleetAlert, MAX_COMPACT_ALERT_COUNT } from "./fleet-state.js";
|
|
27
|
+
|
|
28
|
+
/** Matches the todo/monitor disclosure shortcut family; arrows keep their navigation roles. */
|
|
29
|
+
export const FLEET_COLLAPSE_KEY = "ctrl+shift+a";
|
|
30
|
+
/** Quiet-period debounce bounds journal writes per toggle burst; shutdown flushes immediately. */
|
|
31
|
+
export const FLEET_DISCLOSURE_SETTLE_MS = 300;
|
|
32
|
+
|
|
33
|
+
/** Existing result-consumption semantics also acknowledge a failed fleet row. */
|
|
34
|
+
function isOutstandingFailure(record: AgentRecord): boolean {
|
|
35
|
+
return (record.status === "error" || record.status === "aborted") && !record.resultConsumed;
|
|
36
|
+
}
|
|
24
37
|
|
|
25
38
|
/** Widget key for the below-editor fleet list. */
|
|
26
39
|
const FLEET_KEY = "fleet";
|
|
@@ -67,6 +80,8 @@ export interface FleetWorkflow {
|
|
|
67
80
|
/** Set once the run settles, which is what freezes its clock. */
|
|
68
81
|
completedAt?: number;
|
|
69
82
|
tokens: number;
|
|
83
|
+
/** Stable, weakly observed owner when the source rebuilds its display projection each tick. */
|
|
84
|
+
observationKey?: object;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
type MainEntry = { kind: "main" };
|
|
@@ -97,20 +112,123 @@ function rightAlign(left: string, right: string, width: number): string {
|
|
|
97
112
|
const rightW = visibleWidth(right);
|
|
98
113
|
const maxLeft = Math.max(0, width - rightW - 1);
|
|
99
114
|
const leftClamped = truncateToWidth(left, maxLeft);
|
|
100
|
-
const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW);
|
|
115
|
+
const gap = Math.max(leftClamped ? 1 : 0, width - visibleWidth(leftClamped) - rightW);
|
|
101
116
|
return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
|
|
102
117
|
}
|
|
103
118
|
|
|
104
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Agent-row tail columns, left to right; text pads right, numbers pad left.
|
|
121
|
+
* `drop` ranks how early a column goes on a narrow terminal (1 first); an
|
|
122
|
+
* undefined rank is never dropped, so state, elapsed, tokens and cost survive
|
|
123
|
+
* and the description is what truncates (#144).
|
|
124
|
+
*/
|
|
105
125
|
const TAIL_SLOTS = [
|
|
106
|
-
{ align: "end" }, //
|
|
107
|
-
{ align: "end" }, //
|
|
108
|
-
{ align: "end" }, //
|
|
126
|
+
{ align: "end" }, // state: glyph + label
|
|
127
|
+
{ align: "end", drop: 3 }, // model
|
|
128
|
+
{ align: "end", drop: 2 }, // thinking level
|
|
129
|
+
{ align: "end", drop: 1 }, // live model-call activity
|
|
109
130
|
{ align: "start" }, // elapsed
|
|
110
131
|
{ align: "start" }, // tokens
|
|
111
132
|
{ align: "start" }, // cost
|
|
112
133
|
] as const;
|
|
113
134
|
|
|
135
|
+
/** Columns reserved for the bullet, name and the start of the description. */
|
|
136
|
+
const MIN_LEFT_WIDTH = 24;
|
|
137
|
+
|
|
138
|
+
/** Which bucket a state counts under in an overflow summary. */
|
|
139
|
+
type StateBucket = "running" | "queued" | "paused" | "done" | "stopped" | "failed";
|
|
140
|
+
|
|
141
|
+
/** How one agent or run state reads: a glyph AND a word, so colour is never the only cue. */
|
|
142
|
+
interface FleetState {
|
|
143
|
+
glyph: string;
|
|
144
|
+
label: string;
|
|
145
|
+
color: string;
|
|
146
|
+
bucket: StateBucket;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The state of an agent record. */
|
|
150
|
+
function agentState(status: AgentRecord["status"]): FleetState {
|
|
151
|
+
switch (status) {
|
|
152
|
+
case "queued": return { glyph: "◦", label: "queued", color: "muted", bucket: "queued" };
|
|
153
|
+
case "running": return { glyph: "▶", label: "running", color: "accent", bucket: "running" };
|
|
154
|
+
case "completed": return { glyph: "✓", label: "done", color: "success", bucket: "done" };
|
|
155
|
+
case "steered": return { glyph: "✓", label: "limit", color: "warning", bucket: "done" };
|
|
156
|
+
case "stopped": return { glyph: "■", label: "stopped", color: "dim", bucket: "stopped" };
|
|
157
|
+
case "aborted": return { glyph: "✗", label: "aborted", color: "error", bucket: "failed" };
|
|
158
|
+
case "error": return { glyph: "✗", label: "failed", color: "error", bucket: "failed" };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The bucket a workflow run counts under in an overflow summary. */
|
|
163
|
+
function workflowBucket(status: FleetWorkflow["status"]): StateBucket {
|
|
164
|
+
switch (status) {
|
|
165
|
+
case "running": return "running";
|
|
166
|
+
case "paused": return "paused";
|
|
167
|
+
case "completed": return "done";
|
|
168
|
+
case "failed":
|
|
169
|
+
case "killed": return "failed";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const BUCKET_ORDER: readonly StateBucket[] = ["running", "queued", "paused", "failed", "stopped", "done"];
|
|
174
|
+
|
|
175
|
+
/** `3 running, 1 failed` for the rows an indicator hides. */
|
|
176
|
+
function summarizeHidden(rows: readonly (WorkflowEntry | AgentEntry)[]): string {
|
|
177
|
+
const counts = new Map<StateBucket, number>();
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
const bucket = row.kind === "workflow" ? workflowBucket(row.workflow.status) : agentState(row.record.status).bucket;
|
|
180
|
+
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
return BUCKET_ORDER.filter(b => counts.has(b)).map(b => `${counts.get(b)} ${b}`).join(", ");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One bounded line, including zero agents; the controller still hides an empty fleet. */
|
|
186
|
+
export function renderFleetSummary(
|
|
187
|
+
agents: readonly AgentRecord[],
|
|
188
|
+
workflows: readonly FleetWorkflow[],
|
|
189
|
+
parked: number,
|
|
190
|
+
showCost: boolean,
|
|
191
|
+
width: number,
|
|
192
|
+
theme: Theme,
|
|
193
|
+
overflowFailures = 0,
|
|
194
|
+
): string {
|
|
195
|
+
const rows: (AgentEntry | WorkflowEntry)[] = [
|
|
196
|
+
...agents.map(record => ({ kind: "agent" as const, record })),
|
|
197
|
+
...workflows.map(workflow => ({ kind: "workflow" as const, workflow })),
|
|
198
|
+
];
|
|
199
|
+
const failed = rows.filter(row => row.kind === "agent"
|
|
200
|
+
? agentState(row.record.status).bucket === "failed"
|
|
201
|
+
: workflowBucket(row.workflow.status) === "failed").length + overflowFailures;
|
|
202
|
+
const paused = workflows.filter(run => run.status === "paused").length;
|
|
203
|
+
const fallback = agents.filter(hasFallback).length;
|
|
204
|
+
// Actionable counts lead. Cap ONLY compact counts, then remove spaces before
|
|
205
|
+
// dropping any alert: all four fit at 20 columns even for arbitrarily large fleets.
|
|
206
|
+
const alerts = [
|
|
207
|
+
{ count: failed, label: "failed", key: "E" },
|
|
208
|
+
{ count: parked, label: "parked", key: "P" },
|
|
209
|
+
{ count: paused, label: "paused", key: "B" },
|
|
210
|
+
{ count: fallback, label: "fallback", key: "F" },
|
|
211
|
+
].filter(alert => alert.count > 0);
|
|
212
|
+
let flagText = alerts.length ? `[${alerts.map(a => `${a.count} ${a.label}`).join(", ")}]` : "";
|
|
213
|
+
if (visibleWidth(flagText) > width) {
|
|
214
|
+
const compact = alerts.map(a => `${a.key}${a.count > MAX_COMPACT_ALERT_COUNT ? `${MAX_COMPACT_ALERT_COUNT}+` : a.count}`);
|
|
215
|
+
flagText = `[${compact.join(" ")}]`;
|
|
216
|
+
if (visibleWidth(flagText) > width) flagText = `[${compact.join("")}]`;
|
|
217
|
+
// Below the supported 20-column floor, shed lowest-priority alerts first.
|
|
218
|
+
// Failure is first and never becomes the shared helper's misleading [fb].
|
|
219
|
+
while (visibleWidth(flagText) > width && compact.length > 1) {
|
|
220
|
+
compact.pop();
|
|
221
|
+
flagText = `[${compact.join("")}]`;
|
|
222
|
+
}
|
|
223
|
+
if (visibleWidth(flagText) > width) flagText = width > 0 ? "!" : "";
|
|
224
|
+
}
|
|
225
|
+
const flag = theme.fg(failed ? "error" : "warning", flagText);
|
|
226
|
+
const cost = showCost ? formatCost(agents.reduce((sum, agent) => sum + getLifetimeCost(agent.lifetimeUsage), 0)) : "";
|
|
227
|
+
const summary = [summarizeHidden(rows), overflowFailures ? `${overflowFailures} more failed` : ""].filter(Boolean).join(", ") || "0 running";
|
|
228
|
+
const text = `agents: ${summary}${parked ? `, ${parked} parked` : ""}${cost ? ` · ${cost}` : ""} · ${FLEET_COLLAPSE_KEY} to expand`;
|
|
229
|
+
return withLeadingFlag("", alerts.length ? flag : "", theme.fg("dim", text), width);
|
|
230
|
+
}
|
|
231
|
+
|
|
114
232
|
export class FleetList {
|
|
115
233
|
private ui: FleetUICtx | undefined;
|
|
116
234
|
private tui: any | undefined;
|
|
@@ -121,6 +239,14 @@ export class FleetList {
|
|
|
121
239
|
private timer: TimerHandle | undefined;
|
|
122
240
|
|
|
123
241
|
private enabled = true;
|
|
242
|
+
/** Explicit session choice. Navigation temporarily reveals rows without changing it. */
|
|
243
|
+
private readonly journal = new FleetJournal();
|
|
244
|
+
private disclosureTimer: TimerHandle | undefined;
|
|
245
|
+
/** Weak observation prevents repeated journal lookups, without retaining manager records. */
|
|
246
|
+
private readonly observed = new WeakMap<AgentRecord, boolean>();
|
|
247
|
+
private readonly observedWorkflows = new WeakMap<object, number>();
|
|
248
|
+
/** Quota parking has no AgentRecord; the scheduler supplies its enabled-job count. */
|
|
249
|
+
private parkedSource: () => number = () => 0;
|
|
124
250
|
/** Whether arrow keys currently navigate the list (vs. flow to the editor). */
|
|
125
251
|
private active = false;
|
|
126
252
|
/** 0 = `main`, 1..N = subagents. */
|
|
@@ -181,8 +307,56 @@ export class FleetList {
|
|
|
181
307
|
*/
|
|
182
308
|
private detachedSource: () => readonly RunningDetached[] = () => [],
|
|
183
309
|
private readonly clock: Clock = realClock,
|
|
310
|
+
/** @internal Host/test session lookup; a session can disappear after roster construction. */
|
|
311
|
+
private readonly options: { sessionFor?: (record: AgentRecord) => AgentRecord["session"] } = {},
|
|
184
312
|
) {}
|
|
185
313
|
|
|
314
|
+
/** UI-only disclosure; never claims a dialog's key or touches running work. */
|
|
315
|
+
toggleCollapse(): void {
|
|
316
|
+
if (!this.enabled || !this.ui || this.viewerClose || this.viewingWorkflowId || !this.editorHasFocus()) return;
|
|
317
|
+
this.journal.collapsed = this.active ? true : !this.journal.collapsed;
|
|
318
|
+
this.disclosureTimer?.cancel();
|
|
319
|
+
this.disclosureTimer = this.clock.setTimeout(() => this.flushState(), FLEET_DISCLOSURE_SETTLE_MS);
|
|
320
|
+
this.active = false;
|
|
321
|
+
this.selectedIndex = 0;
|
|
322
|
+
this.update();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Restore the last valid active-branch disclosure entry without writing another entry. */
|
|
326
|
+
restoreSession(
|
|
327
|
+
entries: readonly FleetJournalEntry[] | (() => readonly FleetJournalEntry[]),
|
|
328
|
+
persist: (state: FleetSnapshot) => void,
|
|
329
|
+
): void {
|
|
330
|
+
this.disclosureTimer?.cancel();
|
|
331
|
+
this.disclosureTimer = undefined;
|
|
332
|
+
this.journal.restore(typeof entries === "function" ? entries : () => entries, persist);
|
|
333
|
+
this.active = false;
|
|
334
|
+
this.selectedIndex = 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Flush while the activation's appendEntry/context is still valid. */
|
|
338
|
+
flushState(): void {
|
|
339
|
+
this.disclosureTimer?.cancel();
|
|
340
|
+
this.disclosureTimer = undefined;
|
|
341
|
+
this.journal.flushDisclosure();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Terminal callback and detached-notice ingress, never dependent on a render tick. */
|
|
345
|
+
ingestFailure(value: unknown): void {
|
|
346
|
+
const alert = fleetAlert(value);
|
|
347
|
+
if (alert) this.journal.report(alert);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Shared semantic edge for result consumption, RPC and successful human inspection. */
|
|
351
|
+
acknowledgeFailure(id: string): void {
|
|
352
|
+
this.journal.acknowledge(id);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Inject scheduler display data, without coupling the fleet to scheduling behavior. */
|
|
356
|
+
setParkedSource(source: () => number): void {
|
|
357
|
+
this.parkedSource = source;
|
|
358
|
+
}
|
|
359
|
+
|
|
186
360
|
// ---- Lifecycle ----
|
|
187
361
|
|
|
188
362
|
setEnabled(enabled: boolean): void {
|
|
@@ -217,6 +391,7 @@ export class FleetList {
|
|
|
217
391
|
}
|
|
218
392
|
|
|
219
393
|
dispose(): void {
|
|
394
|
+
this.flushState();
|
|
220
395
|
if (this.timer) { this.timer.cancel(); this.timer = undefined; }
|
|
221
396
|
this.inputUnsub?.();
|
|
222
397
|
this.inputUnsub = undefined;
|
|
@@ -237,11 +412,9 @@ export class FleetList {
|
|
|
237
412
|
/** Re-register/refresh the below-editor widget; clears it when nothing remains. */
|
|
238
413
|
update(): void {
|
|
239
414
|
if (!this.ui) return;
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
// anything here" drifted apart once before.
|
|
244
|
-
const hasRows = this.enabled && this.roster().length > 1;
|
|
415
|
+
// Summary existence includes pending starts and quota parking: neither has
|
|
416
|
+
// an openable row yet. Navigation still uses the openable roster only.
|
|
417
|
+
const hasRows = this.enabled && (this.agentRecords(true).length > 0 || this.workflows().length > 0 || this.journal.count > 0 || this.parkedSource() > 0);
|
|
245
418
|
|
|
246
419
|
if (!hasRows) {
|
|
247
420
|
if (this.widgetRegistered) {
|
|
@@ -302,22 +475,45 @@ export class FleetList {
|
|
|
302
475
|
* started sooner sit at the top. Every row is openable (has a session), so Enter
|
|
303
476
|
* never dead-ends. Included: running/queued, plus the agent currently being
|
|
304
477
|
* viewed, plus recently-finished ones (they linger briefly before dropping out).
|
|
305
|
-
* Pending agents
|
|
478
|
+
* Pending agents have no openable row; includePending counts them in the summary.
|
|
306
479
|
* (`listAgents()` is newest-first, so we re-sort.)
|
|
307
480
|
*/
|
|
308
|
-
private agentRecords(): AgentRecord[] {
|
|
481
|
+
private agentRecords(includePending = false): AgentRecord[] {
|
|
309
482
|
const now = this.clock.now();
|
|
310
483
|
// Orphans are live objects, so a settled one is simply skipped; the
|
|
311
484
|
// registry drops it itself. Own records win if an id appears in both.
|
|
312
485
|
const own = this.manager.listAgents();
|
|
313
486
|
const ownIds = new Set(own.map(a => a.id));
|
|
314
487
|
const orphans = this.detachedSource().map(d => d.record).filter(r => !ownIds.has(r.id));
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
)
|
|
488
|
+
const records = new Map([...own, ...orphans].map(a => [a.id, a]));
|
|
489
|
+
for (const a of records.values()) {
|
|
490
|
+
if (!isTopLevelAgent(a) || (a.status !== "error" && a.status !== "aborted")) continue;
|
|
491
|
+
const consumed = !!a.resultConsumed;
|
|
492
|
+
if (this.observed.get(a) === consumed) continue;
|
|
493
|
+
this.observed.set(a, consumed);
|
|
494
|
+
if (consumed) this.acknowledgeFailure(a.id);
|
|
495
|
+
else this.ingestFailure(a);
|
|
496
|
+
}
|
|
497
|
+
for (const alert of this.journal.alerts) {
|
|
498
|
+
if (alert.state !== "error" && alert.state !== "aborted") continue;
|
|
499
|
+
const live = records.get(alert.id);
|
|
500
|
+
if (!live || !isOutstandingFailure(live)) records.set(`fleet-alert:${alert.id}`, {
|
|
501
|
+
id: alert.id, type: alert.type, description: alert.description, status: alert.state,
|
|
502
|
+
startedAt: alert.timestamp, completedAt: alert.timestamp, toolUses: 0, compactionCount: 0,
|
|
503
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return [...records.values()]
|
|
507
|
+
.filter(a => {
|
|
508
|
+
if (!isTopLevelAgent(a)) return false;
|
|
509
|
+
if (isOutstandingFailure(a)) return this.journal.alerts.some(alert => alert.id === a.id);
|
|
510
|
+
// Consumed failures may linger as expanded history, never as outstanding alerts.
|
|
511
|
+
if (includePending && (a.status === "error" || a.status === "aborted")) return false;
|
|
512
|
+
return (includePending || a.session) && (
|
|
513
|
+
a.status === "running" || a.status === "queued" || a.id === this.viewingAgentId
|
|
514
|
+
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
|
|
515
|
+
);
|
|
516
|
+
})
|
|
321
517
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
322
518
|
}
|
|
323
519
|
|
|
@@ -347,14 +543,31 @@ export class FleetList {
|
|
|
347
543
|
|
|
348
544
|
/** Live runs, plus recently settled ones — the same linger the agents get. */
|
|
349
545
|
private workflows(): FleetWorkflow[] {
|
|
350
|
-
if (!this.workflowSource) return [];
|
|
351
546
|
const now = this.clock.now();
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
547
|
+
const runs = new Map((this.workflowSource?.() ?? []).map(run => [run.id, run]));
|
|
548
|
+
for (const run of runs.values()) {
|
|
549
|
+
if (run.status === "failed" || run.status === "killed") {
|
|
550
|
+
const key = run.observationKey ?? run;
|
|
551
|
+
const timestamp = run.completedAt ?? run.startedAt;
|
|
552
|
+
if (this.observedWorkflows.get(key) === timestamp) continue;
|
|
553
|
+
this.observedWorkflows.set(key, timestamp);
|
|
554
|
+
this.ingestFailure({ id: run.id, type: "workflow", description: run.name, status: run.status, startedAt: run.startedAt, completedAt: run.completedAt });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
for (const alert of this.journal.alerts) {
|
|
558
|
+
if (alert.state !== "failed" && alert.state !== "killed") continue;
|
|
559
|
+
const live = runs.get(alert.id);
|
|
560
|
+
if (!live || (live.status !== "failed" && live.status !== "killed")) runs.set(`fleet-alert:${alert.id}`, {
|
|
561
|
+
id: alert.id, name: alert.description, status: alert.state, startedAt: alert.timestamp,
|
|
562
|
+
completedAt: alert.timestamp, doneCount: 0, totalCount: 0, tokens: 0,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return [...runs.values()]
|
|
566
|
+
.filter(run => {
|
|
567
|
+
if (run.status === "failed" || run.status === "killed") return this.journal.alerts.some(alert => alert.id === run.id);
|
|
568
|
+
return run.status === "running" || run.status === "paused"
|
|
569
|
+
|| (run.completedAt != null && now - run.completedAt < FINISHED_LINGER_MS);
|
|
570
|
+
})
|
|
358
571
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
359
572
|
}
|
|
360
573
|
|
|
@@ -399,6 +612,13 @@ export class FleetList {
|
|
|
399
612
|
return undefined;
|
|
400
613
|
}
|
|
401
614
|
|
|
615
|
+
if (matchesKey(data, FLEET_COLLAPSE_KEY)) {
|
|
616
|
+
// Kitty event type 2 is auto-repeat; arrows deliberately still repeat.
|
|
617
|
+
if (isKeyRepeat(data)) return { consume: true };
|
|
618
|
+
this.toggleCollapse();
|
|
619
|
+
return { consume: true };
|
|
620
|
+
}
|
|
621
|
+
|
|
402
622
|
if (!this.active) {
|
|
403
623
|
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
404
624
|
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
|
|
@@ -465,23 +685,36 @@ export class FleetList {
|
|
|
465
685
|
return;
|
|
466
686
|
}
|
|
467
687
|
if (entry.kind === "workflow") {
|
|
688
|
+
if (!this.workflowSource?.().some(run => run.id === entry.workflow.id) || !this.openWorkflow) {
|
|
689
|
+
this.ui?.notify(`workflow: ${entry.workflow.name} (${entry.workflow.status}).`, "error");
|
|
690
|
+
this.acknowledgeFailure(entry.workflow.id);
|
|
691
|
+
this.update();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
468
694
|
// The extension owns this overlay and closes it, so there is no
|
|
469
695
|
// `viewerClose` to hold — but the list still has to know one is up, and
|
|
470
696
|
// still has to put the cursor back on the run when it comes down.
|
|
471
697
|
this.viewingWorkflowId = entry.workflow.id;
|
|
472
698
|
void Promise.resolve(this.openWorkflow?.(entry.workflow.id)).then(
|
|
473
|
-
() => this.clearViewer(),
|
|
699
|
+
() => this.clearViewer(true),
|
|
474
700
|
() => this.clearViewer(),
|
|
475
701
|
);
|
|
476
702
|
return;
|
|
477
703
|
}
|
|
478
704
|
const record = entry.record;
|
|
479
705
|
if (!this.ui) return;
|
|
480
|
-
|
|
706
|
+
const session = this.options.sessionFor ? this.options.sessionFor(record) : record.session;
|
|
707
|
+
if (!session) {
|
|
708
|
+
if (isOutstandingFailure(record)) {
|
|
709
|
+
this.ui.notify(record.error ?? `${record.type}: ${record.description} (${record.status}).`, "error");
|
|
710
|
+
markConsumed(record, this.clock.now());
|
|
711
|
+
this.acknowledgeFailure(record.id);
|
|
712
|
+
this.update();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
481
715
|
this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
|
|
482
716
|
return;
|
|
483
717
|
}
|
|
484
|
-
const session = record.session;
|
|
485
718
|
const activity = this.activityFor(record.id);
|
|
486
719
|
const orphan = this.detachedFor(record.id);
|
|
487
720
|
this.viewingAgentId = record.id;
|
|
@@ -515,11 +748,11 @@ export class FleetList {
|
|
|
515
748
|
overlay: true,
|
|
516
749
|
overlayOptions: () => overlayOptions,
|
|
517
750
|
},
|
|
518
|
-
).then(() => this.clearViewer(), () => this.clearViewer());
|
|
751
|
+
).then(() => this.clearViewer(true), () => this.clearViewer());
|
|
519
752
|
}
|
|
520
753
|
|
|
521
754
|
/** Reset overlay state and return to the list (on close, auto-close, or error). */
|
|
522
|
-
private clearViewer(): void {
|
|
755
|
+
private clearViewer(acknowledge = false): void {
|
|
523
756
|
// Keep the cursor on the agent we were viewing — re-resolve by id so it
|
|
524
757
|
// still feels natural if the list reordered (an earlier agent finished)
|
|
525
758
|
// while the overlay was open. If that agent is gone, leave the index for
|
|
@@ -533,6 +766,17 @@ export class FleetList {
|
|
|
533
766
|
);
|
|
534
767
|
if (idx >= 0) this.selectedIndex = idx;
|
|
535
768
|
}
|
|
769
|
+
if (acknowledge && this.viewingAgentId) {
|
|
770
|
+
const record = this.agentRecords(true).find(a => a.id === this.viewingAgentId);
|
|
771
|
+
if (record && isOutstandingFailure(record)) {
|
|
772
|
+
markConsumed(record, this.clock.now());
|
|
773
|
+
this.acknowledgeFailure(record.id);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (acknowledge && this.viewingWorkflowId) {
|
|
777
|
+
const run = this.workflows().find(run => run.id === this.viewingWorkflowId);
|
|
778
|
+
if (run?.status === "failed" || run?.status === "killed") this.acknowledgeFailure(run.id);
|
|
779
|
+
}
|
|
536
780
|
this.viewerClose = undefined;
|
|
537
781
|
this.viewingAgentId = undefined;
|
|
538
782
|
this.viewingWorkflowId = undefined;
|
|
@@ -543,16 +787,21 @@ export class FleetList {
|
|
|
543
787
|
|
|
544
788
|
private renderBar(width: number, theme: Theme): string[] {
|
|
545
789
|
const rows = this.roster().slice(1) as (WorkflowEntry | AgentEntry)[];
|
|
546
|
-
if (rows.length === 0)
|
|
790
|
+
if ((this.journal.collapsed && !this.active) || rows.length === 0) {
|
|
791
|
+
return [renderFleetSummary(this.agentRecords(true), this.workflows(), this.parkedSource(), this.showCost(), width, theme, this.journal.overflowCount)];
|
|
792
|
+
}
|
|
547
793
|
// Clamp locally so a render between a roster shrink and the next update()
|
|
548
794
|
// (e.g. on terminal resize) never loses the selection marker.
|
|
549
795
|
const sel = Math.min(this.selectedIndex, rows.length);
|
|
550
796
|
|
|
551
797
|
const hint = this.active
|
|
552
798
|
? "↑↓ select · enter view · esc back"
|
|
553
|
-
:
|
|
799
|
+
: `esc to interrupt · ← for agents · ↓ to manage · ${FLEET_COLLAPSE_KEY} to collapse`;
|
|
554
800
|
const lines: string[] = [];
|
|
555
|
-
|
|
801
|
+
// Overflow details are deliberately bounded, but their exact failure count stays visible.
|
|
802
|
+
lines.push(this.journal.overflowCount
|
|
803
|
+
? renderFleetSummary(this.agentRecords(true), this.workflows(), this.parkedSource(), this.showCost(), width, theme, this.journal.overflowCount)
|
|
804
|
+
: truncateToWidth(" " + theme.fg("dim", hint), width));
|
|
556
805
|
lines.push("");
|
|
557
806
|
lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
|
|
558
807
|
|
|
@@ -567,15 +816,26 @@ export class FleetList {
|
|
|
567
816
|
|
|
568
817
|
// Both indicator slots remain in the row block, blank at an edge, so every
|
|
569
818
|
// overflowing roster has the same height.
|
|
570
|
-
// Column widths come from
|
|
819
|
+
// Column widths come from EVERY agent row, not the window, so scrolling
|
|
820
|
+
// never shifts a column; a column too wide for the terminal is dropped
|
|
821
|
+
// lowest-value first, never state, elapsed, tokens or cost.
|
|
571
822
|
const cells = new Map<AgentRecord, string[]>();
|
|
572
|
-
for (const r of rows
|
|
573
|
-
if (r.kind === "agent") cells.set(r.record, this.tailCells(r.record));
|
|
823
|
+
for (const r of rows) {
|
|
824
|
+
if (r.kind === "agent") cells.set(r.record, this.tailCells(r.record, theme));
|
|
574
825
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
826
|
+
// Reserve the widest leading flag across ALL rows before fitting optional
|
|
827
|
+
// columns, so a fallback cannot hide the alias or shift columns on scroll.
|
|
828
|
+
const flagWidth = Math.max(0, ...[...cells.keys()].map(record => {
|
|
829
|
+
const flag = fallbackFlag(theme, record);
|
|
830
|
+
return flag ? visibleWidth(flag) + 1 : 0;
|
|
831
|
+
}));
|
|
832
|
+
const colWidths = this.fitColumns(
|
|
833
|
+
TAIL_SLOTS.map((_, i) => Math.max(0, ...[...cells.values()].map(c => visibleWidth(c[i])))),
|
|
834
|
+
width - flagWidth,
|
|
835
|
+
);
|
|
836
|
+
if (start > 0) {
|
|
837
|
+
lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more: ${summarizeHidden(rows.slice(0, start))}`), width));
|
|
838
|
+
} else if (overflow) lines.push("");
|
|
579
839
|
for (let a = start; a < start + visible; a++) {
|
|
580
840
|
const row = rows[a];
|
|
581
841
|
lines.push(
|
|
@@ -584,8 +844,9 @@ export class FleetList {
|
|
|
584
844
|
: this.renderAgentRow(a + 1, sel, row.record, width, theme, cells.get(row.record) ?? [], colWidths),
|
|
585
845
|
);
|
|
586
846
|
}
|
|
587
|
-
if (hiddenBelow > 0)
|
|
588
|
-
|
|
847
|
+
if (hiddenBelow > 0) {
|
|
848
|
+
lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more: ${summarizeHidden(rows.slice(start + visible))}`), width));
|
|
849
|
+
} else if (overflow) lines.push("");
|
|
589
850
|
// pi stacks below-editor widgets directly on its footer with no spacer (#351).
|
|
590
851
|
lines.push("");
|
|
591
852
|
|
|
@@ -619,8 +880,26 @@ export class FleetList {
|
|
|
619
880
|
return rightAlign(left, selected ? theme.fg("text", stats) : theme.fg("dim", stats), width);
|
|
620
881
|
}
|
|
621
882
|
|
|
883
|
+
/** Zero the widths of droppable columns, lowest value first, until the tail leaves room for the description. */
|
|
884
|
+
private fitColumns(widths: number[], width: number): number[] {
|
|
885
|
+
const fitted = [...widths];
|
|
886
|
+
const tail = () => {
|
|
887
|
+
const used = fitted.filter(w => w > 0);
|
|
888
|
+
return used.reduce((sum, w) => sum + w, 0) + 3 * Math.max(0, used.length - 1);
|
|
889
|
+
};
|
|
890
|
+
const droppable = TAIL_SLOTS
|
|
891
|
+
.map((slot, i) => ({ i, rank: "drop" in slot ? slot.drop : 0 }))
|
|
892
|
+
.filter(c => c.rank > 0)
|
|
893
|
+
.sort((x, y) => x.rank - y.rank);
|
|
894
|
+
for (const { i } of droppable) {
|
|
895
|
+
if (width - tail() >= MIN_LEFT_WIDTH) break;
|
|
896
|
+
fitted[i] = 0;
|
|
897
|
+
}
|
|
898
|
+
return fitted;
|
|
899
|
+
}
|
|
900
|
+
|
|
622
901
|
/** One agent's stats tail as {@link TAIL_SLOTS} cells; "" where a slot has no value. */
|
|
623
|
-
private tailCells(record: AgentRecord): string[] {
|
|
902
|
+
private tailCells(record: AgentRecord, theme: Theme): string[] {
|
|
624
903
|
// The record, not the activity tracker — see the note in AgentWidget's
|
|
625
904
|
// running line: only the record carries a nested child's spend, and only it
|
|
626
905
|
// outlives the agent.
|
|
@@ -632,15 +911,18 @@ export class FleetList {
|
|
|
632
911
|
// Leading, and paired: a thinking level means nothing without its model.
|
|
633
912
|
const { modelName, tags } = buildInvocationTags(record.invocation);
|
|
634
913
|
model = modelName ?? "";
|
|
635
|
-
|
|
914
|
+
// `think:high` here, so the configured level cannot read as a duplicate
|
|
915
|
+
// of the live `thinking · 4s` activity beside it.
|
|
916
|
+
thinking = (tags.find(tag => tag.startsWith("thinking: ")) ?? "").replace(/^thinking: /, "think:");
|
|
636
917
|
}
|
|
918
|
+
const state = agentState(record.status);
|
|
637
919
|
// What the in-flight model call is doing, only while the agent is running:
|
|
638
920
|
// a finished row has no call to describe.
|
|
639
921
|
const call = record.status === "running"
|
|
640
922
|
? describeModelCall(this.activityFor(record.id)?.modelCall, this.clock.now())
|
|
641
923
|
: undefined;
|
|
642
924
|
const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
|
|
643
|
-
return [model, thinking, call ?? "", formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost];
|
|
925
|
+
return [theme.fg(state.color, `${state.glyph} ${state.label}`), model, thinking, call ?? "", formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost];
|
|
644
926
|
}
|
|
645
927
|
|
|
646
928
|
private renderAgentRow(
|
|
@@ -660,16 +942,44 @@ export class FleetList {
|
|
|
660
942
|
const name = renderAgentName(record.type, theme, selected
|
|
661
943
|
? { fallbackColor: "text", bold: hasAgentBadge(record.type) }
|
|
662
944
|
: { fallbackColor: "muted" });
|
|
663
|
-
|
|
664
|
-
|
|
945
|
+
// The spawner's `name` leads when there is one, so two agents of one type
|
|
946
|
+
// with similar descriptions stay distinguishable after truncation.
|
|
947
|
+
const label = record.alias ? `${record.alias}: ${record.description}` : record.description;
|
|
948
|
+
const description = selected ? theme.fg("text", label) : label;
|
|
949
|
+
let prefix = ` ${this.bullet(rosterIndex, sel, theme)} `;
|
|
950
|
+
const left = `${name} ${description}`;
|
|
665
951
|
// Fixed-width slots (blank where a row has no value) keep each column at the
|
|
666
952
|
// same cells on every row; an all-empty column is dropped.
|
|
667
|
-
|
|
953
|
+
let stats = TAIL_SLOTS.map((slot, i) => {
|
|
668
954
|
if (colWidths[i] === 0) return undefined;
|
|
669
955
|
const pad = " ".repeat(colWidths[i] - visibleWidth(cells[i] ?? ""));
|
|
670
956
|
return slot.align === "end" ? (cells[i] ?? "") + pad : pad + (cells[i] ?? "");
|
|
671
957
|
}).filter((c): c is string => c !== undefined).join(" · ");
|
|
672
|
-
|
|
673
|
-
|
|
958
|
+
let flag = fallbackFlag(theme, record);
|
|
959
|
+
if (flag && visibleWidth(prefix + flag) + 1 + visibleWidth(stats) > width) {
|
|
960
|
+
// Description space is exhausted and fitColumns already dropped activity,
|
|
961
|
+
// thinking and model. Use the SHARED helper's compact marker, never slice
|
|
962
|
+
// the flag, before sacrificing any spend cell. Rendering an empty body in
|
|
963
|
+
// the flag budget selects [fb] or ! with the same policy as every renderer.
|
|
964
|
+
const flagBudget = Math.max(1, width - visibleWidth(prefix) - visibleWidth(stats) - 1);
|
|
965
|
+
flag = withLeadingFlag("", flag, "", flagBudget).trimEnd();
|
|
966
|
+
}
|
|
967
|
+
// Every row preserves spend, not just rows carrying a fallback marker.
|
|
968
|
+
const markerWidth = flag ? visibleWidth(flag) + 1 : 0;
|
|
969
|
+
if (visibleWidth(prefix) + markerWidth + visibleWidth(stats) > width) {
|
|
970
|
+
// A state word and padded separators are not spend. Keep its glyph and
|
|
971
|
+
// unpadded elapsed/tokens/cost, then remove navigation padding if needed.
|
|
972
|
+
const spend = cells.slice(4).filter(Boolean);
|
|
973
|
+
stats = [agentState(record.status).glyph, ...spend].join(" ");
|
|
974
|
+
if (visibleWidth(prefix) + markerWidth + visibleWidth(stats) > width) prefix = "";
|
|
975
|
+
const remaining = width - markerWidth;
|
|
976
|
+
if (visibleWidth(stats) > remaining) stats = spend.join(" ");
|
|
977
|
+
// Only when all three cannot physically fit may elapsed yield to spend.
|
|
978
|
+
if (visibleWidth(stats) > remaining) stats = spend.slice(1).join(" ");
|
|
979
|
+
}
|
|
980
|
+
const right = fgPreservingNestedStyles(theme, selected ? "text" : "dim", stats);
|
|
981
|
+
// The flag leads the row (even with `showModel` off: a substituted model is
|
|
982
|
+
// not cosmetic) and the name/description/tail are laid out in what is left.
|
|
983
|
+
return withLeadingFlag(prefix, flag, inner => rightAlign(left, right, inner), width);
|
|
674
984
|
}
|
|
675
985
|
}
|