@narumitw/pi-subagents 3.2.1 → 3.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "3.2.1",
3
+ "version": "3.3.2",
4
4
  "description": "Subagent jobs with asynchronous main-agent messaging for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,12 +39,12 @@
39
39
  "typebox": "*"
40
40
  },
41
41
  "devDependencies": {
42
- "@earendil-works/pi-ai": "1.2.0",
43
- "@earendil-works/pi-coding-agent": "1.2.0",
44
- "@earendil-works/pi-tui": "1.2.0",
42
+ "@earendil-works/pi-ai": "1.3.0",
43
+ "@earendil-works/pi-coding-agent": "1.3.0",
44
+ "@earendil-works/pi-tui": "1.3.0",
45
45
  "@types/node": "26.6.5",
46
46
  "esbuild": "0.28.2",
47
- "typebox": "1.3.37",
47
+ "typebox": "1.4.2",
48
48
  "typescript": "7.0.2"
49
49
  },
50
50
  "repository": {
@@ -53,6 +53,7 @@
53
53
  "directory": "packages/pi-subagents"
54
54
  },
55
55
  "dependencies": {
56
- "ignore": "^7.0.12"
56
+ "@narumitw/pi-tui-kit": "^0.66.0",
57
+ "ignore": "^7.0.13"
57
58
  }
58
59
  }
