@alphacoder-v0/pi-loops 0.20.0 → 0.22.1

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 +156 -0
  2. package/CONTRACT.md +139 -0
  3. package/README.md +11 -7
  4. package/README.zh-CN.md +6 -4
  5. package/docs/cli.md +26 -3
  6. package/docs/downstream.md +60 -0
  7. package/docs/goal.md +4 -2
  8. package/docs/loops.md +55 -9
  9. package/docs/recipes.md +94 -18
  10. package/docs/session-archive.md +8 -3
  11. package/docs/web-ui-parity.md +11 -1
  12. package/package.json +5 -2
  13. package/recipes/_tracker/issue-tracker-local.md +3 -1
  14. package/recipes/autoresearch/experiment.md +30 -6
  15. package/recipes/autoresearch/recipe.toml +2 -0
  16. package/recipes/changelog-draft/recipe.toml +4 -0
  17. package/recipes/changelog-draft/release.md +8 -3
  18. package/recipes/ci-sweeper/recipe.toml +4 -0
  19. package/recipes/ci-sweeper/sweep.md +2 -1
  20. package/recipes/daily-digest/digest.md +5 -4
  21. package/recipes/daily-digest/recipe.toml +2 -0
  22. package/recipes/deps-sweeper/README.md +11 -0
  23. package/recipes/deps-sweeper/audit.md +99 -0
  24. package/recipes/deps-sweeper/recipe.toml +17 -0
  25. package/recipes/ecosystem/recipe.toml +3 -0
  26. package/recipes/ecosystem/scan.md +1 -1
  27. package/recipes/issue-loop/implement.md +17 -4
  28. package/recipes/issue-loop/labels.sh +18 -0
  29. package/recipes/issue-loop/recipe.toml +2 -0
  30. package/recipes/issue-loop/triage.md +16 -2
  31. package/recipes/pr-watch/recipe.toml +3 -0
  32. package/skills/pi-loops/SKILL.md +3 -2
  33. package/src/archive.ts +97 -2
  34. package/src/cli.ts +160 -24
  35. package/src/goal.ts +11 -0
  36. package/src/hooks.ts +25 -1
  37. package/src/host-runtime.ts +4 -8
  38. package/src/inbox.ts +118 -55
  39. package/src/job-signal.ts +78 -0
  40. package/src/pi-loops.ts +177 -109
  41. package/src/presence.ts +5 -0
  42. package/src/protocol.ts +45 -2
  43. package/src/recipe.ts +158 -2
  44. package/src/scheduler.ts +6 -2
  45. package/src/store.ts +8 -3
  46. package/src/tools.ts +17 -11
  47. package/src/triggers.ts +8 -3
  48. package/src/web.mjs +73 -7
package/src/cli.ts CHANGED
@@ -15,8 +15,11 @@ import * as path from "node:path";
15
15
  import * as readline from "node:readline";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
18
- import { exportSession, defaultExportPath, importSession, inspectArchive } from "./archive.ts";
18
+ import { applyImport, automationOf, exportSession, defaultExportPath, importSession, inspectArchive, offerToEnable, parseActivateMode } from "./archive.ts";
19
19
  import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
20
+ import { FINDING_FIELDS, Inbox, type InboxEntry, inProject, resolveInboxRef } from "./inbox.ts";
21
+ import { sameProject } from "./presence.ts";
22
+ import { redact } from "./redact.ts";
20
23
  import { liveHost, stopHost } from "./host-control.ts";
21
24
  import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
22
25
  import { JobStore, defaultLoopsDir } from "./store.ts";
