pi-orchestrator-config 4.2.0 → 4.3.2
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/README.md +76 -67
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +42 -3
- package/agents/github-expert.md +11 -0
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +563 -102
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +15 -9
- package/extensions/orchestrator/dreaming.ts +32 -25
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +80 -27
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +78 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +57 -33
- package/extensions/orchestrator/project-settings.ts +333 -127
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +63 -15
- package/extensions/orchestrator/rule-placeholders.ts +411 -2
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +157 -59
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +96 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +9 -3
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +12 -1
- package/rules/20-code-review-loop.md +52 -25
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +563 -0
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL-backed synchronous state persistence.
|
|
3
|
+
*
|
|
4
|
+
* Replaces ad-hoc JSON read/write with an append-only JSONL file.
|
|
5
|
+
* Each mutation appends a single JSON line — if the process crashes mid-write,
|
|
6
|
+
* the previous state is still intact (worst case: a truncated last line that
|
|
7
|
+
* gets skipped on read). Provides:
|
|
8
|
+
*
|
|
9
|
+
* - **Append-only durability** — writes never corrupt existing data
|
|
10
|
+
* - **Crash recovery** — last valid JSON line wins
|
|
11
|
+
* - **State history** — the JSONL file is a log of all state transitions
|
|
12
|
+
* - **Compaction** — rewrites to a single line when the file grows too large
|
|
13
|
+
*
|
|
14
|
+
* All operations are synchronous to match the existing extension API surface
|
|
15
|
+
* (enforcement hooks, status bar pollers, transition callbacks).
|
|
16
|
+
*
|
|
17
|
+
* Part of issue #724: migrate state persistence to JSONL.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export interface JsonlStateStoreOptions {
|
|
24
|
+
/** Maximum number of lines before automatic compaction (default: 100). */
|
|
25
|
+
compactThreshold?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_COMPACT_THRESHOLD = 100;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Synchronous JSONL state store.
|
|
32
|
+
*
|
|
33
|
+
* Usage:
|
|
34
|
+
* ```ts
|
|
35
|
+
* const store = new JsonlStateStore<MyState>(filePath);
|
|
36
|
+
* const state = store.read(); // null if no state
|
|
37
|
+
* store.write({ ...state, field: 1 }); // appends a line
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class JsonlStateStore<T> {
|
|
41
|
+
private readonly filePath: string;
|
|
42
|
+
private readonly compactThreshold: number;
|
|
43
|
+
private lineCount: number;
|
|
44
|
+
|
|
45
|
+
constructor(filePath: string, options?: JsonlStateStoreOptions) {
|
|
46
|
+
this.filePath = filePath;
|
|
47
|
+
this.compactThreshold = options?.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD;
|
|
48
|
+
// Initialize lineCount from existing file to ensure compaction triggers
|
|
49
|
+
// correctly across process restarts and multi-process writers.
|
|
50
|
+
this.lineCount = this.countExistingLines();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Count non-empty lines in the existing JSONL file. Returns 0 if file doesn't exist. */
|
|
54
|
+
private countExistingLines(): number {
|
|
55
|
+
if (!existsSync(this.filePath)) return 0;
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
58
|
+
let count = 0;
|
|
59
|
+
for (const line of raw.split("\n")) {
|
|
60
|
+
if (line.trim()) count++;
|
|
61
|
+
}
|
|
62
|
+
return count;
|
|
63
|
+
} catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read the latest state from the JSONL file. Returns null if no valid state exists. */
|
|
69
|
+
read(): T | null {
|
|
70
|
+
if (!existsSync(this.filePath)) return null;
|
|
71
|
+
let raw: string;
|
|
72
|
+
try {
|
|
73
|
+
raw = readFileSync(this.filePath, "utf-8");
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return parseLastValidLine<T>(raw);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Append a new state snapshot as a JSON line. Auto-compacts when threshold is exceeded.
|
|
81
|
+
* Uses a cross-process lock to prevent concurrent write+compact races. */
|
|
82
|
+
write(state: T): void {
|
|
83
|
+
ensureDir(this.filePath);
|
|
84
|
+
const locked = withFileLock(this.filePath, () => {
|
|
85
|
+
const line = JSON.stringify(state) + "\n";
|
|
86
|
+
appendFileSync(this.filePath, line);
|
|
87
|
+
this.lineCount++;
|
|
88
|
+
|
|
89
|
+
// Check if compaction is needed using in-memory counter (no file re-read)
|
|
90
|
+
if (this.lineCount >= this.compactThreshold) {
|
|
91
|
+
this.compactInner();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
if (!locked) {
|
|
95
|
+
// Lock failed — append-only without compaction to prevent data loss.
|
|
96
|
+
// Compaction will happen on a future write when the lock is available.
|
|
97
|
+
const line = JSON.stringify(state) + "\n";
|
|
98
|
+
appendFileSync(this.filePath, line);
|
|
99
|
+
this.lineCount++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compact the JSONL file to a single line containing the latest state.
|
|
104
|
+
* Always re-reads the file to get the latest state — prevents overwriting
|
|
105
|
+
* newer data from concurrent writers. Uses atomic rename + cross-process lock. */
|
|
106
|
+
compact(currentState?: T): void {
|
|
107
|
+
withFileLock(this.filePath, () => {
|
|
108
|
+
this.compactInner(currentState);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Internal compact — must be called within a lock. */
|
|
113
|
+
private compactInner(currentState?: T): void {
|
|
114
|
+
const state = currentState ?? this.read();
|
|
115
|
+
if (state === null) return;
|
|
116
|
+
const tmp = `${this.filePath}.${process.pid}.compact`;
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(tmp, JSON.stringify(state) + "\n");
|
|
119
|
+
renameSync(tmp, this.filePath);
|
|
120
|
+
this.lineCount = 1;
|
|
121
|
+
} catch {
|
|
122
|
+
// Compaction failure is non-fatal — the JSONL file still has valid data.
|
|
123
|
+
// Silent: no logging because extensions must not use console.* (AGENTS.md policy).
|
|
124
|
+
// The file simply retains extra lines until the next successful compaction.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Check if the file exists. */
|
|
129
|
+
exists(): boolean {
|
|
130
|
+
return existsSync(this.filePath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Get the file path. */
|
|
134
|
+
get path(): string {
|
|
135
|
+
return this.filePath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Shared store factory ───────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** Global store cache — keyed by directory path. */
|
|
143
|
+
const storeCache = new Map<string, JsonlStateStore<any>>();
|
|
144
|
+
/** Track stores where migration failed — prevents infinite retry per-process. */
|
|
145
|
+
const migrationFailed = new Set<string>();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a cached JsonlStateStore with automatic legacy JSON migration.
|
|
149
|
+
* Deduplicates the identical cache+migrate pattern across all JSONL-migrated files.
|
|
150
|
+
*/
|
|
151
|
+
export function createCachedStore<T>(
|
|
152
|
+
dir: string,
|
|
153
|
+
jsonlFilename: string,
|
|
154
|
+
legacyFilename: string,
|
|
155
|
+
options?: JsonlStateStoreOptions,
|
|
156
|
+
): JsonlStateStore<T> {
|
|
157
|
+
const cacheKey = join(dir, jsonlFilename);
|
|
158
|
+
let store = storeCache.get(cacheKey) as JsonlStateStore<T> | undefined;
|
|
159
|
+
if (!store) {
|
|
160
|
+
store = new JsonlStateStore<T>(join(dir, jsonlFilename), options);
|
|
161
|
+
storeCache.set(cacheKey, store);
|
|
162
|
+
}
|
|
163
|
+
// Retry migration when JSONL has no valid state — covers:
|
|
164
|
+
// - File doesn't exist (first run or previous migration didn't write)
|
|
165
|
+
// - File exists but is empty/corrupt (crash during first append)
|
|
166
|
+
// Without this, callers fall back to defaults which can be unsafe
|
|
167
|
+
// (e.g., review state defaults to "none" → isReviewClean returns true).
|
|
168
|
+
// Check exists() first to avoid expensive read/parse when JSONL is healthy.
|
|
169
|
+
if (!migrationFailed.has(cacheKey) && (!store.exists() || store.read() === null)) {
|
|
170
|
+
const legacyPath = join(dir, legacyFilename);
|
|
171
|
+
if (existsSync(legacyPath)) {
|
|
172
|
+
try {
|
|
173
|
+
const raw = JSON.parse(readFileSync(legacyPath, "utf-8")) as T;
|
|
174
|
+
store.write(raw);
|
|
175
|
+
unlinkSync(legacyPath);
|
|
176
|
+
} catch (err: unknown) {
|
|
177
|
+
// Only mark permanent failures (corrupt JSON) — transient I/O errors should retry.
|
|
178
|
+
// SyntaxError = JSON.parse failed on corrupt content → permanent, won't self-heal.
|
|
179
|
+
// Other errors (EACCES, ENOENT race, disk full) → transient, may succeed next call.
|
|
180
|
+
if (err instanceof SyntaxError) {
|
|
181
|
+
migrationFailed.add(cacheKey);
|
|
182
|
+
}
|
|
183
|
+
// Silent: extensions must not use console.* per AGENTS.md policy.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return store;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse the last valid JSON line from raw JSONL content.
|
|
192
|
+
* Scans from the end for efficiency — the last line is what we want.
|
|
193
|
+
* Handles truncated last lines (crash recovery) by falling back to the previous line.
|
|
194
|
+
*/
|
|
195
|
+
export function parseLastValidLine<T>(raw: string): T | null {
|
|
196
|
+
const trimmed = raw.trim();
|
|
197
|
+
if (!trimmed) return null;
|
|
198
|
+
|
|
199
|
+
// Try whole-file parse first — handles both single-line JSON and multi-line
|
|
200
|
+
// pretty-printed JSON (e.g., from LLM dream agents). For well-formed JSONL
|
|
201
|
+
// with multiple entries this fails fast (multiple root values → SyntaxError).
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(trimmed);
|
|
204
|
+
if (isPlainObject(parsed)) return parsed as T;
|
|
205
|
+
} catch {
|
|
206
|
+
// Not a single JSON value — fall through to line-by-line scan
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Line-by-line scan from end — for JSONL files with multiple entries.
|
|
210
|
+
// Only accepts plain objects to avoid matching JSON primitives or arrays
|
|
211
|
+
// that could appear as individual lines inside pretty-printed content.
|
|
212
|
+
const lines = trimmed.split("\n");
|
|
213
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
214
|
+
const line = lines[i].trim();
|
|
215
|
+
if (!line) continue;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(line);
|
|
218
|
+
if (isPlainObject(parsed)) return parsed as T;
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Check if value is a plain object (not null, not array). */
|
|
227
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
228
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── JsonlAppendLog ─────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export interface JsonlAppendLogOptions {
|
|
234
|
+
/** Maximum file size in bytes before truncation (default: 512KB). */
|
|
235
|
+
maxSizeBytes?: number;
|
|
236
|
+
/** Number of lines to keep when truncating (default: 200). */
|
|
237
|
+
keepLines?: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const DEFAULT_MAX_SIZE = 512_000; // 512KB
|
|
241
|
+
const DEFAULT_KEEP_LINES = 200;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Synchronous append-only JSONL log.
|
|
245
|
+
*
|
|
246
|
+
* Unlike JsonlStateStore (which reads the latest state), this is for
|
|
247
|
+
* append-only event logs like telemetry. Each append adds a JSON line
|
|
248
|
+
* with an auto-incremented sequence number. Size-based truncation
|
|
249
|
+
* prevents unbounded growth.
|
|
250
|
+
*
|
|
251
|
+
* Usage:
|
|
252
|
+
* ```ts
|
|
253
|
+
* const log = new JsonlAppendLog<TelemetryEvent>(filePath);
|
|
254
|
+
* log.append({ event: "inject", count: 3 }); // adds { seq: 1, ts: "...", ...data }
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export class JsonlAppendLog<T extends object> {
|
|
258
|
+
private readonly filePath: string;
|
|
259
|
+
private readonly maxSizeBytes: number;
|
|
260
|
+
private readonly keepLines: number;
|
|
261
|
+
private seq: number;
|
|
262
|
+
|
|
263
|
+
constructor(filePath: string, options?: JsonlAppendLogOptions) {
|
|
264
|
+
this.filePath = filePath;
|
|
265
|
+
this.maxSizeBytes = options?.maxSizeBytes ?? DEFAULT_MAX_SIZE;
|
|
266
|
+
this.keepLines = options?.keepLines ?? DEFAULT_KEEP_LINES;
|
|
267
|
+
// Initialize seq from existing log to prevent reset on restart
|
|
268
|
+
this.seq = this.readLastSeq();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Read the last seq number from the existing log file. Returns 0 if no log exists. */
|
|
272
|
+
private readLastSeq(): number {
|
|
273
|
+
if (!existsSync(this.filePath)) return 0;
|
|
274
|
+
try {
|
|
275
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
276
|
+
const lines = raw.split("\n");
|
|
277
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
278
|
+
const line = lines[i].trim();
|
|
279
|
+
if (!line) continue;
|
|
280
|
+
try {
|
|
281
|
+
const entry = JSON.parse(line);
|
|
282
|
+
if (typeof entry.seq === "number") return entry.seq;
|
|
283
|
+
} catch { continue; }
|
|
284
|
+
}
|
|
285
|
+
} catch { /* ignore */ }
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Append a log entry. Adds `seq` (auto-incremented) and `ts` (ISO timestamp) fields.
|
|
290
|
+
* Auto-truncates when file exceeds size limit.
|
|
291
|
+
* Uses cross-process lock to prevent seq duplicates and protect truncation. */
|
|
292
|
+
append(data: T): void {
|
|
293
|
+
ensureDir(this.filePath);
|
|
294
|
+
const locked = withFileLock(this.filePath, () => {
|
|
295
|
+
this.seq++;
|
|
296
|
+
const entry = { seq: this.seq, ts: new Date().toISOString(), ...data };
|
|
297
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
298
|
+
|
|
299
|
+
// Size-based truncation inside lock to prevent overwriting concurrent appends
|
|
300
|
+
this.truncateIfNeeded();
|
|
301
|
+
});
|
|
302
|
+
if (!locked) {
|
|
303
|
+
// Lock failed — skip this telemetry event rather than appending unlocked.
|
|
304
|
+
// An unlocked append can race with truncateIfNeeded's read+rename in another
|
|
305
|
+
// process, causing the append to be silently lost anyway. Dropping one event
|
|
306
|
+
// under extreme lock contention is acceptable for best-effort telemetry.
|
|
307
|
+
// Lock uses immediate retries with no backoff — failure can occur even with
|
|
308
|
+
// brief contention, not just sustained overload.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Read all valid log entries. Returns empty array if file doesn't exist. */
|
|
313
|
+
readAll(): Array<T & { seq: number; ts: string }> {
|
|
314
|
+
if (!existsSync(this.filePath)) return [];
|
|
315
|
+
let raw: string;
|
|
316
|
+
try {
|
|
317
|
+
raw = readFileSync(this.filePath, "utf-8");
|
|
318
|
+
} catch {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
const entries: Array<T & { seq: number; ts: string }> = [];
|
|
322
|
+
for (const line of raw.split("\n")) {
|
|
323
|
+
const trimmed = line.trim();
|
|
324
|
+
if (!trimmed) continue;
|
|
325
|
+
try {
|
|
326
|
+
entries.push(JSON.parse(trimmed));
|
|
327
|
+
} catch {
|
|
328
|
+
// Skip corrupt/truncated lines
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return entries;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Check if the log file exists. */
|
|
336
|
+
exists(): boolean {
|
|
337
|
+
return existsSync(this.filePath);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Get the file path. */
|
|
341
|
+
get path(): string {
|
|
342
|
+
return this.filePath;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Truncate file to keepLines if it exceeds maxSizeBytes. */
|
|
346
|
+
private truncateIfNeeded(): void {
|
|
347
|
+
try {
|
|
348
|
+
const stat = statSync(this.filePath);
|
|
349
|
+
if (stat.size <= this.maxSizeBytes) return;
|
|
350
|
+
|
|
351
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
352
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
353
|
+
const kept = lines.slice(-this.keepLines);
|
|
354
|
+
const tmp = `${this.filePath}.${process.pid}.truncate`;
|
|
355
|
+
writeFileSync(tmp, kept.join("\n") + "\n");
|
|
356
|
+
renameSync(tmp, this.filePath);
|
|
357
|
+
} catch {
|
|
358
|
+
// Truncation failure is non-fatal — log file retains extra entries until next success.
|
|
359
|
+
// Silent: extensions must not use console.* per AGENTS.md policy.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Cross-process file lock ────────────────────────────────────────────────
|
|
365
|
+
// Prevents concurrent write+compact races between orchestrator and subagents.
|
|
366
|
+
// Same pattern as pi-config-review-state.ts lock mechanism.
|
|
367
|
+
|
|
368
|
+
const fileLockDepth = new Map<string, number>();
|
|
369
|
+
|
|
370
|
+
/** Execute fn while holding a cross-process lock on the JSONL file.
|
|
371
|
+
* Lock file: <path>.wlock with PID. Uses ".wlock" suffix to avoid collision
|
|
372
|
+
* with pi-config-review-state's ".lock" suffix (separate depth maps). */
|
|
373
|
+
function withFileLock(filePath: string, fn: () => void): boolean {
|
|
374
|
+
const lockFile = filePath + ".wlock";
|
|
375
|
+
const depth = fileLockDepth.get(lockFile) || 0;
|
|
376
|
+
if (depth === 0) {
|
|
377
|
+
if (!acquireFileLock(lockFile)) {
|
|
378
|
+
// Lock acquisition failed — return false so caller can skip compaction.
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
fileLockDepth.set(lockFile, depth + 1);
|
|
383
|
+
try {
|
|
384
|
+
fn();
|
|
385
|
+
} finally {
|
|
386
|
+
const newDepth = (fileLockDepth.get(lockFile) || 1) - 1;
|
|
387
|
+
if (newDepth <= 0) {
|
|
388
|
+
fileLockDepth.delete(lockFile);
|
|
389
|
+
releaseFileLock(lockFile);
|
|
390
|
+
} else {
|
|
391
|
+
fileLockDepth.set(lockFile, newDepth);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function acquireFileLock(lockFile: string): boolean {
|
|
398
|
+
const maxRetries = 50;
|
|
399
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
400
|
+
try {
|
|
401
|
+
ensureDir(lockFile);
|
|
402
|
+
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
try {
|
|
406
|
+
const pid = parseInt(readFileSync(lockFile, "utf-8").trim(), 10);
|
|
407
|
+
if (pid === process.pid) return true; // Reentrant
|
|
408
|
+
if (pid) {
|
|
409
|
+
try { process.kill(pid, 0); } catch {
|
|
410
|
+
try { unlinkSync(lockFile); } catch { /* race */ }
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
try { unlinkSync(lockFile); } catch { /* race */ }
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function releaseFileLock(lockFile: string): void {
|
|
424
|
+
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Ensure the parent directory exists. */
|
|
428
|
+
function ensureDir(filePath: string): void {
|
|
429
|
+
const dir = dirname(filePath);
|
|
430
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
431
|
+
}
|