pi-crew 0.10.6 → 0.11.1

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