@@ -96,7 +99,7 @@ export const CLI_USAGE = [
96
99
  "",
97
100
  "pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]",
98
101
  " Restore an archive into --cwd (default: the current directory).",
99
- " Imported automation stays disabled unless --activate-triggers=on (ask: prompt on a terminal).",
102
+ " --activate-triggers: off never asks, on activates, ask (the default) prompts after the import.",
100
103
  "",
101
104
  "pi-loops sessions [--all] [--limit <n>]",
102
105
  " List sessions, newest first: short id, when it started, its automation, what was first said in it.",
@@ -110,6 +113,10 @@ export const CLI_USAGE = [
110
113
  "pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]",
111
114
  " The packaged recipes; what one installs; copy its playbooks into a project (.agents/skills/<name>/).",
112
115
  " The setup script and the jobs need a pi open in the project: /recipe add <name> finds the copies and finishes.",
116
+ "",
117
+ "pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>] [--json]",
118
+ " The triage inbox with no pi open: what /inbox lists, and the two things it does to a finding.",
119
+ " --json prints the shape docs/downstream.md fixes for programs.",
113
120
  ].join("\n");
114
121
 
115
122
  /**
@@ -188,6 +195,12 @@ interface Parsed {
188
195
  flags: Map<string, string | true>;
189
196
  }
190
197
 
198
+ /**
199
+ * Flags that never take a value, so the word after them is a positional: `inbox claim --json <id>`
200
+ * must not read the id as the value of `--json`.
201
+ */
202
+ const BOOLEAN_FLAGS = new Set(["all", "json", "check", "overwrite", "exclude-triggers", "help"]);
203
+
191
204
  export function parseCliArgs(argv: string[]): Parsed {
192
205
  const [command = "", ...rest] = argv;
193
206
  const positional: string[] = [];
@@ -200,7 +213,7 @@ export function parseCliArgs(argv: string[]): Parsed {
200
213
  }
201
214
  const eq = arg.indexOf("=");
202
215
  if (eq > 0) flags.set(arg.slice(2, eq), arg.slice(eq + 1));
203
- else if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) flags.set(arg.slice(2), rest[++i]);
216
+ else if (!BOOLEAN_FLAGS.has(arg.slice(2)) && rest[i + 1] !== undefined && !rest[i + 1].startsWith("--")) flags.set(arg.slice(2), rest[++i]);
204
217
  else flags.set(arg.slice(2), true);
205
218
  }
206
219
  return { command, positional, flags };
@@ -250,6 +263,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
250
263
  if (command === "upgrade") return upgrade(flags.has("check"), out);
251
264
  if (command === "install-launcher") return installLauncher(str("dir"), out);
252
265
  if (command === "recipe") return recipeCommand(positional, { level: str("level"), overwrite: flags.has("overwrite") }, cwd, loopsDir, out);
266
+ if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
253
267
 
