@esso0428/pi-subagents 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/dist/agent-manager.d.ts +8 -0
  4. package/dist/agent-manager.js +87 -19
  5. package/dist/agent-runner.js +85 -66
  6. package/dist/agent-types.js +45 -27
  7. package/dist/context.js +6 -2
  8. package/dist/cross-extension-rpc.js +9 -5
  9. package/dist/custom-agents.js +18 -15
  10. package/dist/default-agents.js +4 -1
  11. package/dist/enabled-models.js +16 -11
  12. package/dist/env.js +4 -1
  13. package/dist/group-join.js +5 -1
  14. package/dist/index.js +280 -221
  15. package/dist/invocation-config.js +6 -2
  16. package/dist/memory.js +34 -24
  17. package/dist/model-resolver.js +4 -1
  18. package/dist/nico-overrides.js +20 -14
  19. package/dist/output-file.js +21 -15
  20. package/dist/prompts.js +4 -1
  21. package/dist/schedule-store.js +21 -16
  22. package/dist/schedule.js +12 -8
  23. package/dist/settings.js +23 -15
  24. package/dist/skill-loader.js +23 -20
  25. package/dist/status-note.js +4 -1
  26. package/dist/types.d.ts +1 -0
  27. package/dist/types.js +4 -1
  28. package/dist/ui/agent-widget.js +37 -23
  29. package/dist/ui/conversation-viewer.js +43 -39
  30. package/dist/ui/fleet-list.js +30 -24
  31. package/dist/ui/markdown-result.d.ts +3 -0
  32. package/dist/ui/markdown-result.js +53 -0
  33. package/dist/ui/schedule-menu.js +4 -1
  34. package/dist/ui/viewer-keys.js +10 -7
  35. package/dist/usage.js +10 -4
  36. package/dist/worktree.js +31 -26
  37. package/package.json +1 -1
  38. package/src/agent-manager.ts +72 -0
  39. package/src/agent-runner.ts +11 -3
  40. package/src/index.ts +39 -16
  41. package/src/types.ts +1 -0
  42. package/src/ui/markdown-result.ts +56 -0
  43. package/test/agent-manager-history.test.ts +84 -0
  44. package/test/ui/markdown-result.test.ts +45 -0
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  type UICtx,
49
49
  } from "./ui/agent-widget.js";
50
50
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
51
+ import { createMarkdownResult } from "./ui/markdown-result.js";
51
52
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
52
53
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
53
54
 
@@ -240,6 +241,9 @@ function buildDetails(
240
241
  };
241
242
  }
242
243
 
244
+ /** Maximum expanded notification body retained in message details. */
245
+ const MAX_NOTIFICATION_RESULT_TEXT_LENGTH = 20_000;
246
+
243
247
  /** Build notification details for the custom message renderer. */
244
248
  function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
245
249
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
@@ -255,6 +259,11 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
255
259
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
256
260
  outputFile: record.outputFile,
257
261
  error: record.error,
262
+ resultText: record.result
263
+ ? record.result.length > MAX_NOTIFICATION_RESULT_TEXT_LENGTH
264
+ ? record.result.slice(0, MAX_NOTIFICATION_RESULT_TEXT_LENGTH) + "…"
265
+ : record.result
266
+ : undefined,
258
267
  resultPreview: record.result
259
268
  ? record.result.length > resultMaxLen
260
269
  ? record.result.slice(0, resultMaxLen) + "…"
