@huseynsnmz/pi-suite 0.2.1 → 0.2.3

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 (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +8 -1
  3. package/package.json +6 -1
  4. package/packages/board/README.md +1 -1
  5. package/packages/commands/README.md +1 -1
  6. package/packages/commands/src/index.ts +22 -0
  7. package/packages/frame/README.md +8 -2
  8. package/packages/frame/docs/reference.md +1 -1
  9. package/packages/frame/src/index.ts +8 -3
  10. package/packages/frame/src/kit/settings/files.ts +8 -8
  11. package/packages/frame/src/kit/settings/store.ts +3 -1
  12. package/packages/frame/src/ui/editor.ts +64 -7
  13. package/packages/frame/src/ui/history-search.ts +21 -0
  14. package/packages/frame/src/ui/pi-entries.ts +2 -0
  15. package/packages/frame/src/ui/thinking.ts +34 -0
  16. package/packages/memory/README.md +1 -1
  17. package/packages/modes/README.md +6 -2
  18. package/packages/modes/src/index.ts +9 -7
  19. package/packages/modes/src/modes.ts +18 -1
  20. package/packages/router/README.md +29 -9
  21. package/packages/router/src/config.ts +28 -27
  22. package/packages/router/src/index.ts +61 -13
  23. package/packages/router/src/route.ts +16 -10
  24. package/packages/subscription/LICENSE +21 -0
  25. package/packages/subscription/README.md +60 -0
  26. package/packages/subscription/package.json +23 -0
  27. package/packages/subscription/src/anchors.ts +75 -0
  28. package/packages/subscription/src/billing.ts +39 -0
  29. package/packages/subscription/src/catalog.ts +310 -0
  30. package/packages/subscription/src/cc-sessions.ts +122 -0
  31. package/packages/subscription/src/config.ts +34 -0
  32. package/packages/subscription/src/convert.ts +161 -0
  33. package/packages/subscription/src/index.ts +69 -0
  34. package/packages/subscription/src/mcp.ts +78 -0
  35. package/packages/subscription/src/models.ts +65 -0
  36. package/packages/subscription/src/prompt.ts +57 -0
  37. package/packages/subscription/src/provider.ts +617 -0
  38. package/packages/subscription/src/session.ts +294 -0
  39. package/packages/subscription/src/util.ts +73 -0
  40. package/packages/web/README.md +5 -5
  41. package/packages/web/src/config.ts +13 -23
  42. package/packages/web/src/index.ts +11 -7
  43. package/packages/web/src/reader.ts +15 -4
  44. package/packages/web/src/validate.ts +2 -2
  45. package/src/index.ts +92 -10
  46. package/src/settings-ui.ts +63 -0
  47. package/src/settings.ts +116 -15
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { type ExtensionAPI, type ExtensionCommandContext, getAgentDir } from "@earendil-works/pi-coding-agent";
7
7
 
8
- import { isPart, loadSettings, type Part, PART_NAMES, PARTS, saveSettings, separateInstalls } from "./settings.ts";
8
+ import { checkSection, type Field, FIELDS, shownValue, typedValue } from "./settings-ui.ts";
9
+ import { isPart, loadSettings, moveOldSettings, type Part, PART_NAMES, PARTS, readSection, saveSettings, separateInstalls, writeSection } from "./settings.ts";
9
10
 
10
11
  function loadPart(part: Part): Promise<{ default: (pi: ExtensionAPI) => void | Promise<void> }> {
11
12
  return import(new URL(`../packages/${part}/src/index.ts`, import.meta.url).href);
@@ -20,8 +21,9 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url));
20
21
 
21
22
  export default async function piSuite(pi: ExtensionAPI): Promise<void> {
22
23
  const agentDir = getAgentDir();
24
+ const moveProblems = moveOldSettings(agentDir);
23
25
  const settings = loadSettings(agentDir);
24
- const notices: string[] = settings.problem === undefined ? [] : [`pi-suite: ${settings.problem}`];
26
+ const notices: string[] = [...moveProblems, ...(settings.problem === undefined ? [] : [`pi-suite: ${settings.problem}`])];
25
27
  const loaded = new Set<Part>();
26
28
  const failed = new Set<Part>();
27
29
  if (settings.off.has("modes")) notices.push(`pi-suite: modes is off, so ${MODES_OFF}`);
@@ -77,20 +79,26 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
77
79
  }
