@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.
- package/CHANGELOG.md +156 -0
- package/CONTRACT.md +139 -0
- package/README.md +11 -7
- package/README.zh-CN.md +6 -4
- package/docs/cli.md +26 -3
- package/docs/downstream.md +60 -0
- package/docs/goal.md +4 -2
- package/docs/loops.md +55 -9
- package/docs/recipes.md +94 -18
- package/docs/session-archive.md +8 -3
- package/docs/web-ui-parity.md +11 -1
- package/package.json +5 -2
- package/recipes/_tracker/issue-tracker-local.md +3 -1
- package/recipes/autoresearch/experiment.md +30 -6
- package/recipes/autoresearch/recipe.toml +2 -0
- package/recipes/changelog-draft/recipe.toml +4 -0
- package/recipes/changelog-draft/release.md +8 -3
- package/recipes/ci-sweeper/recipe.toml +4 -0
- package/recipes/ci-sweeper/sweep.md +2 -1
- package/recipes/daily-digest/digest.md +5 -4
- package/recipes/daily-digest/recipe.toml +2 -0
- package/recipes/deps-sweeper/README.md +11 -0
- package/recipes/deps-sweeper/audit.md +99 -0
- package/recipes/deps-sweeper/recipe.toml +17 -0
- package/recipes/ecosystem/recipe.toml +3 -0
- package/recipes/ecosystem/scan.md +1 -1
- package/recipes/issue-loop/implement.md +17 -4
- package/recipes/issue-loop/labels.sh +18 -0
- package/recipes/issue-loop/recipe.toml +2 -0
- package/recipes/issue-loop/triage.md +16 -2
- package/recipes/pr-watch/recipe.toml +3 -0
- package/skills/pi-loops/SKILL.md +3 -2
- package/src/archive.ts +97 -2
- package/src/cli.ts +160 -24
- package/src/goal.ts +11 -0
- package/src/hooks.ts +25 -1
- package/src/host-runtime.ts +4 -8
- package/src/inbox.ts +118 -55
- package/src/job-signal.ts +78 -0
- package/src/pi-loops.ts +177 -109
- package/src/presence.ts +5 -0
- package/src/protocol.ts +45 -2
- package/src/recipe.ts +158 -2
- package/src/scheduler.ts +6 -2
- package/src/store.ts +8 -3
- package/src/tools.ts +17 -11
- package/src/triggers.ts +8 -3
- 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
|
-
"
|
|
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
|
|
291
|
-
|
|
292
|
-
const rules = new TriggerStore(loopsDir).load()
|
|
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")
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
496
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
714
|
-
//
|
|
715
|
-
|
|
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[];
|
package/src/host-runtime.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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*/, ""),
|
|
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({
|