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 +13 -2
- package/durable.ts +76 -40
- package/index.ts +24 -2
- package/package.json +1 -1
- package/prompts.ts +9 -7
- package/schedule.ts +3 -3
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
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
this.owned = false;
|
|
247
|
+
return false;
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
|
|
229
251
|
release(): void {
|
|
230
252
|
if (!this.owned) return;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
73
|
-
"2.
|
|
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.
|
|
219
|
-
*
|
|
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
|
|
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;
|