pi-condense 2.10.3 → 2.10.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.10.4] - 2026-09-15
11
+
12
+ ### Fixed
13
+
14
+ - `saveConfig` no longer overwrites a `settings.json` it could not read as a JSON object (unreadable, truncated, or non-object); only a missing file starts from empty. A failed `/pruner` save now shows an error notification naming the file instead of an unhandled promise rejection; the change still applies to the current session. (#15)
15
+
16
+ ### Changed
17
+
18
+ - `release.sh <level>` promotes the CHANGELOG `## [Unreleased]` section to the versioned heading and commits it with `package.json` in the single `Release X.Y.Z` commit; a missing or empty section fails the run. New CONFIG field `CHANGELOG_HEADING`.
19
+ - Release skill: a user instruction naming the level is the approval - no proposal step or re-confirmation; bundled follow-ups run after `verify`.
20
+ - AGENTS.md rewritten to always-on essentials plus routing; shared core bumped to v3. Session entry types table moved to `PRUNING.md`.
21
+ - `.pi/gauntlet-overrides.md` gains `tracker: github`, the release path, and a write-gate carve-out for user-named writes; the Tickets section is superseded by the core Ticket convention.
22
+
10
23
  ## [2.10.3] - 2026-09-07
11
24
 
12
25
  - **Protected-path supersession.** Only the newest read of a protected path (`protectedPaths` / `protectedTools` calls with a string `path`) stays verbatim; earlier reads of the same path become a one-line `[Superseded: ...]` stub. Applied at render time (`pruneMessages` phase 1b, `src/supersede.ts`) and only when the pruner is already rewriting at or before that position, or on a cold-cache event (`session_start`, `session_tree`, `model_select`, `session_compact`, `thinking_level_select`) - never as the sole mid-prefix change. No new session entry, index record, or config key; supersession stops exactly when no protected call remains (`protectedPaths: []` with the default `protectedTools: []`); a read protected by tool name alone still participates. Spec: `doc/specs/2026-09-07-protected-path-supersede.md` (partially supersedes the 2026-06-11 protected-paths spec's "verbatim forever" edge case).
package/PRUNING.md CHANGED
@@ -10,6 +10,7 @@
10
10
  2. [What Pruning Does](#what-pruning-does)
11
11
  3. [Pruned Data Is Still Available](#pruned-data-is-still-available)
12
12
  4. [What Actually Lives in the Pruner Index](#what-actually-lives-in-the-pruner-index)
13
+ - [Session entry types](#session-entry-types)
13
14
  5. [How the Model Re-reads Raw Outputs](#how-the-model-re-reads-raw-outputs)
14
15
  6. [How Prefix Caching Works](#how-prefix-caching-works)
15
16
  7. [Why Frequent Pruning Busts Cache](#why-frequent-pruning-busts-cache)
@@ -287,6 +288,21 @@ So after pruning, the model is working with a **two-layer memory**:
287
288
  | Indexed tool-call record | **Stored in pruner index** (`context-prune-index` session entry) | Lets the model re-open the original raw output later via `context_tree_query` |
288
289
  | Duplicate of an already-indexed record (same toolName + content) | **Aliased to the original; no new summary, no LLM call** (`context-prune-dedup-alias` session entry) | See [Content-hash dedup](#content-hash-dedup) |
289
290
 
291
+ ### Session entry types
292
+
293
+ Custom session entry types written by the extension (NOT in LLM context unless noted). Rebuilt on `session_start` where stated; see the `Written by` column for the source.
294
+
295
+ | customType | Written by | Purpose |
296
+ |---|---|---|
297
+ | `context-prune-index` | `indexer.addBatch`; also `indexer.backfillChainRecords` (uncovered-chain deterministic backfill, `src/chain-compressor.ts`) | One entry per summarized batch; rebuilds the in-memory `ToolCallRecord` map on `session_start`. A backfill-carrier entry additionally sets `backfilled: true` and carries `refs` (the allocated `t<N>` `SummaryToolCallRef[]`) - excluded from content-hash dedup canonical seeding on both the live path and `session_start` reconstruction; `refs` are re-registered via `registerSummaryRefs` on reconstruction since backfilled chains have no summary message to derive aliases from |
298
+ | `context-prune-summary` | `flushPending` (runtime: `pi.sendMessage` steer; session: `appendCustomMessageEntry`) | The summary message itself; IS in LLM context (replaces the pruned raw outputs) |
299
+ | `context-prune-stats` | `statsAccum.persist` | Cumulative summarizer token/cost snapshot |
300
+ | `context-prune-frontier` | `flushPending` | Last attempted prune boundary (advances even on `skipped-oversized` / `skipped-trivial` / `skipped-deduped`) |
301
+ | `context-prune-dedup-alias` | `indexer.registerDuplicate` | One entry per content-hash dedup hit; rebuilt on `session_start` to repopulate `dedupAliasToOriginal` |
302
+ | `context-prune-chain` | `chain-compressor.compressEligible` (called from `flushPending` in `index.ts` and from `/pruner compact`) | One entry per chain that has been range-dropped from LLM context; drops are decided **positionally** by `resolveRange` (`src/chain-range-prune.ts`), not by id. `droppedToolCallIds` is a diagnostic cross-check only (recorded-vs-actual mismatch emits `range-id-mismatch`); `droppedOccurrenceKeys` (optional) is load-bearing - it's what the occurrence-keyed synthetic-body lookup (per-batch summary text/coverage) is keyed against; protected-output text is NOT keyed off it - `src/chain-range-prune.ts` pulls `protectedToolCallIds` live by bare id within the resolved range instead. Also carries optional `rangeSummaryText` (fused LLM range summary) when `fuseRangeSummary` is on, and optional `protectedToolCallIds` (verbatim protected outputs - ids protected by tool name or path glob - are relocated into the synthetic body as `<protected-output>` tags at render time). Optional `bodySource: "deterministic"` marks a chain that had zero per-batch summary coverage: `rangeSummaryText` then holds a zero-LLM stub (call count, tool histogram, span duration, `t<N>` refs) built by the uncovered-chain backfill path in `chain-compressor.ts`, instead of a summarizer-derived body. Rebuilt on `session_start` to repopulate the chain registry. |
303
+ | `context-prune-diagnostic` | `pruneMessages` / `applyChainCompressions` / `chain-compressor.compressEligible` (via `DiagnosticSink.report`, `src/diagnostics.ts`) | One entry per distinct `(kind, dedupKey)` prune-time degradation (`unresolved-range` / `range-id-mismatch` / `orphan-sweep` / `backfill-empty`). Never in LLM context; deduped in-memory; reset on `session_start` and `session_tree`. Surfaced on the footer status widget as `diag u<N>/m<N>/o<N>/b<N>`. See [Diagnostics](#diagnostics). |
304
+ | `context-prune-flush-metrics` | `flushPending` (end of every non-concurrent attempt, single `finally` emit site, outside the chain-compression try/catch) | One entry per flush attempt, all outcomes (incl. `empty`/`error`): trigger, batch counts, pre-flush `ContextMetricsSnapshot` (open-cycle thinking, largest-chain share, frontier gap). Append-only observability log - never in LLM context, never reconstructed on `session_start`. |
305
+
290
306
  ## How the Model Re-reads Raw Outputs
291
307
 
292
308
  The intended recovery flow is:
package/README.md CHANGED
@@ -160,7 +160,7 @@ By default the extension is **off**. `/pruner on` enables it and it stays enable
160
160
 
161
161
  ## Configuration - the knobs most people touch
162
162
 
163
- Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings.
163
+ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings. A `settings.json` that cannot be read as a JSON object is never overwritten by a `/pruner` change: the change applies to the current session and an error notification names the file.
164
164
 
165
165
  | Key | Default | Notes |
166
166
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.10.3",
3
+ "version": "2.10.4",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -1,5 +1,15 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Command handlers persist through src/config.ts, which resolves the settings
7
+ // path lazily from PI_CODING_AGENT_DIR; point it at a scratch dir so no test
8
+ // ever touches the developer's real settings.json.
9
+ process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-condense-commands-test-"));
10
+
2
11
  import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
12
+ import { settingsPath } from "./config.js";
3
13
  import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
4
14
  import { DEFAULT_CONFIG } from "./types.js";
5
15
 
@@ -26,6 +36,7 @@ function setupPrunerCommand(overrides: {
26
36
  flushPending?: (ctx: any, options?: any) => Promise<any>;
27
37
  getRearmed?: () => boolean;
28
38
  getContextMetrics?: (ctx: any) => ContextMetricsSnapshot;
39
+ save?: (config: ContextPruneConfig) => Promise<void>;
29
40
  } = {}) {
30
41
  let handler: (args: string, ctx: any) => Promise<void>;
31
42
  const notifications: { message: string; type?: string }[] = [];
@@ -59,6 +70,7 @@ function setupPrunerCommand(overrides: {
59
70
  undefined,
60
71
  overrides.getContextMetrics,
61
72
  overrides.getRearmed,
73
+ overrides.save,
62
74
  );
63
75
 
64
76
  const ctx: any = {
@@ -66,6 +78,7 @@ function setupPrunerCommand(overrides: {
66
78
  notify(message: string, type?: string) {
67
79
  notifications.push({ message, type });
68
80
  },
81
+ setStatus() {},
69
82
  },
70
83
  };
71
84
 
@@ -73,6 +86,7 @@ function setupPrunerCommand(overrides: {
73
86
  run: (args: string) => handler(args, ctx),
74
87
  notifications,
75
88
  flushCalls,
89
+ currentConfig,
76
90
  };
77
91
  }
78
92
 
@@ -176,6 +190,41 @@ describe("setPruneStatusWidget", () => {
176
190
  });
177
191
  });
178
192
 
193
+ describe("/pruner off with a rejecting save (#15)", () => {
194
+ it("keeps the in-memory change, notifies an error naming the settings path, and raises no unhandledRejection", async () => {
195
+ const unhandled: unknown[] = [];
196
+ const recorder = (reason: unknown) => { unhandled.push(reason); };
197
+ process.on("unhandledRejection", recorder);
198
+ try {
199
+ const { run, notifications, currentConfig } = setupPrunerCommand({
200
+ save: () => Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" })),
201
+ });
202
+
203
+ await run("off");
204
+
205
+ // The error toast lands after the handler returns; wait for it with a
206
+ // bounded poll rather than a microtask hop.
207
+ const deadline = Date.now() + 2000;
208
+ while (!notifications.some((n) => n.type === "error") && Date.now() < deadline) {
209
+ await new Promise((r) => setTimeout(r, 10));
210
+ }
211
+ await new Promise((r) => setImmediate(r));
212
+
213
+ const errorIdx = notifications.findIndex((n) => n.type === "error");
214
+ const successIdx = notifications.findIndex((n) => n.message === "Context pruning disabled.");
215
+ expect(errorIdx).toBeGreaterThan(-1);
216
+ expect(successIdx).toBeGreaterThan(-1);
217
+ expect(successIdx).toBeLessThan(errorIdx);
218
+ expect(notifications[errorIdx].message).toContain(settingsPath());
219
+ expect(notifications[errorIdx].message).toContain("EACCES");
220
+ expect(currentConfig.value.enabled).toBe(false);
221
+ expect(unhandled).toEqual([]);
222
+ } finally {
223
+ process.off("unhandledRejection", recorder);
224
+ }
225
+ });
226
+ });
227
+
179
228
  describe("diagnostic counters on the status line", () => {
180
229
  const zeroDiag = { "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 } as const;
181
230
  const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
package/src/commands.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  DEFAULT_CONFIG,
24
24
  } from "./types.js";
25
25
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
26
- import { saveConfig } from "./config.js";
26
+ import { saveConfig, persistConfig } from "./config.js";
27
27
  import { MAX_BUDGET_WINDOW } from "./budget.js";
28
28
  import { formatTokens, formatCost, formatCharProgress, formatCompactCount } from "./stats.js";
29
29
  import { Container, Text, SettingsList, type SettingItem } from "@earendil-works/pi-tui";
@@ -477,6 +477,7 @@ export function registerCommands(
477
477
  getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
478
478
  getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
479
479
  getRearmed?: () => boolean,
480
+ save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
480
481
  ): void {
481
482
  // Register the /pruner command
482
483
  pi.registerCommand("pruner", {
@@ -822,7 +823,7 @@ export function registerCommands(
822
823
  };
823
824
  }
824
825
  currentConfig.value = newConfig;
825
- saveConfig(newConfig);
826
+ void persistConfig((m, t) => ctx.ui.notify(m, t), newConfig, save);
826
827
  setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
827
828
  settingsList?.invalidate();
828
829
  };
@@ -856,7 +857,7 @@ export function registerCommands(
856
857
  // ── /pruner on ──
857
858
  case "on": {
858
859
  currentConfig.value = { ...currentConfig.value, enabled: true };
859
- saveConfig(currentConfig.value);
860
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
860
861
  ctx.ui.notify("Context pruning enabled.");
861
862
  setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
862
863
  break;
@@ -865,7 +866,7 @@ export function registerCommands(
865
866
  // ── /pruner off ──
866
867
  case "off": {
867
868
  currentConfig.value = { ...currentConfig.value, enabled: false };
868
- saveConfig(currentConfig.value);
869
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
869
870
  ctx.ui.notify("Context pruning disabled.");
870
871
  setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
871
872
  break;
@@ -943,7 +944,7 @@ export function registerCommands(
943
944
  summarizerModel: parsed.model,
944
945
  summarizerThinking: parsed.thinking ?? currentConfig.value.summarizerThinking,
945
946
  };
946
- saveConfig(currentConfig.value);
947
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
947
948
  const thinkingText = parsed.thinking ? ` with thinking ${parsed.thinking}` : "";
948
949
  ctx.ui.notify(`Summarizer model set to: ${parsed.model}${thinkingText}`);
949
950
  }
@@ -971,7 +972,7 @@ export function registerCommands(
971
972
  );
972
973
  return;
973
974
  }
974
- saveConfig(currentConfig.value);
975
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
975
976
  ctx.ui.notify(`Summarizer thinking set to: ${currentConfig.value.summarizerThinking}`);
976
977
  break;
977
978
  }
@@ -989,7 +990,7 @@ export function registerCommands(
989
990
  } else {
990
991
  currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
991
992
  }
992
- saveConfig(currentConfig.value);
993
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
993
994
  setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
994
995
  break;
995
996
  }
@@ -1013,7 +1014,7 @@ export function registerCommands(
1013
1014
  }
1014
1015
  currentConfig.value = { ...currentConfig.value, batchingMode: batchArg as ContextPruneConfig["batchingMode"] };
1015
1016
  }
1016
- saveConfig(currentConfig.value);
1017
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
1017
1018
  ctx.ui.notify(`Batching mode set to: ${batchingModeLabel(currentConfig.value.batchingMode)}`);
1018
1019
  break;
1019
1020
  }
@@ -1164,7 +1165,7 @@ export function registerCommands(
1164
1165
  }
1165
1166
 
1166
1167
  currentConfig.value = { ...currentConfig.value, protectedTools: nextList };
1167
- saveConfig(currentConfig.value);
1168
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
1168
1169
  ctx.ui.notify(`Protected tools: ${protectedToolsDisplay(nextList)}`);
1169
1170
  break;
1170
1171
  }
@@ -1197,7 +1198,7 @@ export function registerCommands(
1197
1198
  }
1198
1199
 
1199
1200
  currentConfig.value = { ...currentConfig.value, protectedPaths: nextList };
1200
- saveConfig(currentConfig.value);
1201
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
1201
1202
  ctx.ui.notify(`Protected paths: ${protectedToolsDisplay(nextList)}`);
1202
1203
  break;
1203
1204
  }
@@ -1221,7 +1222,7 @@ export function registerCommands(
1221
1222
  break;
1222
1223
  }
1223
1224
  currentConfig.value = { ...currentConfig.value, minBatchChars: parsed };
1224
- saveConfig(currentConfig.value);
1225
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
1225
1226
  ctx.ui.notify(
1226
1227
  parsed === 0
1227
1228
  ? "minBatchChars set to 0 — pre-flush trivial-batch skipping disabled."
@@ -1244,7 +1245,7 @@ export function registerCommands(
1244
1245
  break;
1245
1246
  }
1246
1247
  currentConfig.value = { ...currentConfig.value, recoveryGraceTurns: parsed };
1247
- saveConfig(currentConfig.value);
1248
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
1248
1249
  ctx.ui.notify(
1249
1250
  parsed === 0
1250
1251
  ? "recovery-grace set to 0 - context_tree_query output stubs immediately."
@@ -1269,7 +1270,7 @@ export function registerCommands(
1269
1270
  }
1270
1271
  const next = arg === "on" || arg === "true";
1271
1272
  currentConfig.value = { ...currentConfig.value, dedupByContentHash: next };
1272
- saveConfig(currentConfig.value);
1273
+ void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
1273
1274
  ctx.ui.notify(`Content-hash dedup turned ${next ? "ON" : "OFF"}.`);
1274
1275
  break;
1275
1276
  }
@@ -1,8 +1,10 @@
1
- import { describe, expect, it, beforeAll, afterAll } from "bun:test";
1
+ import { describe, expect, it, beforeAll, afterAll, afterEach } from "bun:test";
2
2
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { DEFAULT_CONFIG } from "./types.js";
7
+ import type { ContextPruneConfig } from "./types.js";
6
8
 
7
9
  /**
8
10
  * config.ts resolves the settings path from getAgentDir() lazily on each
@@ -15,6 +17,8 @@ import { DEFAULT_CONFIG } from "./types.js";
15
17
  let tmpDir: string;
16
18
  let loadConfig: typeof import("./config.js").loadConfig;
17
19
  let saveConfig: typeof import("./config.js").saveConfig;
20
+ let persistConfig: typeof import("./config.js").persistConfig;
21
+ let SettingsReadError: typeof import("./config.js").SettingsReadError;
18
22
  let settingsPath: typeof import("./config.js").settingsPath;
19
23
 
20
24
  beforeAll(async () => {
@@ -23,9 +27,17 @@ beforeAll(async () => {
23
27
  const mod = await import("./config.js");
24
28
  loadConfig = mod.loadConfig;
25
29
  saveConfig = mod.saveConfig;
30
+ persistConfig = mod.persistConfig;
31
+ SettingsReadError = mod.SettingsReadError;
26
32
  settingsPath = mod.settingsPath;
27
33
  });
28
34
 
35
+ // Every case below may leave a malformed settings.json behind; remove it so
36
+ // the shared path is clean for the next case.
37
+ afterEach(async () => {
38
+ await rm(settingsPath(), { force: true });
39
+ });
40
+
29
41
  afterAll(async () => {
30
42
  delete process.env.PI_CODING_AGENT_DIR;
31
43
  await rm(tmpDir, { recursive: true, force: true });
@@ -163,3 +175,89 @@ describe("loadConfig frontierGapThresholdTokens normalization", () => {
163
175
  }
164
176
  });
165
177
  });
178
+
179
+ describe("saveConfig fails closed (#15)", () => {
180
+ const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
181
+
182
+ it("creates settings.json containing only contextPrune when the file is absent", async () => {
183
+ await rm(settingsPath(), { force: true });
184
+ await saveConfig(config);
185
+ const written = JSON.parse(await readFile(settingsPath(), "utf-8"));
186
+ expect(Object.keys(written)).toEqual(["contextPrune"]);
187
+ expect(written.contextPrune.enabled).toBe(false);
188
+ });
189
+
190
+ it("preserves other top-level keys and replaces contextPrune", async () => {
191
+ await writeFile(settingsPath(), '{"foo":1,"contextPrune":{"enabled":true}}');
192
+ await saveConfig(config);
193
+ const written = JSON.parse(await readFile(settingsPath(), "utf-8"));
194
+ expect(written.foo).toBe(1);
195
+ expect(written.contextPrune.enabled).toBe(false);
196
+ });
197
+
198
+ for (const [label, raw] of [
199
+ ["0-byte file", ""],
200
+ ["truncated JSON", '{"foo":'],
201
+ ["array", "[]"],
202
+ ["null", "null"],
203
+ ["string", '"str"'],
204
+ ["number", "42"],
205
+ ] as const) {
206
+ it(`rejects and leaves the file byte-identical for ${label}; loadConfig returns defaults`, async () => {
207
+ await writeFile(settingsPath(), raw);
208
+ const before = readFileSync(settingsPath());
209
+ await expect(saveConfig(config)).rejects.toBeInstanceOf(SettingsReadError);
210
+ expect(readFileSync(settingsPath()).equals(before)).toBe(true);
211
+ expect(await loadConfig()).toEqual({ ...DEFAULT_CONFIG });
212
+ });
213
+ }
214
+
215
+ it("rejects with reason EACCES when the injected read fails, leaving the file byte-identical", async () => {
216
+ await writeFile(settingsPath(), '{"foo":1}');
217
+ const before = readFileSync(settingsPath());
218
+ const read = (async () => {
219
+ throw Object.assign(new Error("permission denied"), { code: "EACCES" });
220
+ }) as unknown as typeof import("node:fs/promises").readFile;
221
+ const err = await saveConfig(config, read).catch((e) => e);
222
+ expect(err).toBeInstanceOf(SettingsReadError);
223
+ expect(err.reason).toBe("EACCES");
224
+ expect(err.path).toBe(settingsPath());
225
+ expect(readFileSync(settingsPath()).equals(before)).toBe(true);
226
+ });
227
+ });
228
+
229
+ describe("persistConfig (#15)", () => {
230
+ const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
231
+
232
+ it("notifies once with type error and the settings path when the file is truncated", async () => {
233
+ await writeFile(settingsPath(), '{"foo":');
234
+ const calls: { message: string; type?: string }[] = [];
235
+ await persistConfig((message, type) => calls.push({ message, type }), config);
236
+ expect(calls).toHaveLength(1);
237
+ expect(calls[0].type).toBe("error");
238
+ expect(calls[0].message).toContain(settingsPath());
239
+ expect(calls[0].message).toContain("invalid JSON");
240
+ expect(calls[0].message).toContain("Change applies to this session only.");
241
+ });
242
+
243
+ it("does not notify when the file is missing", async () => {
244
+ await rm(settingsPath(), { force: true });
245
+ const calls: unknown[] = [];
246
+ await persistConfig((message, type) => calls.push({ message, type }), config);
247
+ expect(calls).toHaveLength(0);
248
+ expect(JSON.parse(await readFile(settingsPath(), "utf-8")).contextPrune.enabled).toBe(false);
249
+ });
250
+
251
+ it("handles a save that rejects with undefined", async () => {
252
+ const calls: { message: string; type?: string }[] = [];
253
+ await persistConfig(
254
+ (message, type) => calls.push({ message, type }),
255
+ config,
256
+ () => Promise.reject(undefined),
257
+ );
258
+ expect(calls).toHaveLength(1);
259
+ expect(calls[0].type).toBe("error");
260
+ expect(calls[0].message).toContain(settingsPath());
261
+ expect(calls[0].message).toContain("undefined");
262
+ });
263
+ });
package/src/config.ts CHANGED
@@ -115,22 +115,58 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
115
115
  };
116
116
  }
117
117
 
118
- async function readJsonObject(path: string): Promise<Record<string, unknown> | undefined> {
118
+ export class SettingsReadError extends Error {
119
+ constructor(
120
+ public readonly path: string,
121
+ public readonly reason: string,
122
+ ) {
123
+ super(`settings.json unreadable at ${path}: ${reason}`);
124
+ this.name = "SettingsReadError";
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Single classifier for settings.json read outcomes. Only ENOENT means "no
130
+ * file"; every other failure throws so a save never starts from `{}` over a
131
+ * file it could not read.
132
+ */
133
+ async function readJsonObject(
134
+ path: string,
135
+ read: typeof readFile = readFile,
136
+ ): Promise<Record<string, unknown> | undefined> {
137
+ let raw: string;
119
138
  try {
120
- const raw = await readFile(path, "utf-8");
121
- const parsed = JSON.parse(raw);
122
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
123
- return parsed as Record<string, unknown>;
124
- }
125
- return undefined;
139
+ raw = await read(path, "utf-8");
140
+ } catch (err) {
141
+ const e = err as NodeJS.ErrnoException;
142
+ if (e.code === "ENOENT") return undefined;
143
+ throw new SettingsReadError(path, e.code ?? e.message);
144
+ }
145
+ let parsed: unknown;
146
+ try {
147
+ parsed = JSON.parse(raw);
126
148
  } catch {
127
- return undefined;
149
+ throw new SettingsReadError(path, "invalid JSON");
150
+ }
151
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
152
+ return parsed as Record<string, unknown>;
128
153
  }
154
+ throw new SettingsReadError(path, "not a JSON object");
129
155
  }
130
156
 
131
- /** Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or defaults. */
157
+ /**
158
+ * Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or
159
+ * defaults. Fail-soft: an unreadable or malformed file yields defaults, since
160
+ * a broken settings.json is pi-wide and not this extension's to report.
161
+ */
132
162
  export async function loadConfig(): Promise<ContextPruneConfig> {
133
- const main = await readJsonObject(settingsPath());
163
+ let main: Record<string, unknown> | undefined;
164
+ try {
165
+ main = await readJsonObject(settingsPath());
166
+ } catch (err) {
167
+ if (err instanceof SettingsReadError) return { ...DEFAULT_CONFIG };
168
+ throw err;
169
+ }
134
170
  const namespaced = main?.[SETTINGS_KEY];
135
171
  if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) {
136
172
  return normalize(namespaced as Partial<ContextPruneConfig>);
@@ -140,18 +176,39 @@ export async function loadConfig(): Promise<ContextPruneConfig> {
140
176
 
141
177
  /**
142
178
  * Writes the full config back to `<agent-dir>/settings.json` under
143
- * {@link SETTINGS_KEY}, preserving every other top-level key in the file. Uses
144
- * a tmp-file + atomic rename so concurrent pi writes (e.g. theme changes via
145
- * `/settings`) cannot observe a partial file. We do not coordinate with pi's
146
- * own internal lock since both writers do whole-file replacements and a
147
- * last-write-wins race only loses a single change, never corrupts the file.
179
+ * {@link SETTINGS_KEY}, preserving every other top-level key in the file.
180
+ * Tmp-file + atomic rename, so a concurrent reader never observes a partial
181
+ * file. A file that cannot be read as a JSON object is never replaced: the
182
+ * read throws {@link SettingsReadError} before anything is written. Concurrent
183
+ * saves (ours or pi's own) are last-write-wins; that race is not coordinated.
148
184
  */
149
- export async function saveConfig(config: ContextPruneConfig): Promise<void> {
185
+ export async function saveConfig(config: ContextPruneConfig, read: typeof readFile = readFile): Promise<void> {
150
186
  const path = settingsPath();
151
- const current = (await readJsonObject(path)) ?? {};
187
+ const current = (await readJsonObject(path, read)) ?? {};
152
188
  const next = { ...current, [SETTINGS_KEY]: config };
153
189
  await mkdir(dirname(path), { recursive: true });
154
190
  const tmpPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
155
191
  await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
156
192
  await rename(tmpPath, path);
157
193
  }
194
+
195
+ type Notify = (message: string, type?: "info" | "warning" | "error") => void;
196
+
197
+ /**
198
+ * Saves and reports failure through `notify` instead of rejecting, so callers
199
+ * can fire-and-forget. The in-memory change stands; only persistence failed.
200
+ */
201
+ export async function persistConfig(
202
+ notify: Notify,
203
+ config: ContextPruneConfig,
204
+ save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
205
+ ): Promise<void> {
206
+ try {
207
+ await save(config);
208
+ } catch (err) {
209
+ const reason = err instanceof SettingsReadError
210
+ ? err.reason
211
+ : ((err as NodeJS.ErrnoException | null | undefined)?.code ?? String(err));
212
+ notify(`Could not save settings to ${settingsPath()}: ${reason}. Change applies to this session only.`, "error");
213
+ }
214
+ }