pi-crew 0.6.3 → 0.6.4

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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Brief tool overrides — re-registers built-in Pi tools with custom rendering.
3
+ *
4
+ * Inspired by oh-my-pi's pi-brief extension. Wraps each built-in tool
5
+ * (read, bash, edit, write, find, grep, ls) keeping the original execute
6
+ * but replacing renderCall/renderResult with themed, brief-aware versions.
7
+ *
8
+ * Brief mode shows CONTEXTUAL one-liners that preserve WHAT was done:
9
+ * read ~/file.ts:1-50 → 142 lines · 2.3s
10
+ * bash $ npm test → done · 12.5s
11
+ * edit ~/file.ts → +3 -1 · 0.8s
12
+ * write ~/file.ts (42 lines) → ✓ · 0.2s
13
+ * find *.ts in ~/src → 5 files · 0.5s
14
+ * grep /pattern/ in ~/src → 3 matches · 0.3s
15
+ * ls ~/src → 8 entries · 0.1s
16
+ *
17
+ * Timing: ctx.state.startedAt is set in renderCall, read in renderResult.
18
+ * NOTE: Pi's executionStarted is a boolean flag (NOT a timestamp).
19
+ */
20
+
21
+ import { homedir } from "node:os";
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ import {
24
+ createBashTool,
25
+ createEditTool,
26
+ createFindTool,
27
+ createGrepTool,
28
+ createLsTool,
29
+ createReadTool,
30
+ createWriteTool,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { Text } from "@earendil-works/pi-tui";
33
+ import { isBrief } from "../../ui/tool-renderers/brief-mode.ts";
34
+
35
+ // ── Path shortening ────────────────────────────────────────────────────
36
+
37
+ const HOME = homedir();
38
+ function shortenPath(p: string): string {
39
+ if (!p) return "";
40
+ if (p.startsWith(HOME)) return `~${p.slice(HOME.length)}`;
41
+ return p;
42
+ }
43
+
44
+ // ── Types ──────────────────────────────────────────────────────────────
45
+
46
+ interface TextResult {
47
+ content: Array<{ type: string; text?: string }>;
48
+ }
49
+
50
+ interface Theme {
51
+ fg: (slot: string, text: string) => string;
52
+ bold: (text: string) => string;
53
+ }
54
+
55
+ interface RenderCtx {
56
+ args?: Record<string, unknown>;
57
+ state?: Record<string, unknown>;
58
+ }
59
+
60
+ function fullText(result: TextResult): string | undefined {
61
+ const c = result.content.find((x): x is { type: "text"; text: string } => x.type === "text");
62
+ return c?.text;
63
+ }
64
+
65
+ function fullRender(result: TextResult, theme: Theme): Text {
66
+ const text = fullText(result);
67
+ if (!text) return new Text("", 0, 0);
68
+ const lines = text
69
+ .trim()
70
+ .split("\n")
71
+ .map((line) => theme.fg("toolOutput", line))
72
+ .join("\n");
73
+ return new Text(`\n${lines}`, 0, 0);
74
+ }
75
+
76
+ /** Record start time in ctx.state during renderCall (called once per tool invocation). */
77
+ function noteStarted(ctx: RenderCtx | undefined): void {
78
+ if (ctx?.state && !ctx.state.briefStartedAt) {
79
+ ctx.state.briefStartedAt = Date.now();
80
+ }
81
+ }
82
+
83
+ /** Compute elapsed from ctx.state.briefStartedAt. Returns " · 1.2s" or "". */
84
+ function elapsed(ctx: RenderCtx | undefined): string {
85
+ const startedAt = ctx?.state?.briefStartedAt as number | undefined;
86
+ if (!startedAt || typeof startedAt !== "number") return "";
87
+ const ms = Date.now() - startedAt;
88
+ if (ms < 1000) return "";
89
+ if (ms < 60_000) return ` · ${(ms / 1000).toFixed(1)}s`;
90
+ const m = Math.floor(ms / 60_000), s = Math.floor((ms % 60_000) / 1000);
91
+ return ` · ${m}m${s}s`;
92
+ }
93
+
94
+ /** Truncate to maxLen with ellipsis (visual). */
95
+ function trunc(text: string, maxLen: number): string {
96
+ if (text.length <= maxLen) return text;
97
+ return text.slice(0, maxLen - 1) + "…";
98
+ }
99
+
100
+ /** Flatten multiline command to single line, truncated. */
101
+ function flatCmd(cmd: string, maxLen: number): string {
102
+ return trunc(cmd.replace(/\s+/g, " ").trim(), maxLen);
103
+ }
104
+
105
+ // ── Tool registration ──────────────────────────────────────────────────
106
+
107
+ export function registerBriefToolOverrides(pi: ExtensionAPI, cwd: string): void {
108
+ const tools = {
109
+ read: createReadTool(cwd),
110
+ bash: createBashTool(cwd),
111
+ edit: createEditTool(cwd),
112
+ write: createWriteTool(cwd),
113
+ find: createFindTool(cwd),
114
+ grep: createGrepTool(cwd),
115
+ ls: createLsTool(cwd),
116
+ };
117
+
118
+ // ─── Read ───
119
+ pi.registerTool({
120
+ name: "read",
121
+ label: "read",
122
+ description: tools.read.description,
123
+ parameters: tools.read.parameters,
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
126
+ return tools.read.execute(toolCallId, params, signal, onUpdate);
127
+ },
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
+ renderCall(args: any, theme: any, ctx: any): any {
130
+ noteStarted(ctx);
131
+ const p = shortenPath(args.path || "");
132
+ const pathDisplay = p ? theme.fg("accent", p) : theme.fg("toolOutput", "...");
133
+ let text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
134
+ if (args.offset !== undefined || args.limit !== undefined) {
135
+ const startLine = args.offset ?? 1;
136
+ const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
137
+ text += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
138
+ }
139
+ return new Text(text, 0, 0);
140
+ },
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
143
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
144
+ const args = ctx?.args ?? {};
145
+ const p = shortenPath(args.path || "?");
146
+ let range = "";
147
+ if (args.offset || args.limit) {
148
+ const s = args.offset ?? 1;
149
+ const e = args.limit ? s + args.limit - 1 : "";
150
+ range = `:${s}${e ? `-${e}` : ""}`;
151
+ }
152
+ const text = fullText(result);
153
+ const count = text ? text.trim().split("\n").filter(Boolean).length : 0;
154
+ const time = elapsed(ctx);
155
+ const label = count === 0 ? "empty" : `${count} lines`;
156
+ return new Text(
157
+ `${theme.fg("toolTitle", "read")} ${theme.fg("accent", p)}${theme.fg("warning", range)} ${theme.fg("dim", "→")} ${theme.fg("muted", label)}${theme.fg("dim", time)}`,
158
+ 0, 0,
159
+ );
160
+ },
161
+ });
162
+
163
+ // ─── Bash ───
164
+ pi.registerTool({
165
+ name: "bash",
166
+ label: "bash",
167
+ description: tools.bash.description,
168
+ parameters: tools.bash.parameters,
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
171
+ return tools.bash.execute(toolCallId, params, signal, onUpdate);
172
+ },
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ renderCall(args: any, theme: any, ctx: any): any {
175
+ noteStarted(ctx);
176
+ const command = args.command || "...";
177
+ const timeout = args.timeout as number | undefined;
178
+ const timeoutSuffix = timeout ? theme.fg("muted", ` (${timeout}s)`) : "";
179
+ return new Text(theme.fg("toolTitle", theme.bold(`$ ${command}`)) + timeoutSuffix, 0, 0);
180
+ },
181
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
182
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
183
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
184
+ const args = ctx?.args ?? {};
185
+ const cmd = flatCmd(String(args.command || "?"), 50);
186
+ const text = fullText(result);
187
+ const time = elapsed(ctx);
188
+ let label: string;
189
+ let color: string;
190
+ if (!text || !text.trim()) {
191
+ label = "done";
192
+ color = "muted";
193
+ } else {
194
+ const lines = text.trim().split("\n");
195
+ if (lines.length === 1) {
196
+ label = trunc(lines[0]!, 40);
197
+ color = "muted";
198
+ } else {
199
+ label = `${lines.length} lines`;
200
+ color = "muted";
201
+ }
202
+ }
203
+ return new Text(
204
+ `${theme.fg("toolTitle", "$")} ${theme.fg("accent", cmd)} ${theme.fg("dim", "→")} ${theme.fg(color, label)}${theme.fg("dim", time)}`,
205
+ 0, 0,
206
+ );
207
+ },
208
+ });
209
+
210
+ // ─── Edit ───
211
+ pi.registerTool({
212
+ name: "edit",
213
+ label: "edit",
214
+ description: tools.edit.description,
215
+ parameters: tools.edit.parameters,
216
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
217
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
218
+ return tools.edit.execute(toolCallId, params, signal, onUpdate);
219
+ },
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
+ renderCall(args: any, theme: any, ctx: any): any {
222
+ noteStarted(ctx);
223
+ const p = shortenPath(args.path || "");
224
+ const pathDisplay = p ? theme.fg("accent", p) : theme.fg("toolOutput", "...");
225
+ return new Text(`${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`, 0, 0);
226
+ },
227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
228
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
229
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
230
+ const args = ctx?.args ?? {};
231
+ const p = shortenPath(args.path || "?");
232
+ const text = fullText(result);
233
+ const time = elapsed(ctx);
234
+ if (text && (text.includes("Error") || text.includes("error"))) {
235
+ return new Text(
236
+ `${theme.fg("toolTitle", "edit")} ${theme.fg("accent", p)} ${theme.fg("dim", "→")} ${theme.fg("error", "failed")}${theme.fg("dim", time)}`,
237
+ 0, 0,
238
+ );
239
+ }
240
+ const added = text ? (text.match(/^\+ /gm) ?? []).length : 0;
241
+ const removed = text ? (text.match(/^- /gm) ?? []).length : 0;
242
+ if (added === 0 && removed === 0) {
243
+ return new Text(
244
+ `${theme.fg("toolTitle", "edit")} ${theme.fg("accent", p)} ${theme.fg("dim", "→")} ${theme.fg("success", "edited")}${theme.fg("dim", time)}`,
245
+ 0, 0,
246
+ );
247
+ }
248
+ return new Text(
249
+ `${theme.fg("toolTitle", "edit")} ${theme.fg("accent", p)} ${theme.fg("dim", "→")} ${theme.fg("toolDiffAdded", `+${added} `)}${theme.fg("toolDiffRemoved", `-${removed}`)}${theme.fg("dim", time)}`,
250
+ 0, 0,
251
+ );
252
+ },
253
+ });
254
+
255
+ // ─── Write ───
256
+ pi.registerTool({
257
+ name: "write",
258
+ label: "write",
259
+ description: tools.write.description,
260
+ parameters: tools.write.parameters,
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
263
+ return tools.write.execute(toolCallId, params, signal, onUpdate);
264
+ },
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ renderCall(args: any, theme: any, ctx: any): any {
267
+ noteStarted(ctx);
268
+ const p = shortenPath(args.path || "");
269
+ const pathDisplay = p ? theme.fg("accent", p) : theme.fg("toolOutput", "...");
270
+ const lineCount = args.content ? String(args.content).split("\n").length : 0;
271
+ const lineInfo = lineCount > 0 ? theme.fg("muted", ` (${lineCount} lines)`) : "";
272
+ return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${lineInfo}`, 0, 0);
273
+ },
274
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
276
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
277
+ const args = ctx?.args ?? {};
278
+ const p = shortenPath(args.path || "?");
279
+ const text = fullText(result);
280
+ const time = elapsed(ctx);
281
+ if (text) {
282
+ return new Text(
283
+ `${theme.fg("toolTitle", "write")} ${theme.fg("accent", p)} ${theme.fg("dim", "→")} ${theme.fg("error", trunc(text.trim().split("\n")[0] ?? "", 30))}${theme.fg("dim", time)}`,
284
+ 0, 0,
285
+ );
286
+ }
287
+ return new Text(
288
+ `${theme.fg("toolTitle", "write")} ${theme.fg("accent", p)} ${theme.fg("dim", "→")} ${theme.fg("success", "✓")}${theme.fg("dim", time)}`,
289
+ 0, 0,
290
+ );
291
+ },
292
+ });
293
+
294
+ // ─── Find ───
295
+ pi.registerTool({
296
+ name: "find",
297
+ label: "find",
298
+ description: tools.find.description,
299
+ parameters: tools.find.parameters,
300
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
301
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
302
+ return tools.find.execute(toolCallId, params, signal, onUpdate);
303
+ },
304
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
305
+ renderCall(args: any, theme: any, ctx: any): any {
306
+ noteStarted(ctx);
307
+ const pattern = args.pattern || "";
308
+ const p = shortenPath(args.path || ".");
309
+ let text = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}`;
310
+ text += theme.fg("toolOutput", ` in ${p}`);
311
+ return new Text(text, 0, 0);
312
+ },
313
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
314
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
315
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
316
+ const args = ctx?.args ?? {};
317
+ const pattern = trunc(String(args.pattern || "*"), 20);
318
+ const p = shortenPath(args.path || ".");
319
+ const text = fullText(result);
320
+ const count = text ? text.trim().split("\n").filter(Boolean).length : 0;
321
+ const time = elapsed(ctx);
322
+ const label = count === 0 ? "none" : `${count} files`;
323
+ const color = count === 0 ? "dim" : "muted";
324
+ return new Text(
325
+ `${theme.fg("toolTitle", "find")} ${theme.fg("accent", pattern)} ${theme.fg("dim", `in ${p} →`)} ${theme.fg(color, label)}${theme.fg("dim", time)}`,
326
+ 0, 0,
327
+ );
328
+ },
329
+ });
330
+
331
+ // ─── Grep ───
332
+ pi.registerTool({
333
+ name: "grep",
334
+ label: "grep",
335
+ description: tools.grep.description,
336
+ parameters: tools.grep.parameters,
337
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
339
+ return tools.grep.execute(toolCallId, params, signal, onUpdate);
340
+ },
341
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
342
+ renderCall(args: any, theme: any, ctx: any): any {
343
+ noteStarted(ctx);
344
+ const pattern = args.pattern || "";
345
+ const p = shortenPath(args.path || ".");
346
+ let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${pattern}/`)}`;
347
+ text += theme.fg("toolOutput", ` in ${p}`);
348
+ return new Text(text, 0, 0);
349
+ },
350
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
351
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
352
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
353
+ const args = ctx?.args ?? {};
354
+ const pattern = trunc(String(args.pattern || "?"), 20);
355
+ const p = shortenPath(args.path || ".");
356
+ const text = fullText(result);
357
+ const count = text ? text.trim().split("\n").filter(Boolean).length : 0;
358
+ const time = elapsed(ctx);
359
+ const label = count === 0 ? "none" : `${count} matches`;
360
+ const color = count === 0 ? "dim" : "muted";
361
+ return new Text(
362
+ `${theme.fg("toolTitle", "grep")} ${theme.fg("accent", `/${pattern}/`)} ${theme.fg("dim", `in ${p} →`)} ${theme.fg(color, label)}${theme.fg("dim", time)}`,
363
+ 0, 0,
364
+ );
365
+ },
366
+ });
367
+
368
+ // ─── Ls ───
369
+ pi.registerTool({
370
+ name: "ls",
371
+ label: "ls",
372
+ description: tools.ls.description,
373
+ parameters: tools.ls.parameters,
374
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
375
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
376
+ return tools.ls.execute(toolCallId, params, signal, onUpdate);
377
+ },
378
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
+ renderCall(args: any, theme: any, ctx: any): any {
380
+ noteStarted(ctx);
381
+ const p = shortenPath(args.path || ".");
382
+ return new Text(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", p)}`, 0, 0);
383
+ },
384
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
385
+ renderResult(result: any, options: any, theme: any, ctx: any): any {
386
+ if (!isBrief() || options.expanded) return fullRender(result, theme);
387
+ const args = ctx?.args ?? {};
388
+ const p = shortenPath(args.path || ".");
389
+ const text = fullText(result);
390
+ const count = text ? text.trim().split("\n").filter(Boolean).length : 0;
391
+ const time = elapsed(ctx);
392
+ const label = count === 0 ? "empty" : `${count} entries`;
393
+ const color = count === 0 ? "dim" : "muted";
394
+ return new Text(
395
+ `${theme.fg("toolTitle", "ls")} ${theme.fg("accent", p)} ${theme.fg("dim", "→")} ${theme.fg(color, label)}${theme.fg("dim", time)}`,
396
+ 0, 0,
397
+ );
398
+ },
399
+ });
400
+ }
@@ -571,6 +571,31 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
571
571
  pi.registerCommand("team-help", { description: "Show pi-crew command help", handler: async (_args: string, ctx: ExtensionCommandContext) => {
572
572
  await notifyCommandResult(ctx, piTeamsHelp());
573
573
  } });
