@alphacoder-v0/pi-loops 0.17.6 → 0.19.2
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 +13 -6
- package/README.zh-CN.md +19 -6
- package/docs/agents/issue-tracker.md +34 -0
- package/docs/cli.md +22 -2
- package/docs/configuration.md +1 -1
- package/docs/design.md +63 -6
- package/docs/loops.md +25 -17
- package/docs/recipes.md +239 -0
- package/docs/triggers.md +1 -1
- package/docs/troubleshooting.md +3 -2
- package/docs/web-ui-parity.md +9 -3
- package/package.json +2 -1
- package/recipes/_tracker/issue-tracker-github.md +31 -0
- package/recipes/_tracker/issue-tracker-local.md +38 -0
- package/recipes/_tracker-setup.md +12 -0
- package/recipes/autoresearch/README.md +27 -0
- package/recipes/autoresearch/RESEARCH.template.md +37 -0
- package/recipes/autoresearch/example/RESEARCH.md +31 -0
- package/recipes/autoresearch/example/eval.py +60 -0
- package/recipes/autoresearch/example/eval.sh +3 -0
- package/recipes/autoresearch/example/solution.py +20 -0
- package/recipes/autoresearch/experiment.md +82 -0
- package/recipes/autoresearch/recipe.toml +15 -0
- package/recipes/changelog-draft/recipe.toml +9 -0
- package/recipes/changelog-draft/release.md +35 -0
- package/recipes/issue-loop/README.md +16 -0
- package/recipes/issue-loop/implement.md +135 -0
- package/recipes/issue-loop/labels.sh +21 -0
- package/recipes/issue-loop/recipe.toml +18 -0
- package/recipes/issue-loop/triage.md +147 -0
- package/skills/pi-loops/SKILL.md +3 -1
- package/src/archive.ts +5 -5
- package/src/args.ts +1 -8
- package/src/cli.ts +83 -6
- package/src/job-edit.ts +0 -3
- package/src/job-health.ts +6 -7
- package/src/job-owner.ts +29 -0
- package/src/pi-loops.ts +339 -25
- package/src/protocol.ts +2 -3
- package/src/recipe.ts +651 -0
- package/src/scheduler.ts +13 -13
- package/src/session-head.ts +98 -0
- package/src/store.ts +9 -17
- package/src/tools.ts +17 -19
- package/src/trigger-runtime.ts +3 -5
- package/src/triggers.ts +3 -4
- package/src/web.mjs +40 -17
package/src/pi-loops.ts
CHANGED
|
@@ -33,9 +33,12 @@ import { capRedacted, previewRedacted, redact } from "./redact.ts";
|
|
|
33
33
|
import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
|
|
34
34
|
import { type ShareMessage, renderShare, shareSummary } from "./share.ts";
|
|
35
35
|
import { installLauncherWithConfirm } from "./cli.ts";
|
|
36
|
+
import { execFile } from "node:child_process";
|
|
36
37
|
import { createHash } from "node:crypto";
|
|
37
38
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
38
39
|
import { applyJobEdit } from "./job-edit.ts";
|
|
40
|
+
import { asleepNote, runsIn } from "./job-owner.ts";
|
|
41
|
+
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, purgeInstall, readRecord, requireRecipeName, resolveRecipeRef, updateFiles } from "./recipe.ts";
|
|
39
42
|
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
40
43
|
import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
|
|
41
44
|
import { parentRuntimeFlags, type SubagentRequest } from "./runner.ts";
|
|
@@ -661,7 +664,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
661
664
|
if (n > 0) parts.push(`Inbox: ${n} new`);
|
|
662
665
|
// Machine-wide like the inbox count and the running list above it: the clock is one per host,
|
|
663
666
|
// and a loop failing in another checkout is still this machine's automation going quiet.
|
|
664
|
-
const failing = failingSummary(scheduler.store.load()
|
|
667
|
+
const failing = failingSummary(scheduler.store.load());
|
|
665
668
|
if (failing) parts.push(failing);
|
|
666
669
|
const running = [...scheduler.runningLabels(), ...triggers.runningList().map((r) => (r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel))];
|
|
667
670
|
if (running.length) parts.push(`running: ${running.join(", ")}`);
|
|
@@ -728,13 +731,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
728
731
|
|
|
729
732
|
function jobLines(jobs: LoopJob[]): string[] {
|
|
730
733
|
const now = Date.now();
|
|
731
|
-
const here = os.hostname();
|
|
732
734
|
return jobs.map((job, i) => {
|
|
733
|
-
// A
|
|
734
|
-
|
|
735
|
-
// catches a renamed machine or a rebuilt container, which needs no second machine at all.
|
|
736
|
-
const elsewhere = job.host && job.host !== here ? `[other host: ${job.host} — /cron set ${i + 1} --host here]` : undefined;
|
|
737
|
-
const next = job.enabled && !elsewhere
|
|
735
|
+
// A next run is shown where it will happen: a plain job of a session not open here has none.
|
|
736
|
+
const next = job.enabled && runsIn(job, session.sessionId)
|
|
738
737
|
? computeNext(
|
|
739
738
|
{
|
|
740
739
|
schedule: job.schedule,
|
|
@@ -744,9 +743,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
744
743
|
now,
|
|
745
744
|
)
|
|
746
745
|
: undefined;
|
|
747
|
-
const
|
|
746
|
+
const asleepMark = asleepNote(job, session.sessionId);
|
|
747
|
+
const dormant = asleepMark ? `[${asleepMark}]` : undefined;
|
|
748
748
|
const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
749
|
-
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined,
|
|
749
|
+
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
|
|
750
750
|
.filter(Boolean)
|
|
751
751
|
.join(" ");
|
|
752
752
|
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
|
|
@@ -796,7 +796,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
796
796
|
" --verify: maker/checker — a second adversarial sub-agent reviews findings before they enter /inbox (--checker-model <provider/id> to use another model)",
|
|
797
797
|
" more flags: --name <n> --cwd <dir> --model <provider/id> --thinking <lvl> --tools a,b --timeout 20m --catchup|--no-catchup (default: loops catch up a missed tick, plain jobs do not)",
|
|
798
798
|
"/cron enable|disable|remove <n|id|name> /cron run <n|id|name> fire now",
|
|
799
|
-
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-]
|
|
799
|
+
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] change what a job runs with (- = use the session's current)",
|
|
800
800
|
' also [--prompt "<text>"] [--schedule "<expr>"]: reword a loop or move it to another hour in place — the job keeps its id, and a stateful loop keeps its notes',
|
|
801
801
|
"/cron state <n|id|name> the loop's notes (state spine)",
|
|
802
802
|
"/cron runs [n|id|name] recent runs /cron trace [n|id|name] [k] [checker] k-th latest run's transcript (maker, or its checker)",
|
|
@@ -1065,7 +1065,6 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1065
1065
|
// it can be tested. Anything it refuses throws before the store is touched.
|
|
1066
1066
|
const applied = applyJobEdit(job, change, {
|
|
1067
1067
|
now: Date.now(),
|
|
1068
|
-
hostName: os.hostname(),
|
|
1069
1068
|
maxPromptBytes: MAX_PROMPT_BYTES,
|
|
1070
1069
|
checkName: checkJobName,
|
|
1071
1070
|
others: scheduler.store.load().filter((j) => j.id !== job.id),
|
|
@@ -1092,7 +1091,6 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1092
1091
|
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1093
1092
|
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1094
1093
|
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : "default"}`,
|
|
1095
|
-
` host: ${updated.host ?? "(any machine)"}`,
|
|
1096
1094
|
]);
|
|
1097
1095
|
return;
|
|
1098
1096
|
}
|
|
@@ -1510,8 +1508,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1510
1508
|
/* ---------------------------------------------------------- /triggers */
|
|
1511
1509
|
|
|
1512
1510
|
// The menu names every subcommand; the arguments live in TRIGGERS_HELP below, which `/triggers
|
|
1513
|
-
// help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel
|
|
1514
|
-
//
|
|
1511
|
+
// help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel`)
|
|
1512
|
+
// was left out of the product.
|
|
1515
1513
|
const TRIGGERS_USAGE = "[status|rules|sources|hooks|enable|disable|remove|set|run|running|audit|abort|panel|help] — /triggers help for the arguments";
|
|
1516
1514
|
|
|
1517
1515
|
const TRIGGERS_HELP = [
|
|
@@ -1520,7 +1518,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1520
1518
|
"/triggers sources MCP push sources, the local crontab, the dynamic checker — and what each has seen (/triggers hooks is the same view)",
|
|
1521
1519
|
"/triggers enable <n|id> /triggers disable <n|id> also --all (this project) | --all-projects (the machine)",
|
|
1522
1520
|
"/triggers remove <n|id> also remove --all | remove --all-projects; /new-trigger creates one",
|
|
1523
|
-
"/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-]
|
|
1521
|
+
"/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] what the action runs with (- = the session's current)",
|
|
1524
1522
|
"/triggers run <n|id> check one rule now — dedup, audit, sub-agent and promotion as on a poll",
|
|
1525
1523
|
"/triggers running actions in flight (dynamic checks and cron runs), and the sub-agent slots in use",
|
|
1526
1524
|
"/triggers audit [N] [--all] recent decisions, with each run's transcript path (default 10)",
|
|
@@ -1538,9 +1536,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1538
1536
|
const fired = r.firedAt ? `, fired_at=${r.firedAt}` : "";
|
|
1539
1537
|
const head = numbered ? `${String(i + 1).padStart(2)}. ` : " - ";
|
|
1540
1538
|
const other = r.createdBy?.sessionId && r.createdBy.sessionId !== session.sessionId ? ` (session ${r.createdBy.sessionId.slice(0, 8)})` : "";
|
|
1541
|
-
|
|
1542
|
-
const elsewhere = r.host && r.host !== os.hostname() ? ` [other host: ${r.host}]` : "";
|
|
1543
|
-
return `${head}${r.id} [${state}, ${fire}, ${out}${fired}]${elsewhere} when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
|
|
1539
|
+
return `${head}${r.id} [${state}, ${fire}, ${out}${fired}] when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
|
|
1544
1540
|
});
|
|
1545
1541
|
}
|
|
1546
1542
|
|
|
@@ -1669,7 +1665,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1669
1665
|
case "set": {
|
|
1670
1666
|
const change = parseSetArgs(rest);
|
|
1671
1667
|
if (change.name !== undefined) {
|
|
1672
|
-
ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking
|
|
1668
|
+
ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking and --timeout", "warning");
|
|
1673
1669
|
return;
|
|
1674
1670
|
}
|
|
1675
1671
|
const rule = pickRule(change.ref);
|
|
@@ -1678,14 +1674,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1678
1674
|
if (change.model !== undefined) r.model = change.model ?? undefined;
|
|
1679
1675
|
if (change.thinking !== undefined) r.thinking = change.thinking ?? undefined;
|
|
1680
1676
|
if (change.timeoutMs !== undefined) r.timeoutMs = change.timeoutMs ?? undefined;
|
|
1681
|
-
if (change.host !== undefined) r.host = change.host === "here" ? os.hostname() : undefined;
|
|
1682
1677
|
});
|
|
1683
1678
|
if (!updated) return;
|
|
1684
1679
|
show(ctx, `updated trigger ${updated.id}`, [
|
|
1685
1680
|
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1686
1681
|
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1687
1682
|
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : `default (${Math.round(triggers.runTimeoutMs / 1000)}s)`}`,
|
|
1688
|
-
` host: ${updated.host ?? "(any machine)"}`,
|
|
1689
1683
|
]);
|
|
1690
1684
|
return;
|
|
1691
1685
|
}
|
|
@@ -1840,6 +1834,314 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1840
1834
|
},
|
|
1841
1835
|
});
|
|
1842
1836
|
|
|
1837
|
+
/* ------------------------------------------------------------------ /recipe */
|
|
1838
|
+
|
|
1839
|
+
// `add <ref>` rather than `add <name|path>`: the menu is read back by a test that splits on `|`.
|
|
1840
|
+
const RECIPE_USAGE = "[list|show <ref>|add <ref> [--level <l>]|update <name>|remove <name> [--purge]|help]";
|
|
1841
|
+
|
|
1842
|
+
const RECIPE_HELP = [
|
|
1843
|
+
"/recipe the packaged recipes, and which of them are installed here",
|
|
1844
|
+
"/recipe show <name|path> what one would install: playbooks, jobs, levels, setup script",
|
|
1845
|
+
"/recipe add <name|path> install it in this project: copies the playbooks to .agents/skills/<name>/,",
|
|
1846
|
+
" runs the setup script after showing it, creates the jobs. --level report|propose|act skips the question",
|
|
1847
|
+
"/recipe update <name> merge the packaged playbooks into the installed copies; your edits are kept",
|
|
1848
|
+
"/recipe remove <name> remove its jobs; --purge also deletes the installed files and the loops' notes",
|
|
1849
|
+
"/recipe help this text",
|
|
1850
|
+
"",
|
|
1851
|
+
"A recipe is a directory with a recipe.toml (docs/recipes.md). The copies live in .agents/skills/<name>/,",
|
|
1852
|
+
"kept out of the repository through .git/info/exclude, and a run reads them fresh every time.",
|
|
1853
|
+
];
|
|
1854
|
+
|
|
1855
|
+
const LEVEL_TEXT: Record<AutonomyLevel, string> = {
|
|
1856
|
+
report: "read, and file findings; nothing written outside the inbox",
|
|
1857
|
+
propose: "also write to the tracker and open draft pull requests; never a terminal state",
|
|
1858
|
+
act: "also promote, close and merge — the terminal states",
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
/** The prompt that hands the tracker description to the session; the text lives beside the recipes. */
|
|
1863
|
+
function trackerSetupPrompt(recipe: Recipe, project: string): string {
|
|
1864
|
+
const text = fs.readFileSync(path.join(packagedRecipesDir(), "_tracker-setup.md"), "utf8");
|
|
1865
|
+
// Function replacements: a path with `$&` in it must land as typed.
|
|
1866
|
+
return text.replace(/\{recipe\}/g, () => recipe.manifest.name).replace(/\{project\}/g, () => project).replace(/\{templates\}/g, () => path.join(packagedRecipesDir(), "_tracker"));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/** Paths, not contents: the files are on disk and the model has tools; a prompt is not a place to keep a file. */
|
|
1870
|
+
function mergeConflictPrompt(name: string, dir: string, conflicts: UpdateResult["conflicts"]): string {
|
|
1871
|
+
const blocks = conflicts.map((c) => [`### ${c.file}`, `- installed copy (yours, unchanged by the update): ${path.join(dir, c.file)}`, `- untouched copy from install time (the merge base): ${c.basePath}`, `- newly packaged version: ${c.packagedPath}`, `- git's three-way merge with conflict markers: ${c.mergePath}`].join("\n")).join("\n\n");
|
|
1872
|
+
return [
|
|
1873
|
+
`\`/recipe update ${name}\` merged the newly packaged playbooks into the installed copies in ${dir}. The files below have edits on both sides that overlap, so the installed copies were NOT changed.`,
|
|
1874
|
+
"",
|
|
1875
|
+
"Work through them with me: read the merge file, say what changed on each side, propose the resolved text; when I agree, write the resolved file over the installed copy (no conflict markers may remain — a loop reads that file and follows it), copy the packaged version over the untouched copy (that is what the next update merges against), and delete the merge file.",
|
|
1876
|
+
"",
|
|
1877
|
+
blocks,
|
|
1878
|
+
].join("\n");
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function runSetupScript(script: string, cwd: string): Promise<{ code: number; output: string }> {
|
|
1882
|
+
return new Promise((resolve) => {
|
|
1883
|
+
execFile("sh", [script], { cwd, timeout: 120_000, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
1884
|
+
const code = err && typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code as number) : err ? 1 : 0;
|
|
1885
|
+
resolve({ code, output: `${stdout}${stderr ? `\n${stderr}` : ""}`.trim() });
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* `/recipe add`, as a function, because it runs twice when a tracker description has to be
|
|
1892
|
+
* written first: once from the command, and once more on its own when the file the agent was
|
|
1893
|
+
* asked for is there — a person should not have to type the command a second time.
|
|
1894
|
+
*/
|
|
1895
|
+
let pendingRecipeAdd: { words: string[]; project: string } | undefined;
|
|
1896
|
+
async function recipeAdd(ctx: ExtensionContext, words: string[], resumed = false): Promise<void> {
|
|
1897
|
+
const project = session.cwd;
|
|
1898
|
+
const { ref, level: levelFlag } = parseAddWords(words);
|
|
1899
|
+
if (!ref) {
|
|
1900
|
+
ctx.ui.notify(`usage: /recipe add <name|path> [--level ${AUTONOMY_LEVELS.join("|")}]`, "warning");
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
const resolved = resolveRecipeRef(ref, { cwd: project });
|
|
1904
|
+
const recipe = loadRecipe(resolved.dir);
|
|
1905
|
+
const m = recipe.manifest;
|
|
1906
|
+
// The one step a template cannot do: describe this project's tracker. Handed to the
|
|
1907
|
+
// session the way /inbox claim hands over a finding; the wizard resumes when the file exists.
|
|
1908
|
+
if (m.needsTracker && !fs.existsSync(path.join(project, TRACKER_FILE))) {
|
|
1909
|
+
if (resumed) {
|
|
1910
|
+
ctx.ui.notify(`${m.name} still has no ${TRACKER_FILE} to read; /recipe add ${ref} when it exists`, "warning");
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
pendingRecipeAdd = { words, project };
|
|
1914
|
+
pi.sendUserMessage(trackerSetupPrompt(recipe, project), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
1915
|
+
ctx.ui.notify(`${m.name} reads ${TRACKER_FILE}, and this project has none yet — the agent will write it with you now, and the install continues here as soon as it exists`, "info");
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
let level: AutonomyLevel | undefined;
|
|
1919
|
+
if (levelFlag) {
|
|
1920
|
+
if (!m.levels.includes(levelFlag as AutonomyLevel)) throw new Error(`${m.name} supports ${m.levels.join(", ")}, not "${levelFlag}"`);
|
|
1921
|
+
level = levelFlag as AutonomyLevel;
|
|
1922
|
+
} else if (m.levels.length === 1) level = m.levels[0];
|
|
1923
|
+
else {
|
|
1924
|
+
const choice = await ctx.ui.select(`Autonomy level for ${m.name} (the lowest is the default)`, m.levels.map((l) => `${l} — ${LEVEL_TEXT[l]}`));
|
|
1925
|
+
level = choice?.split(" ")[0] as AutonomyLevel | undefined;
|
|
1926
|
+
}
|
|
1927
|
+
if (!level) {
|
|
1928
|
+
ctx.ui.notify("not installed", "info");
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const existing = scheduler.store.load();
|
|
1932
|
+
const clash = m.jobs.map((j) => existing.find((e) => e.name === j.name)).filter((e): e is LoopJob => !!e);
|
|
1933
|
+
if (clash.length) {
|
|
1934
|
+
show(ctx, `not installed: ${clash.length} job name(s) already in use`, [
|
|
1935
|
+
...clash.map((e) => ` "${e.name}" — ${e.recipe ? `from recipe ${e.recipe}` : "created with /cron add"}, in ${homeRel(e.cwd)}`),
|
|
1936
|
+
"",
|
|
1937
|
+
` ${clash.some((e) => e.recipe === m.name) ? `/recipe remove ${m.name}` : `/cron remove <name>`} first, then /recipe add ${ref} again`,
|
|
1938
|
+
]);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const plan = planInstall(recipe, project, level);
|
|
1942
|
+
let overwrite = false;
|
|
1943
|
+
if (plan.changed.length) {
|
|
1944
|
+
overwrite = await ctx.ui.confirm(`Overwrite ${plan.changed.length} edited file(s)?`, [...plan.changed.map((f) => ` ${f}`), "", "These differ from what would be written. Yes replaces them with the packaged version; No keeps your copies (the record is still written, and /recipe update merges later)."].join("\n"));
|
|
1945
|
+
}
|
|
1946
|
+
const setupText = m.setup ? fs.readFileSync(path.join(recipe.dir, m.setup), "utf8") : undefined;
|
|
1947
|
+
// The confirmation is the only gate before `sh <script>` runs with this session's
|
|
1948
|
+
// environment. So the script is shown whole and as written — redaction here would
|
|
1949
|
+
// hide exactly the `password=$(…)` a reader needs to see — or not run at all.
|
|
1950
|
+
if (setupText !== undefined && setupText.length > MAX_SETUP_SHOWN) {
|
|
1951
|
+
show(ctx, `not installed: ${m.setup} is ${setupText.length} characters, more than a confirmation can show`, [` read ${path.join(recipe.dir, m.setup ?? "")} yourself, run it by hand in ${homeRel(project)}, then install a copy of the recipe without \`setup\` in its recipe.toml`]);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
const fromPath = !isRecipeName(resolved.source);
|
|
1955
|
+
// A dialog clips what does not fit the terminal, so the script goes into the transcript
|
|
1956
|
+
// first — whole, scrollable, unredacted — and the dialog points at it.
|
|
1957
|
+
if (setupText !== undefined) show(ctx, `setup script ${m.setup} of recipe ${m.name} — runs once in ${homeRel(project)} with this session's environment, if you say yes below`, setupText.split("\n").map((l) => ` ${l}`));
|
|
1958
|
+
const body = [
|
|
1959
|
+
...(fromPath ? [`NOT shipped with pi-loops: ${homeRel(recipe.dir)}. Its playbooks will be followed by unattended runs, with this session's tools and credentials — read them before saying yes:`, ...plan.files.map((f) => ` ${path.join(recipe.dir, f)}`), ""] : []),
|
|
1960
|
+
`Files → ${homeRel(plan.targetDir)}/ (listed in .git/info/exclude, so nothing enters the repository):`,
|
|
1961
|
+
...plan.files.map((f) => ` ${f}${plan.changed.includes(f) ? (overwrite ? " (overwritten)" : " (kept as edited)") : ""}`),
|
|
1962
|
+
...(setupText !== undefined ? ["", `Setup script ${m.setup} (${setupText.split("\n").length} lines, printed above and at ${path.join(recipe.dir, m.setup ?? "")}) runs once with this session's environment.`] : []),
|
|
1963
|
+
"",
|
|
1964
|
+
"Jobs:",
|
|
1965
|
+
...plan.addLines.map((l) => ` /cron add ${l}`),
|
|
1966
|
+
...(m.budgetHintUsd ? ["", `Budget hint: about $${m.budgetHintUsd}/day — cap it with [limits] daily_budget_usd in config.toml`] : []),
|
|
1967
|
+
];
|
|
1968
|
+
const ok = await ctx.ui.confirm(`Install recipe ${m.name} at level "${level}"?`, body.join("\n"));
|
|
1969
|
+
if (!ok) {
|
|
1970
|
+
ctx.ui.notify("not installed", "info");
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const lines: string[] = [];
|
|
1974
|
+
const record = installFiles(recipe, project, level, { overwrite, source: resolved.source });
|
|
1975
|
+
lines.push(` ${record.files.length} file(s) → ${homeRel(plan.targetDir)}/`);
|
|
1976
|
+
const excluded = ensureExcluded(project, path.join(INSTALL_ROOT, m.name));
|
|
1977
|
+
// The tracker description names an account and a workflow; it is the project's to keep, not
|
|
1978
|
+
// the repository's to publish — so it stays out of history the same way the playbooks do.
|
|
1979
|
+
const trackerExcluded = m.needsTracker ? ensureExcluded(project, TRACKER_FILE, "file") : undefined;
|
|
1980
|
+
lines.push(excluded === "no-git" ? " not a git repository: nothing to exclude" : ` .git/info/exclude: ${excluded === "added" ? "added" : "already listed"}${trackerExcluded ? `, ${TRACKER_FILE} ${trackerExcluded === "added" ? "added" : "already listed"}` : ""}`);
|
|
1981
|
+
if (m.setup) {
|
|
1982
|
+
const r = await runSetupScript(path.join(plan.targetDir, m.setup), project);
|
|
1983
|
+
const out = capRedacted(r.output, 2000).split("\n").map((l) => ` ${l}`);
|
|
1984
|
+
if (r.code !== 0) {
|
|
1985
|
+
show(ctx, `setup script ${m.setup} failed (exit ${r.code}); no jobs were created`, [...lines, ` ${m.setup}:`, ...out, "", ` the files are in place — fix what it needs and run /recipe add ${ref} again`]);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
lines.push(` ${m.setup}: ok`, ...out);
|
|
1989
|
+
}
|
|
1990
|
+
for (const line of plan.addLines) {
|
|
1991
|
+
const parsed = parseAddArgs(line);
|
|
1992
|
+
const job = await createJob({ schedule: parsed.schedule, prompt: parsed.prompt, stateful: parsed.stateful, name: parsed.name, model: parsed.model, thinking: parsed.thinking, tools: parsed.tools, timeoutMs: parsed.timeoutMs, catchUp: parsed.catchUp, verify: parsed.verify, checkerModel: parsed.checkerModel, recipe: m.name });
|
|
1993
|
+
cronControlAudit("add", "slash", undefined, job);
|
|
1994
|
+
const next = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt) }, Date.now());
|
|
1995
|
+
lines.push(` job ${job.name}: ${formatSchedule(job.schedule)}${next ? ` (next run ${formatLocal(next)})` : ""}`);
|
|
1996
|
+
}
|
|
1997
|
+
lines.push("", ` edit the playbooks in ${path.join(INSTALL_ROOT, m.name)}/ — a run reads them fresh every time; /cron run <job> tries one now`);
|
|
1998
|
+
show(ctx, `installed recipe ${m.name} (${level})`, lines);
|
|
1999
|
+
refreshBadge();
|
|
2000
|
+
return;
|
|
2001
|
+
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
pi.registerCommand("recipe", {
|
|
2005
|
+
description: "Install a packaged way of running this project on loops — /recipe help",
|
|
2006
|
+
getArgumentCompletions: (prefix) => {
|
|
2007
|
+
const subs = ["list", "show", "add", "update", "remove", "help"];
|
|
2008
|
+
const items = subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
2009
|
+
return items.length ? items : null;
|
|
2010
|
+
},
|
|
2011
|
+
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
2012
|
+
lastCtx = ctx;
|
|
2013
|
+
const { sub, rest } = splitCommand(args);
|
|
2014
|
+
const words = tokenize(rest).map((t) => t.value);
|
|
2015
|
+
const project = session.cwd;
|
|
2016
|
+
try {
|
|
2017
|
+
switch (sub) {
|
|
2018
|
+
case "":
|
|
2019
|
+
case "list":
|
|
2020
|
+
case "ls": {
|
|
2021
|
+
const packaged = listRecipes();
|
|
2022
|
+
const installed = new Map(installedRecipes(project).map((r) => [r.recipe, r]));
|
|
2023
|
+
const jobs = scheduler.store.load().filter((j) => j.recipe && sameProject(j.cwd, project));
|
|
2024
|
+
const lines = packaged.map((r) => {
|
|
2025
|
+
const rec = installed.get(r.manifest.name);
|
|
2026
|
+
const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
|
|
2027
|
+
return ` ${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`;
|
|
2028
|
+
});
|
|
2029
|
+
for (const rec of installed.values()) {
|
|
2030
|
+
if (packaged.some((r) => r.manifest.name === rec.recipe)) continue;
|
|
2031
|
+
lines.push(` ${rec.recipe.padEnd(17)} installed from ${homeRel(rec.source)} (${rec.level})`);
|
|
2032
|
+
}
|
|
2033
|
+
show(ctx, `Recipes (${homeRel(project)})`, [...(lines.length ? lines : [" none packaged, none installed"]), "", " /recipe show <name> · /recipe add <name> · /recipe help"]);
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
case "show": {
|
|
2037
|
+
const ref = words[0];
|
|
2038
|
+
if (!ref) {
|
|
2039
|
+
ctx.ui.notify("usage: /recipe show <name|path>", "warning");
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const recipe = loadRecipe(resolveRecipeRef(ref, { cwd: project }).dir);
|
|
2043
|
+
const m = recipe.manifest;
|
|
2044
|
+
const rel = path.join(INSTALL_ROOT, m.name);
|
|
2045
|
+
const record = readRecord(installDirFor(project, m.name));
|
|
2046
|
+
const lines = [
|
|
2047
|
+
` ${m.summary}`,
|
|
2048
|
+
` levels: ${m.levels.map((l) => `${l} (${LEVEL_TEXT[l]})`).join("; ")}`,
|
|
2049
|
+
` tracker: ${m.needsTracker ? `needs ${TRACKER_FILE} — ${fs.existsSync(path.join(project, TRACKER_FILE)) ? "present" : "missing here; /recipe add writes it with you"}` : "not needed"}`,
|
|
2050
|
+
` files → ${rel}/: ${playbookFiles(m).join(", ")}`,
|
|
2051
|
+
...(m.setup ? [` setup script: ${m.setup} (shown before it runs, once per project)`] : []),
|
|
2052
|
+
...(m.budgetHintUsd ? [` budget hint: about $${m.budgetHintUsd}/day at the default schedules — [limits] daily_budget_usd in config.toml`] : []),
|
|
2053
|
+
" jobs:",
|
|
2054
|
+
...m.jobs.map((j) => ` /cron add ${addLineFor(j, rel)}`),
|
|
2055
|
+
...(record ? [` installed here: ${record.level}, ${record.installedAt}, pi-loops ${record.version}`] : []),
|
|
2056
|
+
];
|
|
2057
|
+
show(ctx, `recipe ${m.name} (${homeRel(recipe.dir)})`, lines);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
case "add": {
|
|
2061
|
+
pendingRecipeAdd = undefined;
|
|
2062
|
+
await recipeAdd(ctx, words);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
case "update": {
|
|
2066
|
+
const name = words[0];
|
|
2067
|
+
if (!name) {
|
|
2068
|
+
ctx.ui.notify("usage: /recipe update <name>", "warning");
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
requireRecipeName(name);
|
|
2072
|
+
const dir = installDirFor(project, name);
|
|
2073
|
+
const record = readRecord(dir);
|
|
2074
|
+
if (!record) {
|
|
2075
|
+
ctx.ui.notify(`no recipe "${name}" is installed in ${homeRel(project)} (/recipe list)`, "warning");
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
const recipe = loadRecipe(path.isAbsolute(record.source) ? record.source : resolveRecipeRef(record.source, { cwd: project }).dir);
|
|
2079
|
+
const result = updateFiles(recipe, project, record.level);
|
|
2080
|
+
const lines = [
|
|
2081
|
+
...(result.updated.length ? [` updated: ${result.updated.join(", ")}`] : []),
|
|
2082
|
+
...(result.unchanged.length ? [` unchanged: ${result.unchanged.join(", ")}`] : []),
|
|
2083
|
+
...(result.noBase.length ? [` left alone (no .orig to merge against): ${result.noBase.join(", ")}`] : []),
|
|
2084
|
+
...(result.conflicts.length ? [` conflicts, not written: ${result.conflicts.map((c) => c.file).join(", ")} — handed to the agent to resolve with you`] : []),
|
|
2085
|
+
];
|
|
2086
|
+
show(ctx, `recipe ${name}: merged pi-loops ${PI_LOOPS_VERSION} playbooks into ${homeRel(dir)}/`, lines);
|
|
2087
|
+
if (result.conflicts.length) pi.sendUserMessage(mergeConflictPrompt(name, dir, result.conflicts), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
case "remove":
|
|
2091
|
+
case "rm": {
|
|
2092
|
+
pendingRecipeAdd = undefined;
|
|
2093
|
+
const purge = words.includes("--purge");
|
|
2094
|
+
const name = words.find((w) => !w.startsWith("--"));
|
|
2095
|
+
if (!name) {
|
|
2096
|
+
ctx.ui.notify("usage: /recipe remove <name> [--purge]", "warning");
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
requireRecipeName(name);
|
|
2100
|
+
const dir = installDirFor(project, name);
|
|
2101
|
+
const jobs = scheduler.store.load().filter((j) => j.recipe === name && sameProject(j.cwd, project));
|
|
2102
|
+
const record = readRecord(dir);
|
|
2103
|
+
if (!jobs.length && !record) {
|
|
2104
|
+
ctx.ui.notify(`no recipe "${name}" is installed in ${homeRel(project)} (/recipe list)`, "warning");
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
const ok = await ctx.ui.confirm(`Remove recipe ${name}?`, [
|
|
2108
|
+
jobs.length ? `${jobs.length} job(s): ${jobs.map((j) => j.name ?? j.id).join(", ")}` : "no jobs of its own here",
|
|
2109
|
+
purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length} file(s), 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)`,
|
|
2110
|
+
].join("\n"));
|
|
2111
|
+
if (!ok) {
|
|
2112
|
+
ctx.ui.notify("not removed", "info");
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
for (const job of jobs) {
|
|
2116
|
+
await scheduler.store.remove(job.id, { purge });
|
|
2117
|
+
cronControlAudit("remove", "slash", job, undefined);
|
|
2118
|
+
}
|
|
2119
|
+
if (purge) {
|
|
2120
|
+
if (record) purgeInstall(dir, record, listRecipes().find((r) => r.manifest.name === name)?.manifest.setup);
|
|
2121
|
+
else {
|
|
2122
|
+
try {
|
|
2123
|
+
fs.rmdirSync(dir);
|
|
2124
|
+
} catch {
|
|
2125
|
+
// not empty or not there: nothing of ours to take
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
refreshBadge();
|
|
2130
|
+
ctx.ui.notify(`removed recipe ${name}: ${jobs.length} job(s)${purge ? ", its files and notes" : `; files kept in ${homeRel(dir)}/`}`, "info");
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
case "help":
|
|
2134
|
+
show(ctx, "/recipe", RECIPE_HELP.map((l) => ` ${l}`));
|
|
2135
|
+
return;
|
|
2136
|
+
default:
|
|
2137
|
+
ctx.ui.notify(`unknown /recipe command: ${sub}. usage: /recipe ${RECIPE_USAGE}`, "warning");
|
|
2138
|
+
}
|
|
2139
|
+
} catch (e) {
|
|
2140
|
+
ctx.ui.notify(`/recipe ${sub}: ${(e as Error).message}`, "error");
|
|
2141
|
+
}
|
|
2142
|
+
},
|
|
2143
|
+
});
|
|
2144
|
+
|
|
1843
2145
|
// Not `share`: pi has a built-in `/share` of its own, and an extension command with a built-in's
|
|
1844
2146
|
// name is dropped from autocomplete and shadowed at the prompt. The name follows the two
|
|
1845
2147
|
// commands next to it (`/session-export`, `/session-import`), which are about the same object.
|
|
@@ -2161,11 +2463,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2161
2463
|
pruneLogs(dir);
|
|
2162
2464
|
// What was loaded is printed on every start; without a line here a session
|
|
2163
2465
|
// can begin with loops and rules the user has entirely forgotten about.
|
|
2164
|
-
|
|
2466
|
+
// "Active here" means it: a plain job of another session is asleep, not active.
|
|
2467
|
+
const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled && runsIn(j, session.sessionId));
|
|
2165
2468
|
const myRules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && r.enabled);
|
|
2166
2469
|
// What is scheduled says nothing about what is working: a loop that has failed every night
|
|
2167
2470
|
// since Tuesday is still counted as active, and this line is the only one a user reads.
|
|
2168
|
-
const failing = failingSummary(myJobs
|
|
2471
|
+
const failing = failingSummary(myJobs);
|
|
2169
2472
|
log.info(`session start: ${myJobs.length} enabled loop(s), ${myRules.length} enabled rule(s)${failing ? `, ${failing}` : ""} in ${session.cwd}`);
|
|
2170
2473
|
if (ctx.hasUI && (myJobs.length || myRules.length)) {
|
|
2171
2474
|
const next = myJobs
|
|
@@ -2210,6 +2513,17 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2210
2513
|
// The goal is evaluated at the end of a turn; pi's hook for that is `agent_settled` — the point where
|
|
2211
2514
|
// no automatic retry, compaction or queued continuation will run, so a decision here is final.
|
|
2212
2515
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2516
|
+
// The tracker description `/recipe add` was waiting for: written during this turn, so the
|
|
2517
|
+
// wizard picks up where it stopped — in the same project, with the same words.
|
|
2518
|
+
if (pendingRecipeAdd && sameProject(pendingRecipeAdd.project, session.cwd) && fs.existsSync(path.join(pendingRecipeAdd.project, TRACKER_FILE))) {
|
|
2519
|
+
const { words } = pendingRecipeAdd;
|
|
2520
|
+
pendingRecipeAdd = undefined;
|
|
2521
|
+
try {
|
|
2522
|
+
await recipeAdd(ctx, words, true);
|
|
2523
|
+
} catch (e) {
|
|
2524
|
+
ctx.ui.notify(`/recipe add: ${(e as Error).message}`, "error");
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2213
2527
|
lastCtx = ctx;
|
|
2214
2528
|
try {
|
|
2215
2529
|
await evaluateGoal(ctx);
|
|
@@ -2221,14 +2535,14 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2221
2535
|
/** Whether this quitting pi should hand the clock to a background host. May throw on a damaged store. */
|
|
2222
2536
|
function handOffDecision(here: string, wasStarted: boolean): ReturnType<typeof shouldHandOff> | undefined {
|
|
2223
2537
|
if (!wasStarted) return undefined;
|
|
2224
|
-
const enabledRules = triggers.store.load().filter((r) => r.enabled
|
|
2538
|
+
const enabledRules = triggers.store.load().filter((r) => r.enabled).length;
|
|
2225
2539
|
return shouldHandOff({
|
|
2226
2540
|
auto: handsOffOnQuit(),
|
|
2227
2541
|
presence: scheduler.presenceList(),
|
|
2228
2542
|
selfPid: process.pid,
|
|
2229
2543
|
selfInstance: scheduler.self.instance,
|
|
2230
2544
|
hostName: here,
|
|
2231
|
-
enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful
|
|
2545
|
+
enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful).length,
|
|
2232
2546
|
enabledRules,
|
|
2233
2547
|
pushServers: hostPushWork(loadMcpConfigFiles({ dir, projectTrusted: false }).servers, enabledRules),
|
|
2234
2548
|
hostAlive: !!liveHost(dir),
|
package/src/protocol.ts
CHANGED
|
@@ -35,9 +35,8 @@ export function composeLoopPrompt(action: string, previousState: string | undefi
|
|
|
35
35
|
* The run time, and how to write one.
|
|
36
36
|
*
|
|
37
37
|
* The notes this prompt asks for hold watermarks — "everything up to here has been seen" — and
|
|
38
|
-
* a watermark is a time the model writes in whatever shape it likes.
|
|
39
|
-
*
|
|
40
|
-
* the `$HOME` (scheduler.ts, and `/cron set <ref> --host -` asks for exactly that), so run N can
|
|
38
|
+
* a watermark is a time the model writes in whatever shape it likes. A machine's clock is not a
|
|
39
|
+
* constant either: `TZ` gets set, a laptop travels, a container is rebuilt in UTC — so run N can
|
|
41
40
|
* write "checked up to 20:00" in Shanghai and run N+1 read it in New York.
|
|
42
41
|
*
|
|
43
42
|
* The stamp carries its offset; this asks for the same of anything the model writes back. It
|