@@ -0,0 +1,14 @@
1
+ import { sanitizeTerminalText } from "./message-broker.js";
2
+
3
+ /** Single-line terminal display only; keep raw tasks, paths, and identifiers unchanged. */
4
+ export function sanitizeSubagentDisplayText(value: string): string {
5
+ // Bound sequence bodies at another control introducer to keep malformed input linear.
6
+ const text = value.replace(
7
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Strip untrusted terminal sequences before display.
8
+ /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|(?:\u001b[\]PX^_]|[\u0090\u0098\u009d-\u009f])[^\u0007\u001b\u0080-\u009f]*(?:\u0007|\u001b\\|\u009c)|\u001b[ -/]*[0-~]/gu,
9
+ "",
10
+ );
11
+ return sanitizeTerminalText(text.replace(/\p{Bidi_Control}/gu, ""))
12
+ .replace(/\s+/gu, " ")
13
+ .trim();
14
+ }
package/src/menu.ts ADDED
@@ -0,0 +1,188 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runConfirmation, runMenu, runTask, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
3
+ import type { SubagentSettings } from "./settings.js";
4
+ import { JOB_STATES, type JobSummary } from "./types.js";
5
+
6
+ interface SubagentsMenuState {
7
+ settings: SubagentSettings;
8
+ settingsPath: string;
9
+ jobs: JobSummary[];
10
+ omitted: number;
11
+ }
12
+
13
+ interface SubagentsMenuOptions {
14
+ signal: AbortSignal;
15
+ isCurrent(): boolean;
16
+ getState(): SubagentsMenuState;
17
+ cancelJob(jobId: string, signal: AbortSignal): Promise<void>;
18
+ showSettings(signal: AbortSignal): Promise<"back" | "close" | "stale">;
19
+ }
20
+
21
+ export async function showSubagentsMenu(ctx: ExtensionCommandContext, options: SubagentsMenuOptions): Promise<void> {
22
+ if (options.signal.aborted || !options.isCurrent()) return;
23
+ let selectedJobId: string | undefined;
24
+ const current = (signal: AbortSignal) => !signal.aborted && options.isCurrent();
25
+ const menu = defineMenu<
26
+ SubagentsMenuState,
27
+ "main" | "jobs" | "job" | "status" | "help",
28
+ "selectJob" | "cancelJob" | "settings"
29
+ >({
30
+ start: "main",
31
+ screens: {
32
+ main: ({ state }) => ({
33
+ kind: "actions",
34
+ title: "Subagents",
35
+ lines: [jobCounts(state)],
36
+ items: [
37
+ { id: "jobs", label: "Jobs", to: "jobs" },
38
+ { id: "settings", label: "Settings", action: "settings" },
39
+ { id: "status", label: "Status", to: "status" },
40
+ { id: "help", label: "Help", to: "help" },
41
+ { id: "close", label: "Close", close: true },
42
+ ],
43
+ hint: "close",
44
+ }),
45
+ jobs: ({ state }) => ({
46
+ kind: "choice",
47
+ title: "Subagent Jobs",
48
+ lines: [jobCounts(state), ...(state.jobs.length === 0 ? ["No jobs in this session."] : [])],
49
+ items: state.jobs.map((job) => ({
50
+ id: job.jobId,
51
+ label: sanitizeTerminalText(job.jobId),
52
+ description: sanitizeTerminalText(job.state),
53
+ })),
54
+ action: "selectJob",
55
+ hint: "back",
56
+ }),
57
+ job: ({ state }) => {
58
+ const job = state.jobs.find((candidate) => candidate.jobId === selectedJobId);
59
+ return {
60
+ kind: "actions",
61
+ title: "Subagent Job",
62
+ lines: job ? jobLines(job) : ["This job is no longer available."],
63
+ items: job && isActive(job) ? [{ id: "cancel", label: "Cancel job…", action: "cancelJob" }] : [],
64
+ hint: "back",
65
+ };
66
+ },
67
+ status: ({ state }) => ({
68
+ kind: "detail",
69
+ title: "Subagents Status",
70
+ lines: [
71
+ "Runtime: active for this session",
72
+ `Retained jobs: ${state.jobs.length}`,
73
+ `Active jobs: ${state.jobs.filter(isActive).length}`,
74
+ `Omitted jobs: ${state.omitted}`,
75
+ ...JOB_STATES.map((status) => `${status}: ${state.jobs.filter((job) => job.state === status).length}`),
76
+ `Show widget: ${state.settings.showWidget}`,
77
+ `Show job ID: ${state.settings.showJobId}`,
78
+ `Show tools: ${state.settings.showTools}`,
79
+ `Show timing: ${state.settings.showTiming}`,
80
+ `User settings: ${sanitizeTerminalText(state.settingsPath)}`,
81
+ ],
82
+ hint: "back",
83
+ }),
84
+ help: () => ({
85
+ kind: "detail",
86
+ title: "Subagents Help",
87
+ lines: [
88
+ "/subagents opens the job manager.",
89
+ "/subagents settings opens the same settings screen directly.",
90
+ "Jobs shows this session's job summaries. Only queued or running jobs can be cancelled, after confirmation.",
91
+ "The model uses subagent_spawn to start jobs, subagent_inspect to check them, and subagent_wait to wait for results.",
92
+ "subagent_send sends a message; subagent_cancel cancels a job.",
93
+ "Jobs are session-only. The runtime retains up to 32 terminal jobs for up to 24 hours.",
94
+ "Settings changes display preferences only. All four settings default to true.",
95
+ "Successful saves apply immediately. Use /reload after manual settings edits.",
96
+ "In RPC mode, Settings reports the user settings file path.",
97
+ ],
98
+ hint: "back",
99
+ }),
100
+ },
101
+ actions: {
102
+ selectJob: ({ itemId, signal }) => {
103
+ if (!current(signal)) return { kind: "close" };
104
+ if (!options.getState().jobs.some((job) => job.jobId === itemId)) return { kind: "stay" };
105
+ selectedJobId = itemId;
106
+ return { kind: "to", screen: "job" };
107
+ },
108
+ settings: async ({ signal }) => {
109
+ if (!current(signal)) return { kind: "close" };
110
+ const reason = await options.showSettings(signal);
111
+ return { kind: current(signal) && reason === "back" ? "stay" : "close" };
112
+ },
113
+ cancelJob: async ({ signal }) => {
114
+ if (!current(signal)) return { kind: "close" };
115
+ const jobId = selectedJobId;
116
+ const job = options.getState().jobs.find((candidate) => candidate.jobId === jobId);
117
+ if (!job || !isActive(job)) return { kind: "stay" };
118
+ const confirmation = await runConfirmation(ctx, {
119
+ title: "Cancel subagent job?",
120
+ message: `Cancel job ${sanitizeTerminalText(job.jobId)} (${sanitizeTerminalText(job.state)})?`,
121
+ confirmLabel: "Cancel job",
122
+ cancelLabel: "Keep job",
123
+ signal,
124
+ isCurrent: options.isCurrent,
125
+ onError: () => undefined,
126
+ });
127
+ if (!current(signal)) return { kind: "close" };
128
+ if (confirmation.kind === "closed") {
129
+ return { kind: confirmation.reason === "close" ? "close" : "stay" };
130
+ }
131
+ if (confirmation.kind === "error") throw confirmation.error;
132
+ if (confirmation.kind !== "confirmed") return { kind: "close" };
133
+ // The runtime can finish or remove a job while its confirmation is open.
134
+ const latest = options.getState().jobs.find((candidate) => candidate.jobId === jobId);
135
+ if (selectedJobId !== jobId || !latest || !isActive(latest)) return { kind: "stay" };
136
+ const result = await runTask(ctx, {
137
+ label: "Cancelling subagent job…",
138
+ signal,
139
+ isCurrent: options.isCurrent,
140
+ task: ({ signal: taskSignal }) => options.cancelJob(latest.jobId, taskSignal),
141
+ onError: () => undefined,
142
+ });
143
+ if (!current(signal)) return { kind: "close" };
144
+ if (result.kind === "error") throw result.error;
145
+ if (result.kind === "stale") return { kind: "close" };
146
+ if (result.kind === "cancelled") return { kind: "close" };
147
+ return { kind: "stay" };
148
+ },
149
+ },
150
+ });
151
+ await runMenu(ctx, menu, {
152
+ signal: options.signal,
153
+ isCurrent: options.isCurrent,
154
+ getState: options.getState,
155
+ onError: (_ctx, error) => {
156
+ if (!options.signal.aborted && options.isCurrent() && ctx.hasUI) {
157
+ ctx.ui.notify(
158
+ sanitizeTerminalText(`Subagents menu failed: ${error instanceof Error ? error.message : String(error)}`),
159
+ "error",
160
+ );
161
+ }
162
+ },
163
+ });
164
+ }
165
+
166
+ function isActive(job: JobSummary): boolean {
167
+ return job.state === "queued" || job.state === "running";
168
+ }
169
+
170
+ function jobCounts(state: SubagentsMenuState): string {
171
+ return `Jobs: ${state.jobs.length} shown, ${state.jobs.filter(isActive).length} active, ${state.omitted} omitted`;
172
+ }
173
+
174
+ function jobLines(job: JobSummary): string[] {
175
+ return [
176
+ `Job ID: ${sanitizeTerminalText(job.jobId)}`,
177
+ `State: ${sanitizeTerminalText(job.state)}`,
178
+ `Created: ${timestamp(job.createdAt)}`,
179
+ ...(job.startedAt === undefined ? [] : [`Started: ${timestamp(job.startedAt)}`]),
180
+ ...(job.finishedAt === undefined ? [] : [`Finished: ${timestamp(job.finishedAt)}`]),
181
+ ...(job.timeout === undefined ? [] : [`Timeout: ${job.timeout} seconds`]),
182
+ ];
183
+ }
184
+
185
+ function timestamp(value: number): string {
186
+ const date = new Date(value);
187
+ return Number.isFinite(date.getTime()) ? date.toISOString() : "unknown";
188
+ }
@@ -0,0 +1,60 @@
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
+ export const BROKER_MESSAGE_TYPE = "pi-subagents-message";
7
+
8
+ function createMessageBox(children: Component[], outputPad: number, background: (text: string) => string): Component {
9
+ return {
10
+ render(width) {
11
+ if (width <= 0) return [];
12
+ const maxPadding = Math.floor((width - 1) / 2);
13
+ const box = new Box(Math.min(Math.max(0, outputPad), maxPadding), 1, background);
14
+ for (const child of children) box.addChild(child);
15
+ return box.render(width);
16
+ },
17
+ invalidate() {
18
+ for (const child of children) child.invalidate();
19
+ },
20
+ };
21
+ }
22
+
23
+ export function registerMessageRenderers(pi: ExtensionAPI): void {
24
+ for (const [messageType, summary] of [
25
+ [COMPLETION_MESSAGE_TYPE, "Subagent job completion"],
26
+ [BROKER_MESSAGE_TYPE, "Subagent message"],
27
+ ] as const) {
28
+ pi.registerMessageRenderer(messageType, (message, options, theme) => {
29
+ const children: Component[] = [
30
+ new Text(theme.fg("customMessageLabel", theme.bold(`[${messageType}]`)), 0, 0),
31
+ new Spacer(1),
32
+ ];
33
+ if (!options.expanded) {
34
+ children.push(
35
+ new Text(
36
+ theme.fg("customMessageText", `${summary} (`) +
37
+ theme.fg("dim", keyText("app.tools.expand")) +
38
+ theme.fg("customMessageText", " to expand)"),
39
+ 0,
40
+ 0,
41
+ ),
42
+ );
43
+ } else {
44
+ const content =
45
+ typeof message.content === "string"
46
+ ? message.content
47
+ : message.content
48
+ .filter((part) => part.type === "text")
49
+ .map((part) => part.text)
50
+ .join("\n");
51
+ children.push(
52
+ new Markdown(sanitizeTerminalText(content), 0, 0, getMarkdownTheme(), {
53
+ color: (text) => theme.fg("customMessageText", text),
54
+ }),
55
+ );
56
+ }
57
+ return createMessageBox(children, options.outputPad, (text) => theme.bg("customMessageBg", text));
58
+ });
59
+ }
60
+ }
package/src/runtime.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { COMPLETION_MESSAGE_TYPE } from "./completion-renderer.js";
3
2
  import { type BrokerSendAcknowledgement, type MessageBroker, sanitizeTerminalText } from "./message-broker.js";
