pi-orchestrator-config 4.27.0 → 4.28.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.28.0] - 2026-11-07
4
+
5
+ - Settings backups are scoped to a TUI visit: `/pi-config-settings` takes one restore point per
6
+ session instead of one per changed value, so the state you opened the overlay with survives a
7
+ burst of edits. Writes outside the TUI (allowlist approval, browser save) still back up per
8
+ action, and a snapshot left by a crashed visit is promoted rather than overwritten.
9
+ - The backup lock is a single `O_EXCL` file: a dead owner's lock is taken over atomically, an
10
+ orphaned lock clears inside the same save, and a save that genuinely cannot take the lock still
11
+ refuses instead of shifting the chain and losing another process's restore point.
12
+ - `bash -n file` no longer asks for approval — a pure syntax check runs nothing. Expansions,
13
+ backticks, process substitution, `+` options, `--`, `-c`, quotes and backslashes still ask.
14
+ - `rm -rf` on a literal path inside the project `.pi/tmp` or `~/.pi/tmp` skips approval, with the
15
+ exemption logged as a statement hash. The operand must name the scratch both as written and as
16
+ resolved; `..`, backslashes, globs, quoting, subshells and `sudo` all still ask.
17
+ - A missing `HOME` no longer makes the dangerous-command gate throw instead of deciding.
18
+
3
19
  ## [4.27.0] - 2026-11-07
4
20
 
5
21
  - Settings writes keep a configurable history: the previous file is copied to `.bak.1` before every
@@ -1942,6 +1942,26 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
1942
1942
 
1943
1943
  // Resolve and substitute PROJECT_TMP_DIR env var
1944
1944
  const projectTmpDir = path.join(cwd, ".pi", "tmp");
1945
+ // The home scratch (~/.pi/tmp) is our own throwaway tree too: the logger fallback and build
1946
+ // scratch tests write there. Same exemption as the project one, same literal-path guards.
1947
+ // Two roots, and BOTH must match: the scratch path as written (which may itself be a symlink —
1948
+ // `~/.pi/tmp` often points into a dotfiles checkout) and where that path really lives. Matching
1949
+ // only the real root made a symlink's target trusted: with `~/.pi/tmp -> ~`, `rm -rf ~/.ssh`
1950
+ // resolves beneath it and would skip approval without ever naming the scratch.
1951
+ // Both resolutions live inside one try: `homedir()` itself can throw when HOME is unset or
1952
+ // points nowhere, and this helper feeds the dangerous-command gate — an exception here would
1953
+ // replace a decision with a crash instead of simply granting no exemption.
1954
+ let lexicalHomeTmp: string | null = null;
1955
+ let resolvedHomeTmp: string | null = null;
1956
+ try {
1957
+ lexicalHomeTmp = path.resolve(realpathSync(homedir()), ".pi", "tmp").replace(/\/+$/, "");
1958
+ resolvedHomeTmp = realpathSync(lexicalHomeTmp);
1959
+ } catch {
1960
+ lexicalHomeTmp = null; // no home, or no scratch in it — nothing to match
1961
+ resolvedHomeTmp = null;
1962
+ }
1963
+ const underHomeTmp = (pathname: string, root: string): boolean =>
1964
+ pathname === root || pathname.startsWith(root + path.sep);
1945
1965
  let resolvedCwd: string | null;
