@tintinweb/pi-subagents 0.5.2 → 0.6.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.
@@ -0,0 +1,172 @@
1
+ // Persistence for pi-subagents operational settings.
2
+ // - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
3
+ // - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
8
+ import type { JoinMode } from "./types.js";
9
+
10
+ export interface SubagentsSettings {
11
+ maxConcurrent?: number;
12
+ /**
13
+ * 0 = unlimited — the extension's single source of truth for that convention:
14
+ * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
15
+ * `/agents` → Settings input prompt explicitly says "0 = unlimited".
16
+ */
17
+ defaultMaxTurns?: number;
18
+ graceTurns?: number;
19
+ defaultJoinMode?: JoinMode;
20
+ }
21
+
22
+ /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
23
+ export interface SettingsAppliers {
24
+ setMaxConcurrent: (n: number) => void;
25
+ setDefaultMaxTurns: (n: number) => void;
26
+ setGraceTurns: (n: number) => void;
27
+ setDefaultJoinMode: (mode: JoinMode) => void;
28
+ }
29
+
30
+ /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
31
+ export type SettingsEmit = (event: string, payload: unknown) => void;
32
+
33
+ const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
34
+
35
+ // Sanity ceilings — prevent hand-edited configs from asking for values that
36
+ // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
37
+ // that any realistic power-user setting passes through.
38
+ const MAX_CONCURRENT_CEILING = 1024;
39
+ const MAX_TURNS_CEILING = 10_000;
40
+ const GRACE_TURNS_CEILING = 1_000;
41
+
42
+ /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
43
+ function sanitize(raw: unknown): SubagentsSettings {
44
+ if (!raw || typeof raw !== "object") return {};
45
+ const r = raw as Record<string, unknown>;
46
+ const out: SubagentsSettings = {};
47
+ if (
48
+ Number.isInteger(r.maxConcurrent) &&
49
+ (r.maxConcurrent as number) >= 1 &&
50
+ (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
51
+ ) {
52
+ out.maxConcurrent = r.maxConcurrent as number;
53
+ }
54
+ if (
55
+ Number.isInteger(r.defaultMaxTurns) &&
56
+ (r.defaultMaxTurns as number) >= 0 &&
57
+ (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
58
+ ) {
59
+ out.defaultMaxTurns = r.defaultMaxTurns as number;
60
+ }
61
+ if (
62
+ Number.isInteger(r.graceTurns) &&
63
+ (r.graceTurns as number) >= 1 &&
64
+ (r.graceTurns as number) <= GRACE_TURNS_CEILING
65
+ ) {
66
+ out.graceTurns = r.graceTurns as number;
67
+ }
68
+ if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
69
+ out.defaultJoinMode = r.defaultJoinMode as JoinMode;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function globalPath(): string {
75
+ return join(getAgentDir(), "subagents.json");
76
+ }
77
+
78
+ function projectPath(cwd: string): string {
79
+ return join(cwd, ".pi", "subagents.json");
80
+ }
81
+
82
+ /**
83
+ * Read a settings file. Missing file is silent (returns `{}`). A file that
84
+ * exists but can't be parsed emits a warning to stderr so users aren't
85
+ * silently reverted to defaults — and still returns `{}` so startup proceeds.
86
+ */
87
+ function readSettingsFile(path: string): SubagentsSettings {
88
+ if (!existsSync(path)) return {};
89
+ try {
90
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
91
+ } catch (err) {
92
+ const reason = err instanceof Error ? err.message : String(err);
93
+ console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
94
+ return {};
95
+ }
96
+ }
97
+
98
+ /** Load merged settings: global provides defaults, project overrides. */
99
+ export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
100
+ return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
101
+ }
102
+
103
+ /**
104
+ * Write project-local settings. Global is never touched from code.
105
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
106
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
107
+ */
108
+ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
109
+ const path = projectPath(cwd);
110
+ try {
111
+ mkdirSync(dirname(path), { recursive: true });
112
+ writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /** Apply persisted settings to the in-memory state via caller-supplied setters. */
120
+ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
121
+ if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
122
+ if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
123
+ if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
124
+ if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
125
+ }
126
+
127
+ /**
128
+ * Format the user-facing toast for a settings mutation. Pure function —
129
+ * routes the success/failure of `saveSettings` into the right message + level
130
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
131
+ */
132
+ export function persistToastFor(
133
+ successMsg: string,
134
+ persisted: boolean,
135
+ ): { message: string; level: "info" | "warning" } {
136
+ return persisted
137
+ ? { message: successMsg, level: "info" }
138
+ : { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
139
+ }
140
+
141
+ /**
142
+ * Load merged settings, apply them to in-memory state, and emit the
143
+ * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
144
+ * callers can log/inspect. Extension init wires this once.
145
+ */
146
+ export function applyAndEmitLoaded(
147
+ appliers: SettingsAppliers,
148
+ emit: SettingsEmit,
149
+ cwd: string = process.cwd(),
150
+ ): SubagentsSettings {
151
+ const settings = loadSettings(cwd);
152
+ applySettings(settings, appliers);
153
+ emit("subagents:settings_loaded", { settings });
154
+ return settings;
155
+ }
156
+
157
+ /**
158
+ * Persist a settings snapshot, emit the `subagents:settings_changed` event
159
+ * (regardless of persist outcome so listeners see the in-memory change), and
160
+ * return the toast the UI should display. Event payload carries the `persisted`
161
+ * flag so listeners can react to write failures.
162
+ */
163
+ export function saveAndEmitChanged(
164
+ snapshot: SubagentsSettings,
165
+ successMsg: string,
166
+ emit: SettingsEmit,
167
+ cwd: string = process.cwd(),
168
+ ): { message: string; level: "info" | "warning" } {
169
+ const persisted = saveSettings(snapshot, cwd);
170
+ emit("subagents:settings_changed", { settings: snapshot, persisted });
171
+ return persistToastFor(successMsg, persisted);
172
+ }