@@ -271,7 +280,7 @@ export default function (pi: ExtensionAPI) {
271
280
  const d = message.details;
272
281
  if (!d) return undefined;
273
282
 
274
- function renderOne(d: NotificationDetails): string {
283
+ function renderOne(d: NotificationDetails): string | Container {
275
284
  const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
276
285
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
277
286
  const statusText = isError ? d.status
@@ -291,25 +300,34 @@ export default function (pi: ExtensionAPI) {
291
300
  line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
292
301
  }
293
302
 
294
- // Line 3: result preview (collapsed) or full (expanded)
295
- if (expanded) {
296
- const lines = d.resultPreview.split("\n").slice(0, 30);
297
- for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
298
- } else {
303
+ if (!expanded) {
304
+ // Collapsed previews intentionally remain compact plain text.
299
305
  const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
300
306
  line += "\n " + theme.fg("dim", `⎿ ${preview}`);
307
+ if (d.outputFile) {
308
+ line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
309
+ }
310
+ return line;
301
311
  }
302
312
 
303
- // Line 4: output file link (if present)
313
+ const container = new Container();
314
+ container.addChild(new Text(line, 0, 0));
315
+ container.addChild(createMarkdownResult(d.resultText ?? d.resultPreview, theme, 30));
304
316
  if (d.outputFile) {
305
- line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
317
+ container.addChild(new Text(theme.fg("muted", ` transcript: ${d.outputFile}`), 0, 0));
306
318
  }
307
-
308
- return line;
319
+ return container;
309
320
  }
310
321
 
311
322
  const all = [d, ...(d.others ?? [])];
312
- return new Text(all.map(renderOne).join("\n"), 0, 0);
323
+ if (!expanded) return new Text(all.map(renderOne).join("\n"), 0, 0);
324
+
325
+ const container = new Container();
326
+ all.forEach((item, index) => {
327
+ if (index > 0) container.addChild(new Spacer(1));
328
+ container.addChild(renderOne(item) as Container);
329
+ });
330
+ return container;
313
331
  }
314
332
  );
315
333
 
@@ -553,6 +571,11 @@ export default function (pi: ExtensionAPI) {
553
571
  pi.on("session_start", async (_event, ctx) => {
554
572
  currentCtx = ctx;
555
573
  manager.clearCompleted(true);
574
+ const historicalRecords = ctx.sessionManager
575
+ .getBranch()
576
+ .filter((entry: any) => entry?.type === "custom" && entry.customType === "subagents:record")
577
+ .map((entry: any) => entry.data);
578
+ manager.restoreCompleted(historicalRecords);
556
579
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
557
580
  // fires once per activation, but a double-bind must not leak listeners.
558
581
  if (!rpcHandle) {
@@ -1024,15 +1047,15 @@ Terse command-style prompts produce shallow, generic work.
1024
1047
 
1025
1048
  if (expanded) {
1026
1049
  const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
1050
+ const container = new Container();
1051
+ container.addChild(new Text(line, 0, 0));
1027
1052
  if (resultText) {
1028
- const lines = resultText.split("\n").slice(0, 50);
1029
- for (const l of lines) {
1030
- line += "\n" + theme.fg("dim", ` ${l}`);
1031
- }
1053
+ container.addChild(createMarkdownResult(resultText, theme, 50));
1032
1054
  if (resultText.split("\n").length > 50) {
1033
- line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
1055
+ container.addChild(new Text(theme.fg("muted", " ... (use get_subagent_result with verbose for full output)"), 0, 0));
1034
1056
  }
1035
1057
  }
1058
+ return container;
1036
1059
  } else {
1037
1060
  const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
1038
1061
  line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
package/src/types.ts CHANGED
@@ -154,6 +154,7 @@ export interface NotificationDetails {
154
154
  outputFile?: string;
155
155
  error?: string;
156
156
  resultPreview: string;
157
+ resultText?: string;
157
158
  /** Additional agents in a group notification. */
158
159
  others?: NotificationDetails[];
159
160
  }
@@ -0,0 +1,56 @@
1
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import { Markdown, Text, type Component } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "./agent-widget.js";
4
+
5
+ const TRUNCATION_MARKER = "… (more output available)";
6
+
7
+ /**
8
+ * Render a child result as Markdown while keeping notification output bounded.
9
+ *
10
+ * Markdown is rendered at paint time so the TUI's current width controls wrapping.
11
+ * If the host's Markdown implementation is unavailable or throws, the original
12
+ * text is rendered through Text instead of making the result disappear.
13
+ */
14
+ class CappedMarkdownResult implements Component {
15
+ private readonly text: string;
16
+ private readonly theme: Theme;
17
+ private readonly maxLines: number;
18
+ private markdown: Component | undefined;
19
+
20
+ constructor(text: string, theme: Theme, maxLines: number) {
21
+ this.text = text;
22
+ this.theme = theme;
23
+ this.maxLines = Math.max(0, Math.floor(maxLines));
24
+
25
+ try {
26
+ this.markdown = new Markdown(text, 0, 0, getMarkdownTheme());
27
+ } catch {
28
+ // A mismatched host Pi version should still leave the child output visible.
29
+ }
30
+ }
31
+
32
+ invalidate(): void {
33
+ this.markdown?.invalidate();
34
+ }
35
+
36
+ render(width: number): string[] {
37
+ let lines: string[];
38
+ try {
39
+ lines = this.markdown?.render(width) ?? new Text(this.text, 0, 0).render(width);
40
+ } catch {
41
+ lines = new Text(this.text, 0, 0).render(width);
42
+ }
43
+
44
+ if (lines.length <= this.maxLines) return lines;
45
+ if (this.maxLines === 0) return [];
46
+
47
+ return [
48
+ ...lines.slice(0, this.maxLines - 1),
49
+ this.theme.fg("muted", TRUNCATION_MARKER),
50
+ ];
51
+ }
52
+ }
53
+
54
+ export function createMarkdownResult(text: string, theme: Theme, maxLines: number): Component {
55
+ return new CappedMarkdownResult(text, theme, maxLines);
56
+ }
@@ -0,0 +1,84 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { AgentManager } from "../src/agent-manager.js";
3
+
4
+ type PersistedRecord = {
5
+ id: string;
6
+ type: string;
7
+ description: string;
8
+ status: string;
9
+ result?: unknown;
10
+ error?: unknown;
11
+ startedAt: number;
12
+ completedAt: number;
13
+ };
14
+
15
+ function persisted(overrides: Partial<PersistedRecord> = {}): PersistedRecord {
16
+ return {
17
+ id: "agent-1",
18
+ type: "general-purpose",
19
+ description: "history test",
20
+ status: "completed",
21
+ result: "restored result",
22
+ startedAt: 100,
23
+ completedAt: 200,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("AgentManager.restoreCompleted", () => {
29
+ let manager: AgentManager | undefined;
30
+
31
+ afterEach(() => {
32
+ manager?.dispose();
33
+ manager = undefined;
34
+ });
35
+
36
+ it("restores terminal records without creating live runtime state", () => {
37
+ manager = new AgentManager();
38
+
39
+ manager.restoreCompleted([
40
+ persisted(),
41
+ persisted({ id: "agent-error", status: "error", error: "failed" }),
42
+ persisted({ id: "agent-running", status: "running" }),
43
+ persisted({ id: "agent-queued", status: "queued" }),
44
+ persisted({ id: "agent-invalid", completedAt: Number.NaN }),
45
+ ]);
46
+
47
+ expect(manager.listAgents().map((record) => record.id).sort()).toEqual([
48
+ "agent-1",
49
+ "agent-error",
50
+ ]);
51
+ const restored = manager.getRecord("agent-1");
52
+ expect(restored).toMatchObject({
53
+ status: "completed",
54
+ result: "restored result",
55
+ toolUses: 0,
56
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
57
+ compactionCount: 0,
58
+ isBackground: true,
59
+ });
60
+ expect(restored?.session).toBeUndefined();
61
+ expect(restored?.promise).toBeUndefined();
62
+ expect(restored?.abortController).toBeUndefined();
63
+ });
64
+
65
+ it("uses the newest branch entry when an id appears more than once", () => {
66
+ manager = new AgentManager();
67
+
68
+ manager.restoreCompleted([
69
+ persisted({ result: "old", completedAt: 200 }),
70
+ persisted({ result: "new", completedAt: 300 }),
71
+ ]);
72
+
73
+ expect(manager.getRecord("agent-1")?.result).toBe("new");
74
+ });
75
+
76
+ it("does not overwrite a record already held by the manager", () => {
77
+ manager = new AgentManager();
78
+
79
+ manager.restoreCompleted([persisted({ result: "first" })]);
80
+ manager.restoreCompleted([persisted({ result: "second" })]);
81
+
82
+ expect(manager.getRecord("agent-1")?.result).toBe("first");
83
+ });
84
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createMarkdownResult } from "../../src/ui/markdown-result.js";
3
+
4
+ const theme = {
5
+ fg: (_name: string, text: string) => text,
6
+ bold: (text: string) => text,
7
+ };
8
+
9
+ describe("createMarkdownResult", () => {
10
+ it("renders headings, lists, inline code, and fenced code", () => {
11
+ const component = createMarkdownResult(
12
+ [
13
+ "# Result",
14
+ "",
15
+ "- first",
16
+ "- `inline()`",
17
+ "",
18
+ "```ts",
19
+ "const answer: number = 42;",
20
+ "```",
21
+ ].join("\n"),
22
+ theme,
23
+ 50,
24
+ );
25
+
26
+ const rendered = component.render(100).join("\n");
27
+ expect(rendered).toContain("Result");
28
+ expect(rendered).toContain("first");
29
+ expect(rendered).toContain("inline()");
30
+ expect(rendered).toContain("answer");
31
+ });
32
+
33
+ it("caps rendered lines and adds a truncation marker", () => {
34
+ const component = createMarkdownResult("line\n".repeat(20), theme, 5);
35
+ const rendered = component.render(80);
36
+
37
+ expect(rendered).toHaveLength(5);
38
+ expect(rendered.at(-1)).toContain("more");
39
+ });
40
+
41
+ it("keeps unlabelled code blocks safe without language guessing", () => {
42
+ const component = createMarkdownResult("```\nplain text\n```", theme, 20);
43
+ expect(component.render(80).join("\n")).toContain("plain text");
44
+ });
45
+ });