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
@@ -2,16 +2,19 @@
2
2
  * Command registration installer for pi-crew.
3
3
  *
4
4
  * Single entry point that wires every `pi.registerCommand(...)` call
5
- * site in the extension. Today that means one heavy call:
5
+ * site in the extension. Today that means:
6
6
  * • `registerTeamCommands` — registers every `/crew-*`, `/team-*`,
7
7
  * `/teams`, and friends. The full set lives in
8
8
  * `registration/commands.ts` (already extracted); this file is
9
9
  * the orchestrator-level wrapper that calls it with the right deps.
10
+ * • `registerSchedulesCommands` — the headless `/schedules` command
11
+ * (Scheduled Jobs UI tier E; no deps beyond the ExtensionAPI).
10
12
  */
11
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
14
  import { loadConfig } from "../../config/config.ts";
13
15
  import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
14
16
  import { updateCrewWidget } from "../../ui/widget/index.ts";
17
+ import { registerSchedulesCommands } from "./commands/schedules.ts";
15
18
  import { registerTeamCommands } from "./commands.ts";
16
19
  import type { RegistrationContext } from "./registration-types.ts";
17
20
 
@@ -56,4 +59,5 @@ export function registerPiCommands(pi: ExtensionAPI, ctx: RegistrationContext):
56
59
  }
57
60
  },
58
61
  });
62
+ registerSchedulesCommands(pi);
59
63
  }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `/schedules` command — Scheduled Jobs UI tier E (headless-compatible).