78
80
  if (on) off.delete(part);
79
81
  else off.add(part);
80
- saveSettings(agentDir, off);
81
- if (!on && part === "router") await leaveRouter(ctx);
82
+ try {
83
+ saveSettings(agentDir, off);
84
+ } catch (error) {
85
+ ctx.ui.notify(`Nothing changed: ${(error as Error).message}`, "error");
86
+ return false;
87
+ }
88
+ if (!on && part === "router") await leaveModels(ctx, "pi-virtual");
89
+ if (!on && part === "subscription") await leaveModels(ctx, "claude-agent-sdk");
82
90
  return true;
83
91
  }
84
92
 
85
93
  /** The session would otherwise stay on a model that's gone after the reload, and every prompt would fail. */
86
- async function leaveRouter(ctx: ExtensionCommandContext): Promise<void> {
87
- if (ctx.model?.api !== "pi-virtual") return;
94
+ async function leaveModels(ctx: ExtensionCommandContext, api: string): Promise<void> {
95
+ if (ctx.model?.api !== api) return;
88
96
  const lastAnswer = ctx.sessionManager
89
97
  .getBranch()
90
98
  .flatMap((entry) => (entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason !== "error" && entry.message.stopReason !== "aborted" ? [entry.message] : []))
91
99
  .at(-1);
92
100
  const answered = lastAnswer && ctx.modelRegistry.find(lastAnswer.provider, lastAnswer.model);
93
- const next = answered ?? ctx.modelRegistry.getAvailable().find((model) => model.api !== "pi-virtual");
101
+ const next = (answered?.api === api ? undefined : answered) ?? ctx.modelRegistry.getAvailable().find((model) => model.api !== "pi-virtual" && model.api !== api);
94
102
  if (next === undefined) return;
95
103
  await pi.setModel(next);
96
104
  if (ctx.hasUI) ctx.ui.notify(`Now on ${next.provider}/${next.id}.`, "info");
@@ -102,6 +110,80 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
102
110
  else if (ctx.hasUI) ctx.ui.notify("Saved. Run /reload to apply it.", "info");
103
111
  }
104
112
 
113
+ /** Asks for a new value. True if one was saved. */
114
+ async function editField(part: Part, field: Field, ctx: ExtensionCommandContext): Promise<boolean> {
115
+ const section = readSection(agentDir, part);
116
+ const current = section[field.key];
117
+ let value: unknown;
118
+ const prompt = `${part}: ${field.label}`;
119
+ if (field.kind === "boolean" || field.kind === "choice" || field.kind === "model") {
120
+ const options =
121
+ field.kind === "boolean"
122
+ ? ["on", "off"]
123
+ : field.kind === "choice"
124
+ ? [...field.options, "default"]
125
+ : ["default", ...ctx.modelRegistry.getAvailable().filter((model) => model.api !== "pi-virtual").map((model) => `${model.provider}/${model.id}`)];
126
+ const picked = await ctx.ui.select(prompt, options);
127
+ if (picked === undefined) return false;
128
+ value = field.kind === "boolean" ? (picked === "on" ? true : undefined) : picked === "default" ? undefined : picked;
129
+ } else {
130
+ const shown = Array.isArray(current) ? current.join(", ") : current === undefined ? "" : String(current);
131
+ const typed = await ctx.ui.input(`${prompt}${field.kind === "list" ? " (separate with commas, empty clears)" : " (empty clears)"}`, shown);
132
+ if (typed === undefined) return false;
133
+ value = typedValue(field, typed);
134
+ }
135
+ const next = { ...section };
136
+ if (value === undefined) delete next[field.key];
137
+ else next[field.key] = value;
138
+ checkSection(part, next);
139
+ writeSection(agentDir, part, next);
140
+ return true;
141
+ }
142
+
143
+ /** One part: on or off, then its settings. True if anything was saved. */
144
+ async function partMenu(part: Part, ctx: ExtensionCommandContext): Promise<boolean> {
145
+ let changed = false;
146
+ const fields = FIELDS[part] ?? [];
147
+ for (;;) {
148
+ let section: Record<string, unknown> = {};
149
+ try {
150
+ section = readSection(agentDir, part);
151
+ } catch (error) {
152
+ ctx.ui.notify((error as Error).message, "error");
153
+ }
154
+ const off = currentOff();
155
+ const toggle = off.has(part) ? "Turned off. Enter turns it on" : "Turned on. Enter turns it off";
156
+ const extra = part === "router" ? ["Models and reasoning"] : part === "frame" ? ["Icons, frame style and more"] : [];
157
+ const rows = [toggle, ...extra, ...fields.map((field) => `${field.label}: ${shownValue(part, field, section)}`)];
158
+ const row = await ctx.ui.select(`${part}: ${PARTS[part]} (Esc to go back)`, rows);
159
+ if (row === undefined) return changed;
160
+ const index = rows.indexOf(row);
161
+ if (index === 0) {
162
+ if (await setPart(part, off.has(part), ctx)) changed = true;
163
+ continue;
164
+ }
165
+ if (extra.includes(row)) {
166
+ if (part === "frame") {
167
+ ctx.ui.notify("Press Ctrl+S in the editor for the frame's settings, or use /harness with the frame off.", "info");
168
+ continue;
169
+ }
170
+ const probe: { open?: (ctx: ExtensionCommandContext) => Promise<void> } = {};
171
+ pi.events.emit("pi-suite:router-settings", probe);
172
+ if (probe.open === undefined) ctx.ui.notify("Turn router on and reload first.", "warning");
173
+ else {
174
+ await probe.open(ctx);
175
+ }
176
+ continue;
177
+ }
178
+ const field = fields[index - 1 - extra.length] as Field;
179
+ try {
180
+ if (await editField(part, field, ctx)) changed = true;
181
+ } catch (error) {
182
+ ctx.ui.notify(`Not saved: ${(error as Error).message}`, "error");
183
+ }
184
+ }
185
+ }
186
+
105
187
  function status(): string {
106
188
  const off = currentOff();
107
189
  return PART_NAMES.map((part) => {
@@ -114,7 +196,7 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
114
196
  }
115
197
 
116
198
  pi.registerCommand("suite", {
117
- description: "Turn parts of pi-suite on or off: /suite, /suite on web, /suite off board, /suite list",
199
+ description: "Settings for pi-suite, and turning its parts on or off: /suite, /suite on web, /suite off board, /suite list",
118
200
  getArgumentCompletions: (prefix) => {
119
201
  const typed = prefix.trimStart().toLowerCase();
120
202
  const options = ["list", ...PART_NAMES.flatMap((part) => [`on ${part}`, `off ${part}`])];
@@ -145,10 +227,10 @@ export default async function piSuite(pi: ExtensionAPI): Promise<void> {
145
227
  for (;;) {
146
228
  const off = currentOff();
147
229
  const rows = PART_NAMES.map((part) => `${off.has(part) ? "[ ]" : "[x]"} ${part} ${PARTS[part]}`);
148
- const row = await ctx.ui.select("pi-suite: Enter turns a part on or off (Esc when done)", rows);
230
+ const row = await ctx.ui.select("pi-suite: pick a part (Esc when done)", rows);
149
231
  if (row === undefined) break;
150
232
  const part = PART_NAMES[rows.indexOf(row)] as Part;
151
- if (await setPart(part, off.has(part), ctx)) changed = true;
233
+ if (await partMenu(part, ctx)) changed = true;
152
234
  }
153
235
  await finish(changed, ctx);
154
236
  },
@@ -0,0 +1,63 @@
1
+ // What /suite can change in each part's section of suite.json. Each part still checks its own values when it loads.
2
+ import { parseConfig as parseWeb } from "../packages/web/src/config.ts";
3
+ import type { Part } from "./settings.ts";
4
+
5
+ export type Field =
6
+ | { key: string; label: string; kind: "boolean" | "text" | "list" | "model" }
7
+ | { key: string; label: string; kind: "choice"; options: readonly string[] }
8
+ | { key: string; label: string; kind: "number"; min: number; max: number };
9
+
10
+ const MODES_ALLOWED = ["normal", "auto", "plan", "read-only"];
11
+ /** Yolo has to be asked for each time, so it can't be a default. */
12
+ export const FIELDS: Partial<Record<Part, readonly Field[]>> = {
13
+ modes: [{ key: "defaultMode", label: "Mode for new sessions", kind: "choice", options: MODES_ALLOWED }],
14
+ web: [
15
+ { key: "companySearch", label: "Allow searching through a company's service", kind: "boolean" },
16
+ { key: "provider", label: "Search with", kind: "choice", options: ["searxng", "markdown.new"] },
17
+ { key: "searxngUrl", label: "Your SearXNG server", kind: "text" },
18
+ { key: "readerModel", label: "Model that reads pages", kind: "model" },
19
+ { key: "allowDomains", label: "Only fetch from these sites", kind: "list" },
20
+ { key: "denyDomains", label: "Never fetch from these sites", kind: "list" },
21
+ { key: "trustedDomains", label: "Sites it can write its own addresses on", kind: "list" },
22
+ { key: "privateWords", label: "Words a search can never hold", kind: "list" },
23
+ { key: "askNewDomains", label: "Ask before the first fetch from each site", kind: "boolean" },
24
+ { key: "strictUrls", label: "Ask about every address nobody gave it", kind: "boolean" },
25
+ { key: "minConfidence", label: "Withhold answers the reader scores below", kind: "number", min: 1, max: 10 },
26
+ { key: "dnsFilter", label: "Block sites using a DNS filter", kind: "choice", options: ["cloudflare-family", "cloudflare-security", "off"] },
27
+ ],
28
+ subscription: [
29
+ { key: "promptMode", label: "System prompt", kind: "choice", options: ["claude", "pi"] },
30
+ { key: "pathToClaudeCodeExecutable", label: "Claude Code program", kind: "text" },
31
+ { key: "maxSessions", label: "Claude Code processes to keep", kind: "number", min: 1, max: 16 },
32
+ { key: "idleMinutes", label: "Close an idle process after, in minutes", kind: "number", min: 1, max: 1440 },
33
+ ],
34
+ };
35
+
36
+ /** The value /suite shows when nothing's been chosen. */
37
+ const UNSET_WEB: Readonly<Record<string, string>> = { minConfidence: "4", dnsFilter: "cloudflare-family", provider: "automatic" };
38
+
39
+ export function shownValue(part: Part, field: Field, section: Record<string, unknown>): string {
40
+ const value = section[field.key];
41
+ if (value === undefined) return part === "web" ? (UNSET_WEB[field.key] ?? (field.kind === "boolean" ? "off" : "not set")) : field.kind === "boolean" ? "off" : "default";
42
+ if (typeof value === "boolean") return value ? "on" : "off";
43
+ if (Array.isArray(value)) return value.length === 0 ? "not set" : value.join(", ");
44
+ return String(value);
45
+ }
46
+
47
+ /** What was typed, as the value to save. Undefined clears the setting. Throws if it can't be used. */
48
+ export function typedValue(field: Field, typed: string): unknown {
49
+ const text = typed.trim();
50
+ if (text === "") return undefined;
51
+ if (field.kind === "list") return text.split(",").map((item) => item.trim()).filter(Boolean);
52
+ if (field.kind === "number") {
53
+ const number = Number(text);
54
+ if (!Number.isInteger(number) || number < field.min || number > field.max) throw new Error(`${field.label} must be a whole number from ${field.min} to ${field.max}`);
55
+ return number;
56
+ }
57
+ return text;
58
+ }
59
+
60
+ /** Throws rather than save what the part would refuse when it loads. */
61
+ export function checkSection(part: Part, section: Record<string, unknown>): void {
62
+ if (part === "web") parseWeb(section);
63
+ }
package/src/settings.ts CHANGED
@@ -1,4 +1,5 @@
1
- // ~/.pi/agent/suite.json: which parts are off. Everything is on unless it's listed.
1
+ // ~/.pi/agent/suite.json: which parts are off, and each part's settings under its own name.
2
+ // Global only: a repository must not choose where your code or searches go, or which program runs.
2
3
  import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
 
@@ -10,6 +11,7 @@ export const PARTS = {
10
11
  frame: "The start card, framed editor, tool boxes, footer and todo list",
11
12
  board: "The board of agent sessions",
12
13
  commands: "/clear and /security-review",
14
+ subscription: "Claude models on your Claude login, asking before anything is billed as extra usage",
13
15
  } as const;
14
16
 
15
17
  export type Part = keyof typeof PARTS;
@@ -20,33 +22,44 @@ export function settingsPath(agentDir: string): string {
20
22
  return join(agentDir, "suite.json");
21
23
  }
22
24
 
25
+ /** Off until asked for: it adds models that count as logged in, and its README says what using them risks. */
26
+ const OFF_BY_DEFAULT: readonly Part[] = ["subscription"];
27
+
23
28
  export function isPart(name: string): name is Part {
24
29
  return (PART_NAMES as string[]).includes(name);
25
30
  }
26
31
 
27
32
  export interface Loaded {
28
33
  off: ReadonlySet<Part>;
29
- /** Everything stays on when the file can't be understood, so a typo can't turn off plan mode. */
34
+ /** Every part keeps its default when the file can't be understood, so a typo can't turn off plan mode. */
30
35
  problem?: string;
31
36
  }
32
37
 
38
+ function defaults(problem?: string): Loaded {
39
+ return { off: new Set(OFF_BY_DEFAULT), ...(problem === undefined ? {} : { problem }) };
40
+ }
41
+
33
42
  export function parseSettings(text: string): Loaded {
34
43
  let raw: unknown;
35
44
  try {
36
45
  raw = JSON.parse(text);
37
46
  } catch {
38
- return { off: new Set(), problem: "suite.json isn't valid JSON, so everything is on" };
47
+ return defaults("suite.json isn't valid JSON, so every part is at its default");
39
48
  }
40
- const off = (raw as { off?: unknown } | null)?.off;
41
- if (raw === null || typeof raw !== "object" || Array.isArray(raw) || (off !== undefined && !Array.isArray(off))) {
42
- return { off: new Set(), problem: 'suite.json should look like { "off": ["board"] }, so everything is on' };
49
+ const { off, on } = (raw ?? {}) as { off?: unknown; on?: unknown };
50
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw) || (off !== undefined && !Array.isArray(off)) || (on !== undefined && !Array.isArray(on))) {
51
+ return defaults('suite.json should look like { "off": ["board"], "on": ["subscription"] }, so every part is at its default');
43
52
  }
44
- const names = (off ?? []) as unknown[];
45
- const unknown = names.filter((name) => typeof name !== "string" || !isPart(name));
53
+ const offNames = (off ?? []) as unknown[];
54
+ const onNames = (on ?? []) as unknown[];
55
+ const unknown = [...offNames, ...onNames].filter((name) => typeof name !== "string" || !isPart(name));
46
56
  if (unknown.length > 0) {
47
- return { off: new Set(), problem: `suite.json names ${unknown.join(", ")}, which isn't one of ${PART_NAMES.join(", ")}, so everything is on` };
57
+ return defaults(`suite.json names ${unknown.join(", ")}, which isn't one of ${PART_NAMES.join(", ")}, so every part is at its default`);
48
58
  }
49
- return { off: new Set(names as Part[]) };
59
+ // Saying off wins, in case a part is in both.
60
+ const result = new Set<Part>(OFF_BY_DEFAULT.filter((name) => !onNames.includes(name)));
61
+ for (const name of offNames as Part[]) result.add(name);
62
+ return { off: result };
50
63
  }
51
64
 
52
65
  export function loadSettings(agentDir: string): Loaded {
@@ -54,21 +67,109 @@ export function loadSettings(agentDir: string): Loaded {
54
67
  try {
55
68
  text = readFileSync(settingsPath(agentDir), "utf8");
56
69
  } catch (error) {
57
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { off: new Set() };
58
- return { off: new Set(), problem: `suite.json can't be read, so everything is on: ${(error as Error).message}` };
70
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaults();
71
+ return defaults(`suite.json can't be read, so every part is at its default: ${(error as Error).message}`);
59
72
  }
60
73
  return parseSettings(text);
61
74
  }
62
75
 
63
76
  export function saveSettings(agentDir: string, off: ReadonlySet<Part>): void {
64
- // A linked file, as in a dotfiles setup, stays linked.
77
+ const turnedOn = OFF_BY_DEFAULT.filter((name) => !off.has(name));
78
+ const { on: _on, ...raw } = readAll(agentDir);
79
+ writeAll(agentDir, { ...raw, off: PART_NAMES.filter((name) => off.has(name) && !OFF_BY_DEFAULT.includes(name)), ...(turnedOn.length > 0 ? { on: turnedOn } : {}) });
80
+ }
81
+
82
+ type Raw = Record<string, unknown>;
83
+
84
+ const isObject = (value: unknown): value is Raw => value !== null && typeof value === "object" && !Array.isArray(value);
85
+
86
+ /** Throws on a file it can't understand, so a broken one can't quietly mean "no allow list". */
87
+ function readAll(agentDir: string): Raw {
88
+ let text: string;
89
+ try {
90
+ text = readFileSync(settingsPath(agentDir), "utf8");
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
93
+ throw error;
94
+ }
95
+ let raw: unknown;
96
+ try {
97
+ raw = JSON.parse(text);
98
+ } catch {
99
+ throw new Error("suite.json isn't valid JSON");
100
+ }
101
+ if (!isObject(raw)) throw new Error("suite.json must hold a JSON object");
102
+ return raw;
103
+ }
104
+
105
+ /** Only you can read it, as it can hold private words. A linked file, as in a dotfiles setup, stays linked. */
106
+ function writeAll(agentDir: string, raw: Raw): void {
65
107
  const named = settingsPath(agentDir);
66
108
  const path = existsSync(named) ? realpathSync(named) : named;
67
- const temp = `${path}.${process.pid}.tmp`;
68
- writeFileSync(temp, `${JSON.stringify({ off: PART_NAMES.filter((name) => off.has(name)) }, null, 2)}\n`);
109
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
110
+ writeFileSync(temp, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
69
111
  renameSync(temp, path);
70
112
  }
71
113
 
114
+ /** Where each part kept its settings before they moved here. */
115
+ const OLD_FILES: Readonly<Record<string, string>> = {
116
+ router: "router.json",
117
+ web: "web.json",
118
+ subscription: "claude-sdk.json",
119
+ modes: join("extensions", "pi-modes", "config.json"),
120
+ frame: join("extensions", "pi-harness", "settings.json"),
121
+ };
122
+
123
+ /** Moves one old file in, keeping it as .bak. What's already here wins. A broken file stays put and throws. */
124
+ function moveOldFile(agentDir: string, section: string): void {
125
+ const name = OLD_FILES[section];
126
+ if (name === undefined) return;
127
+ const path = join(agentDir, name);
128
+ if (!existsSync(path)) return;
129
+ let old: unknown;
130
+ try {
131
+ old = JSON.parse(readFileSync(path, "utf8"));
132
+ } catch (error) {
133
+ throw new Error(`${path} can't be read, so it wasn't moved to suite.json. Fix or delete it: ${(error as Error).message}`);
134
+ }
135
+ if (!isObject(old)) throw new Error(`${path} must hold a JSON object, so it wasn't moved to suite.json. Fix or delete it`);
136
+ const raw = readAll(agentDir);
137
+ // The frame's file already has its settings under their own names.
138
+ const moved = section === "frame" ? old : { [section]: old };
139
+ writeAll(agentDir, { ...moved, ...raw });
140
+ renameSync(path, `${path}.bak`);
141
+ }
142
+
143
+ /** Before any part loads. A file that can't move is reported here and refused again by its part. */
144
+ export function moveOldSettings(agentDir: string): string[] {
145
+ const problems: string[] = [];
146
+ for (const section of Object.keys(OLD_FILES)) {
147
+ try {
148
+ moveOldFile(agentDir, section);
149
+ } catch (error) {
150
+ problems.push(`pi-suite: ${(error as Error).message}`);
151
+ }
152
+ }
153
+ return problems;
154
+ }
155
+
156
+ /** One part's settings. Throws if they can't be understood, for the part to refuse or report. */
157
+ export function readSection(agentDir: string, section: string): Raw {
158
+ moveOldFile(agentDir, section);
159
+ const value = readAll(agentDir)[section];
160
+ if (value === undefined) return {};
161
+ if (!isObject(value)) throw new Error(`${section} in suite.json must be an object`);
162
+ return value;
163
+ }
164
+
165
+ /** Reads the file again first, as another session may have changed another part. */
166
+ export function writeSection(agentDir: string, section: string, value: Raw): void {
167
+ const raw = readAll(agentDir);
168
+ if (Object.keys(value).length === 0) delete raw[section];
169
+ else raw[section] = value;
170
+ writeAll(agentDir, raw);
171
+ }
172
+
72
173
  /** Separate installs of what's now in here would register everything twice. */
73
174
  export function separateInstalls(packages: unknown): string[] {
74
175
  if (!Array.isArray(packages)) return [];