1946
1966
  try {
1947
1967
  resolvedCwd = realpathSync(cwd);
@@ -1952,14 +1972,21 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
1952
1972
  for (const p of paths) {
1953
1973
  // Substitute ${PROJECT_TMP_DIR} or $PROJECT_TMP_DIR
1954
1974
  const expanded = p.replace(/\$\{?PROJECT_TMP_DIR\}?/g, projectTmpDir);
1975
+ // Refuse `..` outright. `path.resolve` collapses it LEXICALLY, while the kernel applies it to
1976
+ // where a preceding symlink actually points — so `~/.pi/tmp/link/../.ssh/key` looks in-scratch
1977
+ // here and deletes `~/.ssh/key` there. Nothing legitimate needs `..` in a scratch operand.
1978
+ if (/(^|\/)\.\.($|\/)/.test(expanded)) return false;
1979
+ // The shell removes backslashes before `rm` sees the path, so `\.\.` is `..` and `\*` is a
1980
+ // glob that no literal-path check ever saw. A literal scratch path needs none of them.
1981
+ if (expanded.includes("\\")) return false;
1982
+ const lexical = path.resolve(cwd, expanded);
1955
1983
  let resolved: string;
1956
1984
  try {
1957
1985
  // Use realpathSync to resolve symlinks — prevents symlink traversal attacks
1958
- resolved = realpathSync(path.resolve(cwd, expanded));
1986
+ resolved = realpathSync(lexical);
1959
1987
  } catch {
1960
1988
  // Path doesn't exist. For /tmp paths, validate the existing parent via realpathSync
1961
1989
  // to catch symlinked parents (e.g., /tmp/evil-link -> / where evil-link exists).
1962
- const lexical = path.resolve(cwd, expanded);
1963
1990
  if (lexical.startsWith("/tmp/") && lexical !== "/tmp" && lexical.length > 5) {
1964
1991
  // Block paths containing traversal sequences — even if they resolve under /tmp/,
1965
1992
  // they may have escaped and re-entered via ..
@@ -1980,6 +2007,19 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
1980
2007
  // Parent also doesn't exist — safe (entire path is non-existent)
1981
2008
  }
1982
2009
  resolved = lexical;
2010
+ } else if (resolvedHomeTmp !== null && lexicalHomeTmp !== null && underHomeTmp(lexical, lexicalHomeTmp)) {
2011
+ // Non-existent path under the home scratch — validate the parent the same way.
2012
+ if (expanded.includes("..")) return false;
2013
+ const parentDir = path.dirname(lexical);
2014
+ try {
2015
+ const resolvedParent = realpathSync(parentDir);
2016
+ if (!resolvedParent.startsWith(resolvedHomeTmp + path.sep) && resolvedParent !== resolvedHomeTmp) {
2017
+ return false;
2018
+ }
2019
+ } catch {
2020
+ // Parent also doesn't exist — safe
2021
+ }
2022
+ resolved = lexical;
1983
2023
  } else if (resolvedCwd !== null &&
1984
2024
  (lexical.startsWith(resolvedCwd + path.sep) || lexical === resolvedCwd) &&
1985
2025
  (lexical.includes(`${path.sep}.pi${path.sep}tmp${path.sep}`) ||
@@ -2012,7 +2052,12 @@ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
2012
2052
  resolved.endsWith(`${path.sep}.pi${path.sep}tmp`));
2013
2053
  const inSystemTmp = resolved.startsWith("/tmp/") && resolved !== "/tmp" && resolved.length > 5;
2014
2054
 
2015
- if (!inProjectTmp && !inSystemTmp) return false;
2055
+ // The path as WRITTEN must name the scratch (a symlinked scratch cannot lend its target out),
2056
+ // and its real location must stay inside the scratch (a symlink inside it cannot escape).
2057
+ const inHomeTmp = lexicalHomeTmp !== null && resolvedHomeTmp !== null &&
2058
+ underHomeTmp(lexical, lexicalHomeTmp) && underHomeTmp(resolved, resolvedHomeTmp);
2059
+
2060
+ if (!inProjectTmp && !inSystemTmp && !inHomeTmp) return false;
2016
2061
  }
2017
2062
 
2018
2063
  return true;
@@ -5,6 +5,7 @@
5
5
  * review state tracking and gitignore checks.
6
6
  */
7
7
 
8
+ import { createHash } from "node:crypto";
8
9
  import { createLogger } from "../shared/logger.js";
9
10
  import { createBridgedUi } from "../shared/ui-bridge.js";
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -27,6 +28,7 @@ import {
27
28
  } from "./enforcement-helpers.js";
28
29
  import {
29
30
  DANGEROUS,
31
+ isNoexecSyntaxCheck,
30
32
  dangerousPatternLabel,
31
33
  getCurrentBranch,
32
34
  isInsideGitWorkTree,
@@ -458,6 +460,16 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
458
460
  const hasDangerous = statements.some((stmt) => {
459
461
  // Skip read-only commands with no dangerous subshells
460
462
  if (isReadOnlyStatement(stmt)) return false;
463
+ // `bash -n file` only parses: nothing executes, so there is nothing to approve. Logged —
464
+ // an exemption from the gate must leave a trail an operator can find.
465
+ if (isNoexecSyntaxCheck(stmt)) {
466
+ // Audit trail without content: a statement can carry paths, tokens or credentials, so the
467
+ // log records a short hash an operator can match, never the words themselves.
468
+ log.info("dangerous_gate_noexec_syntax_check", {
469
+ statementHash: createHash("sha256").update(stmt).digest("hex").slice(0, 12),
470
+ });
471
+ return false;
472
+ }
461
473
  return DANGEROUS.some((p) => p.test(stmt));
462
474
  });
463
475
  if (hasDangerous) {
@@ -497,9 +509,17 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
497
509
 
498
510
  // Allow rm -rf targeting only .pi/tmp/ paths without confirmation
499
511
  const allRmInTmp = statements
500
- .filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt))
512
+ .filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt) && !isNoexecSyntaxCheck(stmt))
501
513
  .every((stmt) => isRmInProjectTmp(stmt, cwd));