574
+
575
+ // Brief mode command — toggle compact tool output display
576
+ pi.registerCommand("crew-brief", {
577
+ description: "Toggle brief tool output mode: on | off | status",
578
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
579
+ const { isBrief, setBrief, BRIEF_ENTRY_TYPE, makeBriefEntry } = await import("../../ui/tool-renderers/brief-mode.ts");
580
+ const trimmed = args.trim();
581
+
582
+ if (trimmed === "on") {
583
+ setBrief(true);
584
+ pi.appendEntry(BRIEF_ENTRY_TYPE, { enabled: true });
585
+ ctx.ui.notify("Brief mode: on — tool output will show compact summaries", "info");
586
+ return;
587
+ }
588
+ if (trimmed === "off") {
589
+ setBrief(false);
590
+ pi.appendEntry(BRIEF_ENTRY_TYPE, { enabled: false });
591
+ ctx.ui.notify("Brief mode: off — full tool output restored", "info");
592
+ return;
593
+ }
594
+ // status (default)
595
+ ctx.ui.notify(`Brief mode: ${isBrief() ? "on" : "off"}`, "info");
596
+ },
597
+ });
598
+
574
599
  time("register.commands");
575
600
  printTimings();
576
601
  }
@@ -22,7 +22,8 @@ import { t } from "../../i18n.ts";
22
22
  import { loadRunManifestById } from "../../state/state-store.ts";
