pi-cc-loop 0.7.6 → 0.7.8

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
@@ -60,6 +60,15 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
60
60
 
61
61
  可以在请求里加 `--durable`(写入 `.pi/scheduled_tasks.json`,重启后继续)和 `--max N`(触发 N 次后自动删除)。
62
62
 
63
+ ### 持久任务与子代理
64
+
65
+ 同一项目的持久任务只由一个调度器实例执行。锁用 PID、主机名和每个实例独有的令牌识别归属,
66
+ 所以主会话与 subagent 即使在同一个 Pi 进程里,也不会同时接管同一批任务;其他会话仍能添加、查看或取消任务。
67
+ 派发持久任务前会重新核验并续期锁,失去归属的实例停止执行已载入的持久任务,不影响它的会话级任务。
68
+
69
+ `/reload` 先由旧实例释放锁,新实例再获取;持锁进程退出或心跳超过 60 秒未更新时,其他实例可以接管。
70
+ 旧实例不会续写或删除新实例的锁。旧版没有实例令牌的锁仍被尊重,释放或过期后才换成新版格式;任务文件无需迁移。
71
+
63
72
  ## 工具
64
73
 
65
74
  | 工具 | 界面显示 | 说明 |
@@ -69,7 +78,9 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
69
78
  | `cron_list` | `CronList` | 列出任务和实际的下次运行时间(已含错峰) |
70
79
  | `schedule_wakeup` | `ScheduleWakeup` | 自定步调:`delaySeconds`(限制在 60–3600)、`reason`(必填,显示在触发块表头)、`prompt`;`stop: true` 结束 |
71
80
 
72
- 四个工具都声明了 `outputSchema`:开了 codemode 时,脚本里 `tools.cron_create(...)`、`tools.cron_list()` 拿到的是结构化结果(`{ id, nextFireAt, … }`、`{ jobs: [...] }`),一个脚本里就能列任务、按条件批量取消,不用从文本里抠 id。没开 codemode 时没有影响。
81
+ 四个工具都声明了 `outputSchema`。三个 cron 工具保持可从 codemode 调用:`tools.cron_create(...)`、`tools.cron_list()` 返回结构化结果(`{ id, nextFireAt, … }`、`{ jobs: [...] }`),可以在脚本中批量查询/取消。
82
+
83
+ `schedule_wakeup` 是 **model-only 的回合控制工具**:仍直接向模型声明,但不能通过 codemode 或其他工具间接调用。新建/续期必须是当前 assistant 响应中的唯一工具调用;先完成 Monitor 等操作并向用户更新,再单独调用。运行时无法确认独立调用、或发现同批有其他调用时,会拒绝且不创建/修改 wakeup。`stop: true` 不强制结束回合,仍可向用户说明停止结果;固定 cron tick 的重复 wakeup 仍保持无操作。
73
84
 
74
85
  ## 渲染
75
86
 
@@ -197,7 +208,7 @@ node scripts/cache-check.mjs # 真实 provider 的缓存检查,默认用免
197
208
 
198
209
  - Esc 只取消被打断回合所属的自定步调 loop;CC 按 Esc(包括空闲时、打断无关回合时)会取消所有自定步调 loop。其他情况用 `/loop stop`。
199
210
  - 固定间隔的触发里调用 `schedule_wakeup` 会被当作空操作并返回说明,防止同一个任务又被自定步调重复调度;CC 没有这条硬规则。
200
- - 不带 `/loop` 的请求(比如"每分钟打印一次hello")也会在建好任务后立即做一次。CC 靠模型自己调用 loop 技能做到(`CronCreate` 在 CC 里是延迟加载的工具,模型会先走技能);Pi 没有延迟加载工具,这里改为在 `cron_create` 的结果里加一句提示,只在普通对话里建周期任务时出现。
211
+ - 不带 `/loop` 的请求(比如"每分钟打印一次hello")也会在建好任务后立即做一次。CC 靠模型自己调用 loop 技能做到(`CronCreate` 在 CC 里是延迟加载的工具,模型会先走技能);本包默认直接声明 cron 工具,这里改为在 `cron_create` 的结果里加一句提示,只在普通对话里建周期任务时出现。
201
212
  - 底部摘要和 `maxFires` 次数上限是额外加的;CC 主会话不显示 loop 状态,也没有次数上限。
