pi-cc-loop 0.7.4 → 0.7.6
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 +3 -1
- package/config.ts +84 -12
- package/durable.ts +32 -13
- package/index.ts +7 -1
- package/package.json +1 -1
- package/settings.ts +1 -1
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
|
|
|
96
96
|
"footer": true, // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
|
|
97
97
|
"draft": true, // pi-cc-plugins:Ctrl+C 找回
|
|
98
98
|
"turnSummary": true, // pi-cc-plugins:回合摘要一行
|
|
99
|
-
"ccKeys": true, // pi-cc-plugins:Enter
|
|
99
|
+
"ccKeys": true, // pi-cc-plugins:Enter 排队、↑ 取回待发消息
|
|
100
100
|
"compaction": true, // pi-cc-compaction:Claude Code 的压缩
|
|
101
101
|
"idleCompaction": true // pi-cc-compaction:空闲压缩
|
|
102
102
|
}
|
|
@@ -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 {
|
|
@@ -50,7 +52,7 @@ export interface PiCcConfig {
|
|
|
50
52
|
draft: boolean;
|
|
51
53
|
/** pi-cc-plugins: one line closes every agent run. */
|
|
52
54
|
turnSummary: boolean;
|
|
53
|
-
/** pi-cc-plugins:
|
|
55
|
+
/** pi-cc-plugins: Enter queues while Pi works; no Ctrl+Enter shortcut. */
|
|
54
56
|
ccKeys: boolean;
|
|
55
57
|
/** pi-cc-compaction: Claude Code's compaction replaces Pi's summary request. */
|
|
56
58
|
compaction: boolean;
|
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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())
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.6",
|
|
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",
|
package/settings.ts
CHANGED
|
@@ -106,7 +106,7 @@ const PAGES: SettingsPage[] = [
|
|
|
106
106
|
on: "on",
|
|
107
107
|
off: "off",
|
|
108
108
|
description:
|
|
109
|
-
"Enter queues while Pi works
|
|
109
|
+
"Enter queues while Pi works; Ctrl+Enter is not bound by this feature. off: Pi's keys (Enter sends, Alt+Enter queues).",
|
|
110
110
|
},
|
|
111
111
|
],
|
|
112
112
|
},
|