@narumitw/pi-subagents 3.2.1 → 3.3.0

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/src/subagents.ts CHANGED
@@ -1,30 +1,158 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { registerCompletionRenderer } from "./completion-renderer.js";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeSubagentDisplayText } from "./display-text.js";
3
+ import { registerMessageRenderers } from "./message-renderers.js";
4
+ import {
5
+ DEFAULT_SUBAGENT_SETTINGS,
6
+ type SubagentSettings,
7
+ SubagentSettingsStore,
8
+ subagentSettingsPath,
9
+ } from "./settings.js";
3
10
  import { registerSubagentTools, type SubagentToolsDependencies } from "./tools.js";
4
11
  import { createSubagentWidgetController } from "./widget.js";
5
12
 
6
- export type SubagentsDependencies = SubagentToolsDependencies;
13
+ export interface SubagentsDependencies extends SubagentToolsDependencies {
14
+ settingsStore?: Pick<SubagentSettingsStore, "userPath" | "load" | "save" | "flush">;
15
+ }
7
16
 
8
17
  export default function subagents(pi: ExtensionAPI, dependencies: SubagentsDependencies = {}): void {
9
- registerCompletionRenderer(pi);
18
+ registerMessageRenderers(pi);
10
19
  const tools = registerSubagentTools(pi, dependencies);
11
20
  const widget = createSubagentWidgetController(tools.runtime);
12
- let activeSession: ExtensionContext["sessionManager"] | undefined;
21
+ const store = dependencies.settingsStore ?? new SubagentSettingsStore(subagentSettingsPath());
22
+ let activeContext: ExtensionContext | undefined;
13
23
  let sessionGeneration = 0;
24
+ let owner = new AbortController();
25
+ let ready = false;
26
+ let menuOpen = false;
27
+ let settings: SubagentSettings = { ...DEFAULT_SUBAGENT_SETTINGS };
28
+ const menuTasks = new Set<Promise<void>>();
29
+ const ownsSession = (ctx: ExtensionContext) => ctx.sessionManager === activeContext?.sessionManager;
30
+
31
+ const openSettings = async (
32
+ ctx: ExtensionCommandContext,
33
+ signal: AbortSignal,
34
+ isCurrent: () => boolean,
35
+ ): Promise<"back" | "close" | "stale"> => {
36
+ if (signal.aborted || !isCurrent()) return "stale";
37
+ if (ctx.mode !== "tui") {
38
+ ctx.ui.notify(sanitizeSubagentDisplayText(`Edit user settings: ${store.userPath}. Then run /reload.`), "info");
39
+ return "back";
40
+ }
41
+ const { showSubagentSettings } = await import("./settings-ui.js");
42
+ if (signal.aborted || !isCurrent()) return "stale";
43
+ return showSubagentSettings(ctx, {
44
+ signal,
45
+ isCurrent,
46
+ get: () => ({ ...settings }),
47
+ save: (key, value, actionSignal) => {
48
+ actionSignal.throwIfAborted();
49
+ if (!isCurrent()) throw new Error("The subagents session changed.");
50
+ return store.save(key, value, actionSignal);
51
+ },
52
+ apply: (next) => {
53
+ if (!isCurrent()) return;
54
+ settings = { ...next };
55
+ widget.update(ctx, settings);
56
+ },
57
+ });
58
+ };
59
+
60
+ pi.registerCommand("subagents", {
61
+ description: "Manage subagent jobs and widget settings",
62
+ getArgumentCompletions: (prefix) =>
63
+ "settings".startsWith(prefix)
64
+ ? [{ value: "settings", label: "settings", description: "Configure the subagent widget" }]
65
+ : null,
66
+ handler: async (args, ctx) => {
67
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
68
+ throw new Error("/subagents requires TUI or RPC mode.");
69
+ }
70
+ const route = args.trim();
71
+ if (route !== "" && route !== "settings") {
72
+ ctx.ui.notify("Usage: /subagents | /subagents settings", "warning");
73
+ return;
74
+ }
75
+ if (!ownsSession(ctx) || !ready || menuOpen) {
76
+ ctx.ui.notify("Subagent controls are loading, unavailable, or already open.", "warning");
77
+ return;
78
+ }
79
+ menuOpen = true;
80
+ const generation = sessionGeneration;
81
+ const signal = owner.signal;
82
+ const isCurrent = () => generation === sessionGeneration && ownsSession(ctx) && !signal.aborted;
83
+ const task = (async () => {
84
+ if (route === "settings") {
85
+ await openSettings(ctx, signal, isCurrent);
86
+ return;
87
+ }
88
+ const { showSubagentsMenu } = await import("./menu.js");
89
+ if (!isCurrent()) return;
90
+ await showSubagentsMenu(ctx, {
91
+ signal,
92
+ isCurrent,
93
+ getState: () => ({
94
+ settings: { ...settings },
95
+ settingsPath: store.userPath,
96
+ ...tools.runtime.inspectJobs(),
97
+ }),
98
+ cancelJob: async (jobId, actionSignal) => {
99
+ actionSignal.throwIfAborted();
100
+ if (!isCurrent()) throw new Error("The subagents session changed.");
101
+ await tools.runtime.cancel(jobId);
102
+ },
103
+ showSettings: (actionSignal) => openSettings(ctx, actionSignal, isCurrent),
104
+ });
105
+ })();
106
+ menuTasks.add(task);
107
+ try {
108
+ await task;
109
+ } catch (error) {
110
+ if (isCurrent()) {
111
+ ctx.ui.notify(sanitizeSubagentDisplayText(error instanceof Error ? error.message : String(error)), "error");
112
+ }
113
+ } finally {
114
+ menuTasks.delete(task);
115
+ if (isCurrent()) menuOpen = false;
116
+ }
117
+ },
118
+ });
14
119
 