3
+ *
4
+ * Pattern: `registration/commands/status.ts` — `pi.registerCommand` with a
5
+ * handler whose ONLY output channel is `notifyCommandResult` (ctx.ui.notify
6
+ * text block, capped at 800 chars). No TUI dependency: works under `pi -p`.
7
+ *
8
+ * Single source of truth (G17): jobs are read through `getScheduledJobs()`
9
+ * from team-tool/handle-schedule.ts — never from a local defaults copy.
10
+ * Rendering reuses the SAME pane renderer as the dashboard (tier A) via
11
+ * `renderSchedulesTextBlock` → `renderSchedulesPane` — no copied table logic,
12
+ * no drift. Clock (D6-T4): `now` is injected into the render path; the single
13
+ * `new Date()` read lives here at the handler boundary, never in a renderer.
14
+ *
15
+ * `/schedules log <jobId-or-name>` resolves a job by id or name, takes its
16
+ * LATEST spawned run (`loadRunManifestById`), and tails the most recent
17
+ * output artifact (run summary + task result artifacts — the same candidate
18
+ * set as team-tool/run.ts) bounded to SCHEDULES_LOG_TAIL_BYTES via
19
+ * `readTextTail`, with every path resolved inside `artifactsRoot` through
20
+ * `resolveRealContainedPath` (H-3 traversal guard).
21
+ */
22
+ import * as fs from "node:fs";
23
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
24
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
25
+ import { readTextTail } from "../../../runtime/agent-observability.ts";
26
+ import type { ScheduledJob } from "../../../runtime/scheduling/scheduler.ts";
27
+ import { loadRunManifestById } from "../../../state/stores/state-store.ts";
28
+ import type { TeamRunManifest, TeamTaskState } from "../../../state/types.ts";
29
+ import { renderSchedulesTextBlock } from "../../../ui/dashboard-panes/schedules-pane.ts";
30
+ import { resolveRealContainedPath } from "../../../utils/safe-paths.ts";
31
+ import { getCrewScheduler, getScheduledJobs, getScheduledJobsHiddenCountView } from "../../team-tool/handle-schedule.ts";
32
+ import { notifyCommandResult } from "../command-utils.ts";
33
+
34
+ /** Bounded tail for `/schedules log` — matches the dashboard's
35
+ * read-agent-output cap (32_000 bytes, commands/shared.ts). */
36
+ export const SCHEDULES_LOG_TAIL_BYTES = 32_000;
37
+
38
+ export type ParsedSchedulesArgs = { mode: "list" } | { mode: "log"; target: string } | { mode: "usage"; error: string };
39
+
40
+ /** Parse `/schedules` arguments: bare → list, `log <target>` → log tail
41
+ * (target may contain spaces — job names do), anything else → usage error. */
42
+ export function parseSchedulesArgs(args: string): ParsedSchedulesArgs {
43
+ const trimmed = args.trim();
44
+ if (trimmed === "") return { mode: "list" };
45
+ const tokens = trimmed.split(/\s+/);
46
+ if ((tokens[0] ?? "").toLowerCase() !== "log") {
47
+ return { mode: "usage", error: `Unknown subcommand '${tokens[0]}'. Usage: /schedules [log <jobId-or-name>]` };
48
+ }
49
+ const target = tokens.slice(1).join(" ").trim();
50
+ if (!target) return { mode: "usage", error: "Usage: /schedules log <jobId-or-name>" };
51
+ return { mode: "log", target };
52
+ }
53
+
54
+ /** Resolve a job by exact id first, then by (case-insensitive) name. When
55
+ * several jobs share a name, the most recently created wins so `log` follows
56
+ * the newest schedule (ISO timestamps sort lexicographically). */
57
+ export function resolveScheduledJobByIdOrName(jobs: ScheduledJob[], target: string): ScheduledJob | undefined {
58
+ const needle = target.trim();
59
+ if (!needle) return undefined;
60
+ const byId = jobs.find((job) => job.id === needle);
61
+ if (byId) return byId;
62
+ const lower = needle.toLowerCase();
63
+ const byName = jobs.filter((job) => job.name.trim().toLowerCase() === lower);
64
+ if (byName.length === 0) return undefined;
65
+ return byName.reduce((newest, job) => (job.createdAt > newest.createdAt ? job : newest));
66
+ }
67
+
68
+ /**
69
+ * Build the `/schedules` text block. Thin wrapper over the shared text-block
70
+ * variant of the pane renderer — kept exported so the parity test can pin
71
+ * byte-for-byte equality against `renderSchedulesPane` (no drift). The
72
+ * optional `hiddenCount` (P2-1) flows straight through to the shared hint
73
+ * line; omitted/0 keeps the legacy layout.
74
+ */
75
+ export function buildSchedulesCommandLines(jobs: ScheduledJob[], now: Date, hiddenCount = 0): string[] {
76
+ return renderSchedulesTextBlock(jobs, now, { hiddenCount });
77
+ }
78
+
79
+ /** Test seam for the run-manifest loader (defaults to the real state store). */
80
+ export interface SchedulesLogDeps {
81
+ loadManifest?: typeof loadRunManifestById;
82
+ }
83
+
84
+ /**
85
+ * Build the `/schedules log <jobId-or-name>` text: resolve the job, take its
86
+ * latest spawned run, and tail the most recent output artifact. Pure-ish
87
+ * read-only helper (no clock, no mutation) so tests can drive every branch.
88
+ */
89
+ export function buildSchedulesLogText(
90
+ cwd: string,
91
+ jobs: ScheduledJob[],
92
+ target: string,
93
+ deps: SchedulesLogDeps = {},
94
+ ): { isError: boolean; text: string } {
95
+ const job = resolveScheduledJobByIdOrName(jobs, target);
96
+ if (!job) return { isError: true, text: `No scheduled job with id or name '${target}'.` };
97
+ const runId = job.spawnedRunIds?.[job.spawnedRunIds.length - 1];
98
+ if (!runId) return { isError: true, text: `Job '${job.name}' has no spawned runs yet.` };
99
+ // Security review F-3: runId is a persisted settings field under only a
100
+ // weak shape guard; the state store's assertSafePathId THROWS on unsafe
101
+ // charsets (traversal containment — it fails closed). Catch it here and
102
+ // degrade to the run-not-found-style error toast instead of an unhandled
103
+ // rejection inside the command handler.
104
+ let loaded: ReturnType<typeof loadRunManifestById>;
105
+ try {
106
+ loaded = (deps.loadManifest ?? loadRunManifestById)(cwd, runId);
107
+ } catch {
108
+ return { isError: true, text: `Run '${runId}' is not a valid run id.` };
109
+ }
110
+ if (!loaded) return { isError: true, text: `Run '${runId}' not found (it may have been cleaned up).` };
111
+ const artifact = pickMostRecentOutputArtifact(loaded.manifest, loaded.tasks);
112
+ if (!artifact) return { isError: true, text: `Run '${runId}' has no output artifacts yet.` };
113
+ const tail = readTextTail(artifact.path, SCHEDULES_LOG_TAIL_BYTES);
114
+ const lines = [
115
+ `Scheduled job '${job.name}' — latest run ${runId}`,
116
+ `artifact: ${artifact.path} (${tail.bytes} bytes${tail.truncated ? `, showing last ${SCHEDULES_LOG_TAIL_BYTES}` : ""})`,
117
+ tail.text.trimEnd(),
118
+ ];
119
+ return { isError: false, text: lines.join("\n") };
120
+ }
121
+
122
+ /**
123
+ * Most-recent output artifact of a run. Candidate set mirrors
124
+ * team-tool/run.ts:252-273 — the run-level summary artifact plus per-task
125
+ * result artifacts — each resolved inside `artifactsRoot` through
126
+ * `resolveRealContainedPath` (rejects absolute paths and `..` traversal);
127
+ * unresolvable/unreadable candidates are skipped, newest mtime wins.
128
+ */
129
+ function pickMostRecentOutputArtifact(manifest: TeamRunManifest, tasks: TeamTaskState[]): { path: string; mtimeMs: number } | undefined {
130
+ const relPaths: string[] = [];
131
+ for (const artifact of manifest.artifacts ?? []) {
132
+ if (artifact?.kind === "summary" || artifact?.kind === "result" || artifact?.kind === "log") relPaths.push(artifact.path);
133
+ }
134
+ for (const task of tasks) {
135
+ if (task?.resultArtifact?.path) relPaths.push(task.resultArtifact.path);
136
+ }
137
+ let best: { path: string; mtimeMs: number } | undefined;
138
+ for (const rel of relPaths) {
139
+ try {
140
+ const resolved = resolveRealContainedPath(manifest.artifactsRoot, rel);
141
+ const mtimeMs = fs.statSync(resolved).mtimeMs;
142
+ if (!best || mtimeMs > best.mtimeMs) best = { path: resolved, mtimeMs };
143
+ } catch {
144
+ /* traversal-guarded or missing artifact — skip */
145
+ }
146
+ }
147
+ return best;
148
+ }
149
+
150
+ /** Error-level variant of notifyCommandResult (same 800-char bound). */
151
+ async function notifySchedulesError(ctx: ExtensionCommandContext, text: string): Promise<void> {
152
+ ctx.ui.notify(text.length > 800 ? `${text.slice(0, 797)}...` : text, "error");
153
+ }
154
+
155
+ /** Tab-completion: `log` subcommand, then job ids (labeled by name). */
156
+ function suggestSchedulesArgs(argumentPrefix: string): AutocompleteItem[] | null {
157
+ const prefix = argumentPrefix ?? "";
158
+ if (prefix === "" || prefix === "l" || prefix === "lo" || prefix === "log") {
159
+ return [{ value: "log", label: "log", description: "tail the latest run output of a job" }];
160
+ }
161
+ // Raw prefix (NOT trimmed) so `log ` (trailing space) means "suggest jobs".
162
+ const match = prefix.match(/^log\s+(\S*)$/i);
163
+ if (!match) return null;
164
+ const query = match[1] ?? "";
165
+ const items: AutocompleteItem[] = [];
166
+ // Review round 1 minor-3: autocomplete runs on EVERY keystroke. Gate on
167
+ // the registered scheduler (same discipline as the widget's default
168
+ // reader) so a missing scheduler can never fall back to the settings-store
169
+ // DISK read per keystroke — pre-registration simply suggests nothing.
170
+ const jobs = getCrewScheduler() ? getScheduledJobs(process.cwd()) : [];
171
+ for (const job of jobs) {
172
+ if (query && !job.id.startsWith(query) && !job.name.toLowerCase().includes(query.toLowerCase())) continue;
173
+ items.push({ value: `log ${job.id}`, label: job.name, description: `id ${job.id}` });
174
+ if (items.length >= 10) break;
175
+ }
176
+ return items.length > 0 ? items : null;
177
+ }
178
+
179
+ /** Register the `/schedules` command (see module doc). Headless-safe: the
180
+ * handler touches only `ctx.cwd` and `ctx.ui.notify`. */
181
+ export function registerSchedulesCommands(pi: ExtensionAPI): void {
182
+ pi.registerCommand("schedules", {
183
+ description: "List scheduled jobs (/schedules log <jobId-or-name> tails the latest output)",
184
+ getArgumentCompletions: (argumentPrefix: string) => suggestSchedulesArgs(argumentPrefix),
185
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
186
+ const parsed = parseSchedulesArgs(args);
187
+ if (parsed.mode === "usage") {
188
+ await notifyCommandResult(ctx, parsed.error);
189
+ return;
190
+ }
191
+ const jobs = getScheduledJobs(ctx.cwd);
192
+ if (parsed.mode === "log") {
193
+ const outcome = buildSchedulesLogText(ctx.cwd, jobs, parsed.target);
194
+ if (outcome.isError) {
195
+ await notifySchedulesError(ctx, outcome.text);
196
+ return;
197
+ }
198
+ await notifyCommandResult(ctx, outcome.text);
199
+ return;
200
+ }
201
+ // D6-T4: the render path takes `now` as a parameter — this single
202
+ // `new Date()` at the handler boundary is the clock injection point.
203
+ // P2-1: the hidden count rides the same user-initiated invocation
204
+ // (stash-first provider read — in-memory when the scheduler singleton
205
+ // is registered, one tiers read otherwise; never on a render tick).
206
+ await notifyCommandResult(
207
+ ctx,
208
+ buildSchedulesCommandLines(jobs, new Date(), getScheduledJobsHiddenCountView(ctx.cwd)).join("\n"),
209
+ );
210
+ },
211
+ });
212
+ }
@@ -35,7 +35,7 @@ import { withSessionId } from "../../team-tool/context.ts";
35
35
  import type { handleTeamTool as HandleTeamToolFn } from "../../team-tool.ts";