502
- if (allRmInTmp) return undefined;
514
+ if (allRmInTmp) {
515
+ // Never exempt silently: the audit line carries a hash, not the statement (paths can be
516
+ // sensitive), matching the syntax-check exemption above.
517
+ log.info("dangerous_gate_rm_scratch_exempt", {
518
+ statements: statements.length,
519
+ statementHash: createHash("sha256").update(statements.join("\n")).digest("hex").slice(0, 12),
520
+ });
521
+ return undefined;
522
+ }
503
523
 
504
524
  // User-approved allowlist (issue #949): the gate identifies the DANGEROUS
505
525
  // STATEMENTS (split out of the chain, benign plumbing tails — descriptor
@@ -517,7 +537,7 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
517
537
  const dangerousStmts = [
518
538
  ...new Set(
519
539
  statements
520
- .filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt))
540
+ .filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt) && !isNoexecSyntaxCheck(stmt))
521
541
  .map((stmt) => normalizeDangerousStatement(stmt)),
522
542
  ),
523
543
  ];
@@ -1154,6 +1154,44 @@ export function listUnmergedFiles(cwd?: string): { ok: boolean; files: string[]
1154
1154
  return { ok: true, files: [...files] };
1155
1155
  }
1156
1156
 
1157
+ /** `bash -n file`, `sh -n script`, `bash -x -n f` — the shell only PARSES: nothing is executed.
1158
+ * A syntax check is not a command to approve, so the gate must not treat it as running a shell.
1159
+ * Only an exact `-n` token counts; anything fuzzier stays dangerous. */
1160
+ export function isNoexecSyntaxCheck(statement: string): boolean {
1161
+ const WRAPPERS =
1162
+ /^(?:(?:[0-9]*>{1,2}|&>)\S*\s+)*(?:\s*(?:[A-Za-z_]\w*=\S*|(?:then|do|else|elif)\b|sudo(?:\s+-\S+(?:\s+\S+)?)*|rtk(?:\s+-\S+)*|(?:command|builtin|exec)\b|env(?:\s+(?:-\S+(?:\s+\S+)?|\S+=\S*))*|timeout\s+(?:-\S+(?:\s+\S+)?\s+)*\S+|nice(?:\s+(?:-\S+(?:\s+\S+)?))*|nohup\b)\s+)*/i;
1163
+ const SHELL = /^(?:\S*\/)?(?:ba|da|z|k|c|tc|fi)?sh\b/i;
1164
+ // The calling shell expands and word-splits before the syntax check runs: `$(...)` executes,
1165
+ // `${IFS}` splits one token into several options (`bash -n${IFS}+n${IFS}-c …`), backticks run,
1166
+ // and `<(...)`/`>(...)` execute. ANY `$` is refused outright — a legitimate `bash -n script.sh`
1167
+ // never needs one. Quotes and backslashes go too: the CALLING shell removes them before Bash
1168
+ // parses, so `bash -n '+n' script.sh` would hand Bash a `+n` option while this predicate saw a
1169
+ // harmless operand.
1170
+ if (/[$`'"\\]|<(?=\()|>(?=\()/.test(statement)) return false;
1171
+ const rest = statement.replace(WRAPPERS, "").trim();
1172
+ const shellMatch = SHELL.exec(rest);
1173
+ if (!shellMatch) return false;
1174
+ const tokens = rest.slice(shellMatch[0].length).trim().split(/\s+/).filter(Boolean);
1175
+
1176
+ // `-n` only counts in the shell's OWN leading option block, and only if nothing turns it back
1177
+ // off. The invocation accepts the `set` options, so `+n` re-enables execution:
1178
+ // `bash script.sh -n` — operand, the script runs
1179
+ // `bash -- -n` — `--` ends the options; it runs a FILE called `-n`
1180
+ // `bash -n +n script` — noexec is switched off again
1181
+ let noexec = false;
1182
+ for (const token of tokens) {
1183
+ if (token === "--") break; // option terminator: everything after is an operand
1184
+ // `+` options enable modes again (`+n`, `+o noexec`, …). Refusing ALL of them keeps this
1185
+ // predicate from having to model the whole `set` option table.
1186
+ if (token.startsWith("+")) return false;
1187
+ if (!token.startsWith("-")) break; // first operand (script name or argument)
1188
+ // `-c` takes the program text — whatever follows is executed as parsed, so never exempt it.
1189
+ if (/^-[a-zA-Z]*c[a-zA-Z]*$/.test(token)) return false;
1190
+ if (/^-[a-zA-Z]*n/.test(token)) noexec = true; // -n, -xn, -nx
1191
+ }
1192
+ return noexec;
1193
+ }
1194
+
1157
1195
  export const DANGEROUS = [
1158
1196
  /\brm\s+(?:-[a-zA-Z]+\s+)*(-[a-zA-Z]*r[a-zA-Z]*|--recursive)/i,
1159
1197
  /\bsudo\b/i,
@@ -3,7 +3,7 @@
3
3
  * No pi-coding-agent or pi-tui dependencies.
4
4
  */
5
5
 
6
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { basename, dirname, join as joinPath, resolve, sep } from "node:path";
9
9
  import stripJsonComments from "strip-json-comments";
@@ -313,6 +313,186 @@ export class SettingsBackupError extends Error {
313
313
  }
314
314
  }
315
315
 
316
+ /** Depth of the interactive editing session (`/pi-config-settings`). While it is open the file
317
+ * is snapshotted ONCE, on the first change: a user who adjusts five values must not rotate the
318
+ * pre-visit state out of existence. Writes outside the TUI are discrete actions and keep their
319
+ * own backup. */
320
+ let editSessionDepth = 0;
321
+ const backedUpInSession = new Set<string>();
322
+ /** Files this visit CREATED: their pre-visit state is "absent", so backing up the first partial
323
+ * version would misrepresent it. */
324
+ const createdInSession = new Set<string>();
325
+ /** Visit snapshots waiting to be promoted into the numbered chain when the visit closes. */
326
+ const visitSnapshots = new Set<string>();
327
+
328
+ /** A visit keeps its own copy, so an external save during the visit (which rotates the numbered
329
+ * chain) cannot destroy the state the user opened the TUI with. The process id is part of the
330
+ * name: two TUI processes editing one file must not overwrite each other's restore point. */
331
+ function visitSnapshotPath(filePath: string): string {
332
+ return `${filePath}.bak.visit-${process.pid}`;
333
+ }
334
+
335
+ /** Serialize chain mutations across processes: rotating or promoting shifts `.bak.N` files, and a
336
+ * concurrent writer interleaving between the shift and the rename would lose a restore point. */
337
+ function withChainLock<T>(filePath: string, fn: () => T): T {
338
+ // The lock is ONE file created with O_EXCL: creating it IS owning it. Splitting that into
339
+ // mkdir-then-write-pid left a window where the directory existed with no owner, and a reader
340
+ // could steal a lock a live writer was still acquiring — both then shifted the chain.
341
+ const lockPath = `${filePath}.bak.lock`;
342
+ let locked = false;
343
+ try {
344
+ for (let attempt = 0; attempt < 100 && !locked; attempt++) {
345
+ try {
346
+ writeFileSync(lockPath, String(process.pid), { flag: "wx", mode: 0o600 });
347
+ locked = true;
348
+ } catch {
349
+ // Take over a lock only when its OWNER IS GONE. Age alone is not evidence: a paused or
350
+ // slow owner could resume and mutate the chain alongside this process.
351
+ let stale = false;
352
+ try {
353
+ const ownerPid = Number(readFileSync(lockPath, "utf-8").trim());
354
+ if (Number.isInteger(ownerPid) && ownerPid > 0) {
355
+ try {
356
+ process.kill(ownerPid, 0); // alive — leave it alone
357
+ } catch {
358
+ stale = true; // owner is gone
359
+ }
360
+ } else {
361
+ // Created but not yet written, or a crash mid-write. A live acquisition writes its pid
362
+ // immediately, so this grace never steals from a live writer, and it is far shorter
363
+ // than the retry budget — an orphaned lock clears inside the same save.
364
+ stale = Date.now() - statSync(lockPath).mtimeMs > 200;
365
+ }
366
+ } catch {
367
+ // Vanished between the failed create and this read — retry immediately.
368
+ }
369
+ if (stale) {
370
+ // Atomic steal: rename first, so exactly one of several racing writers claims the stale
371
+ // lock. Deleting in place let two writers remove each other's fresh lock.
372
+ const grave = `${lockPath}.stale-${process.pid}-${Date.now()}`;
373
+ try {
374
+ renameSync(lockPath, grave);
375
+ unlinkSync(grave);
376
+ } catch {
377
+ /* another writer claimed it — retry the acquisition */
378
+ }
379
+ }
380
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
381
+ }
382
+ }
383
+ if (!locked) {
384
+ // NEVER mutate the chain without the lock: a concurrent shift-and-rename loses a restore
385
+ // point, which is worse than refusing the save.
386
+ log.error("settings_backup_lock_timeout", { filePath });
387
+ throw new SettingsBackupError(filePath, new Error("backup lock timeout"));
388
+ }
389
+ return fn();
390
+ } finally {
391
+ if (locked) {
392
+ try {
393
+ // Only release a lock we still own: if it was stolen, it now belongs to someone else.
394
+ if (readFileSync(lockPath, "utf-8").trim() === String(process.pid)) unlinkSync(lockPath);
395
+ } catch {
396
+ /* already gone */
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ /** Promote one visit snapshot into the numbered chain (`.bak.1`), shifting older copies. */
403
+ function promoteSnapshot(snapshotPath: string): void {
404
+ const filePath = snapshotPath.replace(/\.bak\.visit-\d+$/, "");
405
+ if (!existsSync(snapshotPath)) return;
406
+ withChainLock(filePath, () => promoteSnapshotLocked(snapshotPath, filePath));
407
+ }
408
+
409
+ function promoteSnapshotLocked(snapshotPath: string, filePath: string): void {
410
+ const count = backupCountFor(filePath);
411
+ if (count <= 0) {
412
+ unlinkSync(snapshotPath);
413
+ return;
414
+ }
415
+ const oldest = settingsBackupPath(filePath, count);
416
+ if (existsSync(oldest)) unlinkSync(oldest);
417
+ for (let slot = count - 1; slot >= 1; slot--) {
418
+ const from = settingsBackupPath(filePath, slot);
419
+ if (existsSync(from)) renameSync(from, settingsBackupPath(filePath, slot + 1));
420
+ }
421
+ renameSync(snapshotPath, settingsBackupPath(filePath, 1));
422
+ }
423
+
424
+ /** Snapshots left by a process that is gone are promoted instead of being overwritten by the next
425
+ * visit — a crash must cost nothing. A snapshot owned by a LIVE process is left alone. */
426
+ function reapStaleVisitSnapshots(filePath: string): void {
427
+ const dir = dirname(filePath);
428
+ const marker = `${basename(filePath)}.bak.visit-`;
429
+ let entries: string[] = [];
430
+ try {
431
+ entries = readdirSync(dir);
432
+ } catch {
433
+ return;
434
+ }
435
+ for (const name of entries) {
436
+ if (!name.startsWith(marker)) continue;
437
+ const pid = Number(name.slice(marker.length));
438
+ if (!Number.isInteger(pid)) continue;
439
+ // Skip only a snapshot THIS process owns: a pid can be reused, and a reused pid must not
440
+ // cause a crashed editor's copy to be silently overwritten.
441
+ if (pid === process.pid && visitSnapshots.has(joinPath(dir, name))) continue;
442
+ try {
443
+ process.kill(pid, 0);
444
+ continue; // owner still alive
445
+ } catch {
446
+ /* dead: fall through and promote its snapshot */
447
+ }
448
+ try {
449
+ promoteSnapshot(joinPath(dir, name));
450
+ log.info("settings_visit_snapshot_recovered", { filePath, pid });
451
+ } catch (e: any) {
452
+ log.warn("settings_visit_snapshot_recover_failed", { filePath, pid, error: e?.message });
453
+ }
454
+ }
455
+ }
456
+
457
+ /** Move each visit snapshot into `.bak.1` (shifting the chain), once, when the visit closes. */
458
+ function promoteVisitSnapshots(): void {
459
+ const failed = new Set<string>();
460
+ for (const snapshot of [...visitSnapshots]) {
461
+ try {
462
+ promoteSnapshot(snapshot);
463
+ } catch (e: any) {
464
+ // Kept in the set so a retry can still promote it; if the process ends first, the next
465
+ // visit reaps it by pid.
466
+ failed.add(snapshot);
467
+ log.warn("settings_visit_snapshot_promote_failed", { snapshot, error: e?.message });
468
+ }
469
+ }
470
+ visitSnapshots.clear();
471
+ for (const snapshot of failed) visitSnapshots.add(snapshot);
472
+ }
473
+
474
+ export function beginSettingsEditSession(): void {
475
+ editSessionDepth++;
476
+ }
477
+
478
+ export function endSettingsEditSession(): void {
479
+ editSessionDepth = Math.max(0, editSessionDepth - 1);
480
+ if (editSessionDepth === 0) {
481
+ promoteVisitSnapshots();
482
+ backedUpInSession.clear();
483
+ createdInSession.clear();
484
+ }
485
+ }
486
+
487
+ /** True when this write should rotate a backup. Only the settings TUI asks for dedupe; a browser
488
+ * save or an allowlist approval arriving mid-visit is a separate action and keeps its own
489
+ * restore point. */
490
+ function shouldBackUp(filePath: string, dedupe: boolean): boolean {
491
+ if (!dedupe || editSessionDepth === 0) return true;
492
+ if (createdInSession.has(filePath)) return false;
493
+ return !backedUpInSession.has(filePath);
494
+ }
495
+
316
496
  /** Path of the newest previous-version copy kept next to a settings file. */
317
497
  export function settingsBackupPath(filePath: string, slot = 1): string {
318
498
  return `${filePath}.bak.${slot}`;
@@ -321,8 +501,11 @@ export function settingsBackupPath(filePath: string, slot = 1): string {
321
501
  /** Keep `count` previous versions: shift `.bak.N-1` → `.bak.N`, then current → `.bak.1`.
322
502
  * A count of 0 disables backups. Failures never block the write — they only log. */
323
503
  function rotateSettingsBackups(filePath: string, count: number): void {
324
- pruneExtraBackups(filePath, count);
325
504
  if (count <= 0) return;
505
+ withChainLock(filePath, () => rotateSettingsBackupsLocked(filePath, count));
506
+ }
507
+
508
+ function rotateSettingsBackupsLocked(filePath: string, count: number): void {
326
509
  try {
327
510
  const oldest = settingsBackupPath(filePath, count);
328
511
  if (existsSync(oldest)) unlinkSync(oldest);
@@ -339,7 +522,11 @@ function rotateSettingsBackups(filePath: string, count: number): void {
339
522
  }
340
523
  }
341
524
 
342
- export function writeSettingsFile(filePath: string, data: Record<string, unknown>): void {
525
+ export function writeSettingsFile(
526
+ filePath: string,
527
+ data: Record<string, unknown>,
528
+ options: { dedupeBackup?: boolean } = {},
529
+ ): void {
343
530
  // Write directly to target file. Per issue spec: "comments are lost on write — acceptable
344
531
  // since the example.jsonc is the reference."
345
532
  const dir = dirname(filePath);
@@ -359,13 +546,40 @@ export function writeSettingsFile(filePath: string, data: Record<string, unknown
359
546
 
360
547
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
361
548
 
362
- if (existsSync(filePath)) {
549
+ const existedBefore = existsSync(filePath);
550
+ if (!existedBefore && options.dedupeBackup === true && editSessionDepth > 0) {
551
+ // Created during this visit: nothing to restore to, and the first partial version is not the
552
+ // pre-visit state, so later writes in the same visit must not snapshot it.
553
+ createdInSession.add(filePath);
554
+ }
555
+
556
+ if (existedBefore) {
363
557
  // A corrupt file is recoverable by hand; a cleared one is not. Refuse rather than
364
558
  // overwrite — this is exactly how a project's settings were once emptied.
365
559
  if (readSettingsFile(filePath) === null) throw new SettingsFileCorruptError(filePath);
366
- // Every touch keeps the previous bytes — `settings_backup_count` of them, newest at
367
- // `.bak.1` — so a wrong write is always recoverable.
368
- rotateSettingsBackups(filePath, backupCountFor(filePath));
560
+ // Keep the pre-change bytes — `settings_backup_count` of them, newest at `.bak.1`. Inside the
561
+ // settings TUI that happens once per visit, so editing several values keeps the state the user
562
+ // started from. Recorded only after a successful rotation, so a failed one is retried.
563
+ const count = backupCountFor(filePath);
564
+ // Pruning runs on EVERY write: lowering the retention during a visit must drop the slots
565
+ // above the new limit, and inside a visit only the rotation itself is deduped.
566
+ pruneExtraBackups(filePath, count);
567
+ if (options.dedupeBackup === true && editSessionDepth > 0) {
568
+ // Inside a visit the snapshot is the restore point; the chain is only touched at visit end.
569
+ if (shouldBackUp(filePath, true) && count > 0) {
570
+ try {
571
+ reapStaleVisitSnapshots(filePath);
572
+ copyFileSync(filePath, visitSnapshotPath(filePath));
573
+ backedUpInSession.add(filePath);
574
+ visitSnapshots.add(visitSnapshotPath(filePath));
575
+ } catch (e: any) {
576
+ log.error("settings_backup_failed", { filePath, error: e?.message });
577
+ throw new SettingsBackupError(filePath, e);
578
+ }
579
+ }
580
+ } else {
581
+ rotateSettingsBackups(filePath, count);
582
+ }
369
583
  }
370
584
  const content = JSON.stringify(data, null, 2) + "\n";
371
585
  const tmpPath = `${filePath}.${process.pid}.${Date.now().toString(36)}`;
@@ -35,6 +35,8 @@ import {
35
35
  getFilePathForScope,
36
36
  readSettingsFile,
37
37
  writeSettingsFile,
38
+ beginSettingsEditSession,
39
+ endSettingsEditSession,
38
40
  registerSettingsTuiCommand,
39
41
  isSecretNoChange,
40
42
  resolveSecretPrefill,
@@ -373,7 +375,7 @@ function saveChange(key: string, value: unknown, scope: "project" | "global", cw
373
375
  }
374
376
  if (Object.keys(current).length === 0 && !existsSync(filePath)) return { ok: true };
375
377
  try {
376
- writeSettingsFile(filePath, current);
378
+ writeSettingsFile(filePath, current, { dedupeBackup: true });
377
379
  } catch (e) {
378
380
  return { ok: false, reason: "write_error", error: e };
379
381
  }
@@ -390,7 +392,7 @@ function deleteFromScope(key: string, scope: "project" | "global", cwd: string):
390
392
  if (!(key in current)) return { ok: true };
391
393
  delete current[key];
392
394
  try {
393
- writeSettingsFile(filePath, current);
395
+ writeSettingsFile(filePath, current, { dedupeBackup: true });
394
396
  } catch (e) {
395
397
  return { ok: false, reason: "write_error", error: e };
396
398
  }
@@ -655,11 +657,17 @@ async function openSettingsTui(ctx: ExtensionCommandContext, initialScope?: stri
655
657
  // Clear cached settings so TUI always shows fresh values
656
658
  clearSettingsCache();
657
659
 
658
- await ctx.ui.custom<undefined>(
659
- (tui, theme, _kb, done) =>
660
- new SettingsOverlay(tui, theme, cwd, modelRegistry, editScope, done, (msg, level) => ctx.ui.notify(msg, level)),
661
- OVERLAY_OPTS,
662
- );
660
+ // One backup per visit, not per keystroke-saved value.
661
+ beginSettingsEditSession();
662
+ try {
663
+ await ctx.ui.custom<undefined>(
664
+ (tui, theme, _kb, done) =>
665
+ new SettingsOverlay(tui, theme, cwd, modelRegistry, editScope, done, (msg, level) => ctx.ui.notify(msg, level)),
666
+ OVERLAY_OPTS,
667
+ );
668
+ } finally {
669
+ endSettingsEditSession();
670
+ }
663
671
  }
664
672
 
665
673
  // ── Registration ────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.27.0",
3
+ "version": "4.28.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"