pi-orchestrator-config 4.26.0 → 4.27.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 +31 -0
- package/extensions/orchestrator/enforcement-rules.ts +13 -0
- package/extensions/orchestrator/memory-promotion.ts +8 -4
- package/extensions/orchestrator/memory-write.ts +17 -0
- package/extensions/orchestrator/settings-tui-helpers.ts +156 -3
- package/extensions/orchestrator/tmp-cleanup.ts +9 -16
- package/extensions/shared/daemon-manager.ts +77 -3
- package/extensions/shared/file-logger.ts +23 -7
- package/package.json +1 -1
- package/prompts/qodo-review.md +1 -1
- package/settings-keys.json +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.27.0] - 2026-11-07
|
|
4
|
+
|
|
5
|
+
- Settings writes keep a configurable history: the previous file is copied to `.bak.1` before every
|
|
6
|
+
write, older copies rotate up to `settings_backup_count` (default 5, range 0-20,
|
|
7
|
+
`PI_SETTINGS_BACKUP_COUNT`), and lowering the count prunes the extra copies. A write whose backup
|
|
8
|
+
cannot be made is refused rather than proceeding without a fallback.
|
|
9
|
+
- A settings file that does not parse is never replaced: the save is refused
|
|
10
|
+
(`SettingsFileCorruptError`) and the file is left byte-identical, so a hand-repairable file cannot
|
|
11
|
+
be emptied by generated content.
|
|
12
|
+
- Test processes can no longer write real settings: under `node:test`, writes outside the temp tree
|
|
13
|
+
are refused, checked against the **real** path so a symlinked `.pi` cannot reach a real project.
|
|
14
|
+
- An unscoped `tool_name bash` memory rule is refused at write time and ignored at load time, so a
|
|
15
|
+
memory entry can neither block every command nor append a banner to every result. "always
|
|
16
|
+
ask_user before X" is promoted as an ordering verifier instead of a blanket warning.
|
|
17
|
+
- Log fallbacks stay inside the project: `.pi/tmp/pi-logs` is tried before the home copy, and a
|
|
18
|
+
vanished working directory no longer throws while the fallback list is built.
|
|
19
|
+
- The qodo-review prompt writes its context under `.pi/tmp`, not `/tmp`.
|
|
20
|
+
|
|
21
|
+
## [4.26.1] - 2026-11-06
|
|
22
|
+
|
|
23
|
+
- The tmp sweep carries no hardcoded control-file name list: it asks
|
|
24
|
+
`daemon-manager.isLiveLegacyDaemonFile()`, which owns the two legacy names, so a future daemon
|
|
25
|
+
writing to `.pi/run` can never require the sweep to be edited. A legacy `pidiff.port`/`pidiff.pid`
|
|
26
|
+
is kept only while its recorded PID is alive (`EPERM` counts as alive — another user's running
|
|
27
|
+
process), and a dead daemon's leftovers plus every `reload.*` marker are swept like any other
|
|
28
|
+
entry. Deleting a live daemon's lockfiles would make the next connect() spawn a duplicate on its
|
|
29
|
+
port.
|
|
30
|
+
- A control file can no longer block or exhaust a session: `readLockfile` accepts only regular
|
|
31
|
+
files, so a project FIFO at `pidiff.port`/`pidiff.pid` is ignored, and the pid read is capped at
|
|
32
|
+
64 bytes so an oversized file is rejected without being read.
|
|
33
|
+
|
|
3
34
|
## [4.26.0] - 2026-11-06
|
|
4
35
|
|
|
5
36
|
- Async `chain` mode: steps run in sequence and each step's result is substituted into `{previous}`,
|
|
@@ -16,9 +16,12 @@ import {
|
|
|
16
16
|
type EnforcementTrigger,
|
|
17
17
|
type EnforcementAction,
|
|
18
18
|
} from "./memory-scoring.js";
|
|
19
|
+
import { createLogger } from "../shared/logger.js";
|
|
19
20
|
import { readAllTopicEntries } from "./memory-tree.js";
|
|
20
21
|
import { getSetting } from "./project-settings.js";
|
|
21
22
|
|
|
23
|
+
const log = createLogger("enforcement");
|
|
24
|
+
|
|
22
25
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
23
26
|
|
|
24
27
|
export interface EnforcedEntry {
|
|
@@ -84,6 +87,16 @@ export function loadEnforcedEntries(cwd: string, sessionId?: string): EnforcedEn
|
|
|
84
87
|
|
|
85
88
|
for (const { hash, entry } of all) {
|
|
86
89
|
if (!entry.trigger || !entry.action) continue;
|
|
90
|
+
// A `tool_name bash` rule fires on every command: block stops the session dead, warn
|
|
91
|
+
// buries every result under a banner. Both happened with entries already in the store,
|
|
92
|
+
// so the guard lives at load time as well as write time.
|
|
93
|
+
if (
|
|
94
|
+
entry.trigger.trim().toLowerCase() === "tool_name bash" &&
|
|
95
|
+
["block", "warn"].includes(entry.action.trim().split(/\s+/)[0])
|
|
96
|
+
) {
|
|
97
|
+
log.warn("enforcement_rule_unscoped_bash_ignored", { hash, action: entry.action });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
87
100
|
|
|
88
101
|
result.push({
|
|
89
102
|
hash,
|
|
@@ -38,8 +38,10 @@ export const EVIDENCE_PROJECT_RULE = 5;
|
|
|
38
38
|
const log = createLogger("memory");
|
|
39
39
|
|
|
40
40
|
export interface InferredEnforcement {
|
|
41
|
-
|
|
42
|
-
action
|
|
41
|
+
/** A rule can be ordering-only: then the verifier is the enforcement and there is no
|
|
42
|
+
* trigger/action pair to fire unconditionally. */
|
|
43
|
+
trigger?: EnforcementTrigger;
|
|
44
|
+
action?: EnforcementAction;
|
|
43
45
|
actionCommand?: string;
|
|
44
46
|
verifier?: string;
|
|
45
47
|
confidence: "high" | "low";
|
|
@@ -94,9 +96,11 @@ export function inferMechanicalEnforcement(text: string): InferredEnforcement |
|
|
|
94
96
|
if (askBefore?.[1]) {
|
|
95
97
|
const cmd = askBefore[1].replace(/[.!]+$/, "").trim();
|
|
96
98
|
if (cmd.length > 2 && cmd.length < 80) {
|
|
99
|
+
// The rule is about ORDER, so the verifier is the enforcement. A `bash_contains` warn
|
|
100
|
+
// would also fire after a compliant `ask_user` + command pair, which is exactly the
|
|
101
|
+
// noise this story keeps producing; and a bare `tool_name bash` is refused at write
|
|
102
|
+
// time and ignored at load time.
|
|
97
103
|
return {
|
|
98
|
-
trigger: "tool_name bash" as EnforcementTrigger,
|
|
99
|
-
action: "warn",
|
|
100
104
|
verifier: `tool_called ask_user before ${cmd}`,
|
|
101
105
|
confidence: "high",
|
|
102
106
|
};
|
|
@@ -105,6 +105,21 @@ function validateTrigger(trigger: string): string | null {
|
|
|
105
105
|
return null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** A `tool_name bash` rule fires on EVERY command, so a block (or a warn) there can brick
|
|
109
|
+
* the session instead of enforcing anything specific. Two of them did exactly that: one
|
|
110
|
+
* blocked every bash call, the other printed a banner on every call. Such a rule is never
|
|
111
|
+
* what the writer means — a bash rule has to name the command it is about. */
|
|
112
|
+
export function unscopedBashRuleError(trigger: string, action: string | undefined): string | null {
|
|
113
|
+
if (trigger.trim().toLowerCase() !== "tool_name bash") return null;
|
|
114
|
+
const verb = (action ?? "").trim().split(/\s+/)[0];
|
|
115
|
+
if (verb !== "block" && verb !== "warn") return null;
|
|
116
|
+
log.warn("memory-write: unscoped bash trigger rejected", { trigger, action });
|
|
117
|
+
return (
|
|
118
|
+
`Refusing "${trigger}" with action "${verb}": it would apply to EVERY bash call. ` +
|
|
119
|
+
"Scope it to the command, e.g. 'bash_contains rm -rf /' or 'bash_regex ^git push'."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
function validateAction(action: string): string | null {
|
|
109
124
|
const validActions = ["block", "warn"];
|
|
110
125
|
const isRunAfter = action.startsWith("run_after ");
|
|
@@ -2366,6 +2381,8 @@ export async function addMemoryEntry(cwd: string, input: AddMemoryInput): Promis
|
|
|
2366
2381
|
if (input.trigger) {
|
|
2367
2382
|
const err = validateTrigger(input.trigger);
|
|
2368
2383
|
if (err) return { status: "rejected", message: err };
|
|
2384
|
+
const unscoped = unscopedBashRuleError(input.trigger, input.action);
|
|
2385
|
+
if (unscoped) return { status: "rejected", message: unscoped };
|
|
2369
2386
|
}
|
|
2370
2387
|
if (input.action) {
|
|
2371
2388
|
const err = validateAction(input.action);
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* No pi-coding-agent or pi-tui dependencies.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
|
-
import { dirname, join as joinPath } from "node:path";
|
|
6
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { basename, dirname, join as joinPath, resolve, sep } from "node:path";
|
|
9
9
|
import stripJsonComments from "strip-json-comments";
|
|
10
10
|
import {
|
|
11
11
|
type SettingsKeyDef,
|
|
12
12
|
SETTINGS_KEYS,
|
|
13
|
+
getSetting,
|
|
13
14
|
getSettingsPath,
|
|
14
15
|
getGlobalSettingsPath,
|
|
15
16
|
} from "./project-settings.js";
|
|
@@ -209,11 +210,163 @@ export function readSettingsFile(filePath: string): Record<string, unknown> | nu
|
|
|
209
210
|
return null;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/** Thrown instead of writing when the target file cannot be parsed. A settings file that a
|
|
214
|
+
* human can still repair by hand must never be replaced by generated content. */
|
|
215
|
+
export class SettingsFileCorruptError extends Error {
|
|
216
|
+
constructor(readonly filePath: string) {
|
|
217
|
+
super(`refusing to overwrite unparseable settings file: ${filePath}`);
|
|
218
|
+
this.name = "SettingsFileCorruptError";
|
|
219
|
+
log.error("settings_write_refused_corrupt", { filePath });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `realpath` of the nearest path that exists, so a symlinked parent cannot hide the target. */
|
|
224
|
+
function nearestRealPath(target: string): string {
|
|
225
|
+
const absolute = resolve(target);
|
|
226
|
+
const tail: string[] = [];
|
|
227
|
+
let current = absolute;
|
|
228
|
+
for (;;) {
|
|
229
|
+
try {
|
|
230
|
+
const real = realpathSync(current);
|
|
231
|
+
return tail.length > 0 ? joinPath(real, ...tail.reverse()) : real;
|
|
232
|
+
} catch {
|
|
233
|
+
const parent = dirname(current);
|
|
234
|
+
if (parent === current) return absolute;
|
|
235
|
+
tail.push(basename(current));
|
|
236
|
+
current = parent;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The cwd whose settings govern this file: the project root for a project file, and never a
|
|
242
|
+
* project override for a global one. A removed cwd must not block a save. */
|
|
243
|
+
function settingCwdFor(filePath: string): string | null {
|
|
244
|
+
const parent = dirname(filePath);
|
|
245
|
+
if (basename(parent) === ".pi") {
|
|
246
|
+
const root = dirname(parent);
|
|
247
|
+
if (root === homedir()) return null; // global file: no project scope applies
|
|
248
|
+
return root;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
return process.cwd();
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Backup retention for this file: project setting where a project owns it, env/default for
|
|
258
|
+
* a global file — one project can never change another's retention. */
|
|
259
|
+
function backupCountFor(filePath: string): number {
|
|
260
|
+
const def = SETTINGS_KEYS.settings_backup_count;
|
|
261
|
+
const fallback = Number(def?.default ?? 5);
|
|
262
|
+
const min = Number(def?.min ?? 0);
|
|
263
|
+
const max = Number(def?.max ?? 20);
|
|
264
|
+
// An out-of-range or non-integer value must not decide retention: `-1` would prune every
|
|
265
|
+
// backup and write none, and a huge value would rotate far past the documented limit.
|
|
266
|
+
const accept = (value: number): number | null =>
|
|
267
|
+
Number.isInteger(value) && value >= min && value <= max ? value : null;
|
|
268
|
+
|
|
269
|
+
// Validate the env value FIRST, for its diagnostic: `getSetting` substitutes the default
|
|
270
|
+
// before the range check can see it, so a malformed env var would otherwise be logged only
|
|
271
|
+
// on a global save and silently swallowed on a project one.
|
|
272
|
+
const raw = process.env.PI_SETTINGS_BACKUP_COUNT;
|
|
273
|
+
const envCount = raw !== undefined && raw.trim() !== "" ? accept(Number(raw)) : null;
|
|
274
|
+
if (raw !== undefined && raw.trim() !== "" && envCount === null) {
|
|
275
|
+
log.warn("settings_backup_count_invalid", { raw, min, max, fallback });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const cwd = settingCwdFor(filePath);
|
|
279
|
+
if (cwd !== null) {
|
|
280
|
+
const resolved = accept(Number(getSetting(cwd, "settings_backup_count")));
|
|
281
|
+
if (resolved !== null) return resolved;
|
|
282
|
+
}
|
|
283
|
+
return envCount ?? fallback;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Drop slots beyond the configured count, so lowering retention removes old copies. */
|
|
287
|
+
function pruneExtraBackups(filePath: string, count: number): void {
|
|
288
|
+
const prefix = `${basename(filePath)}.bak.`;
|
|
289
|
+
let entries: string[] = [];
|
|
290
|
+
try {
|
|
291
|
+
entries = readdirSync(dirname(filePath));
|
|
292
|
+
} catch (e: any) {
|
|
293
|
+
log.warn("settings_backup_prune_scan_failed", { filePath, error: e?.message });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const name of entries) {
|
|
297
|
+
if (!name.startsWith(prefix)) continue;
|
|
298
|
+
const slot = Number(name.slice(prefix.length));
|
|
299
|
+
if (!Number.isFinite(slot) || slot <= count) continue;
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(joinPath(dirname(filePath), name));
|
|
302
|
+
} catch (e: any) {
|
|
303
|
+
log.warn("settings_backup_prune_failed", { filePath, slot: name, error: e?.message });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Thrown when the previous version could not be preserved, so nothing is written. */
|
|
309
|
+
export class SettingsBackupError extends Error {
|
|
310
|
+
constructor(readonly filePath: string, readonly cause?: unknown) {
|
|
311
|
+
super(`refusing to write settings without a backup: ${filePath}`);
|
|
312
|
+
this.name = "SettingsBackupError";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Path of the newest previous-version copy kept next to a settings file. */
|
|
317
|
+
export function settingsBackupPath(filePath: string, slot = 1): string {
|
|
318
|
+
return `${filePath}.bak.${slot}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Keep `count` previous versions: shift `.bak.N-1` → `.bak.N`, then current → `.bak.1`.
|
|
322
|
+
* A count of 0 disables backups. Failures never block the write — they only log. */
|
|
323
|
+
function rotateSettingsBackups(filePath: string, count: number): void {
|
|
324
|
+
pruneExtraBackups(filePath, count);
|
|
325
|
+
if (count <= 0) return;
|
|
326
|
+
try {
|
|
327
|
+
const oldest = settingsBackupPath(filePath, count);
|
|
328
|
+
if (existsSync(oldest)) unlinkSync(oldest);
|
|
329
|
+
for (let slot = count - 1; slot >= 1; slot--) {
|
|
330
|
+
const from = settingsBackupPath(filePath, slot);
|
|
331
|
+
if (existsSync(from)) renameSync(from, settingsBackupPath(filePath, slot + 1));
|
|
332
|
+
}
|
|
333
|
+
copyFileSync(filePath, settingsBackupPath(filePath, 1));
|
|
334
|
+
} catch (e: any) {
|
|
335
|
+
// A write without its backup breaks the one guarantee this function exists for: if the
|
|
336
|
+
// new content is wrong there is nothing to fall back to.
|
|
337
|
+
log.error("settings_backup_failed", { filePath, error: e?.message });
|
|
338
|
+
throw new SettingsBackupError(filePath, e);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
212
342
|
export function writeSettingsFile(filePath: string, data: Record<string, unknown>): void {
|
|
213
343
|
// Write directly to target file. Per issue spec: "comments are lost on write — acceptable
|
|
214
344
|
// since the example.jsonc is the reference."
|
|
215
345
|
const dir = dirname(filePath);
|
|
346
|
+
|
|
347
|
+
// Refuse BEFORE touching the filesystem: creating the parent of a rejected path would
|
|
348
|
+
// change a location the write is not allowed to reach.
|
|
349
|
+
if (process.env.NODE_TEST_CONTEXT !== undefined) {
|
|
350
|
+
// Test processes may only write under the OS temp tree (same safety net the global
|
|
351
|
+
// settings path has): a test once wrote a project's real settings file. Compare the REAL
|
|
352
|
+
// paths — a lexical check passes for a fixture whose `.pi` links to a real project.
|
|
353
|
+
const real = nearestRealPath(filePath);
|
|
354
|
+
const tmpRoot = realpathSync(tmpdir());
|
|
355
|
+
if (real !== tmpRoot && !real.startsWith(tmpRoot + sep)) {
|
|
356
|
+
throw new Error(`test process refused to write settings outside the temp tree: ${real}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
216
360
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
361
|
+
|
|
362
|
+
if (existsSync(filePath)) {
|
|
363
|
+
// A corrupt file is recoverable by hand; a cleared one is not. Refuse rather than
|
|
364
|
+
// overwrite — this is exactly how a project's settings were once emptied.
|
|
365
|
+
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));
|
|
369
|
+
}
|
|
217
370
|
const content = JSON.stringify(data, null, 2) + "\n";
|
|
218
371
|
const tmpPath = `${filePath}.${process.pid}.${Date.now().toString(36)}`;
|
|
219
372
|
try {
|
|
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import { promises as fsp } from "node:fs";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { isLiveLegacyDaemonFile } from "../shared/daemon-manager.js";
|
|
18
19
|
import { createLogger } from "../shared/logger.js";
|
|
19
20
|
import { isPiOneshotInvocation } from "../shared/oneshot.js";
|
|
20
21
|
import { getGlobalSettingsPath, getSettingDynamic, getSettingsPath, SETTINGS_KEYS } from "./project-settings.js";
|
|
@@ -29,21 +30,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
29
30
|
/** Defaults live in `settings-keys.json` only — never restated here. */
|
|
30
31
|
export const DEFAULT_MAX_SIZE = String(SETTINGS_KEYS.project_tmp_max_size.default);
|
|
31
32
|
export const DEFAULT_RETENTION_DAYS = Number(SETTINGS_KEYS.project_tmp_retention_days.default);
|
|
32
|
-
/**
|
|
33
|
-
* Transitional debris from pre-#1020 daemons: `.pi/tmp` was the control-file home
|
|
34
|
-
* before `.pi/run`, so a daemon started before the upgrade still holds its pid/port and
|
|
35
|
-
* reload handshake here, and the read-both fallback (#1025) needs these files to find it.
|
|
36
|
-
* Sweeping them would make the next connect() miss the live daemon and spawn a duplicate
|
|
37
|
-
* on the same port. Never deleted, whatever their age; the set disappears once no
|
|
38
|
-
* pre-upgrade daemon remains.
|
|
39
|
-
*/
|
|
40
|
-
export const TMP_PROTECTED_NAMES = new Set([
|
|
41
|
-
"pidiff.pid",
|
|
42
|
-
"pidiff.port",
|
|
43
|
-
"reload.flag",
|
|
44
|
-
"reload.done",
|
|
45
|
-
"reload.pending",
|
|
46
|
-
]);
|
|
47
33
|
/** Lives in `.pi/run`, never in the swept dir. */
|
|
48
34
|
export const TMP_SWEEP_LOCK = ".tmp-sweep.lock";
|
|
49
35
|
/** A crashed sweep would otherwise block every later session forever. */
|
|
@@ -171,7 +157,14 @@ export async function sweepProjectTmp(opts: TmpSweepOptions): Promise<TmpSweepRe
|
|
|
171
157
|
let total = 0;
|
|
172
158
|
for (const name of names) {
|
|
173
159
|
const target = path.join(base, name);
|
|
174
|
-
if (!isInside(base, target)
|
|
160
|
+
if (!isInside(base, target)) continue;
|
|
161
|
+
// One exemption, decided by the daemon layer: a pre-`.pi/run` daemon that is still ALIVE
|
|
162
|
+
// owns its lockfile here, and sweeping it makes the next connect() spawn a duplicate on
|
|
163
|
+
// that port. A dead daemon's leftovers are throwaway like everything else (#1020).
|
|
164
|
+
if (isLiveLegacyDaemonFile(base, name)) {
|
|
165
|
+
log.debug("tmp_sweep_kept_live_legacy_daemon", { tmpDir: base, name });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
175
168
|
let stat: fs.Stats;
|
|
176
169
|
try {
|
|
177
170
|
stat = await fsp.lstat(target);
|
|
@@ -193,19 +193,36 @@ export function writeLockfile(lockDir: string, port: number, pid: number | null,
|
|
|
193
193
|
} catch (e: any) { log(`writeLockfile error: ${e.message}`); }
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/** A pid file holds digits and a newline; anything bigger is not one. */
|
|
197
|
+
const MAX_PID_FILE_BYTES = 64;
|
|
198
|
+
|
|
199
|
+
/** True for a path that exists and is a regular file. Lockfiles are never FIFOs, and reading
|
|
200
|
+
* one would block the event loop, so every control-file read goes through this. */
|
|
201
|
+
function isRegularFile(target: string): boolean {
|
|
202
|
+
try {
|
|
203
|
+
return fs.lstatSync(target).isFile();
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
/** Read port from lockfile. Returns port number or null.
|
|
197
210
|
* `legacyDir` (`.pi/tmp`) is consulted only when the new dir has no lockfile:
|
|
198
|
-
* a daemon already running at upgrade time wrote its lockfile there (#1020).
|
|
211
|
+
* a daemon already running at upgrade time wrote its lockfile there (#1020).
|
|
212
|
+
* Reading never moves or deletes anything — moving a live daemon's record is what the
|
|
213
|
+
* sweep's liveness check exists to avoid. */
|
|
199
214
|
export function readLockfile(lockDir: string, legacyDir?: string): { port: number; pid: number | null } | null {
|
|
200
215
|
const read = (dir: string): { port: number; pid: number | null } | null => {
|
|
201
216
|
try {
|
|
202
217
|
const portFile = path.join(dir, "pidiff.port");
|
|
203
|
-
|
|
218
|
+
// Only a regular file is a lockfile: the dirs are project-controlled, and reading a
|
|
219
|
+
// FIFO here would block the event loop for the whole session.
|
|
220
|
+
if (!isRegularFile(portFile)) return null;
|
|
204
221
|
const port = parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10);
|
|
205
222
|
if (isNaN(port)) return null;
|
|
206
223
|
let pid: number | null = null;
|
|
207
224
|
const pidFile = path.join(dir, "pidiff.pid");
|
|
208
|
-
if (
|
|
225
|
+
if (isRegularFile(pidFile)) {
|
|
209
226
|
pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
|
|
210
227
|
if (isNaN(pid)) pid = null;
|
|
211
228
|
}
|
|
@@ -215,6 +232,63 @@ export function readLockfile(lockDir: string, legacyDir?: string): { port: numbe
|
|
|
215
232
|
return read(lockDir) ?? (legacyDir ? read(legacyDir) : null);
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
/** Read the pid from a lockfile, refusing anything but a regular file: the legacy dir is
|
|
236
|
+
* project-controlled, and reading a FIFO there would block session startup. */
|
|
237
|
+
function readLegacyPid(pidFile: string): number | null {
|
|
238
|
+
try {
|
|
239
|
+
const st = fs.lstatSync(pidFile);
|
|
240
|
+
if (!st.isFile()) {
|
|
241
|
+
log.debug("legacy_pid_not_a_regular_file", { pidFile });
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
// The file is project-controlled: a pid is a few bytes, so refuse anything larger rather
|
|
245
|
+
// than reading an arbitrary file into memory during session start.
|
|
246
|
+
if (st.size > MAX_PID_FILE_BYTES) {
|
|
247
|
+
log.debug("legacy_pid_file_too_large", { pidFile, size: st.size });
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
|
|
251
|
+
if (isNaN(pid) || pid <= 0) {
|
|
252
|
+
log.debug("legacy_pid_unparseable", { pidFile });
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return pid;
|
|
256
|
+
} catch (e: any) {
|
|
257
|
+
log.debug("legacy_pid_unreadable", { pidFile, error: e?.message });
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** True when the legacy `.pi/tmp` lockfile belongs to a LIVE process. `EPERM` means the
|
|
263
|
+
* process exists under another user, so it counts as alive — treating it as dead would let
|
|
264
|
+
* the sweep delete a running daemon's files. */
|
|
265
|
+
export function legacyLockfileOwnerAlive(legacyDir: string): boolean {
|
|
266
|
+
const pid = readLegacyPid(path.join(legacyDir, "pidiff.pid"));
|
|
267
|
+
if (pid === null) return false;
|
|
268
|
+
try {
|
|
269
|
+
process.kill(pid, 0);
|
|
270
|
+
return true;
|
|
271
|
+
} catch (e: any) {
|
|
272
|
+
if (e?.code === "EPERM") {
|
|
273
|
+
log.debug("legacy_daemon_owned_by_another_user", { pid });
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
log.debug("legacy_daemon_not_running", { pid, code: e?.code });
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Control files a pre-`.pi/run` daemon owns inside the legacy dir. Private: the sweep asks
|
|
282
|
+
* `isLiveLegacyDaemonFile`, so neither module keeps a second copy of the names (#1020). */
|
|
283
|
+
const LEGACY_CONTROL_FILES = new Set(["pidiff.port", "pidiff.pid"]);
|
|
284
|
+
|
|
285
|
+
/** True when `name` is a legacy control file whose owner is still alive — the sweep's only
|
|
286
|
+
* exemption. A dead daemon's leftovers are ordinary throwaway. */
|
|
287
|
+
export function isLiveLegacyDaemonFile(legacyDir: string, name: string): boolean {
|
|
288
|
+
if (!LEGACY_CONTROL_FILES.has(name)) return false;
|
|
289
|
+
return legacyLockfileOwnerAlive(legacyDir);
|
|
290
|
+
}
|
|
291
|
+
|
|
218
292
|
/** First lockfile port (in `dirs` order) whose daemon answers /api/health, or
|
|
219
293
|
* null. Exported for testing. */
|
|
220
294
|
export async function findLiveLockfile(dirs: string[]): Promise<number | null> {
|
|
@@ -133,7 +133,8 @@ function appendLine(filePath: string, line: string): void {
|
|
|
133
133
|
* Append one physical line to ~/.pi/logs/<name>/<session>.log.
|
|
134
134
|
* Never writes to console.* (chat UI leak).
|
|
135
135
|
* Returns true on success.
|
|
136
|
-
* On primary-path failure, tries
|
|
136
|
+
* On primary-path failure, tries `~/.pi/tmp/pi-logs` then `<cwd>/.pi/tmp/pi-logs`: both are
|
|
137
|
+
* swept by the tmp cleanup, and the home one still works when the checkout is read-only.
|
|
137
138
|
*/
|
|
138
139
|
const LEVEL_ORDER: Record<string, number> = Object.assign(Object.create(null), { off: -1, debug: 0, info: 1, warn: 2, error: 3 });
|
|
139
140
|
|
|
@@ -214,13 +215,28 @@ export function writeFileLogLine(name: string, line: string): boolean {
|
|
|
214
215
|
return true;
|
|
215
216
|
} catch (primaryErr) {
|
|
216
217
|
recordWriteError(primaryErr);
|
|
218
|
+
// Never the OS temp dir: `.pi/tmp` is what the tmp cleanup sweeps. The project comes
|
|
219
|
+
// first so its sweep owns the file; home is the second chance when the checkout is
|
|
220
|
+
// read-only or has been deleted under us.
|
|
221
|
+
const fallbackDirs: string[] = [];
|
|
217
222
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
} catch (fallbackErr) {
|
|
222
|
-
recordWriteError(fallbackErr);
|
|
223
|
-
return false;
|
|
223
|
+
fallbackDirs.push(path.join(process.cwd(), ".pi", "tmp", "pi-logs"));
|
|
224
|
+
} catch {
|
|
225
|
+
/* cwd removed — the home fallback below still works */
|
|
224
226
|
}
|
|
227
|
+
try {
|
|
228
|
+
fallbackDirs.push(path.join(os.homedir(), ".pi", "tmp", "pi-logs"));
|
|
229
|
+
} catch {
|
|
230
|
+
/* no home — nothing left to try */
|
|
231
|
+
}
|
|
232
|
+
for (const fallbackDir of fallbackDirs) {
|
|
233
|
+
try {
|
|
234
|
+
appendLine(path.join(fallbackDir, `${safe}.log`), line);
|
|
235
|
+
return true;
|
|
236
|
+
} catch (fallbackErr) {
|
|
237
|
+
recordWriteError(fallbackErr);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
225
241
|
}
|
|
226
242
|
}
|
package/package.json
CHANGED
package/prompts/qodo-review.md
CHANGED
|
@@ -51,7 +51,7 @@ log in, refresh tools, or run any audit automatically.
|
|
|
51
51
|
|
|
52
52
|
## Attach session context
|
|
53
53
|
|
|
54
|
-
Before each review, write a self-contained JSON context file under `/tmp
|
|
54
|
+
Before each review, write a self-contained JSON context file under `.pi/tmp/` (the project scratch dir the tmp sweep cleans — never `/tmp`). Include:
|
|
55
55
|
|
|
56
56
|
- what this session changed and why;
|
|
57
57
|
- key implementation decisions and their rationale;
|
package/settings-keys.json
CHANGED
|
@@ -645,5 +645,14 @@
|
|
|
645
645
|
"min": 1,
|
|
646
646
|
"max": 365,
|
|
647
647
|
"group": "Cleanup"
|
|
648
|
+
},
|
|
649
|
+
"settings_backup_count": {
|
|
650
|
+
"description": "Previous versions of a settings file kept next to it before each write (0 disables backups)",
|
|
651
|
+
"type": "int",
|
|
652
|
+
"env": "PI_SETTINGS_BACKUP_COUNT",
|
|
653
|
+
"default": 5,
|
|
654
|
+
"min": 0,
|
|
655
|
+
"max": 20,
|
|
656
|
+
"group": "Settings"
|
|
648
657
|
}
|
|
649
658
|
}
|