36
36
  import { commandText, notifyCommandResult } from "../command-utils.ts";
37
37
  import type { UiState } from "../ui.ts";
38
- import { openLiveConversation, openTranscriptViewer } from "../viewers.ts";
38
+ import { openAgentsJobsBrowser, openLiveConversation, openTranscriptViewer } from "../viewers.ts";
39
39
 
40
40
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
41
41
  let _handleTeamToolPromise: Promise<typeof HandleTeamToolFn> | undefined;
@@ -439,6 +439,42 @@ async function handlePlanDashboardAction(ctx: ExtensionCommandContext, selection
439
439
 
440
440
  let depsRef: RegisterTeamCommandsDeps | undefined;
441
441
 
442
+ /**
443
+ * Tier A finalize (review round 1, MAJOR-1): dashboard schedule mutations
444
+ * (T/N/X in pane 8) consumed at the extension layer. The dashboard emits
445
+ * `done()` selections carrying schedule-* actions + jobId; this routes them
446
+ * through the SAME team-tool channel the chat surface uses —
447
+ * handleTeamTool({action:'schedule', subAction, jobId}) → handle-schedule.ts —
448
+ * so the UI layer never calls the scheduler directly (approved-design
449
+ * constraint #3). Modeled on handlePlanDashboardAction: dispatch, then surface
450
+ * the result at its true level (isError → red). No snapshot-cache
451
+ * invalidation: schedule actions carry no runId (the run list is untouched).
452
+ * The 2-step delete confirm-gate lives in the dashboard itself (pane-8 X);
453
+ * this is the final hop to the mutation channel.
454
+ */
455
+ async function handleScheduleDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
456
+ const jobId = selection.jobId;
457
+ if (!jobId) {
458
+ depsNotify(ctx, "schedule action requires a job id", "warning");
459
+ return;
460
+ }
461
+ // Defer the dynamic import of run-dashboard.ts (already warm — ui()
462
+ // loaded it to host the dashboard) so this module's import graph stays
463
+ // free of the UI chain at Pi startup. The mapping is the UI layer's
464
+ // published action→subAction contract; importing it (instead of re-deriving)
465
+ // keeps the two from drifting.
466
+ // LAZY: run-dashboard.ts — UI chain; warm by the time this runs (ui()).
467
+ const { scheduleDashboardActionToSubAction } = await import("../../../ui/run-dashboard.ts");
468
+ const subAction = scheduleDashboardActionToSubAction(selection.action);
469
+ if (!subAction) {
470
+ depsNotify(ctx, `unhandled schedule action '${selection.action}'`, "warning");
471
+ return;
472
+ }
473
+ const result = await handleTeamTool({ action: "schedule", subAction, jobId }, teamCommandContext(ctx));
474
+ const text = commandText(result);
475
+ depsNotify(ctx, text.length > 800 ? `${text.slice(0, 797)}...` : text, result.isError ? "error" : "info");
476
+ }
477
+
442
478
  /**
443
479
  * Internal setter for `depsRef` (Phase 2.1 split). ESM import bindings are
444
480
  * read-only, so `registerTeamCommands` in index.ts cannot assign to the
@@ -546,8 +582,27 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
546
582
  deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
547
583
  continue;
548
584
  }
585
+ // Tier A (review round 1, MAJOR-1): pane-8 schedule mutations route
586
+ // through the extension channel (handle-schedule.ts subActions) instead
587
+ // of falling into the generic handleTeamTool fall-through below, where
588
+ // they dead-ended with an "unknown action" error and CLOSED the
589
+ // dashboard. runId is "" for these selections — no snapshot invalidation.
590
+ if (
591
+ selection.action === "schedule-enable" ||
592
+ selection.action === "schedule-disable" ||
593
+ selection.action === "schedule-run-now" ||
594
+ selection.action === "schedule-remove"
595
+ ) {
596
+ await handleScheduleDashboardAction(cmdCtx, selection);
597
+ continue;
598
+ }
549
599
  if (selection.action === "agent-transcript" && (await openTranscriptViewer(cmdCtx, selection.runId))) continue;
550
600
  if (selection.action === "agent-live" && (await openLiveConversation(cmdCtx, selection.runId))) continue;
601
+ // feat/agents-browser: `b` from the dashboard opens the unified Agents
602
+ // & Jobs overlay WITHOUT closing the loop — continue reopens the
603
+ // dashboard underneath, mirroring the transcript/live-conversation
604
+ // viewers. runId may be "" (no run selected — jobs-only view).
605
+ if (selection.action === "browser-open" && (await openAgentsJobsBrowser(cmdCtx))) continue;
551
606
  if (selection.action === "agent-live") {
552
607
  await notifyCommandResult(
553
608
  cmdCtx,
@@ -36,7 +36,7 @@ import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts"
36
36
  import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
37
37
  import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
38
38
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
39
- import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers } from "../../runtime/settings-store.ts";
39
+ import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
40
40
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
41
41
  import type { TeamRunManifest } from "../../state/types.ts";
42
42
  import { summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
@@ -61,10 +61,11 @@ import { getBrokerSocketPath } from "../../utils/socket-path.ts";
61
61
  import { startAsyncRunNotifier, stopAsyncRunNotifier } from "../async-notifier.ts";
62
62
  import { registerCrewAutocomplete } from "../crew-autocomplete.ts";
63
63
  import { notifyActiveRuns } from "../session-summary.ts";
64
- import { persistScheduledJobUpdate, registerCrewScheduler } from "../team-tool/handle-schedule.ts";
64
+ import { persistScheduledJobUpdate, registerCrewScheduler, stashScheduledJobsHiddenCount } from "../team-tool/handle-schedule.ts";
65
65
  import { handleTeamTool } from "../team-tool.ts";
66
66
  import { runArtifactCleanup } from "./artifact-cleanup.ts";
67
67
  import type { RegistrationContext } from "./registration-types.ts";
68
+ import { createScheduleEventNotifier } from "./schedule-toast-bridge.ts";
68
69
 
69
70
  /**
70
71
  * Register all session-lifecycle handlers on the ExtensionAPI. The caller
@@ -283,6 +284,11 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
283
284
  // Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
284
285
  // EXT-9: module-scoped setter (was globalThis[Symbol.for(...)]).
285
286
  registerCrewScheduler(ctx.crewScheduler);
287
+ // P2-1 (B2 gate visibility): stash the hidden project-tier count from the
288
+ // SAME tiers read above (zero extra disk I/O — disk reads are legal here
289
+ // at session_start but never on later paint paths) so the crew widget
290
+ // line and the dashboard pane can surface "N project-tier jobs hidden".
291
+ stashScheduledJobsHiddenCount(scheduledJobsHiddenCountOf(crewSettingsTiers));
286
292
  // Load scheduled jobs from settings if present.
287
293
  // BOUNDARY (Wave B2): project-tier scheduledJobs are OPT-IN GATED — the
288
294
  // registration loop reads the gated `effectiveScheduledJobs` view (user
@@ -497,9 +503,17 @@ function setupCrewScheduler(
497
503
  sessionId: string | undefined,
498
504
  ): CrewScheduler {
499
505
  const crewScheduler = new CrewScheduler();
506
+ // Tier D (schedules UI): scheduler events → terminal-status toasts. Bounded
507
+ // (hung-notice pattern): at most one notice per event; headless sessions
508
+ // no-op inside the bridge (hasUI probed defensively) instead of crashing.
509
+ const notifyScheduleEvent = createScheduleEventNotifier({
510
+ hasUI: () => extensionCtx.hasUI,
511
+ ui: extensionCtx.ui,
512
+ });
500
513
  crewScheduler.start({
501
514
  emit: (event) => {
502
515
  if (ctx.cleanedUp) return;
516
+ notifyScheduleEvent(event);
503
517
  pi.events?.emit?.("crew-scheduler", event);
504
518
  },
505
519
  executor: (job) => {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Tier D (schedules UI): bridge ScheduleChangeEvent → terminal-status toasts.
3
+ *
4
+ * Bounded like the hung-notice pattern (foreground-watchdog): at most ONE
5
+ * notice per event, no drip. Mapping (approved design):
6
+ * • fired → `⏰ <name> fired → <agentId>` (info)
7
+ * • error → `⏰ scheduled job <jobId> failed: …` (error — red)
8
+ * • run completion is NOT an event of its own — it surfaces as an `updated`
9
+ * event whose job.lastStatus transitions INTO "success" (tracked per job,
10
+ * the "if trackable" clause), so repeated `updated` emissions
11
+ * (enable/disable patches, runCount bumps) stay silent.
12
+ * • run FAILURE has two producers: the scheduler's synchronous `error`
13
+ * event (fire()'s catch) AND — the common path — the ASYNC executor
14
+ * rejection, which lifecycle-handlers records as an `updated` event whose
15
+ * lastStatus moves running→error with no `error` event at all. That
16
+ * transition gets the red `✗ failed` toast. A per-job `failedNotified`
17
+ * set de-dupes the two producers (fire() marks running → clears the flag;
18
+ * whichever failure notice lands first claims it) so a sync-throwing
19
+ * executor still yields exactly ONE notice per attempt.
20
+ *
21
+ * All interpolated fields pass through sanitizeLine (untrusted job fields
22
+ * and LLM-derived error strings must never inject line breaks / control
23
+ * chars into the terminal status area).
24
+ *
25
+ * Headless-safe: hasUI is probed defensively (the getter THROWS on a stale
26
+ * context) and ui.notify failures are swallowed — the bridge is a no-op, never
27
+ * a crash path, inside the scheduler's emit callback.
28
+ */
29
+ import type { ScheduleChangeEvent } from "../../runtime/scheduling/scheduler.ts";
30
+ import { sanitizeLine } from "../../utils/visual.ts";
31
+
32
+ export interface ScheduleToastTarget {
33
+ notify(text: string, level: "info" | "warning" | "error"): unknown;
34
+ }
35
+
36
+ export interface ScheduleToastDeps {
37
+ /** hasUI probe — may throw on a stale ctx; a throw counts as "no UI". */
38
+ hasUI(): boolean;
39
+ ui: ScheduleToastTarget | undefined;
40
+ }
41
+
42
+ /** Error-toast payload cap: first line only, at most ~200 chars (toast
43
+ * convention — tighter than the 800-char command text block). */
44
+ const ERROR_TEXT_MAX = 200;
45
+
46
+ export function createScheduleEventNotifier(deps: ScheduleToastDeps): (event: ScheduleChangeEvent) => void {
47
+ /** Per-job lastStatus tracker — the "transition" memory for success toasts. */
48
+ const lastStatusByJob = new Map<string, string | undefined>();
49
+ /** Per-job "failure already noticed" marker — de-dupes the error-event and
50
+ * running→error-transition producers so one failed attempt = one notice. */
51
+ const failedNotified = new Set<string>();
52
+ const deliver = (text: string, level: "info" | "error"): void => {
53
+ try {
54
+ if (!deps.hasUI() || !deps.ui) return;
55
+ deps.ui.notify(text, level);
56
+ } catch {
57
+ /* toasts are best-effort — never break the scheduler path */
58
+ }
59
+ };
60
+ return (event: ScheduleChangeEvent): void => {
61
+ switch (event.type) {
62
+ case "fired":
63
+ deliver(`⏰ ${sanitizeLine(event.name)} fired → ${sanitizeLine(event.agentId)}`, "info");
64
+ return;
65
+ case "error": {
66
+ // Sync producer (fire()'s catch / past-time once-job). Skipped only
67
+ // when the running→error transition already claimed this attempt.
68
+ if (failedNotified.has(event.jobId)) return;
69
+ failedNotified.add(event.jobId);
70
+ const firstLine = event.error.split("\n")[0] ?? "";
71
+ const clipped = firstLine.length > ERROR_TEXT_MAX ? `${firstLine.slice(0, ERROR_TEXT_MAX - 1)}…` : firstLine;
72
+ deliver(`⏰ scheduled job ${sanitizeLine(event.jobId)} failed: ${sanitizeLine(clipped)}`, "error");
73
+ return;
74
+ }
75
+ case "added":
76
+ // Persisted jobs re-register here at session start — record, never
77
+ // toast (otherwise every session boot would spam one line per job).
78
+ lastStatusByJob.set(event.job.id, event.job.lastStatus);
79
+ return;
80
+ case "updated": {
81
+ const previous = lastStatusByJob.get(event.job.id);
82
+ lastStatusByJob.set(event.job.id, event.job.lastStatus);
83
+ // A new attempt underway: re-arm the failure notice for it.
84
+ if (event.job.lastStatus === "running") {
85
+ failedNotified.delete(event.job.id);
86
+ return;
87
+ }
88
+ // Async executor rejection (lifecycle finalization path): running →
89
+ // error with NO scheduler `error` event — the most common failure
90
+ // route. Guarded on previous === "running" so a persisted
91
+ // error-status re-registration (undefined/"error" → "error") stays
92
+ // silent and cannot double-toast with the sync producer.
93
+ if (event.job.lastStatus === "error" && previous === "running") {
94
+ failedNotified.add(event.job.id);
95
+ deliver(`⏰ ${sanitizeLine(event.job.name)} ✗ failed`, "error");
96
+ return;
97
+ }
98
+ if (event.job.lastStatus === "success" && previous !== "success") {
99
+ deliver(`⏰ ${sanitizeLine(event.job.name)} ✓ succeeded`, "info");
100
+ }
101
+ return;
102
+ }
103
+ case "removed":
104
+ lastStatusByJob.delete(event.jobId);
105
+ failedNotified.delete(event.jobId);
106
+ return;
107
+ }
108
+ };
109
+ }
@@ -1,9 +1,12 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey } from "@earendil-works/pi-tui";
2
3
  import { loadConfig } from "../../config/config.ts";
