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.
- package/CHANGELOG.md +215 -0
- package/README.md +0 -1
- package/dist/index.mjs +30623 -29844
- package/package.json +170 -169
- package/schema.json +10 -0
- package/scripts/watch-agent-transcript.mjs +120 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +181 -1
- package/src/config/config-validation.ts +0 -2
- package/src/config/defaults.ts +0 -1
- package/src/config/types.ts +0 -5
- package/src/extension/crew-input-router.ts +2 -0
- package/src/extension/crew-vibes/config.ts +1 -77
- package/src/extension/crew-vibes/index.ts +36 -330
- package/src/extension/crew-vibes/render.ts +1 -106
- package/src/extension/help.ts +1 -2
- package/src/extension/register.ts +7 -2
- package/src/extension/registration/command-registration.ts +5 -1
- package/src/extension/registration/commands/manage.ts +5 -4
- package/src/extension/registration/commands/schedules.ts +212 -0
- package/src/extension/registration/commands/shared.ts +56 -2
- package/src/extension/registration/lifecycle-handlers.ts +16 -2
- package/src/extension/registration/schedule-toast-bridge.ts +109 -0
- package/src/extension/registration/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +72 -1
- package/src/extension/team-tool/handle-schedule.ts +120 -1
- package/src/extension/team-tool/handle-settings.ts +14 -2
- package/src/runtime/scheduling/scheduler.ts +120 -6
- package/src/runtime/settings-store.ts +28 -2
- package/src/runtime/task-display.ts +1 -1
- package/src/schema/config-schema.ts +4 -0
- package/src/state/coordination/locks.ts +17 -2
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +791 -0
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +52 -17
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +177 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/inline-panel/crew-editor.ts +32 -9
- package/src/ui/inline-panel/index.ts +66 -4
- package/src/ui/key-utils.ts +3 -0
- package/src/ui/keybinding-map.ts +286 -34
- package/src/ui/live-conversation-overlay.ts +176 -46
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +34 -17
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -28
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -49
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +344 -72
- package/src/ui/settings-overlay.ts +84 -42
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +126 -8
- package/src/ui/tool-renderers/brief-mode.ts +8 -66
- package/src/ui/tool-renderers/index.ts +372 -521
- package/src/ui/transcript-viewer.ts +33 -14
- package/src/ui/widget/index.ts +86 -133
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -399
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +298 -265
- package/src/ui/widget/widget-types.ts +0 -9
- package/src/utils/relative-time.ts +33 -0
- package/src/utils/visual.ts +0 -4
- package/src/extension/crew-vibes/figures.ts +0 -101
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/extension/crew-vibes/footer.ts +0 -313
- package/src/extension/crew-vibes/speed.ts +0 -286
- package/src/ui/dock-footer.ts +0 -49
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- 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:
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
};
|