202
213
  - 自定步调 loop 结束(stop、Esc、`cron_delete`、兜底心跳用完)时,绑定的 Monitor 会被静默停止;CC 靠模型自己调用 `TaskStop`。
203
214
  - Monitor 事件不会取消待触发的 wakeup,它始终是兜底心跳(与 CC 一致)。
package/durable.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * - The session holding the lock runs the durable jobs and picks up jobs
17
17
  * other sessions add or remove. The lock carries a heartbeat; a lock whose
18
18
  * heartbeat is older than a minute (or whose process is gone) is stale and
19
- * can be taken over.
19
+ * can be taken over. A PID identifies a process, not a session: main and
20
+ * subagent sessions in that process each get their own ownership token.
20
21
  * - The runtime files are added to `.git/info/exclude`.
21
22
  */
22
23
 
@@ -132,6 +133,8 @@ export class DurableStore {
132
133
  interface LockInfo {
133
134
  pid: number;
134
135
  hostname: string;
136
+ /** Absent on legacy locks, which remain valid until released or stale. */
137
+ ownerToken?: string;
135
138
  acquiredAt: number;
136
139
  heartbeatAt: number;
137
140
  }
@@ -159,6 +162,7 @@ export class SchedulerLock {
159
162
  owned = false;
160
163
  private readonly pid: number;
161
164
  private readonly hostname: string;
165
+ private readonly ownerToken = randomUUID();
162
166
  private readonly isAlive: (pid: number) => boolean;
163
167
  private readonly staleMs: number;
164
168
 
@@ -184,57 +188,76 @@ export class SchedulerLock {
184
188
  return info.hostname === this.hostname && !this.isAlive(info.pid);
185
189
  }
186
190
 
191
+ private matches(info: LockInfo | null): boolean {
192
+ return info?.pid === this.pid && info.hostname === this.hostname && info.ownerToken === this.ownerToken;
193
+ }
194
+
195
+ /** Check the file, not only the instance's last acquisition result. */
196
+ owns(): boolean {
197
+ if (this.owned && !this.matches(this.holder())) this.owned = false;
198
+ return this.owned;
199
+ }
200
+
201
+ /** Serialize takeovers, heartbeats and releases across processes too. */
202
+ private guarded<T>(action: () => T): T {
203
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
204
+ return withFileLock(this.file, action);
205
+ }
206
+
187
207
  tryAcquire(now = Date.now()): boolean {
188
- if (this.owned) return true;
189
- for (let attempt = 0; attempt < 2; attempt++) {
190
- try {
191
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
208
+ try {
209
+ return this.guarded(() => {
210
+ const held = this.holder();
211
+ if (this.matches(held)) return (this.owned = true);
212
+ this.owned = false;
213
+ // /reload releases the old instance first. Sharing its PID does not
214
+ // let a concurrent child session steal its still-live lock.
215
+ if (held && !this.isStale(held, now)) return false;
216
+ fs.rmSync(this.file, { force: true });
192
217
  const fd = fs.openSync(this.file, "wx");
193
- const info: LockInfo = { pid: this.pid, hostname: this.hostname, acquiredAt: now, heartbeatAt: now };
194
- fs.writeSync(fd, `${JSON.stringify(info)}\n`);
195
- fs.closeSync(fd);
196
- this.owned = true;
197
- return true;
198
- } catch (error) {
199
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") return false;
200
- const info = this.holder();
201
- // The same process after /reload, a dead process, or a missed heartbeat.
202
- const takeOver = !info || info.pid === this.pid || this.isStale(info, now);
203
- if (!takeOver) return false;
204
218
  try {
205
- fs.rmSync(this.file, { force: true });
206
- } catch {
207
- return false;
219
+ const info: LockInfo = { pid: this.pid, hostname: this.hostname, ownerToken: this.ownerToken, acquiredAt: now, heartbeatAt: now };
220
+ fs.writeSync(fd, `${JSON.stringify(info)}\n`);
221
+ } finally {
222
+ fs.closeSync(fd);
208
223
  }
209
- }
224
+ return (this.owned = true);
225
+ });
226
+ } catch {
227
+ this.owned = false;
228
+ return false;
210
229
  }
211
- return false;
212
230
  }
213
231
 
214
- heartbeat(now = Date.now()): void {
215
- if (!this.owned) return;
216
- const info = this.holder();
217
- if (!info || info.pid !== this.pid) {
218
- // Someone took the lock over (we were considered stale).
219
- this.owned = false;
220
- return;
221
- }
232
+ /** Renew only our own lease; a failed renewal must not permit dispatch. */
233
+ heartbeat(now = Date.now()): boolean {
234
+ if (!this.owned) return false;
222
235
  try {
223
- writeAtomic(this.file, `${JSON.stringify({ ...info, heartbeatAt: now })}\n`);
236
+ return this.guarded(() => {
237
+ const info = this.holder();
238
+ if (!this.matches(info)) {
239
+ this.owned = false;
240
+ return false;
241
+ }
242
+ writeAtomic(this.file, `${JSON.stringify({ ...info, heartbeatAt: now })}\n`);
243
+ return true;
244
+ });
224
245
  } catch {
225
- // a missed heartbeat only matters after a minute
246
+ this.owned = false;
247
+ return false;
226
248
  }
227
249
  }
228
250
 
229
251
  release(): void {
230
252
  if (!this.owned) return;
231
- this.owned = false;
232
- if (this.holder()?.pid === this.pid) {
233
- try {
234
- fs.rmSync(this.file, { force: true });
235
- } catch {
236
- // a stale lock is taken over after a minute anyway
237
- }
253
+ try {
254
+ this.guarded(() => {
255
+ if (this.matches(this.holder())) fs.rmSync(this.file, { force: true });
256
+ });
257
+ } catch {
258
+ // a stale lock is taken over after a minute anyway
259
+ } finally {
260
+ this.owned = false;
238
261
  }
239
262
  }
240
263
  }
@@ -272,7 +295,7 @@ export function ensureGitExclude(cwd: string): void {
272
295
  const file = path.join(common, "info", "exclude");
273
296
  const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
274
297
  const lines = current.split("\n").map((line) => line.trim());
275
- const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock", "**/.pi/scheduled_tasks.json.write.lock"];
298
+ const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock", "**/.pi/scheduled_tasks.json.write.lock", "**/.pi/scheduled_tasks.lock.write.lock"];
276
299
  const missing = wanted.filter((entry) => !lines.includes(entry));
277
300
  if (!missing.length) return;
278
301
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -316,7 +339,15 @@ export class DurableRuntime {
316
339
  }
317
340
 
318
341
  get owner(): boolean {
319
- return this.lock.owned;
342
+ return this.lock.owns();
343
+ }
344
+
345
+ /** Revalidate and renew immediately before a durable fire, even when sync is throttled. */
346
+ canDispatch(): boolean {
347
+ this.lastHeartbeat = Date.now();
348
+ if (this.lock.heartbeat(this.lastHeartbeat)) return true;
349
+ this.dropLoadedJobs();
350
+ return false;
320
351
  }
321
352
 
322
353
  holderPid(): number | undefined {
@@ -341,6 +372,7 @@ export class DurableRuntime {
341
372
  this.lastMaintenance = now;
342
373
  this.store.assertWritable();
343
374
  if (!this.owner) {
375
+ this.dropLoadedJobs();
344
376
  if (this.store.exists()) this.acquireAndLoad(now);
345
377
  return;
346
378
  }
@@ -383,6 +415,10 @@ export class DurableRuntime {
383
415
 
384
416
  /** Record a fire: update the job in the file, or remove it after its last run. */
385
417
  afterFire(job: LoopJob, finished: boolean): void {
418
+ if (!this.owner) {
419
+ this.dropLoadedJobs();
420
+ return;
421
+ }
386
422
  try {
387
423
  if (finished) this.store.remove(job.id);
388
424
  else this.store.upsert(job);
package/index.ts CHANGED
@@ -143,6 +143,22 @@ function normalizePrompt(prompt: string): string {
143
143
  return prompt.trim().replace(/^\/loop\s+/i, "").trim();
144
144
  }
145
145
 
146
+ /** Pi only terminates a batch when every call agrees; never arm from a mixed or nested call. */
147
+ function requireStandaloneWakeup(toolCallId: string, ctx: Pick<ExtensionContext, "sessionManager">): void {
148
+ const branch = ctx.sessionManager.getBranch?.() ?? [];
149
+ for (let i = branch.length - 1; i >= 0; i--) {
150
+ const entry = branch[i];
151
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
152
+ const calls = entry.message.content.filter((block) => block.type === "toolCall");
153
+ if (calls.length === 1 && calls[0].id === toolCallId && calls[0].name === "schedule_wakeup") return;
154
+ break;
155
+ }
156
+ throw new Error(
157
+ "No wakeup was scheduled: schedule_wakeup must be called directly as the only tool call in its assistant response, " +
158
+ "not through codemode or in a batch. Finish other tools and write your update first, then call it alone.",
159
+ );
160
+ }
161
+
146
162
  /** A stored job as a codemode script sees it: every field, minus those holding no value (the value is JSON). */
147
163
  function jobValue(job: LoopJob): Record<string, string | number | boolean> {
148
164
  return Object.fromEntries(Object.entries(storedJob(job)).filter(([, value]) => value !== undefined)) as Record<
@@ -194,11 +210,12 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
194
210
  persist(jobs);
195
211
  refreshStatus(jobs);
196
212
  },
197
- canDispatch: () => {
213
+ canDispatch: (job) => {
198
214
  const ctx = lastCtx;
199
215
  if (!ctx) return false;
200
216
  try {
201
- return ctx.isIdle() && !ctx.hasPendingMessages();
217
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
218
+ return !job.durable || durable?.canDispatch() === true;
202
219
  } catch {
203
220
  return false;
204
221
  }
@@ -865,6 +882,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
865
882
  pi.registerTool({
866
883
  name: "schedule_wakeup",
867
884
  label: "ScheduleWakeup",
885
+ // A turn-control tool: codemode cannot propagate its terminate flag.
886
+ exposure: "model-only",
868
887
  description: SCHEDULE_WAKEUP_DESCRIPTION,
869
888
  promptSnippet: SCHEDULE_WAKEUP_SNIPPET,
870
889
  parameters: Type.Object({
@@ -960,6 +979,9 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
960
979
  };
961
980
  }
962
981
 
982
+ // Check the finalized assistant batch before changing any chain or timer.
983
+ requireStandaloneWakeup(_toolCallId, ctx);
984
+
963
985
  // Claude Code rounds and clamps; only a change after rounding counts as clamped.
964
986
  const requested = params.delaySeconds;
965
987
  const rounded = Number.isFinite(requested) ? Math.round(requested) : requested > 0 ? MAX_DELAY_SECONDS : MIN_DELAY_SECONDS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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
@@ -69,10 +69,10 @@ const LOOP_COMMAND_GUIDELINES = [
69
69
  "4. Never call `schedule_wakeup` for this loop: the cron fires its own ticks. Scheduling one would run the task twice.",
70
70
  "",
71
71
  "## Self-paced (no interval)",
72
- "1. Do the task now, then write your update for the user.",
73
- "2. Then call `schedule_wakeup` with `delaySeconds` (60-3600), a one-sentence `reason`, and `prompt` set to the same `/loop` input verbatim ({quoted}). The turn ends as soon as it returns, so call it last.",
72
+ "1. Do the task now.",
73
+ "2. If the next iteration waits on an external event and no monitor is running for it, arm `Monitor` once with `loopPrompt` set to {quoted}; keep `delaySeconds` at 1200-1800 as the fallback heartbeat.",
74
+ "3. Write your update for the user, then call `schedule_wakeup` directly with `delaySeconds` (60-3600), a one-sentence `reason`, and `prompt` set to the same `/loop` input verbatim ({quoted}). It must be the only tool call in that assistant response, not through codemode or in a batch. The turn ends as soon as it returns, so call it last.",
74
75
  "{delayAdvice}",
75
- "3. If the next iteration waits on an external event and no monitor is running for it, arm `Monitor` once with `loopPrompt` set to {quoted}; keep `delaySeconds` at 1200-1800 as the fallback heartbeat.",
76
76
  "4. If the task is complete or cannot progress without the user, call `schedule_wakeup` with `stop: true` and say why.",
77
77
  "",
78
78
  "## status and stop",
@@ -94,6 +94,8 @@ const LOOP_GUIDELINES = [
94
94
  "- To continue, call the `schedule_wakeup` tool with `delaySeconds` (60-3600), a one-sentence `reason`,",
95
95
  " and `prompt` set to the same /loop prompt verbatim: {quoted}",
96
96
  "- Write your update for the user before you call `schedule_wakeup`: the turn ends as soon as it returns.",
97
+ "- Call it directly as the only tool call in that assistant response, never through codemode or in a batch.",
98
+ " Finish any Monitor or other tool calls in earlier responses, before this final wakeup call.",
97
99
  DELAY_ADVICE,
98
100
  "- If the task is complete, or the loop cannot move further without the user, call `schedule_wakeup`",
99
101
  " with `stop: true` instead and say why.",
@@ -112,7 +114,7 @@ const MONITOR_GUIDELINES = [
112
114
  "this step while a monitor for it is still running.",
113
115
  "While a monitor is armed, keep `delaySeconds` at 1200-1800: the monitor is the wake signal and the",
114
116
  "`schedule_wakeup` delay is only the fallback heartbeat. When a monitor event wakes you, handle the event,",
115
- "then decide whether the loop continues.",
117
+ "then decide whether the loop continues. Finish monitor calls before the final, standalone `schedule_wakeup` call.",
116
118
  ].join("\n");
117
119
 
118
120
  /**
@@ -215,8 +217,8 @@ Returns a job ID you can pass to cron_delete.`;
215
217
  * /loop request, not a scheduled tick). Claude Code's /loop skill says "Then
216
218
  * immediately run it now ... Don't wait for the first cron fire", and its model
217
219
  * reaches that skill for "every 5 minutes ..." typed without /loop: the skill is
218
- * listed every turn and CronCreate is a deferred tool. Pi has no deferred tools,
219
- * so the model calls cron_create directly and the result says it instead. A
220
+ * listed every turn and CronCreate is a deferred tool. This package exposes
221
+ * cron_create directly, so its result supplies that guidance instead. A
220
222
  * tool result is appended at the end of the transcript; the cache is untouched.
221
223
  */
222
224
  export const RUN_NOW_HINT =
@@ -229,7 +231,7 @@ export const CRON_DELETE_DESCRIPTION =
229
231
  export const CRON_LIST_DESCRIPTION =
230
232
  "List the scheduled jobs, both durable (.pi/scheduled_tasks.json) and session-only, with their ids, schedules, actual next run times and prompts.";
231
233
 
232
- export const SCHEDULE_WAKEUP_DESCRIPTION = `Schedule when to resume work in a self-paced /loop \u2014 the user invoked /loop without an interval, asking you to self-pace iterations of a task. Call it as the last thing in the turn to keep the loop alive: the turn ends as soon as it returns, so write your update for the user first. To end the loop, call it with stop: true (omit every other field).
234
+ export const SCHEDULE_WAKEUP_DESCRIPTION = `Schedule when to resume work in a self-paced /loop \u2014 the user invoked /loop without an interval, asking you to self-pace iterations of a task. Call it as the last thing in the turn to keep the loop alive: the turn ends as soon as it returns, so write your update for the user first. Call it directly as the only tool call in that assistant response, never through codemode or in a batch. Finish Monitor and other tools in earlier responses; unsafe arm calls are rejected without scheduling. To end the loop, call it with stop: true (omit every other field); stopping does not force the turn to end, so explain the outcome afterward.
233
235
 
234
236
  Pass the same /loop prompt back via \`prompt\` each turn so the next firing repeats the task. For a bare /loop (no prompt), pass the literal sentinel "${LOOP_FILE_SENTINEL}": it expands at fire time to the current loop.md tasks, or to the built-in maintenance pass when there is no loop.md.
235
237
 
package/schedule.ts CHANGED
@@ -149,8 +149,8 @@ export interface SchedulerHooks {
149
149
  afterDispatch?: (job: LoopJob, finished: boolean) => void;
150
150
  /** Called whenever the job set changes, so state can be persisted. */
151
151
  onChange?: (jobs: LoopJob[]) => void;
152
- /** True when the agent is idle and has nothing queued: a tick may go out. */
153
- canDispatch?: () => boolean;
152
+ /** True when idle and this job may fire (durable jobs also need their live lock). */
153
+ canDispatch?: (job: LoopJob) => boolean;
154
154
  /** The offsets to apply to run times. */
155
155
  timing?: () => TimingConfig;
156
156
  /** Max age of a recurring job in ms; 0 disables the limit. */
@@ -321,7 +321,7 @@ export class LoopScheduler {
321
321
  .filter((candidate) => candidate.pending)
322
322
  .sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0))[0];
323
323
  if (!job) return;
324
- if (this.hooks.canDispatch && !this.hooks.canDispatch()) return;
324
+ if (this.hooks.canDispatch && !this.hooks.canDispatch(job)) return;
325
325
 
326
326
  job.pending = false;
327
327
  job.dueAt = undefined;