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
@@ -4,30 +4,22 @@
4
4
  * Extracted from crew-widget.ts.
5
5
  */
6
6
 
7
+ import { getCrewScheduler, getScheduledJobs, getScheduledJobsHiddenCountView } from "../../extension/team-tool/handle-schedule.ts";
7
8
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
8
- import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
9
9
  import { isPlanApprovalStatePending } from "../../runtime/plan-approval.ts";
10
10
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
11
- import type { TeamRunManifest } from "../../state/types.ts";
12
- import { truncate } from "../../utils/visual.ts";
11
+ import type { ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
12
+ import { formatRelativeTime } from "../../utils/relative-time.ts";
13
+ import { truncate, visibleWidth } from "../../utils/visual.ts";
13
14
  import { Box, Text } from "../layout-primitives.ts";
15
+ import { ACTIVE, RAIL, type RailSlot, railLeaders, railRaw, shortId, statusSlot } from "../rail.ts";
14
16
  import { spinnerFrame } from "../spinner.ts";
15
- import { colorizeStatusGlyphs, iconForStatus } from "../status-colors.ts";
16
- import type { CrewTheme } from "../theme-adapter.ts";
17
- import {
18
- agentActivity,
19
- agentStats,
20
- budgetedRow,
21
- dockElapsed,
22
- dockStatusIcon,
23
- dockStatusLabel,
24
- dockUsageText,
25
- notificationBadge,
26
- } from "./widget-formatters.ts";
17
+ import { colorizeStatusGlyphs } from "../status-colors.ts";
18
+ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
19
+ import { notificationBadge } from "./widget-formatters.ts";
27
20
  import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
28
21
  import type { WidgetRun } from "./widget-types.ts";
29
22
 
30
- export const MAX_AGENTS_DISPLAY = 3;
31
23
  const FINISHED_LINGER_MAX_AGE = 1;
32
24
  /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
33
25
  * (the same default used elsewhere in pi-crew tool renderers) so we never paint
@@ -39,19 +31,158 @@ export const TASK_DESC_MAX = 60;
39
31
  const ERROR_LINGER_MAX_AGE = 2;
40
32
  const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
41
33
 
34
+ // ── RAIL dock composition (design system Β§2.B) ─────────────────────────
35
+ //
36
+ // The dock is the ONE surface that must never grow a second row: it speaks the
37
+ // rail grammar with the BODY glyph (`┃`) only β€” never `┏`/`β”—`, which imply a
38
+ // multi-line card. The builders below return PLAIN text (that is the exported
39
+ // contract: `buildWidgetLines` is the raw row, `colorWidgetLine` the paint
40
+ // pass), but they COMPOSE through the shared rail helpers with a no-op theme
41
+ // so the glyph/leader vocabulary lives in `rail.ts` alone.
42
+ const PLAIN_THEME = asCrewTheme(undefined);
43
+
44
+ /** Identity word of the dock + status bar. */
45
+ const DOCK_WORD = "CREW";
46
+
47
+ /** The dock's tail: four leaders + the one-key entry hint. */
48
+ const DOCK_HINT_RIGHT = "↓·enter";
49
+ const DOCK_HINT = `Β·Β·Β·Β· ${DOCK_HINT_RIGHT}`;
50
+
51
+ /** The dock's FOCUS marker (RAIL §2.B: "focused row keeps the `❯ ` prefix").
52
+ * It is deliberately not `rail.ts`'s `CURSOR` (`β€Ί`) β€” that glyph marks the
53
+ * selected row of a LIST; the dock is a single line, and `❯` is the marker
54
+ * the prompt area has always acknowledged the ↓ keystroke with. */
55
+ const FOCUS_MARKER = "❯";
56
+
57
+ /**
58
+ * `Β·Β·Β·Β· ↓·enter` appended to a dock row. Composed through `railLeaders` with a
59
+ * budget that pins the leader run at exactly four dots β€” the dock is a
60
+ * left-aligned single row, so the leaders must NOT stretch to the terminal
61
+ * width the way a card's metrics→elapsed leaders do.
62
+ */
63
+ function dockTail(left: string, theme: CrewTheme, maxWidth?: number): string {
64
+ const pinned = visibleWidth(left) + visibleWidth(DOCK_HINT_RIGHT) + 6;
65
+ // The pin gives a fixed four-dot leader on a wide terminal. On a NARROW one
66
+ // the budget shrinks instead, so `railLeaders` trims the LEFT segment (with
67
+ // `…`) and the actionable `↓·enter` hint never gets clipped away β€” the live
68
+ // row used to end in `↓…` at 50 columns.
69
+ const budget = maxWidth === undefined ? pinned : Math.min(pinned, maxWidth - 2);
70
+ return railLeaders(left, DOCK_HINT_RIGHT, budget, theme);
71
+ }
72
+
73
+ /** `CREW β–Έ <subject>` β€” the dock identity (colour is applied on the paint pass). */
74
+ function dockIdentity(subject: string): string {
75
+ return subject ? `${DOCK_WORD} ${ACTIVE} ${subject}` : DOCK_WORD;
76
+ }
77
+
78
+ /** `┃ <content>` β€” the dock rail, unpadded (`truncate` stays the single clip). */
79
+ function dockLine(content: string, theme: CrewTheme, slot: RailSlot): string {
80
+ return railRaw(RAIL.body, slot, content, theme);
81
+ }
82
+
83
+ /**
84
+ * Aggregate run status β†’ the dock's rail colour (Β§2.B: rail colour = state).
85
+ * A failure outranks live work; live work outranks idle. Zero runs is idle.
86
+ */
87
+ export function widgetRailSlot(runs: readonly WidgetRun[]): RailSlot {
88
+ if (runs.length === 0) return "border";
89
+ const slots = runs.map((entry) => statusSlot(entry.run.status));
90
+ if (slots.includes("error")) return "error";
91
+ if (slots.includes("borderAccent")) return "borderAccent";
92
+ return slots[0] ?? "border";
93
+ }
94
+
95
+ /** `<team>` (or `team/workflow` when they differ); several live runs collapse
96
+ * to `<n> runs` β€” the counts on the row stay aggregate. */
97
+ function dockSubject(runs: WidgetRun[]): string {
98
+ if (runs.length > 1) return `${runs.length} runs`;
99
+ const first = runs[0];
100
+ return first ? shortRunLabel(first.run) : "idle";
101
+ }
102
+
103
+ /**
104
+ * The zero-run dock row: `┃ CREW β–Έ idle Β· ⏰ 1 sched Β·Β·Β·Β· ↓·enter`.
105
+ *
106
+ * Returns `undefined` when nothing schedules-related paints. The keep-alive
107
+ * path MUST then render NOTHING (`[]`) β€” a bare hint, and above all the
108
+ * literal `undefined β€” ↓·enter`, is the live regression this guards against.
109
+ */
110
+ export function idleWidgetLine(schedLine: string | undefined, focused = false, maxWidth?: number): string | undefined {
111
+ if (!schedLine) return undefined;
112
+ const left = `${dockIdentity("idle")} Β· ${schedLine}`;
113
+ const line = dockLine(dockTail(left, PLAIN_THEME, maxWidth), PLAIN_THEME, "border");
114
+ return focused ? `${FOCUS_MARKER} ${line}` : line;
115
+ }
116
+
117
+ /**
118
+ * The dock's leading activity glyph: a braille spinner ONLY while something is
119
+ * actually running, otherwise the outcome glyph of the aggregate state.
120
+ *
121
+ * Live-run bug (2026-09-16): the dock spun forever because `buildWidgetLines`
122
+ * always passed `spinnerFrame("widget-header")` β€” a finished run painted
123
+ * `┃ β Ή CREW β–Έ fast-fix Β· 0 running Β· 3/3 done`, i.e. a spinner with nothing to
124
+ * spin for. `βœ“`/`βœ—` are used (not `●`/`βœ–`) because they are in
125
+ * `STATUS_GLYPH_CHARS`, so the shared colorizer paints them.
126
+ */
127
+ export function widgetActivityGlyph(runs: readonly WidgetRun[]): string {
128
+ const anyAgentRunning = runs.some((entry) => entry.agents.some((agent) => agent.status === "running"));
129
+ const anyRunRunning = runs.some((entry) => entry.run.status === "running");
130
+ if (anyAgentRunning || anyRunRunning) return spinnerFrame("widget-header");
131
+ const slot = widgetRailSlot(runs);
132
+ if (slot === "error") return "βœ—";
133
+ if (slot === "success") return "βœ“";
134
+ return "";
135
+ }
136
+
42
137
  // ── Header ────────────────────────────────────────────────────────────
43
138
 
44
- export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines = 20, notificationCount = 0): string {
139
+ /**
140
+ * The dock's ONE row (RAIL Β§2.B):
141
+ *
142
+ * `┃ β § CREW β–Έ fast-fix Β· 2 running Β· 3/5 done Β· ⏰ 1 sched Β·Β·Β·Β· ↓·enter`
143
+ *
144
+ * Zero runs paint `┃ CREW β–Έ idle Β· …` (see `idleWidgetLine`) instead. Returns
145
+ * PLAIN text β€” the component colorizes index 0 with `colorWidgetLine`.
146
+ */
147
+ export function widgetHeader(
148
+ runs: WidgetRun[],
149
+ runningGlyph: string,
150
+ maxLines = 20,
151
+ notificationCount = 0,
152
+ schedSegment?: string,
153
+ maxWidth?: number,
154
+ ): string {
45
155
  const agents = runs.flatMap((item) => item.agents);
46
- const runningAgents = agents.filter((a) => a.status === "running").length;
47
- const queuedAgents = agents.filter((a) => a.status === "queued").length;
48
- const waitingAgents = agents.filter((a) => a.status === "waiting").length;
49
- const completedAgents = agents.filter((a) => a.status === "completed").length;
50
- const parts = [`${runningAgents} running`];
51
- if (queuedAgents) parts.push(`${queuedAgents} queued`);
52
- if (waitingAgents) parts.push(`${waitingAgents} waiting`);
53
- if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
54
- return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} Β· ${parts.join(" Β· ")} Β· /team-dashboard`;
156
+ const segments: string[] = [];
157
+ if (runs.length > 0) {
158
+ const runningAgents = agents.filter((a) => a.status === "running").length;
159
+ const queuedAgents = agents.filter((a) => a.status === "queued").length;
160
+ const waitingAgents = agents.filter((a) => a.status === "waiting").length;
161
+ const completedAgents = agents.filter((a) => a.status === "completed").length;
162
+ // Zero counts are noise on a one-line dock: a finished run reads
163
+ // `3/3 done`, not `0 running Β· 3/3 done`.
164
+ if (runningAgents) segments.push(`${runningAgents} running`);
165
+ if (queuedAgents) segments.push(`${queuedAgents} queued`);
166
+ if (waitingAgents) segments.push(`${waitingAgents} waiting`);
167
+ if (completedAgents) segments.push(`${completedAgents}/${agents.length} done`);
168
+ // WP-3 on the single line (2026-09-14 round 2): a run parked awaiting plan
169
+ // approval surfaces as a `⚠ plan:<run8>` segment β€” the row-level badge is
170
+ // gone with the run tree, so the count row carries the signal.
171
+ const planPending = runs.find((item) => isPlanApprovalStatePending(item.run.planApproval));
172
+ if (planPending) segments.push(`⚠ plan:${shortId(planPending.run.runId)}`);
173
+ }
174
+ // Tier C: `schedSegment` is the ALREADY-BUILT `⏰ …` string (jobs, hidden
175
+ // count and clock are all injected upstream β€” buildWidgetLines); undefined
176
+ // means nothing schedules-related paints.
177
+ if (schedSegment) segments.push(schedSegment);
178
+ // Bug 021: the alerts badge is one more segment (no πŸ””, capped at 99+).
179
+ const badge = notificationBadge(notificationCount)
180
+ .replace(/^\s*Β·\s*/, "")
181
+ .trim();
182
+ if (badge) segments.push(badge);
183
+ const head = runningGlyph ? `${runningGlyph} ${dockIdentity(dockSubject(runs))}` : dockIdentity(dockSubject(runs));
184
+ const left = segments.length > 0 ? `${head} Β· ${segments.join(" Β· ")}` : head;
185
+ return dockLine(dockTail(left, PLAIN_THEME, maxWidth), PLAIN_THEME, "border");
55
186
  }
56
187
 
57
188
  // ── Agent ordering (shared with the inline panel) ──────────────────────
@@ -98,146 +229,114 @@ export function orderWidgetAgents(entry: WidgetRun, now = Date.now()): { active:
98
229
 
99
230
  // ── Line builder ──────────────────────────────────────────────────────
100
231
 
101
- /**
102
- * Row layout for the per-agent lines.
103
- *
104
- * - `detailed` β€” the historical two-line tree (name row + `⊢ activity` row).
105
- * - `compact` β€” one width-budgeted line per agent, so a wide terminal shows the
106
- * full description instead of the same clip a narrow one gets.
107
- */
108
- export type WidgetRowStyle = "compact" | "detailed";
109
-
110
232
  export interface WidgetRenderOptions {
111
- rowStyle?: WidgetRowStyle;
112
233
  /** Task id under the inline panel cursor, if any. */
113
234
  selectedTaskId?: string;
114
235
  /** Task id whose transcript pane is open, if any. */
115
236
  viewedTaskId?: string;
116
237
  /**
117
- * True while the inline panel holds the cursor. Every agent is then listed
118
- * (no MAX_AGENTS_DISPLAY cap) so keyboard navigation can reach all of them;
119
- * the idle widget stays capped to keep the prompt area small.
238
+ * True while the inline panel holds the cursor. The inline panel then
239
+ * gets a visible cursor acknowledgement; the idle widget stays compact
240
+ * to keep the prompt area small.
120
241
  */
121
242
  focused?: boolean;
243
+ /**
244
+ * Injected clock (dialect D6-T4) for the Tier-C schedules line β€” pinned by
245
+ * tests; defaults to "now" at the top of buildWidgetLines. The pure
246
+ * schedules builder never reads the clock itself.
247
+ */
248
+ now?: Date;
122
249
  }
123
250
 
124
- /** Short display form of a model id: `zai/glm-5.3` β†’ `glm-5.3`. */
125
- function shortModelLabel(agent: CrewAgentRecord, run: TeamRunManifest): string | undefined {
126
- const model = agent.model ?? run.modelContext?.parentModel ?? run.modelContext?.override;
127
- if (typeof model !== "string" || !model) return undefined;
128
- return model.split("/").at(-1) ?? model;
129
- }
251
+ // ── Schedules line (Tier C) ───────────────────────────────────────────
130
252
 
131
- /** One flat dock row (pi-subtask style) for an agent β€” active or finished. */
132
- function compactAgentRow(
133
- run: TeamRunManifest,
134
- agent: CrewAgentRecord,
135
- finished: boolean,
136
- runs: readonly WidgetRun[],
137
- options: WidgetRenderOptions,
138
- width: number,
139
- liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined,
140
- ): string {
141
- const marker = options.selectedTaskId === agent.taskId ? "❯" : " ";
142
- const dockGlyph = options.viewedTaskId === agent.taskId ? "⏺" : dockStatusIcon(agent.status);
143
- const name = liveHandle?.agent ?? agent.agent;
144
- const label = liveHandle?.description ?? agent.role ?? "";
145
- // Task-first: the agent exists to run its task, so the row names the task
146
- // right after the agent. With multiple runs, prefix each row with its run
147
- // label so the flat dock still says which run an agent belongs to.
148
- const runTag = runs.length > 1 ? `${shortRunLabel(run)} Β· ` : "";
149
- const taskTag = agent.taskId ? ` Β· ${agent.taskId}` : "";
150
- const roleTag = label && label !== agent.taskId && label !== name ? ` Β· ${label}` : "";
151
- const nameText = runTag + name + taskTag + roleTag;
152
- // pi-subtask activity: the worker's latest line while running, otherwise
153
- // the status word.
154
- const liveLine = liveHandle?.activity?.responseText
155
- ?.split("\n")
156
- .find((line) => line.trim())
157
- ?.trim();
158
- const activity =
159
- !finished && liveHandle?.status === "running" && liveLine
160
- ? liveLine.length > 60
161
- ? `${liveLine.slice(0, 60)}…`
162
- : liveLine
163
- : finished
164
- ? dockStatusLabel(agent.status)
165
- : agentActivity(agent, liveHandle);
166
- const usage = dockUsageText(agent, liveHandle, { viewed: options.viewedTaskId === agent.taskId });
167
- const ageText = dockElapsed(agent.completedAt ?? agent.startedAt);
168
- const model = shortModelLabel(agent, run);
169
- // Stats tail: `Β· glm-5.3 Β· ↑1.2k ↓350 Β· 41s` β€” the model the worker is
170
- // actually on first, then usage, then elapsed.
171
- const suffix = `${model ? ` Β· ${model}` : ""}${usage ? ` Β· ${usage}` : ""}${ageText ? ` Β· ${ageText}` : ""}`;
172
- return budgetedRow({ lead: `${marker} ${dockGlyph} `, name: nameText, activity, suffix }, width);
253
+ /**
254
+ * Tier C (schedules UI): the ONE low-priority schedules line for the crew
255
+ * widget β€” `⏰ N sched Β· next Xm`. Painted when β‰₯1 ENABLED job exists, always
256
+ * as the LAST row (below active-run info, per the widget priority rules),
257
+ * with an optional compact `Β· N hidden` segment (P2-1) when the B2 gate is
258
+ * hiding project-tier jobs. Pure (dialect D6-T4): jobs, the clock, AND the
259
+ * hidden count are injected β€” no Date.now()/settings read happens here.
260
+ * Returns undefined when the line must not paint (0 enabled jobs AND nothing
261
+ * hidden), so callers skip the row entirely. With ZERO enabled jobs but a
262
+ * hidden count > 0, the hidden-only line still paints β€” that is exactly the
263
+ * all-gated case where an invisible gate used to show nothing at all.
264
+ */
265
+ export function buildSchedulesWidgetLine(jobs: readonly ScheduledJob[], now: Date, hiddenCount = 0): string | undefined {
266
+ const enabled = jobs.filter((job) => job.enabled);
267
+ const hidden = hiddenCount > 0 ? ` Β· ${hiddenCount} hidden` : "";
268
+ if (enabled.length === 0) return hiddenCount > 0 ? `⏰ 0 sched${hidden}` : undefined;
269
+ const nextTargets = enabled
270
+ // new Date(iso) here is a STORED-ISO parse, not a clock read.
271
+ .map((job) => (job.nextRun ? new Date(job.nextRun).getTime() : Number.NaN))
272
+ .filter((ms) => Number.isFinite(ms));
273
+ if (nextTargets.length === 0) return `⏰ ${enabled.length} sched${hidden}`;
274
+ const next = Math.min(...nextTargets);
275
+ // "in 84m" β†’ "next 84m": the future prefix is redundant right after
276
+ // "next"; overdue targets keep their "Xm ago" tail so a stale nextRun
277
+ // stays legible instead of silently reading as future work.
278
+ const relative = formatRelativeTime(now, new Date(next)).replace(/^in /, "");
279
+ return `⏰ ${enabled.length} sched · next ${relative}${hidden}`;
173
280
  }
174
281
 
175
282
  /**
176
- * The compact dock: hint line, the `main` conversation row, then a
177
- * MAX_AGENTS_DISPLAY-row SCROLL WINDOW over the flat agent list (exactly the
178
- * order the inline panel navigates β€” `panelRowsFromRuns` parity). The window
179
- * follows the panel selection bottom-pinned: moving the cursor past the
180
- * window scrolls one row at a time and the ❯ marker is always painted; idle
181
- * (no selection) shows the top of the list, which the shared ordering puts at
182
- * the highest-priority live agents. Hidden rows surface as `… ↑N earlier` /
183
- * `… +N more` indicators.
283
+ * Injectable scheduled-jobs reader for the widget (single source of truth,
284
+ * G17): the default reads through getScheduledJobs(); tests swap in a stub
285
+ * so the render path never touches real scheduler/settings state.
286
+ *
287
+ * The default is gated on a REGISTERED scheduler: without one (unit tests,
288
+ * pre-registration, post-cleanup) there are no armed jobs to report β€” and we
289
+ * must never hit the settings store from a paint path (P0-6: no disk per
290
+ * render tick).
184
291
  */
185
- function compactDockLines(
186
- runs: WidgetRun[],
187
- options: WidgetRenderOptions,
188
- width: number,
189
- maxLines: number,
190
- notificationCount: number,
191
- runningGlyph: string,
192
- ): string[] {
193
- const now = Date.now();
194
- const flat: Array<{
195
- run: TeamRunManifest;
196
- agent: CrewAgentRecord;
197
- finished: boolean;
198
- liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined;
199
- }> = [];
200
- for (const entry of runs) {
201
- const { active, finished } = orderWidgetAgents(entry, now);
202
- const liveForRun = listLiveAgents().filter((a) => a.runId === entry.run.runId);
203
- for (const agent of active) {
204
- flat.push({ run: entry.run, agent, finished: false, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
205
- }
206
- for (const agent of finished) {
207
- flat.push({ run: entry.run, agent, finished: true, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
208
- }
292
+ export type WidgetScheduledJobsReader = (cwd: string) => ScheduledJob[];
293
+ let scheduledJobsReader: WidgetScheduledJobsReader = defaultScheduledJobsReader;
294
+
295
+ function defaultScheduledJobsReader(cwd: string): ScheduledJob[] {
296
+ if (!getCrewScheduler()) return [];
297
+ try {
298
+ return getScheduledJobs(cwd);
299
+ } catch {
300
+ return [];
209
301
  }
302
+ }
210
303
 
211
- // No agents at all: fall back to the legacy header so the space under the
212
- // editor is never just blank.
213
- if (flat.length === 0) return [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
214
-
215
- const lines: string[] = [];
216
- let hint: string;
217
- if (options.viewedTaskId) {
218
- const viewedName = flat.find((row) => row.agent.taskId === options.viewedTaskId)?.agent.agent ?? "agent";
219
- hint = `viewing @${viewedName} β€” typing goes to the agent Β· ↓ switch Β· esc back`;
220
- } else if (options.focused) {
221
- hint = "enter to view Β· x to stop/cancel Β· esc back";
222
- } else {
223
- hint = `agents (${flat.length}) β€” ↓ to select`;
224
- }
225
- lines.push(truncate(hint, width));
226
- // Filled ● = you're on the main conversation; hollow β—― = an agent view is
227
- // open (same convention as pi-subtask's main row).
228
- const mainMarker = options.focused && !options.selectedTaskId ? "❯" : " ";
229
- const mainIcon = options.viewedTaskId ? "β—―" : "●";
230
- lines.push(truncate(`${mainMarker} ${mainIcon} main`, width));
231
-
232
- const selectedIndex = options.selectedTaskId ? flat.findIndex((row) => row.agent.taskId === options.selectedTaskId) : -1;
233
- const windowStart = selectedIndex >= 0 ? Math.max(0, selectedIndex - MAX_AGENTS_DISPLAY + 1) : 0;
234
- const windowEnd = Math.min(flat.length, windowStart + MAX_AGENTS_DISPLAY);
235
- if (windowStart > 0) lines.push(truncate(` … ↑${windowStart} earlier (↑ to scroll)`, width));
236
- for (const row of flat.slice(windowStart, windowEnd)) {
237
- lines.push(compactAgentRow(row.run, row.agent, row.finished, runs, options, width, row.liveHandle));
238
- }
239
- if (windowEnd < flat.length) lines.push(truncate(` … +${flat.length - windowEnd} more (↓ to scroll)`, width));
240
- return lines;
304
+ /** @internal β€” test seam: inject a deterministic jobs view. */
305
+ export function setWidgetScheduledJobsReader(reader: WidgetScheduledJobsReader): void {
306
+ scheduledJobsReader = reader;
307
+ }
308
+
309
+ /** @internal β€” test seam: restore the provider-backed default. */
310
+ export function resetWidgetScheduledJobsReader(): void {
311
+ scheduledJobsReader = defaultScheduledJobsReader;
312
+ }
313
+
314
+ /** Injectable hidden-jobs reader (P2-1) β€” mirrors the jobs reader seam: the
315
+ * default is gated on the REGISTERED scheduler exactly like
316
+ * defaultScheduledJobsReader (P0-6 β€” a paint path must never hit the settings
317
+ * store), and with a registered scheduler it only ever serves the in-memory
318
+ * registration-time stash from handle-schedule.ts. */
319
+ export type WidgetHiddenJobsReader = (cwd: string) => number;
320
+ let hiddenJobsReader: WidgetHiddenJobsReader = defaultHiddenJobsReader;
321
+
322
+ function defaultHiddenJobsReader(_cwd: string): number {
323
+ if (!getCrewScheduler()) return 0;
324
+ return getScheduledJobsHiddenCountView(); // stash-only when registered: no disk
325
+ }
326
+
327
+ /** @internal β€” test seam: inject a deterministic hidden count. */
328
+ export function setWidgetHiddenJobsReader(reader: WidgetHiddenJobsReader): void {
329
+ hiddenJobsReader = reader;
330
+ }
331
+
332
+ /** @internal β€” test seam: restore the stash-backed default. */
333
+ export function resetWidgetHiddenJobsReader(): void {
334
+ hiddenJobsReader = defaultHiddenJobsReader;
335
+ }
336
+
337
+ /** Reader-backed schedules line β€” the widget's live data path. */
338
+ export function schedulesWidgetLine(cwd: string, now: Date): string | undefined {
339
+ return buildSchedulesWidgetLine(scheduledJobsReader(cwd), now, hiddenJobsReader(cwd));
241
340
  }
242
341
 
243
342
  export function buildWidgetLines(
@@ -249,133 +348,67 @@ export function buildWidgetLines(
249
348
  width = DEFAULT_WIDGET_WIDTH,
250
349
  options: WidgetRenderOptions = {},
251
350
  ): string[] {
252
- const rowStyle: WidgetRowStyle = options.rowStyle ?? "detailed";
253
- const focused = options.focused === true;
254
- // Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
255
- // auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
256
- // only `(cwd, frame)` would render an empty line set (regression vs. the
257
- // pre-refactor implementation that called activeWidgetRuns here).
351
+ // SINGLE-LINE DOCK (design system Β§2.B, maintainer design 2026-09-14): the
352
+ // dock paints EXACTLY ONE row β€” `┃ <identity> Β· counts Β· schedules Β·Β·Β·Β·
353
+ // ↓·enter`. No per-agent rows, no run tree, no scroll window; ALL browsing
354
+ // lives in the Agents & Jobs browser, opened by ↓·enter from THIS line
355
+ // (crew-editor: idle enter at the line target = browser). The rail glyph is
356
+ // always `┃` β€” `┏`/`β”—` would imply a multi-line card.
357
+ //
358
+ // Focused (the ↓ cursor sits ON this line): prefix a ❯ marker so the
359
+ // keystroke is visibly acknowledged β€” the line itself is the cursor target;
360
+ // there is no second row to land on. maxLines/frame remain in the signature
361
+ // for call-site compatibility; a single line is always within budget.
362
+ const schedLine = schedulesWidgetLine(cwd, options.now ?? new Date());
258
363
  const runs = providedRuns ?? activeWidgetRuns(cwd);
259
- if (!runs.length) return [];
260
-
261
- const runningGlyph = spinnerFrame("widget-header");
262
-
263
- // Compact = pi-subtask's dock: NO "Crew agents" header, NO tree β€” hint
264
- // line, `main` row, then a 3-row scroll window over the flat agent list.
265
- if (rowStyle === "compact") {
266
- const lines = compactDockLines(runs, options, width, maxLines, notificationCount, runningGlyph);
267
- return focused ? lines : lines.slice(0, maxLines);
268
- }
269
-
270
- const lines: string[] = [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
271
-
272
- for (const entry of runs) {
273
- const { run, agents } = entry;
274
- const now = Date.now();
275
- const { active: activeAgents, finished: finishedAgents } = orderWidgetAgents(entry, now);
276
- const completed = agents.filter((a) => a.status === "completed").length;
277
- // WP-3 (H4): while a run is parked awaiting plan approval, the spinner
278
- // glyph is replaced by a `⚠ plan:<last-8 runId>` badge. Plain-unicode ⚠
279
- // (the same glyph needs_attention already uses) stays legible in no-color
280
- // mode and is colorized by the shared colorizeStatusGlyphs pass. In-place
281
- // glyph swap only β€” no extra line, so the MAX_AGENTS_DISPLAY / maxLines
282
- // budget and the truncate(width) rule are untouched.
283
- const planPending = isPlanApprovalStatePending(run.planApproval);
284
- const runGlyph = planPending ? `⚠ plan:${run.runId.slice(-8)}` : iconForStatus(run.status, { runningGlyph });
285
- const isTerminal = isFinishedRunStatus(run.status);
286
- // Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
287
- // too minimal (`0/1 agents` only), v5 duplicated the worker activity
288
- // line (tools/tokens/duration already shown one row below). v6 (this)
289
- // shows only data that is RUN-level (not already in the per-agent
290
- // activity line) and is GUARANTEED stable across ticks:
291
- // - agents count β€” from `agents` array, always populated, never empty.
292
- // - run elapsed β€” from `run.createdAt`, always set on manifest.
293
- //
294
- // Bug 022 (timer-fix + label): for TERMINAL runs (failed/cancelled/
295
- // completed) the elapsed counter previously kept ticking up forever
296
- // from createdAt (a failed run showed `2028s` and climbing, read as
297
- // "still running"). Now it FREEZES at updatedAt (when the run
298
- // reached its terminal status). The status label is also surfaced
299
- // explicitly so the row cannot be misread as an active run.
300
- const agentCountText = `${completed}/${agents.length} agents`;
301
- const runEndMs = isTerminal ? new Date(run.updatedAt).getTime() : now;
302
- const runElapsedMs = Math.max(0, Number.isFinite(runEndMs) ? runEndMs - new Date(run.createdAt).getTime() : 0);
303
- const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
304
- const statusLabel = isTerminal ? ` Β· ${run.status}` : "";
305
- const progressPart = `${agentCountText} Β· ${runElapsedText}${statusLabel}`;
306
- lines.push(truncate(`β”œβ”€ ${runGlyph} ${shortRunLabel(run)} Β· ${progressPart} Β· ${run.runId.slice(-8)}`, width));
307
-
308
- const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
309
-
310
- // Focused: list every agent so the keyboard cursor can reach it. Idle: keep
311
- // the historical cap so the prompt area stays small.
312
- const activeCap = focused ? activeAgents.length : MAX_AGENTS_DISPLAY;
313
- // Finished rows only appear in slots not used by active agents (max 2). When
314
- // there are >= MAX_AGENTS_DISPLAY live workers, finished rows are suppressed
315
- // entirely so they cannot push a live agent's activity line off-screen.
316
- const finishedSlots = focused ? finishedAgents.length : Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
317
-
318
- /** Cursor marker column, mirroring the inline panel's selection. */
319
- const markerFor = (taskId: string): string => (options.selectedTaskId === taskId ? "❯" : " ");
320
-
321
- const visibleAgents = activeAgents.slice(0, activeCap);
322
- for (const [index, agent] of visibleAgents.entries()) {
323
- const last = index === visibleAgents.length - 1 && activeAgents.length <= activeCap && finishedSlots === 0;
324
- const branch = last ? "└─" : "β”œβ”€";
325
- const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
326
- const legacyGlyph = options.viewedTaskId === agent.taskId ? "β—‰" : iconForStatus(agent.status, { runningGlyph });
327
- const stats = agentStats(agent, liveHandle);
328
- const name = liveHandle?.agent ?? agent.agent;
329
- const activity = agentActivity(agent, liveHandle);
330
- const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
331
- const _activeMain = truncate(`β”‚ ${branch} ${legacyGlyph} ${name}${desc ? ` Β· ${desc}` : ` Β· ${agent.role}`}`, width);
332
- lines.push(_activeMain);
333
- const _activity = truncate(`β”‚ ⊢ ${activity}${stats ? ` Β· ${stats}` : ""}`, width);
334
- lines.push(_activity);
335
- }
336
-
337
- if (activeAgents.length > activeCap) {
338
- lines.push(truncate(`β”‚ └─ … +${activeAgents.length - activeCap} more agents`, width));
339
- }
340
-
341
- for (const [index, agent] of finishedAgents.slice(0, finishedSlots).entries()) {
342
- const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
343
- const name = liveHandle?.agent ?? agent.agent;
344
- const legacyIcon =
345
- agent.status === "completed" ? "βœ“" : agent.status === "failed" ? "βœ—" : agent.status === "needs_attention" ? "⚠" : "β–ͺ";
346
- const stats = agentStats(agent, liveHandle);
347
- const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
348
- const isLastFinished = index === Math.min(finishedAgents.length, finishedSlots) - 1;
349
- const branch = isLastFinished ? "└─" : "β”œβ”€";
350
- const _finished = truncate(`β”‚ ${branch} ${legacyIcon} ${name} Β· ${desc}${stats ? ` Β· ${stats}` : ""}`, width);
351
- lines.push(_finished);
352
- }
353
-
354
- // Focused (inline panel cursor active): the keyboard can reach every
355
- // agent, so the renderer must list them ALL β€” clipping here would put
356
- // the cursor marker (❯) on rows that are never painted. The idle
357
- // widget keeps its historical cap to hold the prompt area small.
358
- if (lines.length >= maxLines && !focused) break;
364
+ const focused = options.focused === true;
365
+ if (!runs.length) {
366
+ // Zero runs keep-alive (Tier C): jobs are exactly what run while no
367
+ // interactive run is active. The ⏰ segment stands alone as the row; with
368
+ // no schedules either there is NOTHING to paint β€” `[]`, never a bare
369
+ // hint and never the literal `undefined β€” ↓·enter` (live bug 2026-09-16).
370
+ const idle = idleWidgetLine(schedLine, focused);
371
+ return idle ? [truncate(idle, width)] : [];
359
372
  }
360
-
361
- return focused ? lines : lines.slice(0, maxLines);
373
+ const base = widgetHeader(runs, widgetActivityGlyph(runs), maxLines, notificationCount, schedLine, width);
374
+ return [truncate(focused ? `${FOCUS_MARKER} ${base}` : base, width)];
362
375
  }
363
376
 
364
377
  // ── Colorization ──────────────────────────────────────────────────────
365
378
 
366
- export function colorWidgetLine(line: string, index: number, theme: CrewTheme): string {
379
+ /**
380
+ * Paint pass for a PLAIN dock/plan line (`index 0` = the identity row).
381
+ *
382
+ * The builders above return plain text, so this adds the identity/rail/chrome
383
+ * colours: the leading `┃` (or plan `┏`) takes the rail slot β€” `statusSlot` of
384
+ * the aggregate run status, injected by the component β€” the identity word goes
385
+ * accent+bold, and the tail hint is dimmed. Lines that were ALREADY built with
386
+ * a real theme (the task-list variant builds through `rail.ts` directly) carry
387
+ * escapes and are passed through untouched, apart from the shared glyph
388
+ * colorizer below.
389
+ */
390
+ export function colorWidgetLine(line: string, index: number, theme: CrewTheme, slot: RailSlot = "border"): string {
367
391
  let result = line;
368
- if (index === 0) {
369
- result = result.replace("Crew agents", theme.bold(theme.fg("accent", "Crew agents")));
392
+ if (index === 0 && !result.includes("\u001b")) {
393
+ // `┏|┃ <WORD> β–Έ <subject>`: rail glyph takes the state slot, the identity
394
+ // word accent+bold, the subject toolTitle+bold. The subject is bounded by
395
+ // the first ` Β·` so a count segment can never be swallowed.
396
+ result = result.replace(
397
+ /^([❯] )?([┃┏]) (?:(\S+) )?((?:CREW|PLAN)(?: β–Έ [^Β·]+?)?)(?= Β·|$)/,
398
+ (_match, cursor: string | undefined, glyph: string, spinner: string | undefined, identity: string) => {
399
+ const [word = "", subject] = identity.split(" β–Έ ");
400
+ const tail = subject ? ` ${theme.fg("dim", ACTIVE)} ${theme.fg("toolTitle", theme.bold(subject))}` : "";
401
+ // The spinner is re-emitted raw: the shared glyph colorizer below
402
+ // paints the braille range accent.
403
+ return `${cursor ?? ""}${theme.fg(slot, glyph)} ${spinner ? `${spinner} ` : ""}${theme.fg("accent", theme.bold(word))}${tail}`;
404
+ },
405
+ );
406
+ result = result.replace(DOCK_HINT, theme.fg("dim", DOCK_HINT));
370
407
  }
371
408
  // Shared glyph colorizer covers ALL status glyphs β€” including ⏳ (waiting),
372
409
  // ⚠ (needs_attention), and the braille spinner range ⠁-β£Ώ (running) β€” which the
373
410
  // previous local statusGlyphColor map + regex omitted (F-1, V-3).
374
- result = colorizeStatusGlyphs(result, theme);
375
- if (index === 0) {
376
- result = theme.fg("accent", result);
377
- }
378
- return result;
411
+ return colorizeStatusGlyphs(result, theme);
379
412
  }
380
413
 
381
414
  export function renderLines(lines: string[], width: number): string[] {