23
23
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
24
24
  import { formatCompactToolProgress } from "../../ui/tool-progress-formatter.ts";
25
- import { renderAgentToolCall, renderAgentToolResult } from "../../ui/tool-render.ts";
25
+ import { Text } from "@earendil-works/pi-tui";
26
+ import { agentToolRenderer } from "../../ui/tool-renderers/index.ts";
26
27
 
27
28
  const TOOL_PROGRESS_TICK_MS = 1000;
28
29
 
@@ -98,11 +99,15 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
98
99
  },
99
100
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
101
  renderCall(args: any, theme: any, context: any): any {
101
- return renderAgentToolCall(args, theme, context);
102
+ return agentToolRenderer.renderCall(args, theme, context);
102
103
  },
103
104
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
105
  renderResult(result: any, options: any, theme: any, context: any): any {
105
- return renderAgentToolResult(result, options, theme, context);
106
+ try {
107
+ return agentToolRenderer.renderResult(result, options, theme, context);
108
+ } catch (e: any) {
109
+ return new Text('agent-err: ' + (e?.message ?? 'unknown'), 0, 0);
110
+ }
106
111
  },
107
112
  };
108
113
 
@@ -1,15 +1,16 @@
1
- import * as fs from "node:fs";
1
+ import { statSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { loadConfig } from "../../config/config.ts";
4
4
  import { TeamToolParams, type TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
5
- import type { CrewWidgetState } from "../../ui/crew-widget.ts";
6
- import { updateCrewWidget } from "../../ui/crew-widget.ts";
5
+ import type { CrewWidgetState } from "../../ui/widget/index.ts";
6
+ import { updateCrewWidget } from "../../ui/widget/index.ts";
7
7
  import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
8
8
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
9
9
  import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
10
10
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
11
11
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
12
- import { renderTeamToolCall, renderTeamToolResult } from "../../ui/tool-render.ts";
12
+ import { Text } from "@earendil-works/pi-tui";
13
+ import { teamToolRenderer, statusIcon } from "../../ui/tool-renderers/index.ts";
13
14
  // Team tool handler — lazy-loaded because team-tool.ts imports many modules
14
15
  import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
15
16
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
@@ -48,7 +49,7 @@ export function resolveCwdOverride(baseCwd: string, override: string | undefined
48
49
  if (!override) return { ok: true, cwd: baseCwd };
49
50
  try {
50
51
  const resolved = resolveRealContainedPath(baseCwd, override);
51
- const stat = fs.statSync(resolved);
52
+ const stat = statSync(resolved);
52
53
  if (!stat.isDirectory()) return { ok: false, error: `cwd override is not a directory: ${resolved}` };
53
54
  return { ok: true, cwd: resolved };
54
55
  } catch (error) {
@@ -107,11 +108,15 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
107
108
  },
108
109
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
110
  renderCall(args: any, theme: any, context: any): any {
110
- return renderTeamToolCall(args, theme, context);
111
+ return teamToolRenderer.renderCall(args, theme, context);
111
112
  },
112
113
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
114
  renderResult(result: any, options: any, theme: any, context: any): any {
114
- return renderTeamToolResult(result, options, theme, context);
115
+ try {
116
+ return teamToolRenderer.renderResult(result, options, theme, context);
117
+ } catch {
118
+ return new Text(statusIcon("completed", theme) + " done", 0, 0);
119
+ }
115
120
  },
116
121
  };
117
122
  pi.registerTool(tool);
@@ -133,13 +138,15 @@ function startTeamToolProgressBinder(onUpdate: OnUpdate | undefined): TeamToolPr
133
138
  try {
134
139
  if (!cwd || !runId) {
135
140
  const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
136
- onUpdate({ content: [{ type: "text", text: `team status=starting elapsed=${elapsed}s` }] });
141
+ const msg = `team status=starting elapsed=${elapsed}s`;
142
+ onUpdate({ content: [{ type: "text", text: msg }] });
137
143
  return;
138
144
  }
139
- const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
145
+ const loaded = loadRunManifestById(cwd, runId);
140
146
  if (!loaded) {
141
147
  const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
142
- onUpdate({ content: [{ type: "text", text: `team run=${runId} elapsed=${elapsed}s (manifest pending)` }] });
148
+ const msg = `team run=${runId} elapsed=${elapsed}s (manifest pending)`;
149
+ onUpdate({ content: [{ type: "text", text: msg }] });
143
150
  return;
144
151
  }
145
152
  let agents;
@@ -162,7 +169,7 @@ function startTeamToolProgressBinder(onUpdate: OnUpdate | undefined): TeamToolPr
162
169
  const timer = setInterval(tick, TEAM_TOOL_PROGRESS_TICK_MS);
163
170
  if (typeof timer.unref === "function") timer.unref();
164
171
  return {
165
- attach: (boundCwd, boundRunId) => { cwd = boundCwd; runId = boundRunId; tick(); },
172
+ attach: (boundCwd: string, boundRunId: string) => { cwd = boundCwd; runId = boundRunId; tick(); },
166
173
  stop: () => clearInterval(timer),
167
174
  };
168
175
  }
@@ -254,6 +254,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
254
254
  const asyncManifest = { ...effectiveManifest, async: { pid: spawned.pid, logPath: spawned.logPath, spawnedAt: new Date().toISOString() } };
255
255
  atomicWriteJson(paths.manifestPath, asyncManifest);
256
256
  void appendEventAsync(effectiveManifest.eventsPath, { type: "async.spawned", runId: effectiveManifest.runId, data: { pid: spawned.pid, logPath: spawned.logPath } });
257
+ ctx.onRunStarted?.(effectiveManifest.runId);
257
258
  scheduleBackgroundEarlyExitGuard(resolvedCtx.cwd, effectiveManifest.runId, spawned.pid, spawned.logPath);
258
259
  // Wait for the async run to complete and return actual results.
259
260
  try {
@@ -341,7 +342,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
341
342
  }
342
343
 
343
344
  const runFailed = completed.manifest.status === "failed" || completed.manifest.status === "blocked";
344
- return result(lines.join("\n"), { action: "run", status: runFailed ? "error" : "ok", runId: completed.manifest.runId, artifactsRoot: completed.manifest.artifactsRoot }, runFailed);
345
+ return result(lines.join("\n"), { action: "run", status: runFailed ? "error" : "ok", runId: completed.manifest.runId, artifactsRoot: completed.manifest.artifactsRoot, metrics }, runFailed);
345
346
  } catch (waitError: unknown) {
346
347
  const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
347
348
  return result(
@@ -474,7 +475,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
474
475
  }
475
476
 
476
477
  const runFailed = completed.manifest.status === "failed" || completed.manifest.status === "blocked";
477
- return result(lines.join("\n"), { action: "run", status: runFailed ? "error" : "ok", runId: completed.manifest.runId, artifactsRoot: completed.manifest.artifactsRoot }, runFailed);
478
+ return result(lines.join("\n"), { action: "run", status: runFailed ? "error" : "ok", runId: completed.manifest.runId, artifactsRoot: completed.manifest.artifactsRoot, metrics }, runFailed);
478
479
  } catch (waitError: unknown) {
479
480
  const errorMessage = waitError instanceof Error ? waitError.message : String(waitError);
480
481
  return result(
@@ -514,5 +515,5 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
514
515
  ? "Experimental live-session worker execution was enabled."
515
516
  : "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.",
516
517
  ].join("\n");
517
- return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
518
+ return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot, metrics: collectRunMetrics(resolvedCtx.cwd, executed.manifest.runId) }, executed.manifest.status === "failed");
518
519
  }
