@alphacoder-v0/pi-loops 0.22.3 → 0.22.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/CHANGELOG.md +91 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -1
- package/docs/cli.md +8 -1
- package/docs/configuration.md +1 -0
- package/docs/design.md +7 -5
- package/docs/loops.md +8 -2
- package/docs/recipes.md +2 -1
- package/docs/web-ui-parity.md +17 -6
- package/package.json +1 -1
- package/src/cli.ts +10 -4
- package/src/goal.ts +5 -1
- package/src/job-lines.ts +68 -0
- package/src/pi-loops.ts +54 -55
- package/src/recipe.ts +26 -6
- package/src/scheduler.ts +97 -27
- package/src/session-head.ts +6 -2
- package/src/store.ts +55 -8
- package/src/tools.ts +3 -2
- package/src/triggers.ts +24 -0
- package/src/web.mjs +61 -22
package/src/pi-loops.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { sameProject } from "./presence.ts";
|
|
|
25
25
|
import { isExactlyTrusted, sessionTrustCovers } from "./trust.ts";
|
|
26
26
|
import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, runEndEvent, runStartEvent, truncateSummary } from "./hooks.ts";
|
|
27
27
|
import { failingSummary } from "./job-health.ts";
|
|
28
|
-
import {
|
|
28
|
+
import { loopSignal, signalSummary } from "./job-signal.ts";
|
|
29
29
|
import { LoopsLog, pruneLogs } from "./log.ts";
|
|
30
30
|
import { type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
|
|
31
31
|
import { McpPool } from "./mcp-pool.ts";
|
|
@@ -38,7 +38,8 @@ import { execFile } from "node:child_process";
|
|
|
38
38
|
import { createHash } from "node:crypto";
|
|
39
39
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
40
40
|
import { applyJobEdit } from "./job-edit.ts";
|
|
41
|
-
import {
|
|
41
|
+
import { jobLines as cronJobLines } from "./job-lines.ts";
|
|
42
|
+
import { runsIn } from "./job-owner.ts";
|
|
42
43
|
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, RECIPE_TIERS, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, neverSection, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, preflightChecks, purgeInstall, readRecord, renderPreflight, requireRecipeName, resolveRecipeRef, runPreflight, updateFiles } from "./recipe.ts";
|
|
43
44
|
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
44
45
|
import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
|
|
@@ -49,8 +50,8 @@ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
|
|
|
49
50
|
import { waitForHost, HOST_LOG, crashedHost, hostPushWork, liveHost, piPackageDir, shouldHandOff, spawnHost, stopHost } from "./host-control.ts";
|
|
50
51
|
import { summarizeSessionFile } from "./transcript.ts";
|
|
51
52
|
import { TriggerRuntime, type TriggerOutcome } from "./trigger-runtime.ts";
|
|
52
|
-
import { auditCronFinish, auditCronStart, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
|
|
53
|
-
import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob } from "./tools.ts";
|
|
53
|
+
import { auditArgs, auditCronFinish, auditCronStart, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
|
|
54
|
+
import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob, resolveJobRefScoped } from "./tools.ts";
|
|
54
55
|
import { panelEnabled, readUiPrefs, writeUiPref } from "./ui-prefs.ts";
|
|
55
56
|
import * as fs from "node:fs";
|
|
56
57
|
import * as os from "node:os";
|
|
@@ -197,6 +198,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
197
198
|
onCatchUp: (job, dueAt) => {
|
|
198
199
|
if (lastCtx?.hasUI && sameProject(job.cwd, session.cwd)) lastCtx.ui.notify(`cron ${job.name ?? job.id}: catching up the run missed at ${formatLocal(dueAt)}`, "info");
|
|
199
200
|
},
|
|
201
|
+
onJobRetired: (job) => cronControlAudit("remove", "scheduler", job, undefined),
|
|
200
202
|
onRunFinished: ({ job, record, findings, result }) => {
|
|
201
203
|
auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
|
|
202
204
|
refreshBadge();
|
|
@@ -653,8 +655,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
653
655
|
const n = scheduler.inbox.newCount();
|
|
654
656
|
const decisions = scheduler.inbox.decisionCount();
|
|
655
657
|
if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${decisions} decision${decisions === 1 ? "" : "s"})` : ""}`);
|
|
656
|
-
// Machine-wide like the inbox count and the running list above it:
|
|
657
|
-
// and a loop failing in another checkout is still this machine's automation
|
|
658
|
+
// Machine-wide like the inbox count and the running list above it: one process owns the
|
|
659
|
+
// machine's clock, and a loop failing in another checkout is still this machine's automation
|
|
660
|
+
// going quiet.
|
|
658
661
|
const failing = failingSummary(scheduler.store.load());
|
|
659
662
|
if (failing) parts.push(failing);
|
|
660
663
|
const running = [...scheduler.runningLabels(), ...triggers.runningList().map((r) => (r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel))];
|
|
@@ -721,41 +724,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
721
724
|
};
|
|
722
725
|
|
|
723
726
|
function jobLines(jobs: LoopJob[]): string[] {
|
|
724
|
-
const now = Date.now();
|
|
725
727
|
// Read once for the whole listing: the run log and the inbox are each one file, and a
|
|
726
|
-
// project with a dozen loops would otherwise parse them a dozen times per `/cron`.
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const next = job.enabled && runsIn(job, session.sessionId)
|
|
732
|
-
? computeNext(
|
|
733
|
-
{
|
|
734
|
-
schedule: job.schedule,
|
|
735
|
-
createdAt: Date.parse(job.createdAt),
|
|
736
|
-
lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
|
|
737
|
-
},
|
|
738
|
-
now,
|
|
739
|
-
)
|
|
740
|
-
: undefined;
|
|
741
|
-
const asleepMark = asleepNote(job, session.sessionId);
|
|
742
|
-
const dormant = asleepMark ? `[${asleepMark}]` : undefined;
|
|
743
|
-
const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
744
|
-
// What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
|
|
745
|
-
// runs are the newest, and the findings line — filed, claimed, dismissed — over 30 days.
|
|
746
|
-
// A loop was, until now, judged by whether it ran; this is whether it was worth running.
|
|
747
|
-
const signal = job.stateful ? loopSignal(job.id, allRuns, allInbox, now - SIGNAL_WINDOW_MS) : undefined;
|
|
748
|
-
const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
|
|
749
|
-
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
|
|
750
|
-
.filter(Boolean)
|
|
751
|
-
.join(" ");
|
|
752
|
-
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
|
|
753
|
-
const action = ` action: ${previewRedacted(job.prompt, 120)}`;
|
|
754
|
-
const worth = signal ? signalSummary(signal) : undefined;
|
|
755
|
-
const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · overlap skips ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${homeRel(job.cwd)}`;
|
|
756
|
-
const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
|
|
757
|
-
return [head, action, meta, err].filter((l): l is string => !!l);
|
|
758
|
-
}).flat();
|
|
728
|
+
// project with a dozen loops would otherwise parse them a dozen times per `/cron`. The shape
|
|
729
|
+
// of the lines is src/job-lines.ts, where a test can see a marker go.
|
|
730
|
+
const runs = jobs.some((j) => j.stateful) ? scheduler.store.allRuns() : [];
|
|
731
|
+
const inbox = jobs.some((j) => j.stateful) ? scheduler.inbox.list() : [];
|
|
732
|
+
return cronJobLines(jobs, { runs, inbox, sessionId: session.sessionId, now: Date.now(), home: process.env.HOME ?? "", cwdExists: (cwd) => fs.existsSync(cwd) });
|
|
759
733
|
}
|
|
760
734
|
|
|
761
735
|
/* --------------------------------------------------------- creation */
|
|
@@ -766,7 +740,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
766
740
|
* The `cron_control_plane` audit: every add / enable / disable / remove, from a slash
|
|
767
741
|
* command or a tool, leaves a custom entry in the session (never in LLM context).
|
|
768
742
|
*/
|
|
769
|
-
function cronControlAudit(op: "add" | "enable" | "disable" | "remove", actor: "slash" | "tool" | "sub-agent", before?: LoopJob, after?: LoopJob): string {
|
|
743
|
+
function cronControlAudit(op: "add" | "enable" | "disable" | "remove", actor: "slash" | "tool" | "sub-agent" | "scheduler", before?: LoopJob, after?: LoopJob): string {
|
|
770
744
|
const job = after ?? before;
|
|
771
745
|
const next = after?.enabled ? computeNext({ schedule: after.schedule, createdAt: Date.parse(after.createdAt), lastFiredAt: after.lastFiredAt ? Date.parse(after.lastFiredAt) : undefined }, Date.now()) : undefined;
|
|
772
746
|
// pi.appendEntry returns no id; mint one so tool results can point at the entry.
|
|
@@ -952,12 +926,26 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
952
926
|
// remove-and-re-add is how a schedule or prompt gets changed, and that must not
|
|
953
927
|
// throw away months of accumulated state.
|
|
954
928
|
const purge = /(^|\s)--purge(\s|$)/.test(rest);
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
929
|
+
// `pick` resolves a name machine-wide, which is right for `/cron run ci` and wrong
|
|
930
|
+
// here: a removal cannot be undone, and a name this project does not have would
|
|
931
|
+
// reach into a checkout nobody was looking at and delete its job with a message
|
|
932
|
+
// that never says so. `resolveJobRefScoped` is the rule `cron_remove` already
|
|
933
|
+
// follows — a name resolves in this project, another project's job needs its
|
|
934
|
+
// exact id — so the two faces of the same deletion agree.
|
|
935
|
+
const ref = rest.replace(/(^|\s)--purge(\s|$)/, " ").trim();
|
|
936
|
+
const all = jobs();
|
|
937
|
+
const job = /^\d+$/.test(ref) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref, { ordinals: true }) : resolveJobRefScoped(all, ref, session.cwd);
|
|
938
|
+
if (!job) {
|
|
939
|
+
ctx.ui.notify(ref ? `no cron job with id '${ref}' in ${homeRel(session.cwd)} (another project's job needs its exact id)` : "usage: /cron remove <id> [--purge]", "warning");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const { aborted } = await scheduler.removeJob(job.id, { purge });
|
|
958
943
|
cronControlAudit("remove", "slash", job, undefined);
|
|
959
944
|
const kept = job.stateful && !purge ? `; its loop state is kept (${homeRel(scheduler.store.statePath(job.id))}) — /cron gc --purge clears orphaned state` : purge && job.stateful ? " and its loop state" : "";
|
|
960
|
-
|
|
945
|
+
// An exact id may still name another project's job. Say which, so a removal that
|
|
946
|
+
// reached outside this checkout is never a line that looks like any other.
|
|
947
|
+
const elsewhere = sameProject(job.cwd, session.cwd) ? "" : ` in ${homeRel(job.cwd)}`;
|
|
948
|
+
ctx.ui.notify(`removed cron job ${job.id}${job.name ? ` "${job.name}"` : ""}${elsewhere}${kept}${aborted > 0 ? "; aborted its running run" : ""}`, "info");
|
|
961
949
|
return;
|
|
962
950
|
}
|
|
963
951
|
case "host": {
|
|
@@ -1056,9 +1044,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1056
1044
|
if (purge && orphans.length) for (const o of orphans) scheduler.store.purgeState(o.id);
|
|
1057
1045
|
show(ctx, `removed ${removed.length} job(s) whose session no longer exists${all ? " (every project)" : " in this project"}`, [
|
|
1058
1046
|
...removed.map((j) => ` - ${j.id}${j.name ? ` "${j.name}"` : ""} (session ${(j.sessionId ?? "?").slice(0, 8)})`),
|
|
1059
|
-
...(orphans.length && !purge ? [` ${orphans.length}
|
|
1047
|
+
...(orphans.length && !purge ? [` ${orphans.length} job(s) that are gone left loop state or transcripts behind (${Math.round(orphans.reduce((n, o) => n + o.bytes, 0) / 1024)} KB) — /cron gc --purge deletes them`] : []),
|
|
1060
1048
|
...(all ? [] : [" --all also collects other projects' dead jobs"]),
|
|
1061
|
-
...(purge && orphans.length ? [` deleted ${orphans.length}
|
|
1049
|
+
...(purge && orphans.length ? [` deleted the loop state and transcripts of ${orphans.length} job(s) that are gone`] : []),
|
|
1062
1050
|
]);
|
|
1063
1051
|
refreshBadge();
|
|
1064
1052
|
return;
|
|
@@ -1786,10 +1774,13 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1786
1774
|
return;
|
|
1787
1775
|
}
|
|
1788
1776
|
case "audit": {
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1777
|
+
const args = auditArgs(rest);
|
|
1778
|
+
if ("usage" in args) {
|
|
1779
|
+
ctx.ui.notify(args.usage, "warning");
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1791
1782
|
// This project's rows by default (rows without a cwd predate 0.1.3 and are shown too).
|
|
1792
|
-
const rows = store.listAudit(limit, all ? undefined : (r) => !r.cwd || sameProject(r.cwd, session.cwd));
|
|
1783
|
+
const rows = store.listAudit(args.limit, args.all ? undefined : (r) => !r.cwd || sameProject(r.cwd, session.cwd));
|
|
1793
1784
|
show(
|
|
1794
1785
|
ctx,
|
|
1795
1786
|
rows.length ? `Recent trigger audit (${rows.length}):` : "(no trigger audit entries)",
|
|
@@ -2205,19 +2196,24 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2205
2196
|
}
|
|
2206
2197
|
const ok = await ctx.ui.confirm(`Remove recipe ${name}?`, [
|
|
2207
2198
|
jobs.length ? `${jobs.length} job(s): ${jobs.map((j) => j.name ?? j.id).join(", ")}` : "no jobs of its own here",
|
|
2208
|
-
purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length} file(s)
|
|
2199
|
+
purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length} file(s) as installed — an edited one is kept — their untouched copies, the record` : "no record: only the directory if it is empty"}) and the loops' notes` : `the files in ${homeRel(dir)}/ and the loops' notes are kept (--purge deletes both)`,
|
|
2209
2200
|
].join("\n"));
|
|
2210
2201
|
if (!ok) {
|
|
2211
2202
|
ctx.ui.notify("not removed", "info");
|
|
2212
2203
|
return;
|
|
2213
2204
|
}
|
|
2205
|
+
let aborted = 0;
|
|
2206
|
+
let keptNote = "";
|
|
2214
2207
|
for (const job of jobs) {
|
|
2215
|
-
await scheduler.
|
|
2208
|
+
const r = await scheduler.removeJob(job.id, { purge });
|
|
2209
|
+
aborted += r.aborted;
|
|
2216
2210
|
cronControlAudit("remove", "slash", job, undefined);
|
|
2217
2211
|
}
|
|
2218
2212
|
if (purge) {
|
|
2219
|
-
if (record)
|
|
2220
|
-
|
|
2213
|
+
if (record) {
|
|
2214
|
+
const { kept } = purgeInstall(dir, record, listRecipes().find((r) => r.manifest.name === name)?.manifest.setup);
|
|
2215
|
+
if (kept.length > 0) keptNote = `; kept ${kept.length} edited file(s): ${kept.join(", ")}`;
|
|
2216
|
+
} else {
|
|
2221
2217
|
try {
|
|
2222
2218
|
fs.rmdirSync(dir);
|
|
2223
2219
|
} catch {
|
|
@@ -2226,7 +2222,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2226
2222
|
}
|
|
2227
2223
|
}
|
|
2228
2224
|
refreshBadge();
|
|
2229
|
-
ctx.ui.notify(
|
|
2225
|
+
ctx.ui.notify(
|
|
2226
|
+
`removed recipe ${name}: ${jobs.length} job(s)${purge ? ", its files and notes" : `; files kept in ${homeRel(dir)}/`}${aborted > 0 ? `; aborted ${aborted} running run(s)` : ""}${keptNote}`,
|
|
2227
|
+
"info",
|
|
2228
|
+
);
|
|
2230
2229
|
return;
|
|
2231
2230
|
}
|
|
2232
2231
|
case "help":
|
package/src/recipe.ts
CHANGED
|
@@ -576,10 +576,12 @@ export function installFiles(recipe: Recipe, projectDir: string, level: Autonomy
|
|
|
576
576
|
fs.writeFileSync(origPath(targetDir, f), content);
|
|
577
577
|
}
|
|
578
578
|
if (recipe.manifest.setup) {
|
|
579
|
-
// The setup script is copied too, so what ran is what is there to read afterwards
|
|
579
|
+
// The setup script is copied too, so what ran is what is there to read afterwards — and its
|
|
580
|
+
// untouched copy is kept the way a playbook's is, so `--purge` can tell an edit from the original.
|
|
580
581
|
const dest = path.join(targetDir, recipe.manifest.setup);
|
|
581
582
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
582
583
|
fs.copyFileSync(path.join(recipe.dir, recipe.manifest.setup), dest);
|
|
584
|
+
fs.writeFileSync(origPath(targetDir, recipe.manifest.setup), fs.readFileSync(dest));
|
|
583
585
|
}
|
|
584
586
|
const record: InstallRecord = { recipe: recipe.manifest.name, version: VERSION, level, installedAt: stamp(), files, source: opts.source };
|
|
585
587
|
fs.writeFileSync(path.join(targetDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
|
|
@@ -610,13 +612,30 @@ export function requireRecipeName(name: string): string {
|
|
|
610
612
|
}
|
|
611
613
|
|
|
612
614
|
/**
|
|
613
|
-
* `--purge`: only what the install wrote —
|
|
614
|
-
*
|
|
615
|
-
*
|
|
615
|
+
* `--purge`: only what the install wrote — a recorded file or the setup script, and each only while
|
|
616
|
+
* it still matches its untouched copy, plus the untouched copies and the record — then the directory
|
|
617
|
+
* if that emptied it. A file the person edited, or one with no `.orig` to compare against, is kept
|
|
618
|
+
* and named. `.agents/skills/<name>/` may also hold a project's own files, and those are not ours to
|
|
619
|
+
* delete.
|
|
616
620
|
*/
|
|
617
|
-
export function purgeInstall(targetDir: string, record: InstallRecord, setup?: string):
|
|
621
|
+
export function purgeInstall(targetDir: string, record: InstallRecord, setup?: string): { removed: string[]; kept: string[] } {
|
|
622
|
+
const removed: string[] = [];
|
|
623
|
+
const kept: string[] = [];
|
|
618
624
|
for (const f of [...record.files, ...(setup ? [setup] : [])]) {
|
|
619
|
-
|
|
625
|
+
const dest = path.join(targetDir, f);
|
|
626
|
+
if (!fs.existsSync(dest)) continue;
|
|
627
|
+
let untouched = false;
|
|
628
|
+
try {
|
|
629
|
+
untouched = fs.readFileSync(path.join(targetDir, ".orig", f)).equals(fs.readFileSync(dest));
|
|
630
|
+
} catch {
|
|
631
|
+
untouched = false; // no untouched copy: leave the file alone
|
|
632
|
+
}
|
|
633
|
+
if (untouched) {
|
|
634
|
+
fs.rmSync(dest, { force: true });
|
|
635
|
+
removed.push(f);
|
|
636
|
+
} else {
|
|
637
|
+
kept.push(f);
|
|
638
|
+
}
|
|
620
639
|
}
|
|
621
640
|
fs.rmSync(path.join(targetDir, ".orig"), { recursive: true, force: true });
|
|
622
641
|
fs.rmSync(path.join(targetDir, RECORD_FILE), { force: true });
|
|
@@ -634,6 +653,7 @@ export function purgeInstall(targetDir: string, record: InstallRecord, setup?: s
|
|
|
634
653
|
} catch {
|
|
635
654
|
// something else lives here; leave it
|
|
636
655
|
}
|
|
656
|
+
return { removed, kept };
|
|
637
657
|
}
|
|
638
658
|
|
|
639
659
|
/** Every recipe installed in a project: the directories under `.agents/skills/` that carry a record. */
|
package/src/scheduler.ts
CHANGED
|
@@ -33,6 +33,12 @@ export const FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60_000;
|
|
|
33
33
|
export const FOREIGN_RUN_STALE_MS = 24 * 60 * 60 * 1000;
|
|
34
34
|
export const DEFAULT_RUN_TIMEOUT_MS = 15 * 60_000;
|
|
35
35
|
export const MAX_CONCURRENT_RUNS = 3;
|
|
36
|
+
/**
|
|
37
|
+
* How long `removeJob` waits for an aborted run to write its record before removing the job anyway.
|
|
38
|
+
* Two seconds is enough: the drain exists to let a normal abort write its record first, not to
|
|
39
|
+
* outlast a wedged sub-agent — the job is removed regardless, and `runOnce` discards its state.
|
|
40
|
+
*/
|
|
41
|
+
export const REMOVE_DRAIN_MS = 2_000;
|
|
36
42
|
|
|
37
43
|
export interface SessionSnapshot {
|
|
38
44
|
sessionId?: string;
|
|
@@ -69,6 +75,8 @@ export interface SchedulerHooks {
|
|
|
69
75
|
/** A run is being fired for a tick that was missed while no pi was open. */
|
|
70
76
|
onCatchUp?: (job: LoopJob, dueAt: number) => void;
|
|
71
77
|
onRunFinished?: (outcome: RunOutcome) => void;
|
|
78
|
+
/** A one-shot removed itself: the control plane records it the way a person's removal is recorded. */
|
|
79
|
+
onJobRetired?: (job: LoopJob, reason: string) => void;
|
|
72
80
|
onInboxChanged?: () => void;
|
|
73
81
|
/** Every tick, after leadership is settled. Other subsystems (dynamic triggers) piggyback on it. */
|
|
74
82
|
onTick?: (now: number, leader: boolean) => void | Promise<void>;
|
|
@@ -172,12 +180,14 @@ export class LoopScheduler {
|
|
|
172
180
|
this.dir = opts.dir;
|
|
173
181
|
this.store = new JobStore(opts.dir);
|
|
174
182
|
this.inbox = new Inbox(opts.dir);
|
|
175
|
-
// One leader
|
|
183
|
+
// One leader at a time, for the machine: every process here elects through this one file. Two
|
|
184
|
+
// machines sharing a $HOME contend for it and both end up running every job, which is not
|
|
185
|
+
// supported (docs/design.md §19).
|
|
176
186
|
this.leaderFile = path.join(opts.dir, "scheduler.json");
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
187
|
+
// One plain file beside the leader record. A cron expression is matched against local time, so
|
|
188
|
+
// if two machines sharing a `$HOME` both ran, they would write different answers over one
|
|
189
|
+
// another and the panel would show whichever wrote last — which is why that arrangement is
|
|
190
|
+
// unsupported rather than handled (docs/design.md §19).
|
|
181
191
|
this.nextRunsFile = path.join(opts.dir, "next-runs.json");
|
|
182
192
|
this.leaderLock = path.join(opts.dir, "scheduler.lock");
|
|
183
193
|
this.getSession = opts.getSession;
|
|
@@ -226,6 +236,26 @@ export class LoopScheduler {
|
|
|
226
236
|
return true;
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Remove a job. A run of it that is in flight is aborted first and given up to
|
|
241
|
+
* REMOVE_DRAIN_MS to write its record, so the removal does not race the run's end.
|
|
242
|
+
* Returns the removed job (undefined when there was none) and how many runs were aborted.
|
|
243
|
+
*/
|
|
244
|
+
async removeJob(id: string, opts: { purge?: boolean } = {}): Promise<{ job: LoopJob | undefined; aborted: number }> {
|
|
245
|
+
const runIds = [...this.inflight.entries()].filter(([, r]) => r.jobId === id).map(([runId]) => runId);
|
|
246
|
+
for (const runId of runIds) this.inflight.get(runId)?.ctrl.abort();
|
|
247
|
+
// The wall clock, not `this.now()`: this is a real wait, and a test that moved its injected
|
|
248
|
+
// clock must not be able to hold the removal open for ever.
|
|
249
|
+
const deadline = Date.now() + REMOVE_DRAIN_MS;
|
|
250
|
+
while (runIds.some((runId) => this.inflight.has(runId)) && Date.now() < deadline) {
|
|
251
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
252
|
+
}
|
|
253
|
+
// If a run is still there past the drain the job is removed anyway; `runOnce` re-reads the
|
|
254
|
+
// store before it writes anything, so a late run leaves nothing behind.
|
|
255
|
+
const job = await this.store.remove(id, opts);
|
|
256
|
+
return { job, aborted: runIds.length };
|
|
257
|
+
}
|
|
258
|
+
|
|
229
259
|
/** Idempotent. Starts the tick timer and takes leadership if free. */
|
|
230
260
|
start(): void {
|
|
231
261
|
if (this.timer) return;
|
|
@@ -500,9 +530,13 @@ export class LoopScheduler {
|
|
|
500
530
|
}
|
|
501
531
|
}
|
|
502
532
|
|
|
503
|
-
/**
|
|
533
|
+
/**
|
|
534
|
+
* Remove the jobs `disableDeadSessionJobs` parked; returns them. These are plain jobs of deleted
|
|
535
|
+
* sessions with nothing left to run, so gc says yes to `purge`: their leftover state and
|
|
536
|
+
* transcripts go with them.
|
|
537
|
+
*/
|
|
504
538
|
async gc(inScope: (job: LoopJob) => boolean = () => true): Promise<LoopJob[]> {
|
|
505
|
-
return this.store.removeWhere((j) => inScope(j) && !j.enabled && !!j.lastError?.endsWith(DEAD_SESSION_MARKER));
|
|
539
|
+
return this.store.removeWhere((j) => inScope(j) && !j.enabled && !!j.lastError?.endsWith(DEAD_SESSION_MARKER), { purge: true });
|
|
506
540
|
}
|
|
507
541
|
|
|
508
542
|
/** Clear running markers left behind by processes that are gone. Mutates `jobs` in place. */
|
|
@@ -668,7 +702,14 @@ export class LoopScheduler {
|
|
|
668
702
|
// here, and the caller used to read it back out of the string this just formatted.
|
|
669
703
|
const runId = newId("run");
|
|
670
704
|
await this.hooks.onInject?.(job, `[Trigger ${runId}] ${job.prompt}${note}`, runId);
|
|
671
|
-
if (job.schedule.kind === "once")
|
|
705
|
+
if (job.schedule.kind === "once") {
|
|
706
|
+
await this.store.remove(job.id);
|
|
707
|
+
try {
|
|
708
|
+
this.hooks.onJobRetired?.(job, "one-shot fired");
|
|
709
|
+
} catch (err: any) {
|
|
710
|
+
this.log(`onJobRetired hook failed: ${err?.message ?? err}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
672
713
|
return true;
|
|
673
714
|
}
|
|
674
715
|
|
|
@@ -811,10 +852,24 @@ export class LoopScheduler {
|
|
|
811
852
|
// make `clearStaleRunning` see "my pid, not in flight", clear the running marker, roll the
|
|
812
853
|
// schedule back and re-fire the same job while its checker is still working.
|
|
813
854
|
|
|
855
|
+
// The job may have been removed while the run was in flight — by `/cron remove` here, by the
|
|
856
|
+
// tool, or by another pi window. Its state and findings belong to a job that is gone, so they
|
|
857
|
+
// are discarded; the run record is still written below, because the run did happen.
|
|
858
|
+
// A store that cannot be read (a torn write, a file from a newer build) must not cost a run its
|
|
859
|
+
// record and its findings: the question here is only whether the job was removed, and the safe
|
|
860
|
+
// answer to "cannot tell" is no.
|
|
861
|
+
let stillThere = true;
|
|
862
|
+
try {
|
|
863
|
+
stillThere = this.store.load().some((j) => j.id === job.id);
|
|
864
|
+
} catch (err: any) {
|
|
865
|
+
this.log(`loop ${job.id}: could not read the store after the run (${err?.message ?? err}); keeping its state and findings`);
|
|
866
|
+
}
|
|
867
|
+
if (!stillThere) this.log(`loop ${job.id}: removed during the run; its state and findings are discarded`);
|
|
868
|
+
|
|
814
869
|
// Tag extraction never fails a run: malformed/missing tags leave the state untouched.
|
|
815
870
|
const parsed = parseRunOutput(result.text);
|
|
816
871
|
let stateUpdated = false;
|
|
817
|
-
if (result.ok && parsed.state !== undefined) {
|
|
872
|
+
if (stillThere && result.ok && parsed.state !== undefined) {
|
|
818
873
|
try {
|
|
819
874
|
this.store.writeState(job.id, parsed.state);
|
|
820
875
|
stateUpdated = true;
|
|
@@ -827,13 +882,13 @@ export class LoopScheduler {
|
|
|
827
882
|
// still enter the inbox, but marked unverified — a broken checker must not silence the loop.
|
|
828
883
|
let checker: CheckerRecord | undefined;
|
|
829
884
|
let reviewed: Array<{ text: string; verified?: boolean; reason?: string }> = parsed.findings.map((text) => ({ text }));
|
|
830
|
-
if (result.ok && job.verify && parsed.findings.length && !ctrl.signal.aborted) {
|
|
885
|
+
if (stillThere && result.ok && job.verify && parsed.findings.length && !ctrl.signal.aborted) {
|
|
831
886
|
const outcome = await this.runChecker(job, runId, parsed.state ?? previousState, parsed.findings, ctrl.signal, job.checkerModel ?? model, thinking);
|
|
832
887
|
checker = outcome.record;
|
|
833
888
|
reviewed = outcome.reviewed;
|
|
834
889
|
}
|
|
835
890
|
const findings: string[] = [];
|
|
836
|
-
if (result.ok) {
|
|
891
|
+
if (stillThere && result.ok) {
|
|
837
892
|
// cron:<name>, else cron:<id prefix> — "cron-" plus 8 hex, the width /inbox shows.
|
|
838
893
|
const source = `cron:${job.name ?? job.id.slice(0, "cron-".length + 8)}`;
|
|
839
894
|
for (const f of reviewed) {
|
|
@@ -882,20 +937,28 @@ export class LoopScheduler {
|
|
|
882
937
|
// slot it claimed is given back and the next tick re-fires it (a tick is never lost this
|
|
883
938
|
// way because its runs die with the session that owned them).
|
|
884
939
|
const aborted = !result.ok && (result.stopReason === "aborted" || ctrl.signal.aborted);
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
j.
|
|
892
|
-
|
|
893
|
-
j.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
940
|
+
// The same unreadable store that was tolerated above must not cost the run its `onRunFinished`
|
|
941
|
+
// either: the record is already written, and the job's own bookkeeping is the one part that
|
|
942
|
+
// can be skipped when the file cannot be read.
|
|
943
|
+
let updated: LoopJob | undefined;
|
|
944
|
+
try {
|
|
945
|
+
updated = await this.store.update(job.id, (j) => {
|
|
946
|
+
if (j.running?.runId === runId) j.running = undefined;
|
|
947
|
+
j.lastCompletedAt = finishedAt;
|
|
948
|
+
j.lastError = result.ok ? undefined : record.error;
|
|
949
|
+
if (aborted) {
|
|
950
|
+
j.lastDueAt = prior.dueAt;
|
|
951
|
+
j.lastFiredAt = prior.firedAt;
|
|
952
|
+
} else {
|
|
953
|
+
j.runCount++;
|
|
954
|
+
// A job that fails every time costs money every time. Count the streak so `dispatch`
|
|
955
|
+
// can back off, and clear it the moment one run works.
|
|
956
|
+
j.consecutiveFailures = result.ok ? undefined : (j.consecutiveFailures ?? 0) + 1;
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
} catch (err: any) {
|
|
960
|
+
this.log(`loop ${job.id}: could not write the store after the run (${err?.message ?? err})`);
|
|
961
|
+
}
|
|
899
962
|
if (updated && !result.ok && (updated.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER) {
|
|
900
963
|
this.hooks.onSchedulerError?.(`${updated.name ?? updated.id} has failed ${updated.consecutiveFailures} times in a row; backing off (last: ${record.error ?? "unknown"})`);
|
|
901
964
|
}
|
|
@@ -907,13 +970,20 @@ export class LoopScheduler {
|
|
|
907
970
|
// not for a job — and is then retired too, with the error kept in the run log and reported by
|
|
908
971
|
// `onRunFinished`. Anything else leaves a failed `in 10m` enabled forever with no next run.
|
|
909
972
|
if (updated && job.schedule.kind === "once" && !aborted) {
|
|
910
|
-
if (result.ok || updated.runCount >= 2)
|
|
911
|
-
|
|
973
|
+
if (result.ok || updated.runCount >= 2) {
|
|
974
|
+
await this.store.remove(job.id);
|
|
975
|
+
try {
|
|
976
|
+
this.hooks.onJobRetired?.(job, result.ok ? "one-shot finished" : "one-shot failed twice");
|
|
977
|
+
} catch (err: any) {
|
|
978
|
+
this.log(`onJobRetired hook failed: ${err?.message ?? err}`);
|
|
979
|
+
}
|
|
980
|
+
} else {
|
|
912
981
|
await this.store.update(job.id, (j) => {
|
|
913
982
|
j.lastDueAt = undefined;
|
|
914
983
|
j.lastFiredAt = undefined;
|
|
915
984
|
j.lastError = `${record.error ?? "run failed"} (one-shot: retrying once)`;
|
|
916
985
|
});
|
|
986
|
+
}
|
|
917
987
|
}
|
|
918
988
|
|
|
919
989
|
try {
|
package/src/session-head.ts
CHANGED
|
@@ -27,11 +27,15 @@ const HEAD_BYTES = 64 * 1024;
|
|
|
27
27
|
|
|
28
28
|
export function readSessionHead(file: string, bytes: number = HEAD_BYTES): SessionHead | undefined {
|
|
29
29
|
let text: string;
|
|
30
|
+
let readBytes = 0;
|
|
30
31
|
try {
|
|
31
32
|
const fd = fs.openSync(file, "r");
|
|
32
33
|
try {
|
|
33
34
|
const buf = Buffer.alloc(bytes);
|
|
34
|
-
|
|
35
|
+
// `bytes` is a byte window, so the decision to cut is the byte count read, not the
|
|
36
|
+
// decoded string's length: a multi-byte character makes those differ.
|
|
37
|
+
readBytes = fs.readSync(fd, buf, 0, bytes, 0);
|
|
38
|
+
text = buf.toString("utf8", 0, readBytes);
|
|
35
39
|
} finally {
|
|
36
40
|
fs.closeSync(fd);
|
|
37
41
|
}
|
|
@@ -46,7 +50,7 @@ export function readSessionHead(file: string, bytes: number = HEAD_BYTES): Sessi
|
|
|
46
50
|
return undefined;
|
|
47
51
|
}
|
|
48
52
|
if (header?.type !== "session" || typeof header.id !== "string") return undefined;
|
|
49
|
-
const truncated =
|
|
53
|
+
const truncated = readBytes >= bytes;
|
|
50
54
|
let name: string | undefined;
|
|
51
55
|
let first: string | undefined;
|
|
52
56
|
let messages = 0;
|
package/src/store.ts
CHANGED
|
@@ -359,9 +359,14 @@ export class JobStore {
|
|
|
359
359
|
return this.mutate((jobs) => ({ jobs: [...jobs, job], result: job }));
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
/**
|
|
363
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Remove every job matching `pred`; returns them. Its state and transcripts are kept unless
|
|
364
|
+
* `purge` is set — the caller decides, as with `remove` — because the usual way to change a job
|
|
365
|
+
* is to remove it and add it again, and a stateful loop's notes are what it accumulated.
|
|
366
|
+
*/
|
|
367
|
+
async removeWhere(pred: (job: LoopJob) => boolean, opts: { purge?: boolean } = {}): Promise<LoopJob[]> {
|
|
364
368
|
const removed = await this.mutate((jobs) => ({ jobs: jobs.filter((j) => !pred(j)), result: jobs.filter(pred) }));
|
|
369
|
+
if (!opts.purge) return removed;
|
|
365
370
|
for (const job of removed) {
|
|
366
371
|
try {
|
|
367
372
|
fs.rmSync(this.statePath(job.id), { force: true });
|
|
@@ -405,17 +410,59 @@ export class JobStore {
|
|
|
405
410
|
}
|
|
406
411
|
}
|
|
407
412
|
|
|
408
|
-
/**
|
|
413
|
+
/**
|
|
414
|
+
* Loop state and transcripts left behind by removed jobs, so `/cron gc` can offer to clear both.
|
|
415
|
+
* An id qualifies if a `state/<id>.md` or a `sessions/<id>/` directory is there and no job claims
|
|
416
|
+
* it; `bytes` is the state file plus the whole transcript tree, since that is what `purgeState` deletes.
|
|
417
|
+
*/
|
|
409
418
|
orphanStates(): Array<{ id: string; bytes: number }> {
|
|
410
419
|
const live = new Set(this.load().map((j) => j.id));
|
|
420
|
+
const bytes = new Map<string, number>();
|
|
421
|
+
const add = (id: string, n: number) => {
|
|
422
|
+
if (!live.has(id)) bytes.set(id, (bytes.get(id) ?? 0) + n);
|
|
423
|
+
};
|
|
424
|
+
let states: string[] = [];
|
|
411
425
|
try {
|
|
412
|
-
|
|
413
|
-
.readdirSync(path.join(this.dir, "state"))
|
|
414
|
-
.filter((f) => f.endsWith(".md") && !live.has(f.slice(0, -3)))
|
|
415
|
-
.map((f) => ({ id: f.slice(0, -3), bytes: fs.statSync(path.join(this.dir, "state", f)).size }));
|
|
426
|
+
states = fs.readdirSync(this.stateDir);
|
|
416
427
|
} catch {
|
|
417
|
-
|
|
428
|
+
/* no state directory yet */
|
|
429
|
+
}
|
|
430
|
+
for (const f of states) {
|
|
431
|
+
if (!f.endsWith(".md")) continue;
|
|
432
|
+
let size = 0;
|
|
433
|
+
try {
|
|
434
|
+
size = fs.statSync(path.join(this.stateDir, f)).size;
|
|
435
|
+
} catch {
|
|
436
|
+
/* a file that vanished between the read and the stat counts 0 */
|
|
437
|
+
}
|
|
438
|
+
add(f.slice(0, -3), size);
|
|
439
|
+
}
|
|
440
|
+
let transcripts: fs.Dirent[] = [];
|
|
441
|
+
try {
|
|
442
|
+
transcripts = fs.readdirSync(this.sessionsDir, { withFileTypes: true });
|
|
443
|
+
} catch {
|
|
444
|
+
/* no transcript directory yet */
|
|
445
|
+
}
|
|
446
|
+
for (const e of transcripts) {
|
|
447
|
+
if (!e.isDirectory()) continue;
|
|
448
|
+
let size = 0;
|
|
449
|
+
try {
|
|
450
|
+
// The whole tree: `purgeState` deletes the directory recursively, so counting only the
|
|
451
|
+
// files directly under it understated what `/cron gc` is about to remove.
|
|
452
|
+
for (const f of fs.readdirSync(path.join(this.sessionsDir, e.name), { recursive: true, withFileTypes: true })) {
|
|
453
|
+
if (!f.isFile()) continue;
|
|
454
|
+
try {
|
|
455
|
+
size += fs.statSync(path.join(f.parentPath, f.name)).size;
|
|
456
|
+
} catch {
|
|
457
|
+
/* a file that vanished between the read and the stat counts 0 */
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
size = 0; // an unreadable directory counts 0
|
|
462
|
+
}
|
|
463
|
+
add(e.name, size);
|
|
418
464
|
}
|
|
465
|
+
return [...bytes.entries()].map(([id, n]) => ({ id, bytes: n }));
|
|
419
466
|
}
|
|
420
467
|
|
|
421
468
|
/* ---------------------------------------------------- loop state */
|
package/src/tools.ts
CHANGED
|
@@ -416,15 +416,16 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
416
416
|
// free: it is what the user is shown before deciding.
|
|
417
417
|
const denied = await host.confirmTool(ctx, { label: `remove cron job ${label}`, tool: "cron_remove", reason: "remove a scheduled job", preview: `${formatSchedule(job.schedule)} · ${previewRedacted(job.prompt, 120)}`, args: params }, scope.hop);
|
|
418
418
|
if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
|
|
419
|
-
await host.scheduler.
|
|
419
|
+
const { aborted } = await host.scheduler.removeJob(job.id);
|
|
420
420
|
const auditEntryId = host.cronControlAudit("remove", scope.actor, job, undefined);
|
|
421
421
|
// The slash command says where a loop's notes went; a tool-driven removal has to say it too,
|
|
422
422
|
// or the person who asked the agent to delete a job is the only one left guessing. The prose
|
|
423
423
|
// carries the fact and `details` carries the path, which is the division this tool already
|
|
424
424
|
// uses elsewhere (`cron_list` reports `storage_path` the same way).
|
|
425
425
|
const kept = job.stateful ? "\nits loop state and run transcripts are kept; /cron gc --purge clears them" : "";
|
|
426
|
+
const abortedNote = aborted > 0 ? "\naborted its running run" : "";
|
|
426
427
|
const statePath = job.stateful ? host.scheduler.store.statePath(job.id) : undefined;
|
|
427
|
-
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}` }], details: { id: job.id as string | undefined, removed_count: 1, confirmation_required: false, audit_entry_id: auditEntryId, state_path: statePath } };
|
|
428
|
+
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1, confirmation_required: false, audit_entry_id: auditEntryId, state_path: statePath } };
|
|
428
429
|
},
|
|
429
430
|
});
|
|
430
431
|
|
package/src/triggers.ts
CHANGED
|
@@ -406,6 +406,30 @@ export class TriggerStore {
|
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
export const AUDIT_USAGE = "usage: /triggers audit [N] [--all] (N is the number of rows, default 10)";
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `/triggers audit [N] [--all]`, parsed where it can be tested: `src/pi-loops.ts` is the extension's
|
|
413
|
+
* default export, so nothing can import the handler. A limit the command cannot read is refused
|
|
414
|
+
* rather than silently replaced with the default, and a negative one is refused rather than handed
|
|
415
|
+
* to `listAudit`, where `slice(-limit)` stops meaning "limit" and prints the file from the top.
|
|
416
|
+
* `--all` lifts the project filter and may sit before or after the number.
|
|
417
|
+
*/
|
|
418
|
+
export function auditArgs(rest: string): { limit: number; all: boolean } | { usage: string } {
|
|
419
|
+
let limit = 10;
|
|
420
|
+
let all = false;
|
|
421
|
+
let numbered = false;
|
|
422
|
+
for (const part of rest.split(/\s+/).filter(Boolean)) {
|
|
423
|
+
if (part === "--all") all = true;
|
|
424
|
+
else if (!numbered && /^\d+$/.test(part)) {
|
|
425
|
+
limit = Number(part);
|
|
426
|
+
numbered = true;
|
|
427
|
+
} else return { usage: AUDIT_USAGE };
|
|
428
|
+
}
|
|
429
|
+
if (limit < 1) return { usage: AUDIT_USAGE };
|
|
430
|
+
return { limit, all };
|
|
431
|
+
}
|
|
432
|
+
|
|
409
433
|
/**
|
|
410
434
|
* The control-plane prompt gate, pre-flight part. Prompt-class tool calls (create or remove
|
|
411
435
|
* a trigger, re-enable a trigger or a cron job) need a human. Sub-agents have no prompt channel
|