import * as path from "node:path"; import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { loadConfig } from "../../../config/config.ts"; import { DEFAULT_UI } from "../../../config/defaults.ts"; import type { MetricRegistry } from "../../../observability/metric-registry.ts"; import { listRecentDiagnostic } from "../../../runtime/diagnostic-export.ts"; import { isPlanApprovalPendingEffective } from "../../../runtime/plan-approval.ts"; import type { TeamAction } from "../../../schema/team-tool-schema.ts"; import { loadRunManifestById } from "../../../state/stores/state-store.ts"; import type { TeamRunManifest } from "../../../state/types.ts"; import { getKeybindingOverrideWarnings } from "../../../ui/keybinding-map.ts"; import type { AnimatedMascot as AnimatedMascotType } from "../../../ui/mascot.ts"; import type { AgentPickerOverlay as AgentPickerOverlayType } from "../../../ui/overlays/agent-picker-overlay.ts"; import type { ConfirmOptions, ConfirmOverlay as ConfirmOverlayType } from "../../../ui/overlays/confirm-overlay.ts"; import type { MailboxComposeOverlay as MailboxComposeOverlayType, MailboxComposeResult, } from "../../../ui/overlays/mailbox-compose-overlay.ts"; import type { MailboxAction, MailboxDetailOverlay as MailboxDetailOverlayType } from "../../../ui/overlays/mailbox-detail-overlay.ts"; import { requestRenderTarget } from "../../../ui/pi-ui-compat.ts"; import { dispatchDiagnosticExport, dispatchHealthRecovery, dispatchKillStaleWorkers, dispatchMailboxAck, dispatchMailboxAckAll, dispatchMailboxCompose, dispatchMailboxNudge, } from "../../../ui/run-action-dispatcher.ts"; import type { RunDashboardSelection, RunDashboard as RunDashboardType } from "../../../ui/run-dashboard.ts"; import type { createRunSnapshotCache } from "../../../ui/run-snapshot-cache.ts"; import type { DurableTextViewer as DurableTextViewerType } from "../../../ui/transcript-viewer.ts"; import { withSessionId } from "../../team-tool/context.ts"; // Lazy-loaded: team-tool.ts pulls in entire runtime chain (1.4s+). import type { handleTeamTool as HandleTeamToolFn } from "../../team-tool.ts"; import { commandText, notifyCommandResult } from "../command-utils.ts"; import type { UiState } from "../ui.ts"; import { openAgentsJobsBrowser, openLiveConversation, openTranscriptViewer } from "../viewers.ts"; let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined; let _handleTeamToolPromise: Promise | undefined; export async function handleTeamTool( params: Parameters[0], ctx: Parameters[1], ): Promise>> { if (!_cachedHandleTeamTool) { if (!_handleTeamToolPromise) { _handleTeamToolPromise = import("../../team-tool.ts").then((mod) => { // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) _cachedHandleTeamTool = mod.handleTeamTool; return mod.handleTeamTool; }); } const fn = await _handleTeamToolPromise; return fn(params, ctx); } return _cachedHandleTeamTool(params, ctx); } /** * TEST SEAM (STEP 1.9a) — substitute the lazy-loaded team-tool facade. * Production `handleTeamTool` populates `_cachedHandleTeamTool` on first use * via `import("../../team-tool.ts")` (1.4s+ runtime chain). This lets unit tests * inject a recording stub and exercise command handlers without importing the * chain. No production behavior change: the lazy-import path is untouched when * this is never called, and handlers always resolve through the same cache. */ export function __test__setHandleTeamTool(fn: typeof HandleTeamToolFn | undefined): void { _cachedHandleTeamTool = fn; _handleTeamToolPromise = undefined; } export interface RegisterTeamCommandsDeps { startForegroundRun: (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise, runId?: string) => void; abortForegroundRun: (runId: string) => boolean; openLiveSidebar: (ctx: ExtensionContext, runId: string) => void; getManifestCache: (cwd: string) => { list(max?: number): TeamRunManifest[]; }; getRunSnapshotCache?: (cwd: string) => ReturnType; getMetricRegistry?: () => MetricRegistry | undefined; uiState?: UiState; dismissNotifications?: () => void; /** * The session file of the CURRENT session (RegistrationContext.currentCtx). * Commands whose handler can span a session switch — team-run awaits the * whole foreground run — use this after the await to detect that the * captured ctx is stale and skip notifications that pi would drop with a * "ctx is stale" error. */ getCurrentSessionFile?: () => string | undefined; } // Lazy-loaded UI module cache — avoids importing 900ms+ of UI at Pi startup. // These modules are only needed when user invokes /crew commands. let _uiCache: | { RunDashboard: typeof RunDashboardType; DurableTextViewer: typeof DurableTextViewerType; ConfirmOverlay: typeof ConfirmOverlayType; MailboxDetailOverlay: typeof MailboxDetailOverlayType; MailboxComposeOverlay: typeof MailboxComposeOverlayType; AgentPickerOverlay: typeof AgentPickerOverlayType; AnimatedMascot: typeof AnimatedMascotType; } | undefined; let _uiCachePromise: Promise> | undefined; export async function ui(): Promise> { if (!_uiCache) { if (!_uiCachePromise) { _uiCachePromise = (async () => { const [rd, tv, co, md, mc, ap, ma] = await Promise.all([ import("../../../ui/run-dashboard.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/transcript-viewer.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/overlays/confirm-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/overlays/mailbox-detail-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/overlays/mailbox-compose-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/overlays/agent-picker-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/mascot.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) ]); const cache = { RunDashboard: rd.RunDashboard, DurableTextViewer: tv.DurableTextViewer, ConfirmOverlay: co.ConfirmOverlay, MailboxDetailOverlay: md.MailboxDetailOverlay, MailboxComposeOverlay: mc.MailboxComposeOverlay, AgentPickerOverlay: ap.AgentPickerOverlay, AnimatedMascot: ma.AnimatedMascot, }; _uiCache = cache; return cache; })(); } return _uiCachePromise; } return _uiCache; } async function openConfirm(ctx: ExtensionCommandContext, options: ConfirmOptions): Promise { if (!ctx.hasUI) return false; const { ConfirmOverlay } = await ui(); return await ctx.ui.custom((_tui, theme, _keybindings, done) => new ConfirmOverlay(options, done, theme), { overlay: true, overlayOptions: { width: 64, maxHeight: "70%", anchor: "center" }, }); } async function handleMailboxDashboardAction(ctx: ExtensionCommandContext, runId: string): Promise { if (!ctx.hasUI) return; const { MailboxDetailOverlay } = await ui(); const action = await ctx.ui.custom( (_tui, theme, _keybindings, done) => new MailboxDetailOverlay({ runId, cwd: ctx.cwd, done, theme }), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center", }, }, ); if (!action || action.type === "close") return; let resultMessage: string | undefined; let ok = true; if (action.type === "ack") { const result = await dispatchMailboxAck(ctx as ExtensionContext, runId, action.messageId); ok = result.ok; resultMessage = result.message; } else if (action.type === "ackAll") { const confirmed = await openConfirm(ctx, { title: "Acknowledge all unread messages?", body: "This cannot be undone. Y=ack all, N=cancel.", dangerLevel: "medium", defaultAction: "cancel", }); if (!confirmed) return; const result = await dispatchMailboxAckAll(ctx as ExtensionContext, runId); ok = result.ok; resultMessage = result.message; } else if (action.type === "compose") { const { MailboxComposeOverlay } = await ui(); const compose = await ctx.ui.custom( (_tui, theme, _keybindings, done) => new MailboxComposeOverlay({ done, theme }), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center", }, }, ); if (compose.type === "cancel") return; const result = await dispatchMailboxCompose(ctx as ExtensionContext, runId, compose.payload); ok = result.ok; resultMessage = result.message; } else if (action.type === "nudge") { let agentId = action.agentId; if (!agentId) { const { AgentPickerOverlay } = await ui(); const picked = await ctx.ui.custom<{ agentId: string } | undefined>( (_tui, theme, _keybindings, done) => new AgentPickerOverlay({ cwd: ctx.cwd, runId, done, theme, }), { overlay: true, overlayOptions: { width: 72, maxHeight: "75%", anchor: "center", }, }, ); agentId = picked?.agentId; } if (!agentId) return; const result = await dispatchMailboxNudge( ctx as ExtensionContext, runId, agentId, "Please report your current status, blocker, or smallest next step.", ); ok = result.ok; resultMessage = result.message; } depsNotify(ctx, resultMessage ?? "Mailbox action complete.", ok ? "info" : "error"); } function depsNotify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void { if (!ctx.hasUI) return; ctx.ui.notify(message, level); } export function teamCommandContext(ctx: ExtensionCommandContext): ExtensionCommandContext & { sessionId?: string } { return withSessionId(ctx); } /** * Open the pi-crew settings overlay (config editor + theme picker). * * Extracted from the `team-settings` command so it is reusable from a * keyboard shortcut. Takes the base `ExtensionContext` (the shortcut * handler's context) — uses only `hasUI`, `cwd`, and `ui` fields, so both * `ExtensionContext` and `ExtensionCommandContext` satisfy it. */ export async function openTeamSettingsOverlay(ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([ import("../../../config/config.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/theme-adapter.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) import("../../../ui/settings-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2) ]); const loaded = loadConfig(ctx.cwd); const config = loaded.config as Record; await ctx.ui.custom( (_tui, _theme, _keybindings, done) => { const theme = asCrewTheme(_theme); const { overlay } = createSettingsOverlay( config, theme, (id: string, value: unknown) => { try { const patch: Record = {}; const keys = id.split("."); let target: Record = patch; for (let i = 0; i < keys.length - 1; i++) { if (!target[keys[i]!] || typeof target[keys[i]!] !== "object") target[keys[i]!] = {}; target = target[keys[i]!] as Record; } target[keys[keys.length - 1]!] = value; if (value === undefined) { updateConfig({}, { unsetPaths: [id] }); } else { updateConfig(parseConfig(patch)); } } catch (error) { ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error"); } }, () => done(undefined), async (action: string, value: unknown) => { // Action callbacks (Pi theme switch) write to a different store // than pi-crew config (e.g. ~/.pi/agent/settings.json). try { if (action === "piTheme" && typeof value === "string") { // Live theme switch: ctx.ui.setTheme() swaps the global theme, // persists it to settings.json, and triggers a UI redraw — no // restart needed. Falls back to file-write + restart hint if // the live API is unavailable (e.g. non-TUI mode). if (typeof ctx.ui.setTheme === "function") { const res = ctx.ui.setTheme(value); if (res.success) { ctx.ui.notify(`Theme: ${value} (applied live)`, "info"); } else { // LAZY: defer dynamic import of ../../ui/theme-discovery.ts to its call site. const { setPiTheme } = await import("../../../ui/theme-discovery.ts"); setPiTheme(value); ctx.ui.notify( `Theme saved as '${value}' but failed to apply: ${res.error ?? "unknown"}. Restart Pi.`, "warning", ); } } else { // LAZY: defer dynamic import of ../../ui/theme-discovery.ts to its call site. const { setPiTheme } = await import("../../../ui/theme-discovery.ts"); setPiTheme(value); ctx.ui.notify(`Pi theme set to '${value}'. Restart Pi to apply.`, "info"); } } } catch (error) { ctx.ui.notify(`Failed: ${error instanceof Error ? error.message : String(error)}`, "error"); } }, ); return overlay; }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center", }, }, ); } async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise { const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency if (!loaded) { depsNotify(ctx, `Run '${selection.runId}' not found.`, "error"); return; } if (selection.action === "health-recovery") { if (loaded.manifest.async) { depsNotify(ctx, "Recovery is only available for foreground runs.", "warning"); return; } const confirmed = await openConfirm(ctx, { title: "Interrupt foreground run?", body: "Tasks may be marked failed. Y=interrupt, N=cancel.", dangerLevel: "high", defaultAction: "cancel", }); if (!confirmed) return; const result = await dispatchHealthRecovery(ctx as ExtensionContext, selection.runId); depsNotify(ctx, result.message, result.ok ? "info" : "error"); return; } if (selection.action === "health-kill-stale") { const confirmed = await openConfirm(ctx, { title: "Mark stale workers dead?", body: "This updates worker heartbeat state. Y=mark dead, N=cancel.", dangerLevel: "medium", defaultAction: "cancel", }); if (!confirmed) return; const result = await dispatchKillStaleWorkers(ctx as ExtensionContext, selection.runId); depsNotify(ctx, result.message, result.ok ? "info" : "error"); return; } if (selection.action === "health-diagnostic-export") { const diagDir = path.join(loaded.manifest.artifactsRoot, "diagnostic"); const recent = listRecentDiagnostic(diagDir, 60_000); if (recent) { const confirmed = await openConfirm(ctx, { title: "Recent diagnostic exists", body: `File ${recent} was created <1min ago. Export another diagnostic?`, defaultAction: "cancel", }); if (!confirmed) return; } const result = await dispatchDiagnosticExport(ctx as ExtensionContext, selection.runId, { registry: depsRef?.getMetricRegistry?.(), }); depsNotify(ctx, result.message, result.ok ? "info" : "error"); } } /** * WP-3 (H4-subset): dashboard plan-approval actions (A approve / n deny in * the progress pane). Modeled on handleHealthDashboardAction above: reload the * manifest, pre-check pending (the backend api op re-validates under * withRunLock — this pre-check is UX-only), dispatch the EXISTING team api op, * then notify. The caller invalidates the snapshot cache afterwards. */ async function handlePlanDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise { // F3 decision (WP-3 review round 1, documented): NO manifest.async gate — // unlike health-recovery (which interrupts the foreground loop and is // foreground-only by necessity). Async/background runs park on plan approval // exactly like foreground runs (background-runner executeTeamRun → plan // gate), so their approvals MUST be decidable from the dashboard. Deny on // an async run terminates tasks via the SAME backend path as chat `team api // cancel-plan` (parity), guarded by the medium-danger confirm below + the // backend's withRunLock re-validation. const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency if (!loaded) { depsNotify(ctx, `Run '${selection.runId}' not found.`, "error"); return; } if (!isPlanApprovalPendingEffective(loaded.manifest)) { depsNotify(ctx, "no pending plan approval", "warning"); return; } if (selection.action === "plan-deny") { // cancel-plan KILLS tasks, so deny gets a medium-danger confirm. // Keystroke math (plan AC ≤ 2 per action): approve = "A" → 1 keystroke // (dispatched directly below, no confirm); deny = "n" + confirm = 2. // ConfirmOverlay.handleInput treats n/N as CANCEL in its own overlay, // so the copy must instruct Y-to-confirm — pressing "n" twice would // abort instead of confirming. const confirmed = await openConfirm(ctx, { title: "Cancel plan and kill tasks?", body: "Y=cancel plan and kill tasks, N=abort.", dangerLevel: "medium", defaultAction: "cancel", }); if (!confirmed) return; } const operation = selection.action === "plan-approve" ? "approve-plan" : "cancel-plan"; const result = await handleTeamTool( { action: "api", runId: selection.runId, config: { operation }, }, teamCommandContext(ctx), ); // F4 (WP-3 review round 1): notifyCommandResult hardcodes level "info", // which downgraded backend rejections (e.g. the deny TOCTOU tail — approval // answered between keypress and dispatch, refused under withRunLock) to // info toasts. Surface isError results at their true level. const text = commandText(result); depsNotify(ctx, text.length > 800 ? `${text.slice(0, 797)}...` : text, result.isError ? "error" : "info"); } let depsRef: RegisterTeamCommandsDeps | undefined; /** * Tier A finalize (review round 1, MAJOR-1): dashboard schedule mutations * (T/N/X in pane 8) consumed at the extension layer. The dashboard emits * `done()` selections carrying schedule-* actions + jobId; this routes them * through the SAME team-tool channel the chat surface uses — * handleTeamTool({action:'schedule', subAction, jobId}) → handle-schedule.ts — * so the UI layer never calls the scheduler directly (approved-design * constraint #3). Modeled on handlePlanDashboardAction: dispatch, then surface * the result at its true level (isError → red). No snapshot-cache * invalidation: schedule actions carry no runId (the run list is untouched). * The 2-step delete confirm-gate lives in the dashboard itself (pane-8 X); * this is the final hop to the mutation channel. */ async function handleScheduleDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise { const jobId = selection.jobId; if (!jobId) { depsNotify(ctx, "schedule action requires a job id", "warning"); return; } // Defer the dynamic import of run-dashboard.ts (already warm — ui() // loaded it to host the dashboard) so this module's import graph stays // free of the UI chain at Pi startup. The mapping is the UI layer's // published action→subAction contract; importing it (instead of re-deriving) // keeps the two from drifting. // LAZY: run-dashboard.ts — UI chain; warm by the time this runs (ui()). const { scheduleDashboardActionToSubAction } = await import("../../../ui/run-dashboard.ts"); const subAction = scheduleDashboardActionToSubAction(selection.action); if (!subAction) { depsNotify(ctx, `unhandled schedule action '${selection.action}'`, "warning"); return; } const result = await handleTeamTool({ action: "schedule", subAction, jobId }, teamCommandContext(ctx)); const text = commandText(result); depsNotify(ctx, text.length > 800 ? `${text.slice(0, 797)}...` : text, result.isError ? "error" : "info"); } /** * Internal setter for `depsRef` (Phase 2.1 split). ESM import bindings are * read-only, so `registerTeamCommands` in index.ts cannot assign to the * shared binding directly; this setter preserves the original single-module * behavior (module-level state set once at registration). */ export function setTeamCommandsDeps(deps: RegisterTeamCommandsDeps): void { depsRef = deps; } /** * Open the pi-crew run dashboard overlay and run its action loop. * * Extracted verbatim from the `team-dashboard` command so it is reusable from * a keyboard shortcut (alt+c, see crew-shortcuts.ts). Takes the base * `ExtensionContext` (the shortcut handler's context) — uses only `hasUI`, * `cwd`, `ui`, and `sessionManager` fields, so both `ExtensionContext` and * `ExtensionCommandContext` satisfy it. Reads run caches via the module-level * `depsRef` (set by `registerTeamCommands`), so it is a no-op if commands * have not been registered yet. `deps` is captured into a local const so the * non-undefined narrowing survives the awaited overlay call. * * The dashboard action helpers (handleMailboxDashboardAction, the viewers, * notifyCommandResult, teamCommandContext/handleTeamTool) are declared for * `ExtensionCommandContext` but only ever read base `ExtensionContext` fields * (verified). The keyboard-shortcut path supplies an `ExtensionContext`, so we * bridge those over-typed helpers with a single cast rather than relaxing * signatures across several modules (some outside this file's scope). */ export async function openTeamDashboard(ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; const deps = depsRef; if (!deps) return; if (deps.uiState) deps.uiState.dashboardOpen = true; const cmdCtx = ctx as ExtensionCommandContext; // F1 (WP-3 review round 1): keybinding-override collisions are reverted to // defaults by computeEffectiveBindings to keep dispatch unambiguous — but // the warning API (getKeybindingOverrideWarnings) previously had NO src // consumer, so users whose overrides claimed the new progress-pane keys // ("A"/"n") got a silent, persistent keybinding break. Surface the revert // once per dashboard open instead. const keyWarnings = getKeybindingOverrideWarnings(); if (keyWarnings.length > 0) { depsNotify(cmdCtx, `keybindings reverted to defaults: ${keyWarnings.join("; ")}`, "warning"); } for (;;) { // Extract sessionId for workspace-scoped filtering const sessionId = cmdCtx.sessionManager?.getSessionId?.(); const runs = deps.getManifestCache(cmdCtx.cwd).list(50); const uiConfig = loadConfig(cmdCtx.cwd).config.ui; const rightPanel = (uiConfig?.dashboardPlacement ?? DEFAULT_UI.dashboardPlacement) === "right"; const width = rightPanel ? Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth)) : "90%"; const { RunDashboard } = await ui(); const selection = await cmdCtx.ui.custom( (tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools, snapshotCache: deps.getRunSnapshotCache?.(cmdCtx.cwd), runProvider: () => deps.getManifestCache(cmdCtx.cwd).list(50), registry: deps.getMetricRegistry?.(), workspaceId: sessionId, requestRender: () => requestRenderTarget(tui), }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 }, } : { width, maxHeight: "90%", anchor: "center", margin: 2 }, }, ); if (!selection) break; if (selection.action === "reload") continue; if (selection.action === "notifications-dismiss") { deps.dismissNotifications?.(); cmdCtx.ui.notify("pi-crew notifications dismissed.", "info"); continue; } if (selection.action === "mailbox-detail") { await handleMailboxDashboardAction(cmdCtx, selection.runId); deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId); continue; } if ( selection.action === "health-recovery" || selection.action === "health-kill-stale" || selection.action === "health-diagnostic-export" ) { await handleHealthDashboardAction(cmdCtx, selection); deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId); continue; } // US-020: dashboard `x` cancel — dispatches the EXISTING cancel channel // (control-domain handleCancel). The 2-step dashboard gate is the user's // explicit intent; `intent` satisfies requireIntentForDestructiveActions // when that policy is on. Dashboard reopens on continue (selection kept). if (selection.action === "cancel" && selection.runId) { // F4 pattern: surface backend rejections at error level, truncate long text. const cancelResult = await handleTeamTool( { action: "cancel", runId: selection.runId, config: { intent: "dashboard cancel keystroke (2-step confirm)" }, }, teamCommandContext(cmdCtx), ); const cancelText = commandText(cancelResult); depsNotify( cmdCtx, cancelText.length > 800 ? `${cancelText.slice(0, 797)}...` : cancelText, cancelResult.isError ? "error" : "info", ); deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId); continue; } if (selection.action === "plan-approve" || selection.action === "plan-deny") { await handlePlanDashboardAction(cmdCtx, selection); deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId); continue; } // Tier A (review round 1, MAJOR-1): pane-8 schedule mutations route // through the extension channel (handle-schedule.ts subActions) instead // of falling into the generic handleTeamTool fall-through below, where // they dead-ended with an "unknown action" error and CLOSED the // dashboard. runId is "" for these selections — no snapshot invalidation. if ( selection.action === "schedule-enable" || selection.action === "schedule-disable" || selection.action === "schedule-run-now" || selection.action === "schedule-remove" ) { await handleScheduleDashboardAction(cmdCtx, selection); continue; } if (selection.action === "agent-transcript" && (await openTranscriptViewer(cmdCtx, selection.runId))) continue; // F1 (2026-10-08): openLiveConversation now owns ALL not-found messaging // with a runtime-aware notice (child-process/scaffold runs point at the // transcript viewer; live-session runs keep "No live agent found"). Its // old fall-through block here fired even on picker-cancel — removed. if (selection.action === "agent-live" && (await openLiveConversation(cmdCtx, selection.runId))) continue; // feat/agents-browser: `b` from the dashboard opens the unified Agents // & Jobs overlay WITHOUT closing the loop — continue reopens the // dashboard underneath, mirroring the transcript/live-conversation // viewers. runId may be "" (no run selected — jobs-only view). if (selection.action === "browser-open" && (await openAgentsJobsBrowser(cmdCtx))) continue; const result = selection.action === "api" ? await handleTeamTool( { action: "api", runId: selection.runId, config: { operation: "read-manifest" }, }, teamCommandContext(cmdCtx), ) : selection.action === "agents" ? await handleTeamTool( { action: "api", runId: selection.runId, config: { operation: "agent-dashboard" }, }, teamCommandContext(cmdCtx), ) : selection.action === "mailbox" ? await handleTeamTool( { action: "api", runId: selection.runId, config: { operation: "read-mailbox" }, }, teamCommandContext(cmdCtx), ) : selection.action === "agent-events" ? await handleTeamTool( { action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50, }, }, teamCommandContext(cmdCtx), ) : selection.action === "agent-output" ? await handleTeamTool( { action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000, }, }, teamCommandContext(cmdCtx), ) : selection.action === "agent-transcript" ? await handleTeamTool( { action: "api", runId: selection.runId, config: { operation: "read-agent-transcript", }, }, teamCommandContext(cmdCtx), ) : await handleTeamTool( { action: selection.action as TeamAction, runId: selection.runId, }, teamCommandContext(cmdCtx), ); await notifyCommandResult(cmdCtx, commandText(result)); break; } if (deps.uiState) deps.uiState.dashboardOpen = false; }