pi-cc-loop 0.7.3 → 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
@@ -96,12 +96,13 @@ 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 排队、Ctrl+Enter 直发
99
100
  "compaction": true, // pi-cc-compaction:Claude Code 的压缩
100
101
  "idleCompaction": true // pi-cc-compaction:空闲压缩
101
102
  }
102
103
  ```
103
104
 
104
- draft、turnSummary 只被 pi-cc-plugins 读取,compaction、idleCompaction 只被 pi-cc-compaction 读取;没装对应包的会话里,面板中的这些项不生效,值原样保留。
105
+ draft、turnSummary、ccKeys 只被 pi-cc-plugins 读取,compaction、idleCompaction 只被 pi-cc-compaction 读取;没装对应包的会话里,面板中的这些项不生效,值原样保留。
105
106
 
106
107
  在 TUI 里输入 `/pi_cc` 会打开设置面板,和 pi-cc-extensions 的 `/ccstyle` 一样按页组织,每个插件一页:
107
108
  **Loop & Monitor**(消息样式、底部摘要,两个包共用)、**Plugins**(pi-cc-plugins)、**Compact**(pi-cc-compaction)。
@@ -128,7 +129,7 @@ Loop & Monitor / Plugins / Compact ~/.pi/agent/pi-cc.json
128
129
  也可以用文字命令:`/pi_cc messages on|off`、`/pi_cc footer on|off`,适合脚本和 RPC;
129
130
  非 TUI 模式下,不带参数的 `/pi_cc` 按页输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
130
131
 
131
- 环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的两项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`,pi-cc-compaction 的两项是 `PI_CC_COMPACTION`、`PI_CC_IDLE_COMPACTION`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
132
+ 环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的三项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`、`PI_CC_KEYS`,pi-cc-compaction 的两项是 `PI_CC_COMPACTION`、`PI_CC_IDLE_COMPACTION`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
132
133
  说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
133
134
 
134
135
  ## 底部摘要
@@ -176,6 +177,8 @@ npx --yes -p typescript@5.9.3 tsc --noEmit -p tsconfig.json
176
177
  node scripts/cache-check.mjs # 真实 provider 的缓存检查,默认用免费模型,发版前跑
177
178
  ```
178
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
+
179
182
  ### 缓存约束
180
183
 
181
184
  提示缓存按前缀匹配(工具 → system prompt → 消息),扩展只能往对话末尾追加:
package/config.ts CHANGED
@@ -13,12 +13,13 @@
13
13
  * "footer": true,
14
14
  * "draft": true,
15
15
  * "turnSummary": true,
16
+ * "ccKeys": true,
16
17
  * "compaction": true,
17
18
  * "idleCompaction": true
18
19
  * }
19
20
  *
20
- * compactMessages and footer drive loop and monitor; draft and turnSummary
21
- * drive pi-cc-plugins; compaction and idleCompaction drive pi-cc-compaction. A package that is not installed simply ignores its
21
+ * compactMessages and footer drive loop and monitor; draft, turnSummary and
22
+ * ccKeys drive pi-cc-plugins; compaction and idleCompaction drive pi-cc-compaction. A package that is not installed simply ignores its
22
23
  * keys, and they are kept as the user left them.
23
24
  *
24
25
  * Environment overrides (useful for tests and quick experiments) win over the
@@ -28,12 +29,15 @@
28
29
  * PI_CC_FOOTER=0|1
29
30
  * PI_CC_DRAFT=0|1
30
31
  * PI_CC_TURN_SUMMARY=0|1
32
+ * PI_CC_KEYS=0|1
31
33
  * PI_CC_COMPACTION=0|1
32
34
  * PI_CC_IDLE_COMPACTION=0|1
33
35
  */
34
36
 
35
37
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
38
+ import { randomUUID } from "node:crypto";
36
39
  import fs from "node:fs";
40
+ import os from "node:os";
37
41
  import path from "node:path";
