@alphacoder-v0/pi-loops 0.19.3 → 0.22.0
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 +164 -0
- package/CONTRACT.md +139 -0
- package/README.md +12 -7
- package/README.zh-CN.md +6 -4
- package/docs/cli.md +19 -1
- package/docs/downstream.md +60 -0
- package/docs/goal.md +4 -2
- package/docs/loops.md +55 -9
- package/docs/recipes.md +140 -18
- package/docs/session-archive.md +8 -3
- package/docs/web-ui-parity.md +11 -1
- package/package.json +6 -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 +6 -1
- package/recipes/changelog-draft/release.md +25 -5
- package/recipes/ci-sweeper/README.md +9 -0
- package/recipes/ci-sweeper/recipe.toml +16 -0
- package/recipes/ci-sweeper/sweep.md +71 -0
- package/recipes/daily-digest/README.md +10 -0
- package/recipes/daily-digest/digest.md +65 -0
- package/recipes/daily-digest/recipe.toml +14 -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/README.md +9 -0
- package/recipes/ecosystem/recipe.toml +15 -0
- package/recipes/ecosystem/scan.md +59 -0
- 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/README.md +9 -0
- package/recipes/pr-watch/recipe.toml +15 -0
- package/recipes/pr-watch/watch.md +70 -0
- package/skills/pi-loops/SKILL.md +3 -2
- package/src/archive.ts +97 -2
- package/src/cli.ts +103 -16
- 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/archive.ts
CHANGED
|
@@ -17,11 +17,12 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as os from "node:os";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
+
import { sameProject } from "./presence.ts";
|
|
20
21
|
import { LOOP_STATE_MAX_CHARS, capChars } from "./protocol.ts";
|
|
21
22
|
import { previewRedacted } from "./redact.ts";
|
|
22
23
|
import { computeNext, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
|
|
23
|
-
import { newId, type LoopJob } from "./store.ts";
|
|
24
|
-
import { type DynamicTriggerRule, newRuleId } from "./triggers.ts";
|
|
24
|
+
import { type JobStore, newId, type LoopJob } from "./store.ts";
|
|
25
|
+
import { type DynamicTriggerRule, type TriggerStore, newRuleId } from "./triggers.ts";
|
|
25
26
|
|
|
26
27
|
export const ARCHIVE_SCHEMA = "pi-loops.session_export.v1";
|
|
27
28
|
export const ARCHIVE_EXT = ".pisession";
|
|
@@ -182,6 +183,24 @@ export function defaultExportPath(cwd: string, sessionId: string): string {
|
|
|
182
183
|
return path.join(cwd, `pi-session-${sessionId.slice(0, 16)}${ARCHIVE_EXT}`);
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/**
|
|
187
|
+
* What an export of `sessionId` in `cwd` carries: this project's automation, narrowed to what that
|
|
188
|
+
* session created.
|
|
189
|
+
*
|
|
190
|
+
* The store is machine-global, so the project alone would sweep in a colleague's loops — or your
|
|
191
|
+
* own from another window — and the session that created a job or rule is what scopes the archive.
|
|
192
|
+
* A job or rule from before `createdBy` existed has no owner to ask, and counts as this session's
|
|
193
|
+
* when it is in this project. "This project" is `sameProject`, not a string comparison: a worktree
|
|
194
|
+
* or a symlinked path is the same project and its loops belong in the same archive.
|
|
195
|
+
*/
|
|
196
|
+
export function automationOf(jobs: LoopJob[], rules: DynamicTriggerRule[], cwd: string, sessionId?: string): { jobs: LoopJob[]; rules: DynamicTriggerRule[] } {
|
|
197
|
+
const mine = (owner: { sessionId?: string } | undefined, ownerCwd: string): boolean => (owner?.sessionId ? owner.sessionId === sessionId : sameProject(ownerCwd, cwd));
|
|
198
|
+
return {
|
|
199
|
+
jobs: jobs.filter((j) => sameProject(j.cwd, cwd) && mine(j.createdBy, j.cwd)),
|
|
200
|
+
rules: rules.filter((r) => sameProject(r.cwd, cwd) && mine(r.createdBy, r.cwd)),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
185
204
|
export function exportSession(input: ExportInput): ExportSummary {
|
|
186
205
|
const sessionBytes = fs.readFileSync(input.sessionFile);
|
|
187
206
|
if (sessionBytes.length > MAX_SESSION_BYTES) throw new Error("session file exceeds the 50 MiB archive cap");
|
|
@@ -437,6 +456,82 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
437
456
|
};
|
|
438
457
|
}
|
|
439
458
|
|
|
459
|
+
/**
|
|
460
|
+
* What `--activate-triggers` says about the automation an archive carries: leave it off and say
|
|
461
|
+
* nothing (`off`), ask once after the import whether to switch back on what the source had on
|
|
462
|
+
* (`ask`, the default), or activate it as part of the import (`on`).
|
|
463
|
+
*/
|
|
464
|
+
export type ActivateMode = "off" | "ask" | "on";
|
|
465
|
+
|
|
466
|
+
/** `--activate-triggers=X` and `--activate-triggers X`, wherever either is typed. */
|
|
467
|
+
export function parseActivateMode(value: string | undefined): ActivateMode {
|
|
468
|
+
if (value === undefined) return "ask";
|
|
469
|
+
if (value === "off" || value === "ask" || value === "on") return value;
|
|
470
|
+
throw new Error(`--activate-triggers must be off, ask or on (got ${value})`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Commit an import to the stores: jobs, then their loop state, then rules.
|
|
475
|
+
*
|
|
476
|
+
* A write that fails part way is rolled back — the imported session file, the imported jobs and
|
|
477
|
+
* their state files go, and the original error is rethrown — because the alternative is a store
|
|
478
|
+
* holding half an archive: jobs whose rules never arrived, or a session file nothing points at.
|
|
479
|
+
* The rollback is best effort by construction; the failure it is undoing is usually a store that
|
|
480
|
+
* cannot be written.
|
|
481
|
+
*/
|
|
482
|
+
export async function applyImport(imp: ImportSummary, jobStore: JobStore, triggerStore: TriggerStore): Promise<void> {
|
|
483
|
+
try {
|
|
484
|
+
if (imp.jobs.length) await jobStore.mutate((jobs) => ({ jobs: [...jobs, ...imp.jobs], result: undefined }));
|
|
485
|
+
for (const [id, text] of Object.entries(imp.states)) jobStore.writeState(id, text);
|
|
486
|
+
if (imp.rules.length) await triggerStore.mutate((rules) => rules.push(...imp.rules));
|
|
487
|
+
} catch (err) {
|
|
488
|
+
const ids = new Set(imp.jobs.map((j) => j.id));
|
|
489
|
+
const attempt = (fn: () => void) => {
|
|
490
|
+
try {
|
|
491
|
+
fn();
|
|
492
|
+
} catch {
|
|
493
|
+
/* best effort */
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
attempt(() => fs.rmSync(imp.sessionPath, { force: true }));
|
|
497
|
+
await jobStore.mutate((jobs) => ({ jobs: jobs.filter((j) => !ids.has(j.id)), result: undefined })).catch(() => {});
|
|
498
|
+
for (const id of ids) attempt(() => fs.rmSync(jobStore.statePath(id), { force: true }));
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* What `ask` means: after an import that did not activate, offer once to switch back on what the
|
|
505
|
+
* source session had on, and do it if the answer is yes. Returns what was enabled.
|
|
506
|
+
*
|
|
507
|
+
* `confirm` is the caller's way of asking — a TUI dialog, a terminal prompt — and a caller with
|
|
508
|
+
* nobody to ask passes nothing, which is the same as no. `onJobEnabled` is the hook the extension
|
|
509
|
+
* uses to keep its cron audit trail; nothing else needs it.
|
|
510
|
+
*/
|
|
511
|
+
export async function offerToEnable(
|
|
512
|
+
imp: ImportSummary,
|
|
513
|
+
jobStore: JobStore,
|
|
514
|
+
triggerStore: TriggerStore,
|
|
515
|
+
confirm?: (question: string, detail: string) => boolean | Promise<boolean>,
|
|
516
|
+
onJobEnabled?: (before: LoopJob, after: LoopJob) => void,
|
|
517
|
+
): Promise<{ jobs: number; rules: number }> {
|
|
518
|
+
const none = { jobs: 0, rules: 0 };
|
|
519
|
+
if (imp.automationEnabled) return none;
|
|
520
|
+
const pending = imp.originallyEnabledJobs.length + imp.originallyEnabledRules.length;
|
|
521
|
+
if (!pending || !confirm) return none;
|
|
522
|
+
const ok = await confirm("Enable imported automation?", `${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s) were enabled in the source session. Enable them here now?`);
|
|
523
|
+
if (!ok) return none;
|
|
524
|
+
for (const id of imp.originallyEnabledJobs) {
|
|
525
|
+
const before = jobStore.load().find((j) => j.id === id);
|
|
526
|
+
const after = await jobStore.update(id, (j) => {
|
|
527
|
+
j.enabled = true;
|
|
528
|
+
});
|
|
529
|
+
if (before && after) onJobEnabled?.(before, after);
|
|
530
|
+
}
|
|
531
|
+
for (const id of imp.originallyEnabledRules) await triggerStore.setEnabled(id, true);
|
|
532
|
+
return { jobs: imp.originallyEnabledJobs.length, rules: imp.originallyEnabledRules.length };
|
|
533
|
+
}
|
|
534
|
+
|
|
440
535
|
/**
|
|
441
536
|
* Recognises automation this store already imported from the same archive. Without it, importing an
|
|
442
537
|
* archive twice leaves two live copies of every job and rule, each running and each billing.
|
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
|
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({
|