@@ -10,6 +10,8 @@ export interface TeamToolDetails {
10
10
  resumedIds?: string[];
11
11
  retriedTaskIds?: string[];
12
12
  mailboxIds?: string[];
13
+ /** Run metrics for compact display in TUI tool result rendering. */
14
+ metrics?: { taskCount?: number; completedCount?: number; totalTokens?: number; totalCost?: number; durationMs?: number; consistencyScore?: number };
13
15
  /** Structured data for programmatic consumption (e.g. TUI widgets). */
14
16
  data?: Record<string, unknown>;
15
17
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Session-scoped state container.
3
+ *
4
+ * Generic Map keyed by session ID. Used by widget, dashboard, and
5
+ * live-agent-manager to store per-session state without polluting globals.
6
+ *
7
+ * Inspired by @ayulab/pi-checkpoint SessionStateMap.
8
+ */
9
+ export class SessionStateMap<T> {
10
+ private readonly map = new Map<string, T>();
11
+
12
+ getOrUndefined(sessionId: string): T | undefined {
13
+ return this.map.get(sessionId);
14
+ }
15
+
16
+ get(sessionId: string): T | undefined {
17
+ return this.map.get(sessionId);
18
+ }
19
+
20
+ set(sessionId: string, value: T): void {
21
+ this.map.set(sessionId, value);
22
+ }
23
+
24
+ has(sessionId: string): boolean {
25
+ return this.map.has(sessionId);
26
+ }
27
+
28
+ delete(sessionId: string): boolean {
29
+ return this.map.delete(sessionId);
30
+ }
31
+
32
+ clear(): void {
33
+ this.map.clear();
34
+ }
35
+
36
+ get size(): number {
37
+ return this.map.size;
38
+ }
39
+
40
+ entries(): IterableIterator<[string, T]> {
41
+ return this.map.entries();
42
+ }
43
+
44
+ values(): IterableIterator<T> {
45
+ return this.map.values();
46
+ }
47
+
48
+ keys(): IterableIterator<string> {
49
+ return this.map.keys();
50
+ }
51
+ }
@@ -10,7 +10,7 @@ import { listLiveAgents } from "../runtime/live-agent-manager.ts";
10
10
  import { logInternalError } from "../utils/internal-error.ts";
11
11
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
12
12
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
13
- import { notificationBadge } from "./crew-widget.ts";
13
+ import { notificationBadge } from "./widget/widget-formatters.ts";
14
14
  import { RenderCoalescer } from "./render-coalescer.ts";
15
15
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
16
16
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
@@ -1,6 +1,6 @@
1
1
  import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
2
2
 
3
- export type RunStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | (string & {});
3
+ export type RunStatus = "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | "stale" | "needs_attention" | (string & {});
4
4
 
5
5
  export function colorForStatus(status: RunStatus): CrewThemeColor {
6
6
  switch (status) {
@@ -17,6 +17,8 @@ export function colorForStatus(status: RunStatus): CrewThemeColor {
17
17
  case "blocked":
18
18
  case "stopped":
19
19
  return "warning";
20
+ case "needs_attention":
21
+ return "warning";
20
22
  case "queued":
21
23
  default:
22
24
  return "dim";
@@ -42,6 +44,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
42
44
  return "◦";
43
45
  case "blocked":
44
46
  return "⏸";
47
+ case "needs_attention":
48
+ return "⚠";
45
49
  default:
46
50
  return "·";
47
51
  }