3
4
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
4
5
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
5
6
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
7
+ import { AgentsJobsBrowser } from "../../ui/agents-jobs-browser.ts";
6
8
  import { LiveConversationOverlay } from "../../ui/live-conversation-overlay.ts";
9
+ import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
7
10
  import { asCrewTheme } from "../../ui/theme-adapter.ts";
8
11
  // Lazy-loaded: DurableTranscriptViewer is 658ms — only needed for /crew transcript command
9
12
  import type { DurableTranscriptViewer as DurableTranscriptViewerType } from "../../ui/transcript-viewer.ts";
@@ -87,7 +90,7 @@ export async function openLiveConversation(
87
90
  return overlay.render(width);
88
91
  },
89
92
  handleInput(data: string) {
90
- if (data === "\x1b" || data === "q") {
93
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
91
94
  overlay.close();
92
95
  done(undefined);
93
96
  }
@@ -111,3 +114,65 @@ export async function openLiveConversation(
111
114
  );
112
115
  return true;
113
116
  }
117
+
118
+ /**
119
+ * feat/agents-browser: open the unified Agents & Jobs browser overlay.
120
+ *
121
+ * Full-view component (the SAME custom() precedent as openLiveConversation —
122
+ * pi's overlay-anchor option was evaluated and set aside: overlays float over
123
+ * chat with different key routing; the modal form matches every existing
124
+ * pi-crew viewer, so users get one consistent interaction model). Opened from the run
125
+ * dashboard with ONE keypress (`b`); lists ALL live agents + scheduled jobs
126
+ * via the G17 providers — independent of any single run. Headless sessions
127
+ * (`ctx.hasUI === false`) are a silent no-op returning false.
128
+ */
129
+ export async function openAgentsJobsBrowser(
130
+ ctx: Pick<ExtensionCommandContext, "hasUI" | "cwd" | "ui" | "sessionManager">,
131
+ ): Promise<boolean> {
132
+ if (!ctx.hasUI) return false;
133
+ const theme = asCrewTheme({});
134
+ const workspaceId = ctx.sessionManager?.getSessionId?.();
135
+ await ctx.ui.custom<undefined>(
136
+ (tui, _theme, _keybindings, done) => {
137
+ const columns = tui?.terminal?.columns ?? 80;
138
+ const rows = tui?.terminal?.rows ?? 24;
139
+ const browser = new AgentsJobsBrowser({
140
+ cwd: ctx.cwd,
141
+ workspaceId,
142
+ theme,
143
+ columns,
144
+ rows,
145
+ requestRender: () => requestRenderTarget(tui),
146
+ // Enter on an agent opens the full transcript overlay — the
147
+ // `/crew transcript` experience ("chi tiết như subagent cũ").
148
+ onOpenTranscript: ({ runId, taskId }) => {
149
+ void openTranscriptViewer(ctx as ExtensionCommandContext, runId, taskId);
150
+ },
151
+ });
152
+ return {
153
+ render(width: number) {
154
+ return browser.render(width);
155
+ },
156
+ handleInput(data: string) {
157
+ browser.handleInput(data);
158
+ if (browser.isClosed) done(undefined);
159
+ },
160
+ invalidate() {
161
+ browser.refreshData(true);
162
+ },
163
+ dispose() {
164
+ browser.dispose();
165
+ },
166
+ };
167
+ },
168
+ {
169
+ overlay: true,
170
+ overlayOptions: {
171
+ width: "92%",
172
+ maxHeight: "70%",
173
+ anchor: "center",
174
+ },
175
+ },
176
+ );
177
+ return true;
178
+ }