@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +13 -6
  3. package/README.zh-CN.md +19 -6
  4. package/docs/agents/issue-tracker.md +34 -0
  5. package/docs/cli.md +22 -2
  6. package/docs/configuration.md +1 -1
  7. package/docs/design.md +63 -6
  8. package/docs/loops.md +25 -17
  9. package/docs/recipes.md +239 -0
  10. package/docs/triggers.md +1 -1
  11. package/docs/troubleshooting.md +3 -2
  12. package/docs/web-ui-parity.md +9 -3
  13. package/package.json +2 -1
  14. package/recipes/_tracker/issue-tracker-github.md +31 -0
  15. package/recipes/_tracker/issue-tracker-local.md +38 -0
  16. package/recipes/_tracker-setup.md +12 -0
  17. package/recipes/autoresearch/README.md +27 -0
  18. package/recipes/autoresearch/RESEARCH.template.md +37 -0
  19. package/recipes/autoresearch/example/RESEARCH.md +31 -0
  20. package/recipes/autoresearch/example/eval.py +60 -0
  21. package/recipes/autoresearch/example/eval.sh +3 -0
  22. package/recipes/autoresearch/example/solution.py +20 -0
  23. package/recipes/autoresearch/experiment.md +82 -0
  24. package/recipes/autoresearch/recipe.toml +15 -0
  25. package/recipes/changelog-draft/recipe.toml +9 -0
  26. package/recipes/changelog-draft/release.md +35 -0
  27. package/recipes/issue-loop/README.md +16 -0
  28. package/recipes/issue-loop/implement.md +135 -0
  29. package/recipes/issue-loop/labels.sh +21 -0
  30. package/recipes/issue-loop/recipe.toml +18 -0
  31. package/recipes/issue-loop/triage.md +147 -0
  32. package/skills/pi-loops/SKILL.md +3 -1
  33. package/src/archive.ts +5 -5
  34. package/src/args.ts +1 -8
  35. package/src/cli.ts +83 -6
  36. package/src/job-edit.ts +0 -3
  37. package/src/job-health.ts +6 -7
  38. package/src/job-owner.ts +29 -0
  39. package/src/pi-loops.ts +339 -25
  40. package/src/protocol.ts +2 -3
  41. package/src/recipe.ts +651 -0
  42. package/src/scheduler.ts +13 -13
  43. package/src/session-head.ts +98 -0
  44. package/src/store.ts +9 -17
  45. package/src/tools.ts +17 -19
  46. package/src/trigger-runtime.ts +3 -5
  47. package/src/triggers.ts +3 -4
  48. 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(), os.hostname());
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 job stamped with another machine's hostname is filtered out by the scheduler
734
- // (`job.host !== os.hostname()`), so promising a next run would be a lie. This also
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 dormant = !job.stateful && job.sessionId !== session.sessionId ? `[dormant: session ${(job.sessionId ?? "?").slice(0, 8)} not open here]` : undefined;
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, elsewhere, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
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>|-] [--host here|-] change what a job runs with (- = use the session's current)",
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
- // `set --host`) was left out of the product.
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>|-] [--host here|-] what the action runs with (- = the session's current; --host - = any machine)",
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
- // Same as jobLines: the runtime skips a rule stamped with another machine's hostname.
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, --timeout and --host", "warning");
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
- const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled);
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, os.hostname());
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 && (!r.host || r.host === here)).length;
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 && (!j.host || j.host === here)).length,
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. That was survivable while
39
- * a loop belonged to one machine. It does not: a job with no `host` runs on any machine sharing
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