3
+ import { COMPLETION_MESSAGE_TYPE } from "./message-renderers.js";
4
4
  import { modelVisibleJson, requireBoundedModelText } from "./model-output.js";
5
5
  import { runChild as defaultRunChild } from "./process.js";
6
6
  import type { ExtensionAttachment } from "./resource-attachments.js";
@@ -29,6 +29,7 @@ interface InternalJob extends JobSummary {
29
29
  resolveTerminal: () => void;
30
30
  task?: Promise<void>;
31
31
  taskText: string;
32
+ thinkingLevel: SubagentThinkingLevel;
32
33
  stopRequest?: StopRequest;
33
34
  control?: ChildControl;
34
35
  controlReady: Promise<ChildControl>;
@@ -51,6 +52,7 @@ export interface ActiveJobDisplay {
51
52
  jobId: string;
52
53
  task: string;
53
54
  state: Extract<SubagentJobState, "queued" | "running">;
55
+ thinkingLevel: SubagentThinkingLevel;
54
56
  elapsedMs: number;
55
57
  timeout?: number;
56
58
  tools: string[];
@@ -117,6 +119,7 @@ export class SubagentRuntime {
117
119
  jobId: job.jobId,
118
120
  task: job.taskText,
119
121
  state: job.state,
122
+ thinkingLevel: job.thinkingLevel,
120
123
  elapsedMs: Math.max(0, now - (job.startedAt ?? job.createdAt)),
121
124
  ...(job.timeout !== undefined ? { timeout: job.timeout } : {}),
122
125
  tools: [...job.tools],
@@ -159,6 +162,7 @@ export class SubagentRuntime {
159
162
  controller,
160
163
  tools: effectiveTools,
161
164
  taskText: input.task,
165
+ thinkingLevel: input.thinkingLevel,
162
166
  terminal,
163
167
  resolveTerminal,
164
168
  controlReady,
@@ -0,0 +1,204 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { type ExtensionCommandContext, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Key, matchesKey, type SettingItem, SettingsList, Text, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
+ import {
5
+ HorizontalRule,
6
+ renderBoundedFrameLayout,
7
+ runCustomInteraction,
8
+ sanitizeTerminalText,
9
+ } from "@narumitw/pi-tui-kit";
10
+ import type { SubagentSettings } from "./settings.js";
11
+
12
+ interface SubagentSettingsUIOptions {
13
+ signal: AbortSignal;
14
+ isCurrent(): boolean;
15
+ get(): SubagentSettings;
16
+ save(key: keyof SubagentSettings, value: boolean, signal: AbortSignal): Promise<SubagentSettings>;
17
+ apply(settings: SubagentSettings): void;
18
+ }
19
+
20
+ type CloseReason = "back" | "close" | "stale";
21
+
22
+ const SETTINGS: { key: keyof SubagentSettings; label: string; description: string }[] = [
23
+ { key: "showWidget", label: "Show widget", description: "Show subagent jobs above the editor." },
24
+ { key: "showJobId", label: "Show job ID", description: "Show job IDs in the widget." },
25
+ { key: "showTools", label: "Show tools", description: "Show active tools in the widget." },
26
+ { key: "showTiming", label: "Show timing", description: "Show job timing in the widget." },
27
+ ];
28
+
29
+ export async function showSubagentSettings(
30
+ ctx: ExtensionCommandContext,
31
+ options: SubagentSettingsUIOptions,
32
+ ): Promise<CloseReason> {
33
+ const result = await runCustomInteraction<CloseReason>(ctx, {
34
+ signal: options.signal,
35
+ isCurrent: options.isCurrent,
36
+ // The command owner reports interaction failures and non-TUI settings paths.
37
+ onError: () => {},
38
+ create({ tui, theme, signal, complete }) {
39
+ let queue = Promise.resolve();
40
+ const current = () => !signal.aborted && options.isCurrent();
41
+ const revisions = new Map<string, number>();
42
+ const pending = new Map<string, number>();
43
+ const mouseRows = new Map<number, number>();
44
+ let renderedWidth = 0;
45
+ let contentHeight = 0;
46
+ let mousePressed = false;
47
+ const reportFailure = (message: string) => {
48
+ if (!current()) return;
49
+ try {
50
+ ctx.ui.notify(sanitizeTerminalText(`Could not save pi-subagents.json: ${message}`), "error");
51
+ } catch {
52
+ // The host can dispose its notifier after the ownership check.
53
+ }
54
+ };
55
+ const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
56
+ // Keep native SettingsList semantics, but use this screen's callback theme.
57
+ const listTheme = {
58
+ ...getSettingsListTheme(),
59
+ label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
60
+ value: (text: string, selected: boolean) => theme.fg(selected ? "accent" : "muted", text),
61
+ description: (text: string) => theme.fg("dim", text),
62
+ cursor: theme.fg("accent", "→ "),
63
+ hint: (text: string) => theme.fg("dim", text),
64
+ };
65
+ const initial = options.get();
66
+ const items: SettingItem[] = SETTINGS.map(({ key, label, description }) => ({
67
+ id: key,
68
+ label,
69
+ description,
70
+ currentValue: String(initial[key]),
71
+ values: ["true", "false"],
72
+ }));
73
+ const list = new SettingsList(
74
+ items,
75
+ items.length,
76
+ listTheme,
77
+ (id, value) => {
78
+ if (!current()) return;
79
+ const key = id as keyof SubagentSettings;
80
+ const revision = (revisions.get(id) ?? 0) + 1;
81
+ revisions.set(id, revision);
82
+ pending.set(id, revision);
83
+ queue = queue
84
+ .then(async () => {
85
+ if (!current()) {
86
+ complete("stale");
87
+ return;
88
+ }
89
+ const before = { ...options.get() };
90
+ let effective = before;
91
+ try {
92
+ const settings = await options.save(key, value === "true", signal);
93
+ // A successful rename is durable, even when closing aborted the save signal.
94
+ if (!options.isCurrent()) {
95
+ complete("stale");
96
+ return;
97
+ }
98
+ options.apply(settings);
99
+ effective = options.get();
100
+ } catch (error) {
101
+ if (!options.isCurrent()) {
102
+ complete("stale");
103
+ return;
104
+ }
105
+ let message = error instanceof Error ? error.message : String(error);
106
+ try {
107
+ options.apply(before);
108
+ } catch (rollbackError) {
109
+ message += `; could not restore effective settings: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`;
110
+ }
111
+ reportFailure(message);
112
+ }
113
+ if (!current()) return;
114
+ if (pending.get(id) === revision) pending.delete(id);
115
+ // Saving rereads the full document, including unrelated manual edits. Keep newer
116
+ // optimistic choices, but refresh every row with no outstanding user change.
117
+ for (const { key: settingKey } of SETTINGS) {
118
+ if (!pending.has(settingKey)) list.updateValue(settingKey, String(effective[settingKey]));
119
+ }
120
+ tui.requestRender();
121
+ })
122
+ .catch((error: unknown) => {
123
+ // A failed action must not prevent later requests from entering the queue.
124
+ reportFailure(error instanceof Error ? error.message : String(error));
125
+ });
126
+ },
127
+ () => complete("back"),
128
+ );
129
+ return {
130
+ render(width: number) {
131
+ if (!current()) complete("stale");
132
+ renderedWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
133
+ listTheme.cursor = theme.fg("accent", "→ ");
134
+ // The native footer hardcodes keys. Advertise only the hard-cancel key owned here;
135
+ // configured native navigation, change, and Back actions remain unchanged.
136
+ const content = list.render(Math.max(4, renderedWidth)).slice(0, -2);
137
+ contentHeight = content.length;
138
+ const focusedRow = content.findIndex((line) => stripVTControlCharacters(line).startsWith("→"));
139
+ mouseRows.clear();
140
+
141
+ const terminalRows = Number.isFinite(tui.terminal.rows) ? Math.floor(tui.terminal.rows) : 24;
142
+ const frame = renderBoundedFrameLayout({
143
+ width: renderedWidth,
144
+ maxRows: Math.max(1, terminalRows - 3),
145
+ rule: rule.render(renderedWidth)[0] ?? "",
146
+ title: new Text(theme.fg("accent", theme.bold("Subagents Settings")), 0, 0).render(renderedWidth),
147
+ context: [theme.fg("muted", "Saved changes apply immediately.")],
148
+ content,
149
+ hints: new Text(theme.fg("muted", "Ctrl+C close"), 0, 0).render(renderedWidth),
150
+ priorityRows: focusedRow < 0 ? [] : [focusedRow],
151
+ focusedRow,
152
+ });
153
+ for (const { frameIndex, contentIndex } of frame.contentRows) mouseRows.set(frameIndex, contentIndex);
154
+ return frame.lines;
155
+ },
156
+ invalidate() {
157
+ mouseRows.clear();
158
+ mousePressed = false;
159
+ list.invalidate();
160
+ },
161
+ handleInput(data: string) {
162
+ if (!current()) return complete("stale");
163
+ mouseRows.clear();
164
+ mousePressed = false;
165
+ if (matchesKey(data, Key.ctrl("c"))) complete("close");
166
+ else {
167
+ // SettingsList uses Pi's effective native keybindings for standard actions.
168
+ list.handleInput(data);
169
+ }
170
+ tui.requestRender();
171
+ },
172
+ handleMouse(event: TuiMouseEvent) {
173
+ if (!current()) {
174
+ complete("stale");
175
+ return;
176
+ }
177
+ const nativeRow = mouseRows.get(event.y);
178
+ if (nativeRow === undefined || event.x < 0 || event.x >= renderedWidth) {
179
+ if (event.button === "left" && (event.type === "press" || event.type === "click")) mousePressed = false;
180
+ return;
181
+ }
182
+ const nativeEvent = { ...event, y: nativeRow, width: renderedWidth, height: contentHeight };
183
+ if (event.type === "click" && event.button === "left" && !mousePressed) {
184
+ list.handleMouse({ ...nativeEvent, type: "press" });
185
+ }
186
+ const result = list.handleMouse(nativeEvent);
187
+ if (event.type === "press" && event.button === "left") mousePressed = result?.handled === true;
188
+ if (event.type === "click" || event.type === "wheel") mousePressed = false;
189
+ if (result?.handled && result.render !== false) tui.requestRender();
190
+ return result;
191
+ },
192
+ waitForPending() {
193
+ return queue;
194
+ },
195
+ dispose() {
196
+ mouseRows.clear();
197
+ mousePressed = false;
198
+ },
199
+ };
200
+ },
201
+ });
202
+ if (result.kind === "error") throw result.error;
203
+ return result.kind === "completed" ? result.value : result.kind === "unsupported" ? "back" : "stale";
204
+ }
@@ -0,0 +1,142 @@
1
+ import fs from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface SubagentSettings {
6
+ showWidget: boolean;
7
+ showJobId: boolean;
8
+ showTools: boolean;
9
+ showTiming: boolean;
10
+ }
11
+
12
+ export const DEFAULT_SUBAGENT_SETTINGS: Readonly<SubagentSettings> = Object.freeze({
13
+ showWidget: true,
14
+ showJobId: true,
15
+ showTools: true,
16
+ showTiming: true,
17
+ });
18
+
19
+ const SETTINGS_KEYS = Object.keys(DEFAULT_SUBAGENT_SETTINGS) as (keyof SubagentSettings)[];
20
+
21
+ type SettingsDocument = {
22
+ document?: Record<string, unknown>;
23
+ diagnostics: string[];
24
+ };
25
+
26
+ export function subagentSettingsPath(): string {
27
+ return join(getAgentDir(), "pi-subagents.json");
28
+ }
29
+
30
+ /** User-only settings with in-process ordering; this does not lock other Pi processes. */
31
+ export class SubagentSettingsStore {
32
+ private effective: SubagentSettings = { ...DEFAULT_SUBAGENT_SETTINGS };
33
+ private tail: Promise<void> = Promise.resolve();
34
+
35
+ constructor(readonly userPath: string) {}
36
+
37
+ get settings(): SubagentSettings {
38
+ return { ...this.effective };
39
+ }
40
+
41
+ load(): Promise<{ settings: SubagentSettings; diagnostics: string[] }> {
42
+ return this.enqueue(async () => {
43
+ const loaded = await readDocument(this.userPath);
44
+ const settings = normalizeSettings(loaded.document ?? {}, loaded.diagnostics);
45
+ this.effective = settings;
46
+ return { settings: this.settings, diagnostics: loaded.diagnostics };
47
+ }).catch((error: unknown) => ({
48
+ settings: this.settings,
49
+ diagnostics: [readDiagnostic(this.userPath, error)],
50
+ }));
51
+ }
52
+
53
+ save(key: keyof SubagentSettings, value: boolean, signal: AbortSignal): Promise<SubagentSettings> {
54
+ return this.enqueue(async () => {
55
+ signal.throwIfAborted();
56
+ if (!SETTINGS_KEYS.includes(key) || typeof value !== "boolean") {
57
+ throw new Error("Invalid subagent settings update");
58
+ }
59
+ const loaded = await readDocument(this.userPath);
60
+ signal.throwIfAborted();
61
+ if (!loaded.document) {
62
+ throw new Error(`Cannot save subagent settings: ${loaded.diagnostics.join("; ")}`);
63
+ }
64
+ // Read again for each save and patch only the requested key, including after a failed save.
65
+ const document = { ...loaded.document, [key]: value };
66
+ const settings = normalizeSettings(document, []);
67
+ const parent = dirname(this.userPath);
68
+ await fs.mkdir(parent, { recursive: true });
69
+ signal.throwIfAborted();
70
+ const temporary = await fs.mkdtemp(join(parent, ".pi-subagents-"));
71
+ try {
72
+ signal.throwIfAborted();
73
+ const staged = join(temporary, "settings.json");
74
+ await fs.writeFile(staged, `${JSON.stringify(document, null, 2)}\n`, { flag: "wx", signal });
75
+ signal.throwIfAborted();
76
+ await fs.rename(staged, this.userPath);
77
+ // Rename is the commit point. Cancellation or cleanup must not turn a commit into a failure.
78
+ this.effective = settings;
79
+ return this.settings;
80
+ } finally {
81
+ await fs.rm(temporary, { recursive: true, force: true }).catch(() => {});
82
+ }
83
+ });
84
+ }
85
+
86
+ flush(): Promise<void> {
87
+ return this.tail;
88
+ }
89
+
90
+ private enqueue<T>(operation: () => Promise<T>): Promise<T> {
91
+ const previous = this.tail;
92
+ // Register immediately so other stores and Pi file operations retain request order too.
93
+ const result = withFileMutationQueue(this.userPath, async () => {
94
+ await previous;
95
+ return operation();
96
+ });
97
+ this.tail = result.then(
98
+ () => {},
99
+ () => {},
100
+ );
101
+ return result;
102
+ }
103
+ }
104
+
105
+ async function readDocument(path: string): Promise<SettingsDocument> {
106
+ let text: string;
107
+ try {
108
+ text = await fs.readFile(path, "utf8");
109
+ } catch (error) {
110
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
111
+ return { document: {}, diagnostics: [] };
112
+ }
113
+ return { diagnostics: [readDiagnostic(path, error)] };
114
+ }
115
+ let document: unknown;
116
+ try {
117
+ document = JSON.parse(text);
118
+ } catch {
119
+ return { diagnostics: [`Cannot load subagent settings at ${path}: invalid JSON`] };
120
+ }
121
+ if (typeof document !== "object" || document === null || Array.isArray(document)) {
122
+ return { diagnostics: [`Cannot load subagent settings at ${path}: expected a JSON object`] };
123
+ }
124
+ return { document: document as Record<string, unknown>, diagnostics: [] };
125
+ }
126
+
127
+ function normalizeSettings(document: Record<string, unknown>, diagnostics: string[]): SubagentSettings {
128
+ const settings = { ...DEFAULT_SUBAGENT_SETTINGS };
129
+ for (const key of SETTINGS_KEYS) {
130
+ if (!Object.hasOwn(document, key)) continue;
131
+ if (typeof document[key] === "boolean") {
132
+ settings[key] = document[key];
133
+ } else {
134
+ diagnostics.push(`Ignored invalid subagent setting ${key}: expected a boolean`);
135
+ }
136
+ }
137
+ return settings;
138
+ }
139
+
140
+ function readDiagnostic(path: string, error: unknown): string {
141
+ return `Cannot load subagent settings at ${path}: ${error instanceof Error ? error.message : String(error)}`;
142
+ }