pi-cc-loop 0.7.4 → 0.7.5

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 CHANGED
@@ -177,6 +177,8 @@ npx --yes -p typescript@5.9.3 tsc --noEmit -p tsconfig.json
177
177
  node scripts/cache-check.mjs # 真实 provider 的缓存检查,默认用免费模型,发版前跑
178
178
  ```
179
179
 
180
+ 四个仓库共同使用的 `config.ts`、`settings.ts`(以及 loop/monitor 的 `render.ts`)以本仓库为维护源。修改后在本仓库运行 `node scripts/sync-shared.mjs` 同步到同级仓库;`node scripts/sync-shared.mjs --check` 只检查,不改文件。各仓库的 `tests/shared.test.mjs` 也会检查副本一致。
181
+
180
182
  ### 缓存约束
181
183
 
182
184
  提示缓存按前缀匹配(工具 → system prompt → 消息),扩展只能往对话末尾追加:
package/config.ts CHANGED
@@ -35,7 +35,9 @@
35
35
  */
36
36
 
37
37
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
38
+ import { randomUUID } from "node:crypto";
38
39
  import fs from "node:fs";
40
+ import os from "node:os";
39
41
  import path from "node:path";
40
42
 
41
43
  export interface PiCcConfig {
@@ -132,6 +134,70 @@ export function invalidateConfig(): void {
132
134
  cached = undefined;
133
135
  }
134
136
 
137
+ const LOCK_WAIT_MS = 5000;
138
+ const LOCK_STALE_MS = 60_000;
139
+ const sleep = new Int32Array(new SharedArrayBuffer(4));
140
+
141
+ function removeStaleLock(lock: string): void {
142
+ try {
143
+ const stat = fs.statSync(lock);
144
+ const age = Date.now() - stat.mtimeMs;
145
+ if (age < 100) return; // A new writer may not have filled it yet.
146
+ let stale = age > LOCK_STALE_MS;
147
+ if (!stale) {
148
+ const holder = JSON.parse(fs.readFileSync(lock, "utf8")) as { pid?: number; hostname?: string };
149
+ if (holder.hostname === os.hostname() && typeof holder.pid === "number") {
150
+ try { process.kill(holder.pid, 0); } catch (error) {
151
+ stale = (error as NodeJS.ErrnoException).code === "ESRCH";
152
+ }
153
+ }
154
+ }
155
+ // Do not unlink a lock that another process replaced while we inspected it.
156
+ const current = fs.statSync(lock);
157
+ if (stale && current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) fs.rmSync(lock);
158
+ } catch {
159
+ // A writer may still be filling it, or another process removed it.
160
+ }
161
+ }
162
+
163
+ /** Serialize a short read-modify-write across Pi processes, not just modules in one process. */
164
+ export function withFileLock<T>(file: string, change: () => T): T {
165
+ const lock = `${file}.write.lock`;
166
+ const token = randomUUID();
167
+ const started = Date.now();
168
+ for (;;) {
169
+ let fd: number;
170
+ try {
171
+ fd = fs.openSync(lock, "wx", 0o600);
172
+ } catch (error) {
173
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
174
+ removeStaleLock(lock);
175
+ if (Date.now() - started > LOCK_WAIT_MS) throw new Error(`Timed out waiting for ${lock}`);
176
+ Atomics.wait(sleep, 0, 0, 20);
177
+ continue;
178
+ }
179
+ try {
180
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, hostname: os.hostname(), token }));
181
+ } catch (error) {
182
+ fs.rmSync(lock, { force: true });
183
+ throw error;
184
+ } finally {
185
+ fs.closeSync(fd);
186
+ }
187
+ break;
188
+ }
189
+ try {
190
+ return change();
191
+ } finally {
192
+ try {
193
+ const holder = JSON.parse(fs.readFileSync(lock, "utf8")) as { token?: string };
194
+ if (holder.token === token) fs.rmSync(lock);
195
+ } catch {
196
+ // A stale lock may have been replaced; never remove a new owner's lock.
197
+ }
198
+ }
199
+ }
200
+
135
201
  /**
136
202
  * Write the changed keys to the settings file and return the settings now in
137
203
  * effect. Only the patch is written: every other key stays as the user left
@@ -142,17 +208,23 @@ export function invalidateConfig(): void {
142
208
  export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
143
209
  const file = configPath();
144
210
  fs.mkdirSync(path.dirname(file), { recursive: true });
145
- // Keep keys this version does not know about: the file is the user's.
146
- let raw: Record<string, unknown> = {};
147
- try {
148
- const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
149
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
150
- } catch {
151
- // A missing or malformed file is rewritten from scratch.
152
- }
153
- const temp = `${file}.tmp`;
154
- fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...patch }, null, 2)}\n`, "utf8");
155
- fs.renameSync(temp, file);
211
+ withFileLock(file, () => {
212
+ // Keep keys this version does not know about: the file is the user's.
213
+ let raw: Record<string, unknown> = {};
214
+ try {
215
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
216
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
217
+ } catch {
218
+ // A missing or malformed file is rewritten from scratch.
219
+ }
220
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
221
+ try {
222
+ fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...patch }, null, 2)}\n`, "utf8");
223
+ fs.renameSync(temp, file);
224
+ } finally {
225
+ fs.rmSync(temp, { force: true });
226
+ }
227
+ });
156
228
  cached = undefined;
157
229
  return loadConfig();
158
230
  }
package/durable.ts CHANGED
@@ -20,9 +20,11 @@
20
20
  * - The runtime files are added to `.git/info/exclude`.
21
21
  */
22
22
 
23
+ import { randomUUID } from "node:crypto";
23
24
  import fs from "node:fs";
24
25
  import os from "node:os";
25
26
  import path from "node:path";
27
+ import { withFileLock } from "./config.ts";
26
28
  import { nextRunTime, storedJob, type LoopJob, type LoopScheduler, type TimingConfig } from "./schedule.ts";
27
29
 
28
30
  export const DURABLE_FILE = path.join(".pi", "scheduled_tasks.json");
@@ -37,9 +39,13 @@ interface DurableFileData {
37
39
  }
38
40
 
39
41
  function writeAtomic(file: string, text: string): void {
40
- const temp = `${file}.${process.pid}.tmp`;
41
- fs.writeFileSync(temp, text, "utf8");
42
- fs.renameSync(temp, file);
42
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
43
+ try {
44
+ fs.writeFileSync(temp, text, "utf8");
45
+ fs.renameSync(temp, file);
46
+ } finally {
47
+ fs.rmSync(temp, { force: true });
48
+ }
43
49
  }
44
50
 
45
51
  function isSymlink(file: string): boolean {
@@ -94,17 +100,26 @@ export class DurableStore {
94
100
  /** Insert or replace one job, keeping every other job in the file. */
95
101
  upsert(job: LoopJob): void {
96
102
  this.assertWritable();
97
- const jobs = this.read().filter((other) => other.id !== job.id);
98
- jobs.push(storedJob(job));
99
- this.write(jobs);
103
+ fs.mkdirSync(this.dir, { recursive: true });
104
+ withFileLock(this.file, () => {
105
+ this.assertWritable();
106
+ const jobs = this.read().filter((other) => other.id !== job.id);
107
+ jobs.push(storedJob(job));
108
+ this.write(jobs);
109
+ });
100
110
  }
101
111
 
102
112
  remove(id: string): boolean {
103
- const jobs = this.read();
104
- const kept = jobs.filter((job) => job.id !== id);
105
- if (kept.length === jobs.length) return false;
106
- this.write(kept);
107
- return true;
113
+ this.assertWritable();
114
+ if (!this.exists()) return false;
115
+ return withFileLock(this.file, () => {
116
+ this.assertWritable();
117
+ const jobs = this.read();
118
+ const kept = jobs.filter((job) => job.id !== id);
119
+ if (kept.length === jobs.length) return false;
120
+ this.write(kept);
121
+ return true;
122
+ });
108
123
  }
109
124
 
110
125
  private write(jobs: LoopJob[]): void {
@@ -257,7 +272,7 @@ export function ensureGitExclude(cwd: string): void {
257
272
  const file = path.join(common, "info", "exclude");
258
273
  const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
259
274
  const lines = current.split("\n").map((line) => line.trim());
260
- const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock"];
275
+ const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock", "**/.pi/scheduled_tasks.json.write.lock"];
261
276
  const missing = wanted.filter((entry) => !lines.includes(entry));
262
277
  if (!missing.length) return;
263
278
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -310,7 +325,10 @@ export class DurableRuntime {
310
325
 
311
326
  /** Take the lock if the project has durable jobs, then load them. */
312
327
  start(now = Date.now()): void {
313
- if (this.store.exists()) this.acquireAndLoad(now);
328
+ if (this.store.exists()) {
329
+ this.store.assertWritable();
330
+ this.acquireAndLoad(now);
331
+ }
314
332
  }
315
333
 
316
334
  stop(): void {
@@ -321,6 +339,7 @@ export class DurableRuntime {
321
339
  maintain(now = Date.now()): void {
322
340
  if (now - this.lastMaintenance < this.options.syncMs()) return;
323
341
  this.lastMaintenance = now;
342
+ this.store.assertWritable();
324
343
  if (!this.owner) {
325
344
  if (this.store.exists()) this.acquireAndLoad(now);
326
345
  return;
package/index.ts CHANGED
@@ -1135,7 +1135,13 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1135
1135
  durable?.stop();
1136
1136
  restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
1137
1137
  durable = createDurable(ctx.cwd);
1138
- durable.start();
1138
+ try {
1139
+ durable.start();
1140
+ } catch (error) {
1141
+ // Refusing a symlinked or unreadable durable store must not disable
1142
+ // this session's ordinary (non-durable) scheduled jobs.
1143
+ notify(ctx, `Durable jobs unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
1144
+ }
1139
1145
  scheduler.start();
1140
1146
  });
1141
1147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
5
5
  "author": "tarnish233",
6
6
  "type": "module",