15
120
  pi.on("session_start", async (_event, ctx) => {
16
- activeSession = ctx.sessionManager;
121
+ owner.abort();
122
+ if (activeContext) widget.shutdown(activeContext);
123
+ owner = new AbortController();
124
+ activeContext = ctx;
125
+ ready = false;
126
+ menuOpen = false;
17
127
  const generation = ++sessionGeneration;
128
+ const isCurrent = () => generation === sessionGeneration && ownsSession(ctx) && !owner.signal.aborted;
129
+ // Stop old children and broker delivery before waiting for slow UI storage work.
18
130
  await tools.startSession();
19
- if (generation !== sessionGeneration) return;
20
- widget.start(ctx);
131
+ if (!isCurrent()) return;
132
+ await Promise.allSettled([...menuTasks]);
133
+ if (!isCurrent()) return;
134
+ const loaded = await store.load();
135
+ if (!isCurrent()) return;
136
+ settings = { ...loaded.settings };
137
+ ready = true;
138
+ widget.start(ctx, settings);
139
+ if (loaded.diagnostics.length > 0) {
140
+ const message = sanitizeSubagentDisplayText(`Subagent settings: ${loaded.diagnostics.join(" ")}`);
141
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
142
+ else throw new Error(message);
143
+ }
21
144
  });
22
145
 
23
146
  pi.on("session_shutdown", async (_event, ctx) => {
24
- if (ctx.sessionManager !== activeSession) return;
147
+ if (!ownsSession(ctx)) return;
25
148
  sessionGeneration++;
26
- activeSession = undefined;
149
+ owner.abort();
150
+ ready = false;
151
+ menuOpen = false;
152
+ activeContext = undefined;
27
153
  widget.shutdown(ctx);
28
- await tools.shutdown();
154
+ const results = await Promise.allSettled([tools.shutdown(), ...menuTasks, store.flush()]);
155
+ const failed = results.find((result) => result.status === "rejected");
156
+ if (failed?.status === "rejected") throw failed.reason;
29
157
  });
30
158
  }
package/src/tools.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  sanitizeTerminalText,
12
12
  validateMessage,
13
13
  } from "./message-broker.js";
14
+ import { BROKER_MESSAGE_TYPE } from "./message-renderers.js";
14
15
  import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
15
16
  import { assertChildCommandCapacity } from "./process.js";