254
268
  if (command === "sessions") {
255
269
  // `pickSession` refuses an unknown id, and there was no way to discover one.
@@ -287,9 +301,9 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
287
301
  const sessions = listSessions(path.join(agentDir, "sessions"));
288
302
  const picked = pickSession(sessions, { id: str("session"), cwd });
289
303
  const jobStore = new JobStore(loopsDir);
290
- // The archive carries this project's automation, the way the slash command does.
291
- const jobs = jobStore.load().filter((job) => job.cwd === picked.cwd);
292
- const rules = new TriggerStore(loopsDir).load().filter((rule) => rule.cwd === picked.cwd);
304
+ // The archive carries what the picked session created in its project — the one rule
305
+ // `/session-export` follows too, in `automationOf`.
306
+ const { jobs, rules } = automationOf(jobStore.load(), new TriggerStore(loopsDir).load(), picked.cwd, picked.id);
293
307
  const states: Record<string, string> = {};
294
308
  for (const job of jobs) {
295
309
  const loopState = job.stateful ? jobStore.readState(job.id) : undefined;
@@ -305,28 +319,31 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
305
319
  if (command === "import") {
306
320
  const file = positional[0];
307
321
  if (!file) throw new Error("import needs an archive path");
308
- const mode = str("activate-triggers") ?? "off";
309
- if (!["off", "ask", "on"].includes(mode)) throw new Error(`--activate-triggers must be off, ask or on (got ${mode})`);
322
+ const mode = parseActivateMode(str("activate-triggers"));
310
323
  const jobStore = new JobStore(loopsDir);
311
324
  const triggerStore = new TriggerStore(loopsDir);
312
325
  // pi encodes a project as `--home-u-proj--` and `SessionManager.list()` reads only that one
313
326
  // directory, with no fallback — a hand-rolled name here would restore a session pi never sees.
314
327
  const sessionDir = SessionManager.create(cwd).getSessionDir();
315
- const activate = mode === "on" || (mode === "ask" && (await askYesNo("Activate the imported automation now?")));
316
328
  const existingJobs = jobStore.load();
317
329
  const existingRules = triggerStore.load();
318
- const summary = importSession({ archivePath: path.resolve(file), sessionDir, targetCwd: cwd, activate, existingJobs, existingRules, existingJobIds: new Set(existingJobs.map((j) => j.id)), existingRuleIds: new Set(existingRules.map((r) => r.id)) });
319
- // The same order the slash command uses, so a half-import leaves neither store inconsistent.
320
- if (summary.jobs.length) await jobStore.mutate((jobs) => ({ jobs: [...jobs, ...summary.jobs], result: undefined }));
321
- for (const [id, text] of Object.entries(summary.states)) jobStore.writeState(id, text);
322
- if (summary.rules.length) await triggerStore.mutate((rules) => rules.push(...summary.rules));
330
+ const summary = importSession({ archivePath: path.resolve(file), sessionDir, targetCwd: cwd, activate: mode === "on", existingJobs, existingRules, existingJobIds: new Set(existingJobs.map((j) => j.id)), existingRuleIds: new Set(existingRules.map((r) => r.id)) });
331
+ // The writes and the rollback the slash command does, in `applyImport`.
332
+ await applyImport(summary, jobStore, triggerStore);
323
333
  const skipped = (summary.skippedJobs ?? 0) + (summary.skippedRules ?? 0);
324
334
  // The id and the notes come out of the archive's own header, like everything `inspect` prints.
325
335
  out(`imported ${plain(summary.originalSessionId)} → ${summary.sessionId}`);
326
336
  out(`entries=${plain(String(summary.entryCount))} cron=${summary.jobs.length} triggers=${summary.rules.length} automation=${summary.automationEnabled ? "enabled" : "disabled"}${skipped ? ` skipped=${skipped} (already imported)` : ""}`);
327
337
  for (const note of summary.notes ?? []) out(`note: ${plain(note)}`);
328
338
  out(`session: ${summary.sessionPath}`);
329
- if (!summary.automationEnabled && (summary.jobs.length || summary.rules.length)) out("automation is disabled; enable it with /cron enable <id> or re-import with --activate-triggers=on");
339
+ // `ask` is the default: the same question the slash command asks, once, after the import.
340
+ // `askYesNo` already answers no with no terminal to ask at.
341
+ const enabled = await offerToEnable(summary, jobStore, triggerStore, mode === "ask" ? (question, detail) => {
342
+ if (process.stdin.isTTY) out(detail); // what the dialog shows above the question
343
+ return askYesNo(question);
344
+ } : undefined);
345
+ if (enabled.jobs || enabled.rules) out(`enabled ${enabled.jobs} cron job(s) and ${enabled.rules} trigger rule(s)`);
346
+ else if (!summary.automationEnabled && (summary.jobs.length || summary.rules.length)) out("automation is disabled; enable it with /cron enable <id> or re-import with --activate-triggers=on");
330
347
  return 0;
331
348
  }
332
349
 
@@ -393,7 +410,77 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
393
410
  return asked ? 2 : 0;
394
411
  }
395
412
 
396
- const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "web", "upgrade", "install-launcher", "help"]);
413
+ const SUBCOMMANDS = new Set(["export", "import", "sessions", "inspect", "host", "recipe", "inbox", "web", "upgrade", "install-launcher", "help"]);
414
+
415
+ /**
416
+ * `pi-loops inbox`: `/inbox list|claim|dismiss` for a shell with no pi open, and — with `--json` —
417
+ * for a program. The JSON is the contract in docs/downstream.md: the fields, their names and their
418
+ * types are fixed there, and `test/downstream/inbox.sh` reads them back with jq. What is not fixed:
419
+ * the plain text, which is for a person at a prompt. A claim here marks the finding and stops,
420
+ * since there is no session to hand it to; the caller is the one who acts on it.
421
+ */
422
+ async function inboxCommand(positional: string[], opts: { all: boolean; json: boolean; reason?: string | true }, cwd: string, loopsDir: string, out: (line: string) => void): Promise<number> {
423
+ const fail = (why: string): number => {
424
+ out(opts.json ? JSON.stringify({ error: why }) : why);
425
+ return 1;
426
+ };
427
+ try {
428
+ return await inboxCommandBody(positional, opts, cwd, loopsDir, out, fail);
429
+ } catch (err: any) {
430
+ // A program reads one object either way: an unreadable file or a lock that timed out is an
431
+ // `error` on stdout, not a usage dump on stderr.
432
+ return fail(err?.message ?? String(err));
433
+ }
434
+ }
435
+
436
+ async function inboxCommandBody(positional: string[], opts: { all: boolean; json: boolean; reason?: string | true }, cwd: string, loopsDir: string, out: (line: string) => void, fail: (why: string) => number): Promise<number> {
437
+ const sub = positional[0] ?? "list";
438
+ const inbox = new Inbox(loopsDir);
439
+ if (sub === "list") {
440
+ const entries = opts.all ? inbox.listNew() : inProject(inbox.listNew(), cwd, sameProject);
441
+ if (opts.json) {
442
+ out(JSON.stringify({ findings: entries.map(findingJson) }));
443
+ return 0;
444
+ }
445
+ if (!entries.length) out(opts.all ? "inbox: nothing new on this machine" : `inbox: nothing new in ${cwd} (--all lists every project)`);
446
+ for (const e of entries) out(findingLine(e));
447
+ return 0;
448
+ }
449
+ if (sub === "claim" || sub === "dismiss") {
450
+ const ref = positional[1];
451
+ if (!ref) return fail(`inbox ${sub} needs a finding id`);
452
+ if (opts.reason === true) return fail("--reason needs a text (--reason=<text> when it starts with --)");
453
+ // An id or a prefix resolves machine-wide, as it does in /inbox, and only among what is new:
454
+ // a finding already claimed or dismissed is not decided twice. A number resolves to nothing
455
+ // here — `/inbox claim 3` is the number on a screen, and this command printed none — and the
456
+ // digits only pick the wording, since "no entry matching '1'" reads like a missing finding.
457
+ const entry = resolveInboxRef(inbox.listNew(), ref);
458
+ if (!entry) return fail(/^\d+$/.test(ref.trim()) ? `inbox ${sub} takes a finding id (inb-…) or a prefix of one, not a number` : `no new inbox entry matching '${ref}'`);
459
+ const updated = await inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? "cli" : undefined, opts.reason);
460
+ if (!updated) return fail(`inbox entry ${entry.id} is gone`);
461
+ out(opts.json ? JSON.stringify({ finding: findingJson(updated) }) : `${updated.status} ${updated.id}`);
462
+ return 0;
463
+ }
464
+ return fail(`unknown inbox command ${JSON.stringify(sub)} (list | claim <id> | dismiss <id> [--reason <text>])`);
465
+ }
466
+
467
+ /**
468
+ * One finding as docs/downstream.md fixes it: the contract's fields of the entry and nothing else,
469
+ * in their order, with what a person may read redacted. Fields are added at the end, never renamed.
470
+ */
471
+ function findingJson(e: InboxEntry): Record<string, unknown> {
472
+ const out: Record<string, unknown> = {};
473
+ for (const field of FINDING_FIELDS) out[field] = e[field];
474
+ out.text = redact(e.text);
475
+ out.dismiss_reason = e.dismiss_reason === null ? null : redact(e.dismiss_reason);
476
+ return out;
477
+ }
478
+
479
+ /** A person's line: the full id (it is what `claim` takes), the finding, where and when it came from. */
480
+ function findingLine(e: InboxEntry): string {
481
+ const mark = `${e.kind === "checkpoint" ? "⚑ " : ""}${e.verified ? "✓ " : ""}`;
482
+ return `${e.id} ${mark}${plain(redact(e.text))} (${path.basename(e.cwd) || e.cwd || "—"}, ${e.source}, ${e.created_at})`;
483
+ }
397
484
 