38
42
 
39
43
  export interface PiCcConfig {
@@ -48,6 +52,8 @@ export interface PiCcConfig {
48
52
  draft: boolean;
49
53
  /** pi-cc-plugins: one line closes every agent run. */
50
54
  turnSummary: boolean;
55
+ /** pi-cc-plugins: Claude Code's Enter queues, Ctrl+Enter sends now. */
56
+ ccKeys: boolean;
51
57
  /** pi-cc-compaction: Claude Code's compaction replaces Pi's summary request. */
52
58
  compaction: boolean;
53
59
  /** pi-cc-compaction: compact while idle, before a long prompt cache expires. */
@@ -59,6 +65,7 @@ export const DEFAULT_CONFIG: PiCcConfig = {
59
65
  footer: true,
60
66
  draft: true,
61
67
  turnSummary: true,
68
+ ccKeys: true,
62
69
  compaction: true,
63
70
  idleCompaction: true,
64
71
  };
@@ -69,6 +76,7 @@ const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
69
76
  footer: "PI_CC_FOOTER",
70
77
  draft: "PI_CC_DRAFT",
71
78
  turnSummary: "PI_CC_TURN_SUMMARY",
79
+ ccKeys: "PI_CC_KEYS",
72
80
  compaction: "PI_CC_COMPACTION",
73
81
  idleCompaction: "PI_CC_IDLE_COMPACTION",
74
82
  };
@@ -93,6 +101,11 @@ export function configPath(): string {
93
101
  return path.join(getAgentDir(), "pi-cc.json");
94
102
  }
95
103
 
104
+ /** One setting: an environment override, else the file's boolean, else the default. */
105
+ function readSetting(file: Partial<PiCcConfig>, key: keyof PiCcConfig): boolean {
106
+ return envBool(ENV_OVERRIDES[key]) ?? (typeof file[key] === "boolean" ? (file[key] as boolean) : DEFAULT_CONFIG[key]);
107
+ }
108
+
96
109
  function readConfig(): PiCcConfig {
97
110
  let file: Partial<PiCcConfig> = {};
98
111
  try {
@@ -101,23 +114,11 @@ function readConfig(): PiCcConfig {
101
114
  } catch {
102
115
  // Missing or malformed files fall back to defaults.
103
116
  }
104
- return {
105
- compactMessages:
106
- envBool(ENV_OVERRIDES.compactMessages) ??
107
- (typeof file.compactMessages === "boolean" ? file.compactMessages : DEFAULT_CONFIG.compactMessages),
108
- footer:
109
- envBool(ENV_OVERRIDES.footer) ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
110
- draft: envBool(ENV_OVERRIDES.draft) ?? (typeof file.draft === "boolean" ? file.draft : DEFAULT_CONFIG.draft),
111
- turnSummary:
112
- envBool(ENV_OVERRIDES.turnSummary) ??
113
- (typeof file.turnSummary === "boolean" ? file.turnSummary : DEFAULT_CONFIG.turnSummary),
114
- compaction:
115
- envBool(ENV_OVERRIDES.compaction) ??
116
- (typeof file.compaction === "boolean" ? file.compaction : DEFAULT_CONFIG.compaction),
117
- idleCompaction:
118
- envBool(ENV_OVERRIDES.idleCompaction) ??
119
- (typeof file.idleCompaction === "boolean" ? file.idleCompaction : DEFAULT_CONFIG.idleCompaction),
120
- };
117
+ // One loop over the defaults, so a setting added to DEFAULT_CONFIG is read
118
+ // without a second edit here.
119
+ const config = { ...DEFAULT_CONFIG };
120
+ for (const key of Object.keys(config) as Array<keyof PiCcConfig>) config[key] = readSetting(file, key);
121
+ return config;
121
122
  }
122
123
 
123
124
  let cached: PiCcConfig | undefined;
@@ -133,6 +134,70 @@ export function invalidateConfig(): void {
133
134
  cached = undefined;
134
135
  }
135
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
+
136
201
  /**
137
202
  * Write the changed keys to the settings file and return the settings now in
138
203
  * effect. Only the patch is written: every other key stays as the user left
@@ -143,17 +208,23 @@ export function invalidateConfig(): void {
143
208
  export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
144
209
  const file = configPath();
145
210
  fs.mkdirSync(path.dirname(file), { recursive: true });
146
- // Keep keys this version does not know about: the file is the user's.
147
- let raw: Record<string, unknown> = {};
148
- try {
149
- const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
150
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
151
- } catch {
152
- // A missing or malformed file is rewritten from scratch.
153
- }
154
- const temp = `${file}.tmp`;
155
- fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...patch }, null, 2)}\n`, "utf8");
156
- 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
+ });
157
228
  cached = undefined;
158
229
  return loadConfig();
159
230
  }
package/cron.ts CHANGED
@@ -158,27 +158,6 @@ export function isUnreachable(expression: string): boolean {
158
158
  return nextFireAfter(fields, new Date(), 366) === null;
159
159
  }
160
160
 
161
- /**
162
- * The divisor of `total` closest to `n`.
163
- *
164
- * Claude Code rounds a non-dividing interval to the nearest clean one: "If the
165
- * interval doesn't cleanly divide its unit, round to the nearest clean interval
166
- * and tell the user what you rounded to."
167
- */
168
- function nearestDivisor(n: number, total: number): number {
169
- let best = 1;
170
- let bestDistance = Number.POSITIVE_INFINITY;
171
- for (let d = 1; d <= total; d++) {
172
- if (total % d !== 0) continue;
173
- const distance = Math.abs(d - n);
174
- if (distance < bestDistance) {
175
- best = d;
176
- bestDistance = distance;
177
- }
178
- }
179
- return best;
180
- }
181
-
182
161
  const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
183
162
 
184
163
 
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
@@ -36,7 +36,7 @@ import {
36
36
  parseCron,
37
37
  } from "./cron.ts";
38
38
  import { DurableRuntime, type Placement } from "./durable.ts";
39
- import { displayPrompt, isLoopFileSentinel, LoopFileDelivery, type TickMode } from "./loopfile.ts";
39
+ import { displayPrompt, isLoopFileSentinel, LoopFileDelivery } from "./loopfile.ts";
40
40
  import {
41
41
  CRON_CREATE_DESCRIPTION,
42
42
  CRON_CREATE_SNIPPET,
@@ -453,8 +453,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
453
453
  }
454
454
 
455
455
  /** The text the model gets for a prompt: the loop.md sentinel expands at fire time. */
456
- function resolvePrompt(prompt: string, mode: TickMode): string {
457
- return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd(), mode) : prompt;
456
+ function resolvePrompt(prompt: string): string {
457
+ return isLoopFileSentinel(prompt) ? loopFile.resolve(lastCtx?.cwd ?? process.cwd()) : prompt;
458
458
  }
459
459
 
460
460
  /**
@@ -464,7 +464,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
464
464
  */
465
465
  function deliverTick(job: LoopJob): void {
466
466
  const tick = Math.max(tickCounters.get(job.prompt) ?? 0, job.tickCount ?? 0) + 1;
467
- const body = resolvePrompt(job.prompt, job.kind === "cron" ? "cron" : "wakeup");
467
+ const body = resolvePrompt(job.prompt);
468
468
  // A fixed-interval tick is what it is - a scheduled task - and a
469
469
  // self-paced tick keeps the loop framing. The rules for the run are
470
470
  // rebuilt from `details` in before_agent_start.
@@ -574,7 +574,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
574
574
  handler: async (args: string, ctx: ExtensionCommandContext) => {
575
575
  lastCtx = ctx;
576
576
  const raw = args.trim();
577
- const [head, ...rest] = raw.split(/\s+/);
577
+ const [head] = raw.split(/\s+/);
578
578
 
579
579
  // Settings moved to /pi_cc: these words stay recognized so they cannot
580
580
  // start a loop by mistake (everything else is a loop request).
@@ -1101,8 +1101,10 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1101
1101
 
1102
1102
  if (event.message.customType === MONITOR_EVENT_TYPE) {
1103
1103
  // A monitor bound to a loop woke the agent: the run belongs to that loop.
1104
+ // The monitor stores the prompt the model gave it; the chain is keyed
1105
+ // without a leading `/loop`, so both spellings find it.
1104
1106
  const loopPrompt = (event.message.details as { loopPrompt?: unknown } | undefined)?.loopPrompt;
1105
- const chain = typeof loopPrompt === "string" ? scheduler.findChain(loopPrompt) : undefined;
1107
+ const chain = typeof loopPrompt === "string" ? scheduler.findChain(normalizePrompt(loopPrompt)) : undefined;
1106
1108
  if (chain) currentRun.chainId = chain.id;
1107
1109
  return;
1108
1110
  }
@@ -1133,7 +1135,13 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
1133
1135
  durable?.stop();
1134
1136
  restoreState(ctx, (event as { reason?: string } | undefined)?.reason);
1135
1137
  durable = createDurable(ctx.cwd);
1136
- 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
+ }
1137
1145
  scheduler.start();
1138
1146
  });
1139
1147
 
package/loopfile.ts CHANGED
@@ -59,8 +59,6 @@ function truncate(content: string): string {
59
59
  return `${content.slice(0, cut > 0 ? cut : LOOP_FILE_MAX_CHARS)}\n> WARNING: loop.md was truncated to ${LOOP_FILE_MAX_CHARS} bytes. Keep the task list concise.`;
60
60
  }
61
61
 
62
- export type TickMode = "cron" | "wakeup";
63
-
64
62
  /** Remembers what a session was last given, so unchanged tasks are not resent. */
65
63
  export class LoopFileDelivery {
66
64
  private lastDelivered: string | null = null;
@@ -71,7 +69,7 @@ export class LoopFileDelivery {
71
69
  }
72
70
 
73
71
  /** The text of one tick for the loop.md sentinel. */
74
- resolve(cwd: string, mode: TickMode): string {
72
+ resolve(cwd: string): string {
75
73
  const file = readLoopFile(cwd);
76
74
  if (file) {
77
75
  if (this.lastDelivered === file.content) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.7.3",
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",
package/prompts.ts CHANGED
@@ -45,19 +45,12 @@ const DELAY_ADVICE = [
45
45
  " fallback (1200s+) instead.",
46
46
  ].join("\n");
47
47
 
48
- /**
49
- * The rules for a `/loop <prompt>` whose interval the command layer could not
50
- * parse. Claude Code's /loop is a skill: the model reads the input and works
51
- * the schedule out itself, so an interval written in any language works.
52
- */
53
48
  /**
54
49
  * The instructions for one `/loop` request. Claude Code's `/loop` is a skill:
55
50
  * the command turns into a user message carrying these rules, and the model
56
51
  * drives CronCreate / CronList / CronDelete / ScheduleWakeup itself. Nothing is
57
52
  * created by code, so the conversation shows what was asked and what was done.
58
53
  */
59
- const LOOP_FILE_SENTINEL_PLACEHOLDER = "__loop_md_sentinel__";
60
-
61
54
  const LOOP_COMMAND_GUIDELINES = [
62
55
  "This is a `/loop` request from the user: {raw}",
63
56
  "",
@@ -91,7 +84,7 @@ const LOOP_COMMAND_GUIDELINES = [
91
84
  "- `--max N`: pass `maxFires: N`.",
92
85
  "",
93
86
  "## loop.md",
94
- "When the request has no task of its own (bare `/loop`, or an interval alone), pass the literal sentinel " + LOOP_FILE_SENTINEL_PLACEHOLDER + " as the prompt: it expands at fire time to `.pi/loop.md`, or to the built-in maintenance pass when there is no loop.md.",
87
+ "When the request has no task of its own (bare `/loop`, or an interval alone), pass the literal sentinel {sentinel} as the prompt: it expands at fire time to `.pi/loop.md`, or to the built-in maintenance pass when there is no loop.md.",
95
88
  "For the run you do right now, read `.pi/loop.md` (or `~/.pi/loop.md`) yourself and do those tasks; the sentinel only expands on later fires.",
96
89
  ].join("\n");
97
90
 
@@ -155,12 +148,12 @@ export function scheduledTaskProvenance(id: string, schedule: string): string {
155
148
  }
156
149
 
157
150
  export function loopCommandGuidelines(raw: string): string {
158
- const quoted = quote(raw || LOOP_FILE_SENTINEL);
159
151
  return fill(LOOP_COMMAND_GUIDELINES, {
160
152
  raw: raw ? `${quote(raw)}` : "(empty: the loop.md tasks)",
161
- quoted,
153
+ quoted: quote(raw || LOOP_FILE_SENTINEL),
162
154
  delayAdvice: DELAY_ADVICE,
163
- }).replace(LOOP_FILE_SENTINEL_PLACEHOLDER, quote(LOOP_FILE_SENTINEL));
155
+ sentinel: quote(LOOP_FILE_SENTINEL),
156
+ });
164
157
  }
165
158
 
166
159
  export function loopGuidelines(prompt: string): string {
package/render.ts CHANGED
@@ -36,11 +36,6 @@ export function contentText(content: unknown): string {
36
36
  return "";
37
37
  }
38
38
 
39
- /** Remove the provenance line the model needs but the reader does not. */
40
- export function stripProvenance(text: string, pattern: RegExp): string {
41
- return text.replace(pattern, "");
42
- }
43
-
44
39
  /** Cap a string for a display line, with an ellipsis. */
45
40
  export function truncate(text: string, max: number): string {
46
41
  const flat = text.replace(/\s+/g, " ").trim();
package/renderers.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
8
- import { bodyLines, contentText, formatStamp, renderBlock, renderCard, stripProvenance } from "./render.ts";
8
+ import { bodyLines, contentText, formatStamp, renderBlock, renderCard } from "./render.ts";
9
9
  import { loadConfig } from "./config.ts";
10
10
  import { humanSchedule } from "./cron.ts";
11
11
  import { displayPrompt } from "./loopfile.ts";
@@ -56,7 +56,8 @@ export interface ScheduledDetails {
56
56
  */
57
57
  export function renderScheduledMessage(message: any, options: any, theme: Theme): any {
58
58
  const details = (message.details ?? {}) as ScheduledDetails;
59
- const prompt = details.prompt ?? stripProvenance(contentText(message.content), /^\[[^\]]*\]\n?/);
59
+ // The provenance line is addressed to the model, not the reader.
60
+ const prompt = details.prompt ?? contentText(message.content).replace(/^\[[^\]]*\]\n?/, "");
60
61
  // A fixed interval fires a scheduled task; only a self-paced tick is a loop.
61
62
  const label = details.missed
62
63
  ? "\u273b loop \u00b7 missed jobs"
package/settings.ts CHANGED
@@ -100,6 +100,14 @@ const PAGES: SettingsPage[] = [
100
100
  off: "off",
101
101
  description: "One line closes every agent run \u2014 tools, duration, done time.",
102
102
  },
103
+ {
104
+ field: "ccKeys",
105
+ label: "CC keys",
106
+ on: "on",
107
+ off: "off",
108
+ description:
109
+ "Enter queues while Pi works and Ctrl+Enter sends now, as in Claude Code. off: Pi's keys (Enter sends, Alt+Enter queues).",
110
+ },
103
111
  ],
104
112
  },
105
113
  {
@@ -262,27 +270,32 @@ function overrideNote(key: keyof PiCcConfig): string {
262
270
  return override ? ` (${override})` : "";
263
271
  }
264
272
 
273
+ /**
274
+ * The word the text summary uses for a setting: the first alias in KEYS. The
275
+ * panel shows the description label instead.
276
+ */
277
+ function commandWord(field: keyof PiCcConfig): string {
278
+ return Object.keys(KEYS).find((word) => KEYS[word].field === field) ?? field;
279
+ }
280
+
281
+ /**
282
+ * The summary a non-TUI /pi_cc prints, generated from the same pages the panel
283
+ * draws: a setting added to PAGES cannot go missing here.
284
+ */
265
285
  function showSettings(ctx: ExtensionContext): void {
266
286
  const config = loadConfig();
267
- const lines = [
268
- `pi-cc settings \u00b7 ${configPath()}`,
269
- PAGES[0].label,
270
- ` messages: ${config.compactMessages ? "compact" : "card"}${overrideNote("compactMessages")}`,
271
- ` footer: ${config.footer ? "summary" : "hidden"}${overrideNote("footer")}`,
272
- PAGES[1].label,
273
- ` draft: ${config.draft ? "on" : "off"}${overrideNote("draft")}`,
274
- ` summary: ${config.turnSummary ? "on" : "off"}${overrideNote("turnSummary")}`,
275
- PAGES[2].label,
276
- ` compact: ${config.compaction ? "on" : "off"}${overrideNote("compaction")}`,
277
- ` idle: ${config.idleCompaction ? "on" : "off"}${overrideNote("idleCompaction")}`,
287
+ const lines = [`pi-cc settings \u00b7 ${configPath()}`];
288
+ for (const page of PAGES) {
289
+ lines.push(page.label);
290
+ for (const spec of page.settings) {
291
+ const value = config[spec.field] ? spec.on : spec.off;
292
+ lines.push(` ${commandWord(spec.field)}:`.padEnd(13) + value + overrideNote(spec.field));
293
+ }
294
+ }
295
+ lines.push(
278
296
  "",
279
- "Usage: /pi_cc messages on|off",
280
- " /pi_cc footer on|off",
281
- " /pi_cc draft on|off",
282
- " /pi_cc summary on|off",
283
- " /pi_cc compact on|off",
284
- " /pi_cc idle on|off",
285
- ];
297
+ `Usage: /pi_cc <item> on|off (${SETTINGS.map((spec) => commandWord(spec.field)).join(", ")})`,
298
+ );
286
299
  notify(ctx, lines.join("\n"), "info");
287
300
  }
288
301
 
@@ -327,6 +340,8 @@ const KEYS: Record<string, { field: keyof PiCcConfig; label: string }> = {
327
340
  draft: { field: "draft", label: "Draft recall" },
328
341
  summary: { field: "turnSummary", label: "Turn summary" },
329
342
  turnsummary: { field: "turnSummary", label: "Turn summary" },
343
+ keys: { field: "ccKeys", label: "CC keys" },
344
+ keymap: { field: "ccKeys", label: "CC keys" },
330
345
  compact: { field: "compaction", label: "CC compaction" },
331
346
  compaction: { field: "compaction", label: "CC compaction" },
332
347
  idle: { field: "idleCompaction", label: "Idle compaction" },
@@ -381,7 +396,7 @@ export function registerPiCcCommand(pi: ExtensionAPI, owner: string): void {
381
396
  };
382
397
 
383
398
  pi.registerCommand("pi_cc", {
384
- description: "pi-cc settings: message style, footer, draft, turn summary, compaction",
399
+ description: "pi-cc settings: message style, footer, draft, turn summary, CC keys, compaction",
385
400
  handler: async (args: string, ctx: ExtensionCommandContext) => {
386
401
  const words = args.trim().split(/\s+/).filter(Boolean);
387
402
  // "/pi_cc settings footer on" reads like the old /loop settings: drop the noun.