16
17
  import {
@@ -32,7 +33,6 @@ import {
32
33
 
33
34
  const MAX_TASK_BYTES = 50 * 1024;
34
35
  const MAX_TOOLS = MAX_SELECTED_TOOLS;
35
- const MESSAGE_TYPE = "pi-subagents-message";
36
36
  const CHILD_CORE_TOOL_SET = new Set<string>(CHILD_CORE_TOOL_NAMES);
37
37
  const THINKING_LEVEL_SET = new Set<string>(SUBAGENT_THINKING_LEVELS);
38
38
 
@@ -347,7 +347,7 @@ function deliverMessage(pi: ExtensionAPI, message: BrokerInboundMessage): void {
347
347
  );
348
348
  pi.sendMessage(
349
349
  {
350
- customType: MESSAGE_TYPE,
350
+ customType: BROKER_MESSAGE_TYPE,
351
351
  content,
352
352
  display: true,
353
353
  details: {
package/src/widget.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import { sanitizeTerminalText } from "./message-broker.js";
3
+ import { EditorStatusWidget } from "@narumitw/pi-tui-kit/editor-status-widget";
4
+ import { sanitizeSubagentDisplayText as sanitizeLabel } from "./display-text.js";
4
5
  import type { ActiveJobDisplay, SubagentRuntime } from "./runtime.js";
6
+ import { DEFAULT_SUBAGENT_SETTINGS, type SubagentSettings } from "./settings.js";
5
7
 
6
8
  export const SUBAGENT_WIDGET_KEY = "subagents";
7
9
  export const SUBAGENT_WIDGET_REFRESH_INTERVAL_MS = 1_000;
@@ -10,7 +12,8 @@ const WIDGET_OPTIONS = { placement: "aboveEditor" } as const;
10
12
  const TASK_PREVIEW_WIDTH = 40;
11
13
 
12
14
  export interface SubagentWidgetController {
13
- start(ctx: ExtensionContext): void;
15
+ start(ctx: ExtensionContext, settings?: SubagentSettings): void;
16
+ update(ctx: ExtensionContext, settings: SubagentSettings): void;
14
17
  shutdown(ctx: ExtensionContext): void;
15
18
  }
16
19
 
@@ -19,6 +22,8 @@ export function createSubagentWidgetController(runtime: SubagentRuntime): Subage
19
22
  let refreshTimer: ReturnType<typeof setInterval> | undefined;
20
23
  let unsubscribeJobs: (() => void) | undefined;
21
24
  let publishedValue: string | undefined;
25
+ let settings = { ...DEFAULT_SUBAGENT_SETTINGS };
26
+ let clearWidget: (() => void) | undefined;
22
27
 
23
28
  const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
24
29
 
@@ -31,19 +36,17 @@ export function createSubagentWidgetController(runtime: SubagentRuntime): Subage
31
36
 
32
37
  const publish = (ctx: ExtensionContext): void => {
33
38
  if (ctx.mode !== "tui" || !ownsSession(ctx)) return;
34
- const jobs = runtime.activeJobsForDisplay();
35
- const value = widgetValue(jobs);
39
+ const jobs = settings.showWidget ? runtime.activeJobsForDisplay() : [];
40
+ const value = `${JSON.stringify(settings)}\n${widgetValue(jobs)}`;
36
41
  if (value === publishedValue) return;
37
42
  if (jobs.length === 0) {
38
43
  ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
39
44
  } else {
40
45
  const snapshot = jobs.map(cloneDisplayJob);
46
+ const preferences = { ...settings };
41
47
  ctx.ui.setWidget(
42
48
  SUBAGENT_WIDGET_KEY,
43
- (_tui, theme) => ({
44
- render: (width) => renderSubagentWidget(snapshot, theme, width),
45
- invalidate: () => {},
46
- }),
49
+ (_tui, theme) => createSubagentWidget(snapshot, theme, preferences),
47
50
  WIDGET_OPTIONS,
48
51
  );
49
52
  }
@@ -51,50 +54,82 @@ export function createSubagentWidgetController(runtime: SubagentRuntime): Subage
51
54
  };
52
55
 
53
56
  return {
54
- start(ctx) {
57
+ start(ctx, preferences = DEFAULT_SUBAGENT_SETTINGS) {
55
58
  stop();
59
+ clearWidget?.();
60
+ clearWidget = undefined;
61
+ settings = { ...preferences };
56
62
  activeSession = ctx.sessionManager;
57
63
  publishedValue = undefined;
58
64
  if (ctx.mode !== "tui") return;
59
- ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
65
+ clearWidget = () => ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
66
+ clearWidget();
60
67
  unsubscribeJobs = runtime.subscribeJobs(() => publish(ctx));
61
68
  refreshTimer = setInterval(() => publish(ctx), SUBAGENT_WIDGET_REFRESH_INTERVAL_MS);
62
69
  refreshTimer.unref();
63
70
  publish(ctx);
64
71
  },
72
+ update(ctx, preferences) {
73
+ if (!ownsSession(ctx)) return;
74
+ settings = { ...preferences };
75
+ publish(ctx);
76
+ },
65
77
  shutdown(ctx) {
66
78
  if (!ownsSession(ctx)) return;
67
79
  stop();
68
- if (ctx.mode === "tui") ctx.ui.setWidget(SUBAGENT_WIDGET_KEY, undefined);
80
+ clearWidget?.();
81
+ clearWidget = undefined;
69
82
  publishedValue = undefined;
70
83
  activeSession = undefined;
71
84
  },
72
85
  };
73
86
  }
74
87
 
75
- export function renderSubagentWidget(jobs: readonly ActiveJobDisplay[], theme: Theme, width: number): string[] {
76
- const renderWidth = Math.max(0, width);
77
- const lines = [
78
- theme.fg("borderMuted", "─".repeat(renderWidth)),
79
- theme.fg("muted", `Subagents · ${jobs.length} active`),
80
- ...jobs.map((job) => renderJob(job, theme)),
81
- ];
82
- return lines.map((line) => truncateToWidth(line, renderWidth, ""));
88
+ export function renderSubagentWidget(
89
+ jobs: readonly ActiveJobDisplay[],
90
+ theme: Theme,
91
+ width: number,
92
+ settings: SubagentSettings = DEFAULT_SUBAGENT_SETTINGS,
93
+ ): string[] {
94
+ if (!settings.showWidget) return [];
95
+ return createSubagentWidget(jobs, theme, settings).render(width);
96
+ }
97
+
98
+ function createSubagentWidget(
99
+ jobs: readonly ActiveJobDisplay[],
100
+ theme: Theme,
101
+ settings: SubagentSettings,
102
+ ): EditorStatusWidget {
103
+ return new EditorStatusWidget({
104
+ theme,
105
+ renderBody: () => [
106
+ theme.fg("muted", `Subagents · ${jobs.length} active`),
107
+ ...jobs.map((job) => renderJob(job, theme, settings)),
108
+ ],
109
+ });
83
110
  }
84
111
 
85
- function renderJob(job: ActiveJobDisplay, theme: Theme): string {
112
+ function renderJob(job: ActiveJobDisplay, theme: Theme, settings: SubagentSettings): string {
86
113
  const running = job.state === "running";
87
114
  const symbol = theme.fg(running ? "accent" : "dim", running ? "▶ " : "○ ");
88
115
  const state = theme.fg(running ? "accent" : "muted", job.state);
89
116
  const jobId = sanitizeLabel(job.jobId);
90
- const taskText = sanitizeTask(job.task);
117
+ const taskText = sanitizeLabel(job.task);
91
118
  // Pi's truncation adds ANSI resets; remove them before applying the callback theme.
92
119
  const task = stripTerminalSequences(truncateToWidth(taskText, TASK_PREVIEW_WIDTH, "…"));
93
- const preview = visibleWidth(task) > 0 ? ` · ${theme.fg("text", task)}` : "";
120
+ const labels = [
121
+ ...(settings.showJobId ? [theme.fg("text", jobId)] : []),
122
+ ...(visibleWidth(task) > 0 ? [theme.fg("text", task)] : []),
123
+ state,
124
+ ];
94
125
  const tools = job.tools.length > 0 ? job.tools.map(sanitizeLabel).join(", ") : "none";
95
126
  const timeout = job.timeout === undefined ? "no timeout" : formatSeconds(job.timeout);
96
- const detail = ` · ${formatSeconds(Math.floor(job.elapsedMs / 1_000))} / ${timeout} · tools: ${tools}`;
97
- return `${symbol}${theme.fg("text", jobId)}${preview} · ${state}${theme.fg("muted", detail)}`;
127
+ const details = [
128
+ ...(settings.showTiming ? [`${formatSeconds(Math.floor(job.elapsedMs / 1_000))} / ${timeout}`] : []),
129
+ ...(settings.showTools ? [`tools: ${tools}`] : []),
130
+ ];
131
+ const detail = details.length > 0 ? theme.fg("muted", ` · ${details.join(" · ")}`) : "";
132
+ return `${symbol}${labels.join(" · ")}${detail}`;
98
133
  }
99
134
 
100
135
  function widgetValue(jobs: readonly ActiveJobDisplay[]): string {
@@ -110,21 +145,6 @@ function cloneDisplayJob(job: ActiveJobDisplay): ActiveJobDisplay {
110
145
  return { ...job, tools: [...job.tools] };
111
146
  }
112
147
 
113
- function sanitizeTask(value: string): string {
114
- // Stop sequence bodies at another control introducer so malformed input cannot
115
- // repeatedly scan the rest of a maximum-size task, as Pi's escape stripper can.
116
- const text = value.replace(
117
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Remove untrusted terminal escape sequences before display.
118
- /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|(?:\u001b[\]PX^_]|[\u0090\u0098\u009d-\u009f])[^\u0007\u001b\u0080-\u009f]*(?:\u0007|\u001b\\|\u009c)|\u001b[ -/]*[0-~]/gu,
119
- "",
120
- );
121
- return sanitizeLabel(text.replace(/\p{Bidi_Control}/gu, ""));
122
- }
123
-
124
- function sanitizeLabel(value: string): string {
125
- return sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
126
- }
127
-
128
148
  function formatSeconds(value: number): string {
129
149
  if (value < 60) return `${formatNumber(value)}s`;
130
150
  const wholeSeconds = Math.floor(value);
@@ -1,58 +0,0 @@
1
- import { type ExtensionAPI, getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
2
- import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
- import { sanitizeTerminalText } from "./message-broker.js";
4
-
5
- export const COMPLETION_MESSAGE_TYPE = "pi-subagents-completion";
6
-
7
- function createCompletionBox(
8
- children: Component[],
9
- outputPad: number,
10
- background: (text: string) => string,
11
- ): Component {
12
- return {
13
- render(width) {
14
- if (width <= 0) return [];
15
- const maxPadding = Math.floor((width - 1) / 2);
16
- const box = new Box(Math.min(Math.max(0, outputPad), maxPadding), 1, background);
17
- for (const child of children) box.addChild(child);
18
- return box.render(width);
19
- },
20
- invalidate() {
21
- for (const child of children) child.invalidate();
22
- },
23
- };
24
- }
25
-
26
- export function registerCompletionRenderer(pi: ExtensionAPI): void {
27
- pi.registerMessageRenderer(COMPLETION_MESSAGE_TYPE, (message, options, theme) => {
28
- const children: Component[] = [
29
- new Text(theme.fg("customMessageLabel", theme.bold(`[${COMPLETION_MESSAGE_TYPE}]`)), 0, 0),
30
- new Spacer(1),
31
- ];
32
- if (!options.expanded) {
33
- children.push(
34
- new Text(
35
- theme.fg("customMessageText", "Subagent job completion (") +
36
- theme.fg("dim", keyText("app.tools.expand")) +
37
- theme.fg("customMessageText", " to expand)"),
38
- 0,
39
- 0,
40
- ),
41
- );
42
- } else {
43
- const content =
44
- typeof message.content === "string"
45
- ? message.content
46
- : message.content
47
- .filter((part) => part.type === "text")
48
- .map((part) => part.text)
49
- .join("\n");
50
- children.push(
51
- new Markdown(sanitizeTerminalText(content), 0, 0, getMarkdownTheme(), {
52
- color: (text) => theme.fg("customMessageText", text),
53
- }),
54
- );
55
- }
56
- return createCompletionBox(children, options.outputPad, (text) => theme.bg("customMessageBg", text));
57
- });
58
- }