398
485
  /**
399
486
  * `pi-loops recipe`: the deterministic part of `/recipe`, for a shell with no pi open. `add` copies
@@ -492,12 +579,21 @@ function packageRoot(): string {
492
579
  return path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
493
580
  }
494
581
 
495
- /** Where this copy came from and what it is called, so an upgrade goes back to the same place. */
496
- function manifest(): { name?: string; repositoryUrl?: string } {
582
+ /**
583
+ * Where this copy came from and what it is called, so an upgrade goes back to the same place —
584
+ * plus the `host/owner/repo` inside that URL, which is how pi spells the same repository in a
585
+ * `git:` spec and on disk. The URL is read here and nowhere else, so the two cannot drift apart.
586
+ */
587
+ function manifest(): { name?: string; repositoryUrl?: string; repoPath?: string } {
497
588
  try {
498
589
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
499
590
  const url = String(pkg?.repository?.url ?? "").replace(/^git\+/, "").replace(/\.git$/, "");
500
- return { name: typeof pkg?.name === "string" ? pkg.name : undefined, repositoryUrl: url || undefined };
591
+ const at = /(?:^|@|\/\/)([^/@\s]+\.[a-z]{2,})[/:]([^/\s]+)\/([^/\s]+)$/.exec(url);
592
+ return {
593
+ name: typeof pkg?.name === "string" ? pkg.name : undefined,
594
+ repositoryUrl: url || undefined,
595
+ repoPath: at ? `${at[1]}/${at[2]}/${at[3]}` : undefined,
596
+ };
501
597
  } catch {
502
598
  return {}; // installed without its package.json: nothing to ask
503
599
  }
@@ -687,17 +783,41 @@ export async function installLauncherWithConfirm(args: string[], confirm: (title
687
783
  const target = launcherTarget(dir);
688
784
  if (!target) return installLauncher(undefined, out);
689
785
  const reach = isOnPath(target) ? ["Afterwards `pi-loops` starts a session from any directory."] : [`${target} is not on your PATH, so \`pi-loops\` will not be found until it is.`];
690
- const ok = await confirm("Put `pi-loops` on your PATH?", [`This writes a launcher into ${target}.`, "", "It is a two-line shell script that runs this package with this node.", ...reach].join("\n"));
786
+ const ok = await confirm("Put `pi-loops` on your PATH?", [`This writes a launcher into ${target}.`, "", "It is a small shell script that runs this package with this node.", ...reach].join("\n"));
691
787
  if (!ok) return undefined;
692
788
  // The absolute directory the question named, not the flag again: resolving a relative --dir a
693
789
  // second time, after the dialog, would follow a working directory that may have moved meanwhile.
694
790
  return installLauncher(target, out);
695
791
  }
696
792
 
793
+ /**
794
+ * What a launcher says when no copy of this package is at any of the paths it knows. One line and
795
+ * an exit code, because the alternative a person actually saw was Node's `Cannot find module` stack
796
+ * trace, which names a file that is gone and nothing they can do about it. It is written into the
797
+ * script inside single quotes, so it must contain none.
798
+ */
799
+ const LAUNCHER_LOST =
800
+ "pi-loops: the package this launcher was written for is not where it was — write the launcher again with `/pi-loops install-launcher` in pi, or `node <the directory pi installed it into>/src/cli-entry.mjs install-launcher`";
801
+
802
+ /**
803
+ * The other places this package can be, for a launcher whose recorded entry has gone: the two
804
+ * directories `pi install` puts it in, under the agent directory the launcher is written with. They
805
+ * are baked into the script as literal paths — the launcher looks, it does not work anything out.
806
+ */
807
+ function piInstallEntries(agentDir: string): string[] {
808
+ const { name, repoPath } = manifest();
809
+ const entries: string[] = [];
810
+ // `<agent dir>/npm/node_modules/<name>` and `<agent dir>/git/<host>/<owner>/<repo>`: the same two
811
+ // layouts `upgradeSpec` reads backwards to work out which route this copy came down.
812
+ if (name) entries.push(path.join(agentDir, "npm", "node_modules", ...name.split("/"), "src", "cli-entry.mjs"));
813
+ if (repoPath) entries.push(path.join(agentDir, "git", ...repoPath.split("/"), "src", "cli-entry.mjs"));
814
+ return entries;
815
+ }
816
+
697
817
  /**
698
818
  * `pi install` puts this package under pi's managed directory rather than on your PATH, so the
699
819
  * command that is supposed to start your sessions is reachable only by absolute path. This writes
700
- * a two-line launcher into a directory that is already on your PATH, which is the smallest thing
820
+ * a small launcher into a directory that is already on your PATH, which is the smallest thing
701
821
  * that fixes it without asking you to publish or install anything else.
702
822
  */
703
823
  export async function installLauncher(dir: string | undefined, out: (line: string) => void): Promise<number> {
@@ -710,9 +830,25 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
710
830
  if (dir && !isOnPath(target)) out(`note: ${target} is not on your PATH, so the command will not be found there yet`);
711
831
  const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "cli-entry.mjs");
712
832
  const file = path.join(target, "pi-loops");
713
- // A launcher rather than a symlink: it survives the package moving, and it names the node that
714
- // is running now, which is the one known to be new enough for this code.
715
- const script = `#!/bin/sh\n# pi-loops launcher, written by \`pi-loops install-launcher\`.\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(entry)} "$@"\n`;
833
+ // A launcher rather than a symlink: it names the node that is running now, which is the one known
834
+ // to be new enough for this code. What it survives is this package being installed again by the
835
+ // other route — `pi install npm:` over a `git:` copy moves it, and a launcher that knew only where
836
+ // it used to be died in Node's `Cannot find module`. So the entry it recorded is the first place
837
+ // it looks and not the only one, and the recorded node is a path it checks rather than trusts.
838
+ const candidates = [entry, ...piInstallEntries(getAgentDir())].map((e) => JSON.stringify(e)).join(" ");
839
+ const script = [
840
+ "#!/bin/sh",
841
+ "# pi-loops launcher, written by `pi-loops install-launcher`.",
842
+ `node=${JSON.stringify(process.execPath)}`,
843
+ '[ -x "$node" ] || node=$(command -v node) || node=node',
844
+ `for entry in ${candidates}; do`,
845
+ '\t[ -f "$entry" ] || continue',
846
+ '\texec "$node" "$entry" "$@"',
847
+ "done",
848
+ `echo '${LAUNCHER_LOST}' >&2`,
849
+ "exit 1",
850
+ "",
851
+ ].join("\n");
716
852
  try {
717
853
  fs.mkdirSync(target, { recursive: true });
718
854
  fs.writeFileSync(file, script, { mode: 0o755 });
package/src/goal.ts CHANGED
@@ -171,6 +171,17 @@ export function pauseFor(state: GoalState, reason: string, now = new Date()): {
171
171
  return { state: { ...state, status: "paused", lastReason: reason, updatedAt: stamp(now.getTime()) }, action: { kind: "pause", reason } };
172
172
  }
173
173
 
174
+ /**
175
+ * Esc on a turn is a person saying stop. The goal it was pursuing pauses — appended to the session,
176
+ * so a `--resume` finds it paused — until `/goal resume`. Without this the turn was merely not
177
+ * judged, and the next message the person sent brought the evaluator back to send them to work.
178
+ * Only a pursued goal changes; a paused, achieved or budget-limited one has nothing to stop.
179
+ */
180
+ export function abortedTurn(state: GoalState, now = new Date()): { state: GoalState; action: GoalAction } | undefined {
181
+ if (state.status !== "pursuing") return undefined;
182
+ return pauseFor(state, "you stopped the turn (Esc); /goal resume to continue", now);
183
+ }
184
+
174
185
  /** One line for `/goal` and the panel. */
175
186
  export function goalLine(state: GoalState): string {
176
187
  const iter = `${state.iterations}/${MAX_CONTINUATIONS}`;
package/src/hooks.ts CHANGED
@@ -12,7 +12,8 @@ import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { envFlag } from "./config.ts";
15
- import { previewRedacted } from "./redact.ts";
15
+ import { previewRedacted, redact } from "./redact.ts";
16
+ import type { LoopJob, RunRecord } from "./store.ts";
16
17
  import { parseToml } from "./toml.ts";
17
18
  import { PI_LOOPS_VERSION } from "./version.ts";
18
19
 
@@ -82,6 +83,29 @@ export interface HookPayload {
82
83
  /** Event-specific fields; the runner fills in the session-level ones. */
83
84
  export type HookEventData = Pick<HookPayload, "event" | "message_kind" | "message_summary" | "assistant_event" | "tool_call_id" | "tool_name" | "tool_is_error" | "tool_args" | "tool_result_summary" | "compaction_trigger" | "compaction_tokens_before" | "compaction_summary" | "compaction_failed" | "run_job" | "run_id" | "run_ok" | "run_findings" | "run_error" | "run_cost_usd">;
84
85
 
86
+ /**
87
+ * The same two events the interactive extension and the headless host fire. Whether a run happens
88
+ * here or there is an accident of who held the clock, and a hook rule should not be able to tell —
89
+ * so both processes fire the object these two return.
90
+ */
91
+ export function runStartEvent(job: LoopJob, runId: string): HookEventData {
92
+ return { event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) };
93
+ }
94
+
95
+ export function runEndEvent(job: LoopJob, record: RunRecord): HookEventData {
96
+ const label = job.name ?? job.id;
97
+ return {
98
+ event: "run_end",
99
+ run_job: label,
100
+ run_id: record.runId,
101
+ run_ok: record.ok,
102
+ run_findings: record.findings,
103
+ run_error: record.error ? redact(record.error) : null,
104
+ run_cost_usd: record.usage?.cost ?? null,
105
+ message_summary: truncateSummary(record.ok ? `${label}: ok · ${record.findings} finding(s)` : `${label}: failed: ${record.error ?? "unknown error"}`),
106
+ };
107
+ }
108
+
85
109
  export interface ParsedHooksFile {
86
110
  allowProjectHooks: boolean;
87
111
  hooks: HookConfig[];
@@ -7,7 +7,7 @@
7
7
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
8
8
  import * as path from "node:path";
9
9
  import type { LoopsConfig } from "./config.ts";
10
- import { HookRunner, truncateSummary } from "./hooks.ts";
10
+ import { HookRunner, runEndEvent, runStartEvent } from "./hooks.ts";
11
11
  import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
12
12
  import { redact } from "./redact.ts";
13
13
  import type { SubagentRequest, SubagentRunner } from "./runner.ts";
@@ -119,7 +119,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
119
119
  // scheduler calls this synchronously). `.catch` because a rejection nobody owns takes
120
120
  // the host down — hooks warn per rule, so there is nothing else to do with one.
121
121
  const hooks = hookRunnerFor(job, runId);
122
- if (hooks) void hooks.fire({ event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) }).catch((err: any) => log(`hooks: ${redact(err?.message ?? String(err))}`));
122
+ if (hooks) void hooks.fire(runStartEvent(job, runId)).catch((err: any) => log(`hooks: ${redact(err?.message ?? String(err))}`));
123
123
  },
124
124
  onCatchUp: (job) => log(`loop ${job.name ?? job.id}: catching up a missed tick`),
125
125
  onRunFinished: ({ job, record, result }) => {
@@ -129,14 +129,10 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
129
129
  if (!hooks) return;
130
130
  // `$PI_RUN_OK` alone answers "did last night's loop fail" — the thing the host exists to
131
131
  // be able to tell someone.
132
- const label = job.name ?? job.id;
133
- const summary = record.ok ? `${label}: ok · ${record.findings} finding(s)` : `${label}: failed: ${record.error ?? "unknown error"}`;
134
132
  // Its own hooks are the last thing a run does; the runner is dropped once they have run
135
133
  // (until then `stop()` still has to drain it).
136
134
  const forget = () => void runHooks.delete(record.runId);
137
- void hooks
138
- .fire({ event: "run_end", run_job: label, run_id: record.runId, run_ok: record.ok, run_findings: record.findings, run_error: record.error ? redact(record.error) : null, run_cost_usd: record.usage?.cost ?? null, message_summary: truncateSummary(summary) })
139
- .then(forget, forget);
135
+ void hooks.fire(runEndEvent(job, record)).then(forget, forget);
140
136
  },
141
137
  onTick: async (now, leader) => {
142
138
  if (!leader) {
@@ -153,7 +149,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
153
149
  });
154
150
 
155
151
  const toInbox = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }) => {
156
- await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), runId: trigger.traceId, jobId: trigger.sourceLabel, cwd: trigger.cwd ?? "" });
152
+ await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd ?? "" });
157
153
  return "inbox" as const;
158
154
  };
159
155
  triggers = new TriggerRuntime({