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
@@ -278,22 +278,23 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
278
278
  });
279
279
 
280
280
  pi.registerCommand("crew-brief", {
281
- description: "Toggle brief tool output mode: on | off | status",
281
+ description:
282
+ "Toggle brief mode for crew `team`/`agent` tool output (native read/bash/edit/write/find/grep/ls output is unaffected): on | off | status",
282
283
  handler: async (args: string, ctx: ExtensionCommandContext) => {
283
284
  // LAZY: defer dynamic import of ../../ui/tool-renderers/brief-mode.ts to its call site. Multi-line form breaks scripts/check-lazy-imports.mjs (which does `lines[lineNum - 2]`), so keep destructuring + await import on one line and place this LAZY marker directly above.
284
- const { isBrief, setBrief, BRIEF_ENTRY_TYPE, makeBriefEntry } = await import("../../../ui/tool-renderers/brief-mode.ts");
285
+ const { isBrief, setBrief, BRIEF_ENTRY_TYPE } = await import("../../../ui/tool-renderers/brief-mode.ts");
285
286
  const trimmed = args.trim();
286
287
 
287
288
  if (trimmed === "on") {
288
289
  setBrief(true);
289
290
  pi.appendEntry(BRIEF_ENTRY_TYPE, { enabled: true });
290
- ctx.ui.notify("Brief mode: on — tool output will show compact summaries", "info");
291
+ ctx.ui.notify("Brief mode: on — crew team/agent tool output will show compact summaries", "info");
291
292
  return;
292
293
  }
293
294
  if (trimmed === "off") {
294
295
  setBrief(false);
295
296
  pi.appendEntry(BRIEF_ENTRY_TYPE, { enabled: false });
296
- ctx.ui.notify("Brief mode: off — full tool output restored", "info");
297
+ ctx.ui.notify("Brief mode: off — full crew team/agent tool output restored", "info");
297
298
  return;
298
299
  }
299
300
  // status (default)
@@ -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
@@ -495,7 +531,6 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
495
531
  const selection = await cmdCtx.ui.custom<RunDashboardSelection | undefined>(
496
532
  (tui, theme, _keybindings, done) =>
497
533
  new RunDashboard(runs, done, theme, {
498
- placement: rightPanel ? "right" : "center",
499
534
  showModel: uiConfig?.showModel,
500
535
  showTokens: uiConfig?.showTokens,
501
536
  showTools: uiConfig?.showTools,
@@ -546,8 +581,27 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
546
581
  deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
547
582
  continue;
548
583
  }
584
+ // Tier A (review round 1, MAJOR-1): pane-8 schedule mutations route
585
+ // through the extension channel (handle-schedule.ts subActions) instead
586
+ // of falling into the generic handleTeamTool fall-through below, where
587
+ // they dead-ended with an "unknown action" error and CLOSED the
588
+ // dashboard. runId is "" for these selections — no snapshot invalidation.
589
+ if (
590
+ selection.action === "schedule-enable" ||
591
+ selection.action === "schedule-disable" ||
592
+ selection.action === "schedule-run-now" ||
593
+ selection.action === "schedule-remove"
594
+ ) {
595
+ await handleScheduleDashboardAction(cmdCtx, selection);
596
+ continue;
597
+ }
549
598
  if (selection.action === "agent-transcript" && (await openTranscriptViewer(cmdCtx, selection.runId))) continue;
550
599
  if (selection.action === "agent-live" && (await openLiveConversation(cmdCtx, selection.runId))) continue;
600
+ // feat/agents-browser: `b` from the dashboard opens the unified Agents
601
+ // & Jobs overlay WITHOUT closing the loop — continue reopens the
602
+ // dashboard underneath, mirroring the transcript/live-conversation
603
+ // viewers. runId may be "" (no run selected — jobs-only view).
604
+ if (selection.action === "browser-open" && (await openAgentsJobsBrowser(cmdCtx))) continue;
551
605
  if (selection.action === "agent-live") {
552
606
  await notifyCommandResult(
553
607
  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
+ }
@@ -291,7 +291,7 @@ export function registerSubagentTools(
291
291
  }),
292
292
  t("agent.retrieveHint"),
293
293
  ].join("\n"),
294
- { agentId: record.id, status: record.status },
294
+ { agentId: record.id, agentName: record.type, status: record.status },
295
295
  ),
296
296
  terminate: true,
297
297
  };
@@ -322,6 +322,7 @@ export function registerSubagentTools(
322
322
  ].join("\n"),
323
323
  {
324
324
  agentId: record.id,
325
+ agentName: record.type,
325
326
  runId: record.runId,
326
327
  status: record.status,
327
328
  },
@@ -459,6 +460,7 @@ export function registerSubagentTools(
459
460
  text,
460
461
  {
461
462
  agentId: current.id,
463
+ agentName: current.type,
462
464
  runId: current.runId,
463
465
  status: current.status,
464
466
  },
@@ -531,14 +533,14 @@ export function registerSubagentTools(
531
533
  if (!manifestTask) {
532
534
  return subagentToolResult(
533
535
  `Task '${taskId}' not found in the owning run; cannot steer.`,
534
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
536
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
535
537
  true,
536
538
  );
537
539
  }
538
540
  if (TEAM_TERMINAL_TASK_STATUSES.has(manifestTask.status)) {
539
541
  return subagentToolResult(
540
542
  `Task '${taskId}' is ${manifestTask.status}; cannot steer.`,
541
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
543
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
542
544
  true,
543
545
  );
544
546
  }
@@ -560,7 +562,7 @@ export function registerSubagentTools(
560
562
  if (existingBytes + Buffer.byteLength(line) > MAX_STEERING_BYTES) {
561
563
  return subagentToolResult(
562
564
  `Steering file for task '${taskId}' has reached its size cap (${(MAX_STEERING_BYTES / 1024).toFixed(0)}KiB); steer refused.`,
563
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
565
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
564
566
  true,
565
567
  );
566
568
  }
@@ -579,7 +581,7 @@ export function registerSubagentTools(
579
581
  );
580
582
  return subagentToolResult(
581
583
  `Steer write failed for task '${taskId}': ${err instanceof Error ? err.message : String(err)} (see pi-crew error log)`,
582
- { agentId: record.id, runId: record.runId, taskId, status: record.status },
584
+ { agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
583
585
  true,
584
586
  );
585
587
  }
@@ -590,6 +592,7 @@ export function registerSubagentTools(
590
592
  ].join("\n"),
591
593
  {
592
594
  agentId: record.id,
595
+ agentName: record.type,
593
596
  runId: record.runId,
594
597
  taskId,
595
598
  status: record.status,
@@ -666,7 +669,14 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
666
669
  agents,
667
670
  error: record.error,
668
671
  });
669
- onUpdate({ content: [{ type: "text", text }] });
672
+ // W2: carry identity in the partial payload too — the renderer's
673
+ // streaming branch reads `result.details.agentName` to label the
674
+ // card. Pi's onUpdate TYPE is content-only; extra keys pass through
675
+ // to renderResult at runtime, hence the deliberate cast.
676
+ onUpdate({
677
+ content: [{ type: "text", text }],
678
+ details: { agentId: record.id, agentName: record.type, runId: record.runId, status: record.status },
679
+ } as never);
670
680
  } catch (error) {
671
681
  logInternalError("subagent-tools.progress", error, `agentId=${agentRecordId}`);
672
682
  }
@@ -10,7 +10,7 @@ import type { createManifestCache } from "../../runtime/manifest-cache.ts";
10
10
  import { TeamToolParams, type TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
11
11
  import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
12
12
  import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
13
- import { statusIcon, type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
13
+ import { type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
14
14
  import type { CrewWidgetState } from "../../ui/widget/index.ts";
15
15
  import { updateCrewWidget } from "../../ui/widget/index.ts";
16
16
  import { safeAbort } from "../../utils/safe-abort.ts";
@@ -260,6 +260,11 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
260
260
  onJsonEvent: deps.onJsonEvent,
261
261
  getRunSnapshotCache: deps.getRunSnapshotCache,
262
262
  });
263
+ // W5: surface the team name on the collapsed run card — the renderer
264
+ // reads `details.team`; enrich (non-destructively) for run results.
265
+ if (resolved.action === "run" && !output.isError && output.details && typeof output.details === "object") {
266
+ (output.details as unknown as Record<string, unknown>).team ??= resolved.team ?? resolved.agent ?? "";
267
+ }
263
268
  if (resolved.action === "run" && !output.isError && typeof output.details?.runId === "string") {
264
269
  pi.appendEntry("crew:run-started", {
265
270
  runId: output.details.runId,
@@ -296,8 +301,10 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
296
301
  theme,
297
302
  context as ToolRenderContext,
298
303
  );
299
- } catch {
300
- return new Text(statusIcon("completed", theme) + " done", 0, 0);
304
+ } catch (e) {
305
+ // W1: fail-visible at the adapter layer too — never fake a "done".
306
+ const msg = e instanceof Error ? e.message : String(e);
307
+ return new Text(theme.fg("error", `✖ team render error: ${msg.slice(0, 60)}`), 0, 0);
301
308
  }
302
309
  },
303
310
  };