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.
Files changed (44) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/index.mjs +34850 -34889
  3. package/package.json +170 -169
  4. package/scripts/watch-agent-transcript.mjs +120 -0
  5. package/src/extension/crew-input-router.ts +2 -0
  6. package/src/extension/crew-vibes/config.ts +1 -56
  7. package/src/extension/crew-vibes/figures.ts +5 -84
  8. package/src/extension/crew-vibes/font-detect.ts +1 -1
  9. package/src/extension/crew-vibes/index.ts +36 -328
  10. package/src/extension/crew-vibes/render.ts +2 -58
  11. package/src/extension/help.ts +1 -0
  12. package/src/extension/registration/command-registration.ts +5 -1
  13. package/src/extension/registration/commands/schedules.ts +212 -0
  14. package/src/extension/registration/commands/shared.ts +56 -1
  15. package/src/extension/registration/lifecycle-handlers.ts +16 -2
  16. package/src/extension/registration/schedule-toast-bridge.ts +109 -0
  17. package/src/extension/registration/viewers.ts +66 -1
  18. package/src/extension/team-tool/handle-schedule.ts +120 -1
  19. package/src/runtime/scheduling/scheduler.ts +120 -6
  20. package/src/runtime/settings-store.ts +28 -2
  21. package/src/state/coordination/locks.ts +17 -2
  22. package/src/ui/agents-jobs-browser.ts +785 -0
  23. package/src/ui/dashboard-panes/agents-pane.ts +7 -4
  24. package/src/ui/dashboard-panes/schedules-pane.ts +154 -0
  25. package/src/ui/inline-panel/crew-editor.ts +19 -6
  26. package/src/ui/inline-panel/index.ts +6 -0
  27. package/src/ui/key-utils.ts +3 -0
  28. package/src/ui/keybinding-map.ts +56 -2
  29. package/src/ui/mascot.ts +2 -1
  30. package/src/ui/overlays/agent-picker-overlay.ts +3 -3
  31. package/src/ui/overlays/confirm-overlay.ts +3 -2
  32. package/src/ui/overlays/mailbox-compose-overlay.ts +4 -3
  33. package/src/ui/overlays/mailbox-detail-overlay.ts +3 -3
  34. package/src/ui/run-dashboard.ts +257 -6
  35. package/src/ui/settings-overlay.ts +3 -3
  36. package/src/ui/transcript-viewer.ts +3 -2
  37. package/src/ui/widget/index.ts +61 -88
  38. package/src/ui/widget/widget-formatters.ts +7 -4
  39. package/src/ui/widget/widget-renderer.ts +134 -174
  40. package/src/ui/widget/widget-types.ts +0 -7
  41. package/src/utils/relative-time.ts +33 -0
  42. package/src/extension/crew-vibes/footer.ts +0 -313
  43. package/src/extension/crew-vibes/speed.ts +0 -286
  44. 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 };