pi-crew 0.10.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/dist/index.mjs +34850 -34889
- package/package.json +170 -169
- package/scripts/watch-agent-transcript.mjs +120 -0
- package/src/extension/crew-input-router.ts +2 -0
- package/src/extension/crew-vibes/config.ts +1 -56
- package/src/extension/crew-vibes/figures.ts +5 -84
- package/src/extension/crew-vibes/font-detect.ts +1 -1
- package/src/extension/crew-vibes/index.ts +36 -328
- package/src/extension/crew-vibes/render.ts +2 -58
- package/src/extension/help.ts +1 -0
- package/src/extension/registration/command-registration.ts +5 -1
- package/src/extension/registration/commands/schedules.ts +212 -0
- package/src/extension/registration/commands/shared.ts +56 -1
- package/src/extension/registration/lifecycle-handlers.ts +16 -2
- package/src/extension/registration/schedule-toast-bridge.ts +109 -0
- package/src/extension/registration/viewers.ts +66 -1
- package/src/extension/team-tool/handle-schedule.ts +120 -1
- package/src/runtime/scheduling/scheduler.ts +120 -6
- package/src/runtime/settings-store.ts +28 -2
- package/src/state/coordination/locks.ts +17 -2
- package/src/ui/agents-jobs-browser.ts +785 -0
- package/src/ui/dashboard-panes/agents-pane.ts +7 -4
- package/src/ui/dashboard-panes/schedules-pane.ts +154 -0
- package/src/ui/inline-panel/crew-editor.ts +19 -6
- package/src/ui/inline-panel/index.ts +6 -0
- package/src/ui/key-utils.ts +3 -0
- package/src/ui/keybinding-map.ts +56 -2
- package/src/ui/mascot.ts +2 -1
- package/src/ui/overlays/agent-picker-overlay.ts +3 -3
- package/src/ui/overlays/confirm-overlay.ts +3 -2
- package/src/ui/overlays/mailbox-compose-overlay.ts +4 -3
- package/src/ui/overlays/mailbox-detail-overlay.ts +3 -3
- package/src/ui/run-dashboard.ts +257 -6
- package/src/ui/settings-overlay.ts +3 -3
- package/src/ui/transcript-viewer.ts +3 -2
- package/src/ui/widget/index.ts +61 -88
- package/src/ui/widget/widget-formatters.ts +7 -4
- package/src/ui/widget/widget-renderer.ts +134 -174
- package/src/ui/widget/widget-types.ts +0 -7
- package/src/utils/relative-time.ts +33 -0
- package/src/extension/crew-vibes/footer.ts +0 -313
- package/src/extension/crew-vibes/speed.ts +0 -286
- package/src/ui/dock-footer.ts +0 -49
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-jobs-browser.ts — unified "Agents & Jobs" browser overlay.
|
|
3
|
+
*
|
|
4
|
+
* Opened from the run dashboard via ONE keypress (`b` → action `browser` →
|
|
5
|
+
* `RunDashboardAction "browser-open"` → `openAgentsJobsBrowser` in
|
|
6
|
+
* extension/registration/viewers.ts). Follows the live-conversation overlay
|
|
7
|
+
* precedent: a dedicated bottom-anchored overlay, NOT a new dashboard pane —
|
|
8
|
+
* the pane route would churn the ActivePane union + keybinding parity goldens.
|
|
9
|
+
*
|
|
10
|
+
* Layout (width-aware for terminals 80..250):
|
|
11
|
+
*
|
|
12
|
+
* Agents & Jobs — 2 agents · 1 job
|
|
13
|
+
* Live agents (2) │ ▸ adaptive-01-executor
|
|
14
|
+
* › ⠋ Explorer · 41 tok/s │ 0/1 tasks · 1 agents
|
|
15
|
+
* ✓ Writer │ ⠋ adaptive-01-executor executor
|
|
16
|
+
* Scheduled jobs (1) │ reading…
|
|
17
|
+
* › ⏰ watch: omo · cron · next 23h … │
|
|
18
|
+
* [↑↓] navigate · [Enter] … │
|
|
19
|
+
*
|
|
20
|
+
* LEFT column — unified list: live agents first (status glyph via
|
|
21
|
+
* iconForStatus/spinnerFrame, role name, tok/s while running — the SAME
|
|
22
|
+
* widget-formatters formula), then scheduled jobs (⏰ name · schedule · next
|
|
23
|
+
* run relative · enabled ●/○).
|
|
24
|
+
*
|
|
25
|
+
* RIGHT column — detail pane. For agents it REUSES the existing agents-pane
|
|
26
|
+
* detail rendering VERBATIM: `renderAgentsPane` is called with a synthetic
|
|
27
|
+
* one-agent snapshot so its formatting is never forked. For jobs it reuses
|
|
28
|
+
* `renderScheduleDetails` (schedules pane key V) plus enabled + hidden-tier
|
|
29
|
+
* count lines.
|
|
30
|
+
*
|
|
31
|
+
* DATA SOURCES (G17 — hard rule): agents via listLiveAgents()/readCrewAgents;
|
|
32
|
+
* jobs via getScheduledJobs + getScheduledJobsHiddenCountView from
|
|
33
|
+
* extension/team-tool/handle-schedule.ts. This module NEVER imports the
|
|
34
|
+
* scheduler for data (ScheduledJob is a TYPE-only import) and NEVER touches
|
|
35
|
+
* settings keys directly.
|
|
36
|
+
*
|
|
37
|
+
* [p] SURFACE: detection reuses resolve-surface.ts's exported env-signal
|
|
38
|
+
* machinery (`surfaceGateEnvSnapshot`); the pane itself is opened with the
|
|
39
|
+
* EXISTING provider primitives via `surfaceProviderForCleanup` (the same
|
|
40
|
+
* singleton accessor doctor uses — no gate matrix, no new subsystem): a new
|
|
41
|
+
* mux pane running `tail -F` on the selected agent's events log. Known gap
|
|
42
|
+
* (reported, not invented around): providers expose no agent→paneId registry,
|
|
43
|
+
* so [p] cannot FOCUS an existing agent pane — it opens a fresh viewer pane.
|
|
44
|
+
*
|
|
45
|
+
* Headless safety: importing this module is inert — nothing happens until a
|
|
46
|
+
* UI host constructs the class, and the keybinding that opens it lives in the
|
|
47
|
+
* dashboard overlay, so headless sessions never reach it.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
51
|
+
import { join } from "node:path";
|
|
52
|
+
import { fileURLToPath } from "node:url";
|
|
53
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
54
|
+
import { getScheduledJobs, getScheduledJobsHiddenCountView } from "../extension/team-tool/handle-schedule.ts";
|
|
55
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
56
|
+
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
57
|
+
import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
|
|
58
|
+
import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
|
|
59
|
+
import { surfaceGateEnvSnapshot } from "../runtime/surface/resolve-surface.ts";
|
|
60
|
+
import { getTaskUsage } from "../runtime/usage-tracker.ts";
|
|
61
|
+
import { readJsonFile } from "../state/atomic-write.ts";
|
|
62
|
+
import { loadRunManifestById } from "../state/stores/state-store.ts";
|
|
63
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
64
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
65
|
+
import { formatRelativeTime } from "../utils/relative-time.ts";
|
|
66
|
+
import { pad, sanitizeLine, truncate, visibleWidth } from "../utils/visual.ts";
|
|
67
|
+
import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
|
|
68
|
+
import { renderScheduleDetails, schedulesHiddenJobsHintLine } from "./dashboard-panes/schedules-pane.ts";
|
|
69
|
+
import { computeLiveDurationMs } from "./live-duration.ts";
|
|
70
|
+
import type { RunUiSnapshot } from "./snapshot-types.ts";
|
|
71
|
+
import { spinnerFrame } from "./spinner.ts";
|
|
72
|
+
import { iconForStatus } from "./status-colors.ts";
|
|
73
|
+
import type { CrewTheme } from "./theme-adapter.ts";
|
|
74
|
+
import { activeWidgetRuns } from "./widget/widget-model.ts";
|
|
75
|
+
|
|
76
|
+
// ─── Public data types ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/** A live agent row in the unified list. */
|
|
79
|
+
export interface AgentsBrowserAgentEntry {
|
|
80
|
+
kind: "agent";
|
|
81
|
+
runId: string;
|
|
82
|
+
taskId: string;
|
|
83
|
+
/** Display role (Explorer/Writer/…) — handle.role, else durable record role. */
|
|
84
|
+
role: string;
|
|
85
|
+
agentName?: string;
|
|
86
|
+
status: CrewAgentRecord["status"];
|
|
87
|
+
/** tok/s while running (widget-formatters formula); undefined otherwise. */
|
|
88
|
+
tokPerSec?: number;
|
|
89
|
+
/** Durable record for detail rendering; undefined when manifest is gone. */
|
|
90
|
+
record?: CrewAgentRecord;
|
|
91
|
+
/** Manifest from the widget pipeline (activeWidgetRuns) — avoids a second
|
|
92
|
+
* lookup through state-store whose root resolution differs from run-index. */
|
|
93
|
+
manifest?: TeamRunManifest;
|
|
94
|
+
/** Live handle when the agent is still tracked in-process. */
|
|
95
|
+
handle?: LiveAgentHandle;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A scheduled-job row in the unified list. */
|
|
99
|
+
export interface AgentsBrowserJobEntry {
|
|
100
|
+
kind: "job";
|
|
101
|
+
job: ScheduledJob;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type AgentsJobsBrowserEntry = AgentsBrowserAgentEntry | AgentsBrowserJobEntry;
|
|
105
|
+
|
|
106
|
+
/** Column focus of the keyboard state machine. */
|
|
107
|
+
export type AgentsJobsBrowserFocus = "list" | "detail";
|
|
108
|
+
|
|
109
|
+
// ─── Options ───────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export interface AgentsJobsBrowserOptions {
|
|
112
|
+
/** cwd for job reads (G17 provider) and run manifests. */
|
|
113
|
+
cwd: string;
|
|
114
|
+
/** Session/workspace filter for live agents (mirrors the dashboard). */
|
|
115
|
+
workspaceId?: string;
|
|
116
|
+
theme?: CrewTheme;
|
|
117
|
+
/** Injected clock (D6-T4) — render paths never read the wall clock directly. */
|
|
118
|
+
now?: () => number;
|
|
119
|
+
/** Terminal size hint; `render(width)` still governs per-line truncation. */
|
|
120
|
+
columns?: number;
|
|
121
|
+
rows?: number;
|
|
122
|
+
/** Test seam: replace the live-agent data source. */
|
|
123
|
+
agentsProvider?: () => AgentsBrowserAgentEntry[];
|
|
124
|
+
/** Wired by viewers.ts: open the DurableTranscriptViewer overlay for an agent (the `/crew transcript` experience). */
|
|
125
|
+
onOpenTranscript?: (entry: { runId: string; taskId: string }) => void;
|
|
126
|
+
/** Test seam: replace the job data source (G17 provider by default). */
|
|
127
|
+
jobsProvider?: () => { jobs: ScheduledJob[]; hiddenCount: number };
|
|
128
|
+
/** Test seam: override surface reachability (default: env probe). */
|
|
129
|
+
surfaceReachable?: boolean;
|
|
130
|
+
/** Test seam: replace the [p] surface action. */
|
|
131
|
+
surfaceAgent?: (entry: AgentsBrowserAgentEntry, tailPath: string | undefined) => Promise<boolean>;
|
|
132
|
+
/** Host repaint hook — called by the refresh timer so spinners animate. */
|
|
133
|
+
requestRender?: () => void;
|
|
134
|
+
/** Data cache TTL ms (default 600). 0 = always read fresh. */
|
|
135
|
+
refreshTtlMs?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Layout constants ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
const REFRESH_TTL_MS_DEFAULT = 600;
|
|
141
|
+
const POLL_INTERVAL_MS = 400;
|
|
142
|
+
/** Body height clamp — mirrors the dashboard's stable-height lesson: a
|
|
143
|
+
* fluctuating line count shifts the overlay anchor every frame. */
|
|
144
|
+
const MIN_BODY = 8;
|
|
145
|
+
const MAX_BODY = 24;
|
|
146
|
+
const MIN_LIST_WIDTH = 26;
|
|
147
|
+
const MAX_LIST_WIDTH = 64;
|
|
148
|
+
|
|
149
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* tok/s for a RUNNING live agent — the exact widget-formatters "viewed"
|
|
153
|
+
* formula (dockUsageText): (input+output+cacheWrite) / elapsed seconds,
|
|
154
|
+
* only when the agent has >1s of history. Reuses getTaskUsage +
|
|
155
|
+
* computeLiveDurationMs rather than forking the math.
|
|
156
|
+
*/
|
|
157
|
+
export function agentTokPerSec(handle: LiveAgentHandle, nowMs: number): number | undefined {
|
|
158
|
+
if (handle.status !== "running") return undefined;
|
|
159
|
+
const usage = getTaskUsage(handle.taskId);
|
|
160
|
+
const totalTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
|
|
161
|
+
if (totalTokens <= 0) return undefined;
|
|
162
|
+
const ms = computeLiveDurationMs(handle.activity, nowMs);
|
|
163
|
+
if (ms <= 1000) return undefined;
|
|
164
|
+
const tps = Math.round(totalTokens / (ms / 1000));
|
|
165
|
+
return tps > 0 ? tps : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Resolve scripts/watch-agent-transcript.mjs in both repo (src) and dist layouts. */
|
|
169
|
+
function watchAgentTranscriptScript(): string | undefined {
|
|
170
|
+
// Live bug (2026-09-14): `new URL(...)` NEVER throws for well-formed paths,
|
|
171
|
+
// so a try/catch fallback never ran — under the dist bundle the first
|
|
172
|
+
// candidate resolved to <workspace>/scripts/... which does not exist and
|
|
173
|
+
// the pane died with "Cannot find module". Probe with existsSync instead.
|
|
174
|
+
// Layouts: src/ui/<file>.ts → ../../scripts · dist/index.mjs → ../scripts
|
|
175
|
+
const candidates = [
|
|
176
|
+
fileURLToPath(new URL("../../scripts/watch-agent-transcript.mjs", import.meta.url)),
|
|
177
|
+
fileURLToPath(new URL("../scripts/watch-agent-transcript.mjs", import.meta.url)),
|
|
178
|
+
];
|
|
179
|
+
return candidates.find((c) => existsSync(c));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** tok/s for record-based entries (no live handle in this process). */
|
|
183
|
+
function recordTokPerSec(record: CrewAgentRecord, nowMs: number): number | undefined {
|
|
184
|
+
if (record.status !== "running") return undefined;
|
|
185
|
+
try {
|
|
186
|
+
const usage = getTaskUsage(record.taskId);
|
|
187
|
+
const total = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheWrite ?? 0);
|
|
188
|
+
if (total <= 0) return undefined;
|
|
189
|
+
const started = record.startedAt ? Date.parse(record.startedAt) : Number.NaN;
|
|
190
|
+
if (!Number.isFinite(started)) return undefined;
|
|
191
|
+
const ms = Math.max(0, nowMs - started);
|
|
192
|
+
if (ms < 1000) return undefined;
|
|
193
|
+
const tps = Math.round(total / (ms / 1000));
|
|
194
|
+
return tps > 0 ? tps : undefined;
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Status rank for "live agents first, running before the rest" ordering. */
|
|
201
|
+
function statusRank(status: CrewAgentRecord["status"]): number {
|
|
202
|
+
if (status === "running") return 0;
|
|
203
|
+
if (status === "waiting" || status === "queued" || status === "needs_attention") return 1;
|
|
204
|
+
return 2;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Which mux can host a viewer pane RIGHT NOW. Reuses resolve-surface.ts's
|
|
209
|
+
* exported env-signal snapshot: $TMUX implies the tmux binary (we are inside
|
|
210
|
+
* tmux), HERDR_ENV=1 implies the herdr host env. NOTE (gap, reported): the
|
|
211
|
+
* socket liveness ping used by resolveSurfaceDetailed is module-private, so
|
|
212
|
+
* herdr detection here is env-signal-only — a dead socket surfaces as a
|
|
213
|
+
* createSurface failure which is caught and logged, never thrown.
|
|
214
|
+
*/
|
|
215
|
+
export function detectViewSurfaceKind(env: NodeJS.ProcessEnv): "tmux" | "herdr" | null {
|
|
216
|
+
const snapshot = surfaceGateEnvSnapshot(env);
|
|
217
|
+
if (snapshot.tmux) return "tmux";
|
|
218
|
+
if (snapshot.herdrEnv) return "herdr";
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** POSIX single-quote — the tail path contains no shell metachars after this. */
|
|
223
|
+
function shellQuote(value: string): string {
|
|
224
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── The browser ───────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
export class AgentsJobsBrowser {
|
|
230
|
+
private readonly options: AgentsJobsBrowserOptions;
|
|
231
|
+
private cachedEntries: AgentsJobsBrowserEntry[] = [];
|
|
232
|
+
private cachedAt = -Infinity;
|
|
233
|
+
private hiddenCount = 0;
|
|
234
|
+
private selected = 0;
|
|
235
|
+
private listScroll = 0;
|
|
236
|
+
private focus: AgentsJobsBrowserFocus = "list";
|
|
237
|
+
private detailScroll = 0;
|
|
238
|
+
private closed = false;
|
|
239
|
+
/** Transient header notice (e.g. why `p` did nothing) — auto-expires. */
|
|
240
|
+
private notice: { text: string; until: number } | undefined;
|
|
241
|
+
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
242
|
+
private readonly manifests = new Map<string, TeamRunManifest | null>();
|
|
243
|
+
|
|
244
|
+
constructor(options: AgentsJobsBrowserOptions) {
|
|
245
|
+
this.options = options;
|
|
246
|
+
this.refreshData(true);
|
|
247
|
+
this.pollTimer = setInterval(() => {
|
|
248
|
+
if (this.closed) return;
|
|
249
|
+
this.refreshData(false);
|
|
250
|
+
try {
|
|
251
|
+
this.options.requestRender?.();
|
|
252
|
+
} catch {
|
|
253
|
+
/* host may be gone */
|
|
254
|
+
}
|
|
255
|
+
}, POLL_INTERVAL_MS);
|
|
256
|
+
this.pollTimer.unref();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Test-facing read access ──────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
get entriesView(): readonly AgentsJobsBrowserEntry[] {
|
|
262
|
+
return this.cachedEntries;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
get selectedIndex(): number {
|
|
266
|
+
return this.selected;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get focusMode(): AgentsJobsBrowserFocus {
|
|
270
|
+
return this.focus;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get hiddenJobsCount(): number {
|
|
274
|
+
return this.hiddenCount;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get isClosed(): boolean {
|
|
278
|
+
return this.closed;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
close(): void {
|
|
284
|
+
this.closed = true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
dispose(): void {
|
|
288
|
+
this.closed = true;
|
|
289
|
+
if (this.pollTimer) {
|
|
290
|
+
clearInterval(this.pollTimer);
|
|
291
|
+
this.pollTimer = undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Retired with the p key (2026-09-14): kept for the future "focus the
|
|
296
|
+
// existing surface pane" key. Re-wire before uncommenting.
|
|
297
|
+
// private setNotice(text: string): void {
|
|
298
|
+
// this.notice = { text, until: this.nowMs() + 2500 };
|
|
299
|
+
// this.options.requestRender?.();
|
|
300
|
+
// }
|
|
301
|
+
|
|
302
|
+
private nowMs(): number {
|
|
303
|
+
return this.options.now ? this.options.now() : Date.now();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Data loading (G17 sources only) ──────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** Reload the merged list when the TTL expired (or `force`). */
|
|
309
|
+
refreshData(force = false): void {
|
|
310
|
+
const ttl = this.options.refreshTtlMs ?? REFRESH_TTL_MS_DEFAULT;
|
|
311
|
+
const at = this.nowMs();
|
|
312
|
+
if (!force && ttl > 0 && at - this.cachedAt < ttl) return;
|
|
313
|
+
this.cachedAt = at;
|
|
314
|
+
const agents = this.loadAgents();
|
|
315
|
+
const { jobs, hiddenCount } = this.loadJobs();
|
|
316
|
+
this.hiddenCount = hiddenCount;
|
|
317
|
+
this.cachedEntries = [...agents, ...jobs.map((job) => ({ kind: "job" as const, job }))];
|
|
318
|
+
if (this.selected >= this.cachedEntries.length) this.selected = Math.max(0, this.cachedEntries.length - 1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private loadAgents(): AgentsBrowserAgentEntry[] {
|
|
322
|
+
if (this.options.agentsProvider) {
|
|
323
|
+
return [...this.options.agentsProvider()].sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
324
|
+
}
|
|
325
|
+
// FIX (live probe 2026-09-14): read the SAME pipeline the widget counts
|
|
326
|
+
// use — activeWidgetRuns() over `.crew/state/runs/*/agents.json` — NOT the
|
|
327
|
+
// in-process live-agent registry. Agent-tool subagents (foreground AND
|
|
328
|
+
// background) run as child processes and never registerLiveAgent() in this
|
|
329
|
+
// process, so the registry listed nothing while the widget said "1 running".
|
|
330
|
+
// Source parity with the widget also means the counts can never disagree.
|
|
331
|
+
let runs: ReturnType<typeof activeWidgetRuns> = [];
|
|
332
|
+
try {
|
|
333
|
+
runs = activeWidgetRuns(this.options.cwd, undefined, undefined, undefined, this.options.workspaceId);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
logInternalError("agents-browser.loadAgents", error as Error);
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
const nowMs = this.nowMs();
|
|
339
|
+
const entries: AgentsBrowserAgentEntry[] = [];
|
|
340
|
+
for (const { run, agents } of runs) {
|
|
341
|
+
for (const record of agents) {
|
|
342
|
+
entries.push({
|
|
343
|
+
kind: "agent",
|
|
344
|
+
runId: run.runId,
|
|
345
|
+
taskId: record.taskId,
|
|
346
|
+
role: record.role || record.agent || "agent",
|
|
347
|
+
agentName: record.agent,
|
|
348
|
+
status: record.status,
|
|
349
|
+
tokPerSec: recordTokPerSec(record, nowMs),
|
|
350
|
+
record,
|
|
351
|
+
manifest: run,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return entries.sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private loadJobs(): { jobs: ScheduledJob[]; hiddenCount: number } {
|
|
359
|
+
if (this.options.jobsProvider) return this.options.jobsProvider();
|
|
360
|
+
try {
|
|
361
|
+
return { jobs: getScheduledJobs(this.options.cwd), hiddenCount: getScheduledJobsHiddenCountView(this.options.cwd) };
|
|
362
|
+
} catch {
|
|
363
|
+
return { jobs: [], hiddenCount: 0 };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private manifestFor(runId: string): TeamRunManifest | null {
|
|
368
|
+
if (this.manifests.has(runId)) return this.manifests.get(runId) ?? null;
|
|
369
|
+
let manifest: TeamRunManifest | null = null;
|
|
370
|
+
try {
|
|
371
|
+
const loaded = loadRunManifestById(this.options.cwd, runId); // NOTE: no run lock — best-effort read, mirrors viewers.ts
|
|
372
|
+
manifest = loaded ? loaded.manifest : null;
|
|
373
|
+
} catch {
|
|
374
|
+
manifest = null;
|
|
375
|
+
}
|
|
376
|
+
this.manifests.set(runId, manifest);
|
|
377
|
+
return manifest;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private recordFor(runId: string, taskId: string): CrewAgentRecord | undefined {
|
|
381
|
+
const manifest = this.manifestFor(runId);
|
|
382
|
+
if (!manifest) return undefined;
|
|
383
|
+
try {
|
|
384
|
+
return readCrewAgents(manifest).find((record) => record.taskId === taskId);
|
|
385
|
+
} catch {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Detail rendering (VERBATIM pane reuse) ───────────────────────────
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* RIGHT-column detail for the selected entry.
|
|
394
|
+
*
|
|
395
|
+
* Agents: `renderAgentsPane` with a synthetic ONE-agent snapshot — the
|
|
396
|
+
* existing pane formatting is reused verbatim, never forked. Jobs:
|
|
397
|
+
* `renderScheduleDetails` (schedules-pane V view) + enabled + hidden-tier
|
|
398
|
+
* count lines.
|
|
399
|
+
*/
|
|
400
|
+
detailLinesFor(entry: AgentsJobsBrowserEntry): string[] {
|
|
401
|
+
if (entry.kind === "job") {
|
|
402
|
+
const lines = renderScheduleDetails(entry.job, new Date(this.nowMs()));
|
|
403
|
+
lines.push(` enabled: ${entry.job.enabled ? "● on" : "○ off"}`);
|
|
404
|
+
if (this.hiddenCount > 0) lines.push(` hidden project-tier jobs: ${this.hiddenCount}`);
|
|
405
|
+
return lines;
|
|
406
|
+
}
|
|
407
|
+
const record = entry.record ?? this.recordFor(entry.runId, entry.taskId);
|
|
408
|
+
const manifest = this.manifestFor(entry.runId);
|
|
409
|
+
if (!record || !manifest) {
|
|
410
|
+
return [
|
|
411
|
+
`▸ ${entry.taskId} (${entry.role})`,
|
|
412
|
+
` status: ${entry.status}`,
|
|
413
|
+
" (agent record unavailable — run manifest not found)",
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
const snapshot: RunUiSnapshot = {
|
|
417
|
+
runId: record.runId,
|
|
418
|
+
cwd: this.options.cwd,
|
|
419
|
+
fetchedAt: this.nowMs(),
|
|
420
|
+
signature: `browser:${record.taskId}`,
|
|
421
|
+
manifest,
|
|
422
|
+
tasks: [],
|
|
423
|
+
agents: [record],
|
|
424
|
+
progress: {
|
|
425
|
+
total: 1,
|
|
426
|
+
completed: record.status === "completed" ? 1 : 0,
|
|
427
|
+
running: record.status === "running" ? 1 : 0,
|
|
428
|
+
failed: record.status === "failed" ? 1 : 0,
|
|
429
|
+
queued: record.status === "queued" ? 1 : 0,
|
|
430
|
+
waiting: record.status === "waiting" ? 1 : 0,
|
|
431
|
+
},
|
|
432
|
+
usage: {
|
|
433
|
+
tokensIn: record.usage?.input ?? 0,
|
|
434
|
+
tokensOut: record.usage?.output ?? 0,
|
|
435
|
+
toolUses: record.toolUses ?? 0,
|
|
436
|
+
},
|
|
437
|
+
mailbox: { inboxUnread: 0, outboxPending: 0, needsAttention: 0 },
|
|
438
|
+
recentEvents: [],
|
|
439
|
+
recentOutputLines: [],
|
|
440
|
+
};
|
|
441
|
+
return renderAgentsPane(snapshot, { workspaceId: this.options.workspaceId, nowMs: this.nowMs() });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── [p] surface action ───────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
/** Best-effort tail target for the selected agent: events log first. */
|
|
447
|
+
/**
|
|
448
|
+
* Session transcript path for the watcher pane — the agent's actual
|
|
449
|
+
* conversation stream.
|
|
450
|
+
*
|
|
451
|
+
* Three tiers (live bug 2026-09-14: `agents.json` records for RUNNING
|
|
452
|
+
* agents have transcriptPath=NULL — the path only lands there on
|
|
453
|
+
* completion. The live source is the per-agent status.json that
|
|
454
|
+
* live-executor maintains from the first turn, pointed to by
|
|
455
|
+
* record.statusPath. Without this tier, `p` died silently on every
|
|
456
|
+
* running agent.)
|
|
457
|
+
*/
|
|
458
|
+
transcriptPathFor(entry: AgentsBrowserAgentEntry): string | undefined {
|
|
459
|
+
const record = entry.record ?? this.recordFor(entry.runId, entry.taskId);
|
|
460
|
+
if (record?.transcriptPath) return record.transcriptPath;
|
|
461
|
+
if (record?.statusPath) {
|
|
462
|
+
try {
|
|
463
|
+
const status = readJsonFile<{ transcriptPath?: string }>(record.statusPath);
|
|
464
|
+
if (status?.transcriptPath) return status.transcriptPath;
|
|
465
|
+
} catch {
|
|
466
|
+
/* status file mid-write — fall through */
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Tier 3 (live probe 2026-09-14, rounds 6-8): for a RUNNING agent
|
|
470
|
+
// neither agents.json nor status.json carries transcriptPath, and with
|
|
471
|
+
// a cold-starting (jiti) worker the transcript FILE may not exist for
|
|
472
|
+
// the first minute either. Resolve the convention path and let the
|
|
473
|
+
// watcher pane WAIT for it — p must open the pane, never refuse.
|
|
474
|
+
const manifest = entry.manifest ?? this.manifestFor(entry.runId);
|
|
475
|
+
if (manifest?.artifactsRoot) {
|
|
476
|
+
const dir = join(manifest.artifactsRoot, "transcripts");
|
|
477
|
+
try {
|
|
478
|
+
const hit = readdirSync(dir)
|
|
479
|
+
.filter((name) => name.startsWith(`${entry.taskId}.attempt-`) && name.endsWith(".jsonl"))
|
|
480
|
+
.sort()
|
|
481
|
+
.at(-1);
|
|
482
|
+
if (hit) return join(dir, hit);
|
|
483
|
+
} catch {
|
|
484
|
+
/* transcripts dir not created yet */
|
|
485
|
+
}
|
|
486
|
+
// Placeholder: attempt-0 is what the first child pi run will open.
|
|
487
|
+
return join(dir, `${entry.taskId}.attempt-0.jsonl`);
|
|
488
|
+
}
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
tailPathFor(entry: AgentsBrowserAgentEntry): string | undefined {
|
|
493
|
+
return this.transcriptPathFor(entry);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private surfaceReachable(): boolean {
|
|
497
|
+
if (this.options.surfaceReachable !== undefined) return this.options.surfaceReachable;
|
|
498
|
+
return detectViewSurfaceKind(process.env) !== null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// (retired with the p key, 2026-09-14 — kept for the future 'focus the
|
|
502
|
+
// existing surface pane' key; see handleInput)
|
|
503
|
+
// /**
|
|
504
|
+
// * [p] — open a mux viewer pane tailing the selected agent's events log.
|
|
505
|
+
// * Reuses the EXISTING provider primitives (surfaceProviderForCleanup →
|
|
506
|
+
// * createSurface); fire-and-forget from the sync handleInput.
|
|
507
|
+
// */
|
|
508
|
+
// private surfaceSelectedAgent(entry: AgentsBrowserAgentEntry): void {
|
|
509
|
+
// const action = this.options.surfaceAgent ?? this.defaultSurfaceAgent.bind(this);
|
|
510
|
+
// void action(entry, this.tailPathFor(entry)).catch(() => {
|
|
511
|
+
// /* provider failure — logged inside defaultSurfaceAgent */
|
|
512
|
+
// });
|
|
513
|
+
// }
|
|
514
|
+
//
|
|
515
|
+
// private async defaultSurfaceAgent(entry: AgentsBrowserAgentEntry, tailPath: string | undefined): Promise<boolean> {
|
|
516
|
+
// try {
|
|
517
|
+
// const kind = detectViewSurfaceKind(process.env);
|
|
518
|
+
// if (!kind || !tailPath) return false;
|
|
519
|
+
// const watcherScript = watchAgentTranscriptScript();
|
|
520
|
+
// const provider = surfaceProviderForCleanup(kind);
|
|
521
|
+
// if (!provider) return false;
|
|
522
|
+
// // Legacy (no tabKey) path: the viewer pane splits from the HOST's
|
|
523
|
+
// // pane, lives independently of any run, and stays open until the
|
|
524
|
+
// // user closes it — no onExit watcher is registered, so nothing leaks.
|
|
525
|
+
// await provider.createSurface(`crew-view-${entry.taskId.slice(0, 12)}`, {
|
|
526
|
+
// cwd: this.options.cwd,
|
|
527
|
+
// // Watcher formats the session transcript one readable line per
|
|
528
|
+
// // message (live "what is the agent doing") — raw tail would
|
|
529
|
+
// // show unbounded JSON walls. Missing script (unexpected layout)
|
|
530
|
+
// // degrades to plain tail so the pane still opens.
|
|
531
|
+
// command: watcherScript
|
|
532
|
+
// ? `${shellQuote(process.execPath)} ${shellQuote(watcherScript)} ${shellQuote(tailPath)}`
|
|
533
|
+
// : `tail -n 40 -F ${shellQuote(tailPath)}`,
|
|
534
|
+
// title: `crew: ${entry.role}/${entry.taskId.slice(-8)}`,
|
|
535
|
+
// });
|
|
536
|
+
// return true;
|
|
537
|
+
// } catch (error) {
|
|
538
|
+
// logInternalError("agents-jobs-browser", error instanceof Error ? error : new Error(String(error)));
|
|
539
|
+
// return false;
|
|
540
|
+
// }
|
|
541
|
+
// }
|
|
542
|
+
//
|
|
543
|
+
//
|
|
544
|
+
|
|
545
|
+
// ── Keyboard state machine ───────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Overlay input handling (overlays are mutually exclusive — the dashboard's
|
|
549
|
+
* dispatch never sees these keys while this overlay owns input).
|
|
550
|
+
*
|
|
551
|
+
* list focus: ↑/↓/k/j move · Enter/\r/\n open detail · p surface (agent +
|
|
552
|
+
* reachable only) · q/Esc/\x1b close.
|
|
553
|
+
* detail focus: ↑/↓/k/j scroll · Esc/\x1b/q/Enter back to list.
|
|
554
|
+
*/
|
|
555
|
+
handleInput(data: string): void {
|
|
556
|
+
if (this.closed) return;
|
|
557
|
+
// Key normalization (freeze fix 2026-09-14): NEVER raw-compare terminal
|
|
558
|
+
// bytes — terminals using the kitty/enhanced keyboard protocol deliver
|
|
559
|
+
// Escape as `\x1b[27u` and letters as `\x1b[<code>u`, which match NO
|
|
560
|
+
// legacy sequence and left the overlay permanently stuck (live bug:
|
|
561
|
+
// every key dead, uncloseable). `matchesKey` is the SAME normalizer
|
|
562
|
+
// every other pi-crew input surface uses (crew-editor precedent).
|
|
563
|
+
const up = matchesKey(data, "k") || matchesKey(data, "up");
|
|
564
|
+
const down = matchesKey(data, "j") || matchesKey(data, "down");
|
|
565
|
+
if (this.focus === "list") {
|
|
566
|
+
if (up || down) {
|
|
567
|
+
const count = this.cachedEntries.length;
|
|
568
|
+
if (count > 0) {
|
|
569
|
+
this.selected = up ? Math.max(0, this.selected - 1) : Math.min(count - 1, this.selected + 1);
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (matchesKey(data, "return")) {
|
|
574
|
+
const entry = this.cachedEntries[this.selected];
|
|
575
|
+
// Agents open the full DurableTranscriptViewer (the `/crew
|
|
576
|
+
// transcript` overlay — "chi tiết như subagent cũ"); the inline
|
|
577
|
+
// detail column stays as the quick summary. Jobs keep the
|
|
578
|
+
// inline detail focus.
|
|
579
|
+
if (entry?.kind === "agent" && this.options.onOpenTranscript) {
|
|
580
|
+
this.options.onOpenTranscript({ runId: entry.runId, taskId: entry.taskId });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (this.cachedEntries.length > 0) {
|
|
584
|
+
this.focus = "detail";
|
|
585
|
+
this.detailScroll = 0;
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// DISABLED (2026-09-14, user): the p watcher pane did NOT meet the
|
|
590
|
+
// spec — "pane phải mở lên 1 session hoàn chỉnh, xem trực tiếp live
|
|
591
|
+
// session, không được resume". Live sessions now come from
|
|
592
|
+
// runtime.surface (visibleAgents: ["agent"]) which boots workers as
|
|
593
|
+
// real pi TUIs in their own panes; this transcript-watcher pane is
|
|
594
|
+
// retired until it can FOCUS an existing surface pane instead.
|
|
595
|
+
// if (matchesKey(data, "p")) {
|
|
596
|
+
// const entry = this.cachedEntries[this.selected];
|
|
597
|
+
// if (entry && entry.kind === "agent") {
|
|
598
|
+
// if (!this.surfaceReachable()) {
|
|
599
|
+
// this.setNotice("⚠ no tmux/herdr surface — pi must run inside tmux OR herdr for panes");
|
|
600
|
+
// } else {
|
|
601
|
+
// this.surfaceSelectedAgent(entry);
|
|
602
|
+
// }
|
|
603
|
+
// }
|
|
604
|
+
// return;
|
|
605
|
+
// }
|
|
606
|
+
if (matchesKey(data, "q") || matchesKey(data, "escape")) {
|
|
607
|
+
this.close();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
// detail focus
|
|
613
|
+
if (up || down) {
|
|
614
|
+
this.detailScroll = up ? Math.max(0, this.detailScroll - 1) : this.detailScroll + 1;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (matchesKey(data, "q") || matchesKey(data, "escape") || matchesKey(data, "return")) {
|
|
618
|
+
this.focus = "list";
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── Render ───────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
/** Render the overlay. Width-aware for 80..250 columns; lines never
|
|
626
|
+
* exceed `width` (truncate/visibleWidth from utils/visual — the same
|
|
627
|
+
* width model pi-tui enforces). */
|
|
628
|
+
render(width?: number): string[] {
|
|
629
|
+
// FRAMED PANEL (redesign 2026-09-14, "dễ nhìn"): a full box —
|
|
630
|
+
//
|
|
631
|
+
// ╭─ Agents & Jobs ── 2 agents · 1 job ─────────────────────╮
|
|
632
|
+
// │ Live agents (2) │ ▸ adaptive-01-executor │
|
|
633
|
+
// │ › ⠋ Explorer · 41 t/s │ 0/1 tasks · 1 agents │
|
|
634
|
+
// ╰─ [↑↓] move · [⏎] detail · [p] pane · [q] close ────────╯
|
|
635
|
+
//
|
|
636
|
+
// Title + counts embed in the TOP border, key hints in the BOTTOM
|
|
637
|
+
// border (the omp EditorTopBorder idiom) so the body is pure content
|
|
638
|
+
// and the panel reads as one visual unit over the terminal.
|
|
639
|
+
const w = Math.max(60, width ?? this.options.columns ?? 80);
|
|
640
|
+
const nowMs = this.nowMs();
|
|
641
|
+
// Inner budget: 2 border cols + 4 padding cols + 1 divider col.
|
|
642
|
+
const inner = w - 7;
|
|
643
|
+
const listWidth = Math.max(20, Math.min(MAX_LIST_WIDTH, Math.round(inner * 0.42)));
|
|
644
|
+
const detailWidth = Math.max(16, inner - listWidth);
|
|
645
|
+
const maxBody = Math.max(MIN_BODY, Math.min(MAX_BODY, (this.options.rows ?? 24) - 6));
|
|
646
|
+
const dim = (text: string) => (this.options.theme ? this.options.theme.fg("border", text) : text);
|
|
647
|
+
const accent = (text: string) => (this.options.theme ? this.options.theme.fg("accent", text) : text);
|
|
648
|
+
const sep = dim("│");
|
|
649
|
+
|
|
650
|
+
const counts = [
|
|
651
|
+
`${this.countAgents()} agent${this.countAgents() === 1 ? "" : "s"}`,
|
|
652
|
+
`${this.countJobs()} job${this.countJobs() === 1 ? "" : "s"}`,
|
|
653
|
+
];
|
|
654
|
+
if (this.hiddenCount > 0) counts.push(`${this.hiddenCount} hidden`);
|
|
655
|
+
const activeNotice = this.notice && this.notice.until > nowMs ? ` ${this.notice.text} ` : ` ${counts.join(" · ")} `;
|
|
656
|
+
const lines: string[] = [this.framedBorderRow("top", accent(` Agents & Jobs `), dim(truncate(activeNotice, w - 20)), w)];
|
|
657
|
+
const hiddenHint = schedulesHiddenJobsHintLine(this.hiddenCount);
|
|
658
|
+
if (hiddenHint) lines.push(`${dim("│")} ${pad(dim(truncate(hiddenHint, w - 4)), w - 3)}${dim("│")}`);
|
|
659
|
+
|
|
660
|
+
const left = this.renderListColumn(listWidth, maxBody, nowMs);
|
|
661
|
+
const right = this.renderDetailColumn(detailWidth, maxBody);
|
|
662
|
+
// Content-fit height (redesign): no seas of blank rows — the body is
|
|
663
|
+
// as tall as its tallest column, clamped to [5, maxBody].
|
|
664
|
+
const bodyHeight = Math.max(5, Math.min(maxBody, Math.max(left.length, right.length)));
|
|
665
|
+
for (let i = 0; i < bodyHeight; i++) {
|
|
666
|
+
const l = pad(left[i] ?? "", listWidth);
|
|
667
|
+
const r = pad(right[i] ?? "", detailWidth);
|
|
668
|
+
lines.push(`${dim("│")} ${l} ${sep} ${r} ${dim("│")}`);
|
|
669
|
+
}
|
|
670
|
+
lines.push(this.framedBorderRow("bottom", dim(" " + this.hintRow() + " "), "", w));
|
|
671
|
+
return lines;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** `╭─ title ── right ─────╮` / `╰─ hint ───────────────╯` — always exactly `w` visible cells. */
|
|
675
|
+
private framedBorderRow(which: "top" | "bottom", title: string, right: string, w: number): string {
|
|
676
|
+
const cornerL = which === "top" ? "╭" : "╰";
|
|
677
|
+
const cornerR = which === "top" ? "╮" : "╯";
|
|
678
|
+
const rule = which === "top" ? "─" : "─";
|
|
679
|
+
const titleCells = visibleWidth(title);
|
|
680
|
+
const rightCells = right ? visibleWidth(right) : 0;
|
|
681
|
+
const budget = w - 2 - titleCells - rightCells;
|
|
682
|
+
if (budget < 1) {
|
|
683
|
+
// Degenerate narrow: title only, truncated.
|
|
684
|
+
const t = truncate(title, w - 2);
|
|
685
|
+
return `${cornerL}${t}${cornerR}`;
|
|
686
|
+
}
|
|
687
|
+
return `${cornerL}${title}${rule.repeat(budget)}${right}${cornerR}`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private countAgents(): number {
|
|
691
|
+
return this.cachedEntries.filter((entry) => entry.kind === "agent").length;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private countJobs(): number {
|
|
695
|
+
return this.cachedEntries.filter((entry) => entry.kind === "job").length;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private hintRow(): string {
|
|
699
|
+
const base = this.focus === "list" ? "[↑↓] move · [⏎] transcript · [q] close" : "[↑↓] scroll · [⏎/Esc] back";
|
|
700
|
+
return base;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** LEFT column: section labels + unified rows, windowed around selection. */
|
|
704
|
+
private renderListColumn(listWidth: number, bodyHeight: number, nowMs: number): string[] {
|
|
705
|
+
const out: string[] = [];
|
|
706
|
+
const push = (text: string) => out.push(truncate(sanitizeLine(text), listWidth));
|
|
707
|
+
if (this.cachedEntries.length === 0) {
|
|
708
|
+
push("No live agents · no scheduled jobs");
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
const agents = this.cachedEntries.filter((entry): entry is AgentsBrowserAgentEntry => entry.kind === "agent");
|
|
712
|
+
const jobs = this.cachedEntries.filter((entry): entry is AgentsBrowserJobEntry => entry.kind === "job");
|
|
713
|
+
// Rows: section labels + one row per entry, in merged order.
|
|
714
|
+
const rows: { entry?: AgentsJobsBrowserEntry; text: string }[] = [];
|
|
715
|
+
if (agents.length > 0) rows.push({ text: `Live agents (${agents.length})` });
|
|
716
|
+
for (const entry of agents) rows.push({ entry, text: this.agentRow(entry, nowMs) });
|
|
717
|
+
if (jobs.length > 0) rows.push({ text: `Scheduled jobs (${jobs.length})` });
|
|
718
|
+
for (const entry of jobs) rows.push({ entry, text: this.jobRow(entry) });
|
|
719
|
+
|
|
720
|
+
// Windowing: keep the selected entry's row inside the visible slot
|
|
721
|
+
// count (fixed-point like the dashboard's run-list window).
|
|
722
|
+
const entryRowIndex = rows.findIndex((row) => row.entry !== undefined && this.indexOf(row.entry) === this.selected);
|
|
723
|
+
const slots = Math.max(1, bodyHeight);
|
|
724
|
+
if (entryRowIndex >= 0) {
|
|
725
|
+
this.listScroll = Math.max(0, Math.min(this.listScroll, entryRowIndex));
|
|
726
|
+
if (entryRowIndex >= this.listScroll + slots) this.listScroll = entryRowIndex - slots + 1;
|
|
727
|
+
}
|
|
728
|
+
for (const row of rows.slice(this.listScroll, this.listScroll + slots)) {
|
|
729
|
+
const isSelected = row.entry !== undefined && this.indexOf(row.entry) === this.selected;
|
|
730
|
+
const marker = row.entry === undefined ? "" : isSelected ? "› " : " ";
|
|
731
|
+
push(`${marker}${row.text}`);
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private indexOf(entry: AgentsJobsBrowserEntry): number {
|
|
737
|
+
return this.cachedEntries.indexOf(entry);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private agentRow(entry: AgentsBrowserAgentEntry, nowMs: number): string {
|
|
741
|
+
const icon = iconForStatus(entry.status, { runningGlyph: spinnerFrame(entry.taskId, nowMs) });
|
|
742
|
+
const tok = entry.tokPerSec !== undefined && entry.status === "running" ? ` · ${entry.tokPerSec} tok/s` : "";
|
|
743
|
+
return `${icon} ${entry.role}${tok} · ${entry.taskId.slice(-8)}`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private jobRow(entry: AgentsBrowserJobEntry): string {
|
|
747
|
+
const job = entry.job;
|
|
748
|
+
const name = sanitizeLine(job.name);
|
|
749
|
+
const schedule = sanitizeLine(job.schedule);
|
|
750
|
+
const next = job.nextRun ? formatRelativeTime(new Date(this.nowMs()), new Date(job.nextRun)) : "—";
|
|
751
|
+
return `⏰ ${name} · ${schedule} · next ${next} · ${job.enabled ? "●" : "○"}`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** RIGHT column: selected entry's detail (or a placeholder), scrolled. */
|
|
755
|
+
private renderDetailColumn(detailWidth: number, bodyHeight: number): string[] {
|
|
756
|
+
const entry = this.cachedEntries[this.selected];
|
|
757
|
+
if (!entry) return [truncate("(nothing selected)", detailWidth)];
|
|
758
|
+
let detail: string[];
|
|
759
|
+
try {
|
|
760
|
+
detail = this.detailLinesFor(entry);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
logInternalError("agents-jobs-browser", error instanceof Error ? error : new Error(String(error)));
|
|
763
|
+
detail = ["(detail render failed)"];
|
|
764
|
+
}
|
|
765
|
+
const maxScroll = Math.max(0, detail.length - (bodyHeight - 1));
|
|
766
|
+
this.detailScroll = Math.max(0, Math.min(this.detailScroll, maxScroll));
|
|
767
|
+
const windowed = detail.slice(this.detailScroll, this.detailScroll + bodyHeight - 1);
|
|
768
|
+
const title = entry.kind === "agent" ? `▸ ${entry.role} · ${entry.taskId.slice(-8)}` : `▸ ${sanitizeLine(entry.job.name)}`;
|
|
769
|
+
const out = [truncate(sanitizeLine(title), detailWidth)];
|
|
770
|
+
for (const line of windowed) out.push(truncate(sanitizeLine(line), detailWidth));
|
|
771
|
+
if (this.detailScroll > 0 || this.detailScroll + windowed.length < detail.length) {
|
|
772
|
+
out.push(
|
|
773
|
+
truncate(
|
|
774
|
+
`… ${this.detailScroll} above · ${Math.max(0, detail.length - this.detailScroll - windowed.length)} below`,
|
|
775
|
+
detailWidth,
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Re-export for consumers/tests that want the width helpers this module's
|
|
784
|
+
// truncation discipline is built on (visibleWidth parity with pi-tui).
|
|
785
|
+
export { visibleWidth };
|