@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/pi-loops.ts
CHANGED
|
@@ -15,16 +15,17 @@ import * as path from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { ProjectTrustStore, VERSION as PI_VERSION, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { ARCHIVE_EXT, defaultExportPath, exportSession, importSession } from "./archive.ts";
|
|
18
|
+
import { ARCHIVE_EXT, type ActivateMode, applyImport, automationOf, defaultExportPath, exportSession, importSession, offerToEnable, parseActivateMode } from "./archive.ts";
|
|
19
19
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { parseAddArgs, parseSetArgs, splitCommand, tokenize } from "./args.ts";
|
|
22
22
|
import { envFlag, loadConfig } from "./config.ts";
|
|
23
|
-
import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
|
|
24
|
-
import {
|
|
23
|
+
import { GOAL_ENTRY, type GoalAction, type GoalState, MAX_CONTINUATIONS, abortedTurn, applyDecision, branchMovedSince, continuationPrompt, evaluatorPrompt, latestGoal, newGoal, parseDecision, pauseFor, transcriptFromMessages } from "./goal.ts";
|
|
24
|
+
import { sameProject } from "./presence.ts";
|
|
25
25
|
import { isExactlyTrusted, sessionTrustCovers } from "./trust.ts";
|
|
26
|
-
import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, truncateSummary } from "./hooks.ts";
|
|
26
|
+
import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, runEndEvent, runStartEvent, truncateSummary } from "./hooks.ts";
|
|
27
27
|
import { failingSummary } from "./job-health.ts";
|
|
28
|
+
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
|
|
28
29
|
import { LoopsLog, pruneLogs } from "./log.ts";
|
|
29
30
|
import { type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
|
|
30
31
|
import { McpPool } from "./mcp-pool.ts";
|
|
@@ -38,7 +39,7 @@ import { createHash } from "node:crypto";
|
|
|
38
39
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
39
40
|
import { applyJobEdit } from "./job-edit.ts";
|
|
40
41
|
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";
|
|
42
|
+
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, RECIPE_TIERS, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, neverSection, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, preflightChecks, purgeInstall, readRecord, renderPreflight, requireRecipeName, resolveRecipeRef, runPreflight, updateFiles } from "./recipe.ts";
|
|
42
43
|
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
43
44
|
import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
|
|
44
45
|
import { parentRuntimeFlags, type SubagentRequest } from "./runner.ts";
|
|
@@ -191,9 +192,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
191
192
|
onRunStart: (job, runId) => {
|
|
192
193
|
auditCronStart(triggers.store, job, runId);
|
|
193
194
|
refreshBadge();
|
|
194
|
-
|
|
195
|
-
// an accident of who held the clock, and a hook rule should not be able to tell.
|
|
196
|
-
fireRunHook({ event: "run_start", run_job: job.name ?? job.id, run_id: runId, message_summary: truncateSummary(`${job.name ?? job.id}: ${job.prompt}`) });
|
|
195
|
+
fireRunHook(runStartEvent(job, runId));
|
|
197
196
|
},
|
|
198
197
|
onCatchUp: (job, dueAt) => {
|
|
199
198
|
if (lastCtx?.hasUI && sameProject(job.cwd, session.cwd)) lastCtx.ui.notify(`cron ${job.name ?? job.id}: catching up the run missed at ${formatLocal(dueAt)}`, "info");
|
|
@@ -201,16 +200,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
201
200
|
onRunFinished: ({ job, record, findings, result }) => {
|
|
202
201
|
auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
|
|
203
202
|
refreshBadge();
|
|
204
|
-
fireRunHook(
|
|
205
|
-
event: "run_end",
|
|
206
|
-
run_job: job.name ?? job.id,
|
|
207
|
-
run_id: record.runId,
|
|
208
|
-
run_ok: record.ok,
|
|
209
|
-
run_findings: record.findings,
|
|
210
|
-
run_error: record.error ? redact(record.error) : null,
|
|
211
|
-
run_cost_usd: record.usage?.cost ?? null,
|
|
212
|
-
message_summary: truncateSummary(record.ok ? `${job.name ?? job.id}: ok · ${record.findings} finding(s)` : `${job.name ?? job.id}: failed: ${record.error ?? "unknown error"}`),
|
|
213
|
-
});
|
|
203
|
+
fireRunHook(runEndEvent(job, record));
|
|
214
204
|
if (!lastCtx?.hasUI) return;
|
|
215
205
|
// Another project's findings and prompt previews do not belong in this transcript; the
|
|
216
206
|
// audit sink below and the inbox already carry them to where they do.
|
|
@@ -255,7 +245,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
255
245
|
// message into the parent session). Only into a chat that belongs to the rule's project;
|
|
256
246
|
// a different project's chat gets nothing — the finding goes to the inbox instead.
|
|
257
247
|
if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
|
|
258
|
-
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""),
|
|
248
|
+
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd });
|
|
259
249
|
refreshBadge();
|
|
260
250
|
return "inbox";
|
|
261
251
|
}
|
|
@@ -267,7 +257,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
267
257
|
},
|
|
268
258
|
onInjectAndRun: async (prompt, trigger) => {
|
|
269
259
|
if (trigger.cwd && !sameProject(trigger.cwd, session.cwd)) {
|
|
270
|
-
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: prompt.replace(/^\[Trigger [^\]]+\]\s*/, ""),
|
|
260
|
+
await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: prompt.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd });
|
|
271
261
|
refreshBadge();
|
|
272
262
|
return "inbox";
|
|
273
263
|
}
|
|
@@ -661,7 +651,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
661
651
|
if (!lastCtx?.hasUI) return;
|
|
662
652
|
const parts: string[] = [];
|
|
663
653
|
const n = scheduler.inbox.newCount();
|
|
664
|
-
|
|
654
|
+
const decisions = scheduler.inbox.decisionCount();
|
|
655
|
+
if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${decisions} decision${decisions === 1 ? "" : "s"})` : ""}`);
|
|
665
656
|
// Machine-wide like the inbox count and the running list above it: the clock is one per host,
|
|
666
657
|
// and a loop failing in another checkout is still this machine's automation going quiet.
|
|
667
658
|
const failing = failingSummary(scheduler.store.load());
|
|
@@ -731,6 +722,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
731
722
|
|
|
732
723
|
function jobLines(jobs: LoopJob[]): string[] {
|
|
733
724
|
const now = Date.now();
|
|
725
|
+
// Read once for the whole listing: the run log and the inbox are each one file, and a
|
|
726
|
+
// project with a dozen loops would otherwise parse them a dozen times per `/cron`.
|
|
727
|
+
const allRuns = jobs.some((j) => j.stateful) ? scheduler.store.allRuns() : [];
|
|
728
|
+
const allInbox = jobs.some((j) => j.stateful) ? scheduler.inbox.list() : [];
|
|
734
729
|
return jobs.map((job, i) => {
|
|
735
730
|
// A next run is shown where it will happen: a plain job of a session not open here has none.
|
|
736
731
|
const next = job.enabled && runsIn(job, session.sessionId)
|
|
@@ -746,12 +741,18 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
746
741
|
const asleepMark = asleepNote(job, session.sessionId);
|
|
747
742
|
const dormant = asleepMark ? `[${asleepMark}]` : undefined;
|
|
748
743
|
const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
749
|
-
|
|
744
|
+
// What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
|
|
745
|
+
// runs are the newest, and the findings line — filed, claimed, dismissed — over 30 days.
|
|
746
|
+
// A loop was, until now, judged by whether it ran; this is whether it was worth running.
|
|
747
|
+
const signal = job.stateful ? loopSignal(job.id, allRuns, allInbox, now - SIGNAL_WINDOW_MS) : undefined;
|
|
748
|
+
const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
|
|
749
|
+
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, 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}` : ""}`;
|
|
753
753
|
const action = ` action: ${previewRedacted(job.prompt, 120)}`;
|
|
754
|
-
const
|
|
754
|
+
const worth = signal ? signalSummary(signal) : undefined;
|
|
755
|
+
const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · overlap skips ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${homeRel(job.cwd)}`;
|
|
755
756
|
const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
|
|
756
757
|
return [head, action, meta, err].filter((l): l is string => !!l);
|
|
757
758
|
}).flat();
|
|
@@ -810,7 +811,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
810
811
|
"/cron host [start|stop] the headless host that keeps the clock after the last pi quits; start = hand off on quit even with [host] auto = false",
|
|
811
812
|
"/inbox triage findings from stateful jobs (/inbox help)",
|
|
812
813
|
`/session-export [path] [--exclude-triggers] transcript + this project's cron jobs, trigger rules and loop state as one ${ARCHIVE_EXT} archive`,
|
|
813
|
-
"/session-import <path> [--activate-triggers=on
|
|
814
|
+
"/session-import <path> [--activate-triggers=off|ask|on] [--cwd <dir>] [--resume] restore it here (automation stays off unless activated)",
|
|
814
815
|
];
|
|
815
816
|
|
|
816
817
|
const cronCompletions = (prefix: string) => {
|
|
@@ -825,7 +826,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
825
826
|
// Numbers refer to the project-scoped list (/loop); ids and names resolve machine-wide.
|
|
826
827
|
const pick = (ref: string): LoopJob | undefined => {
|
|
827
828
|
const all = jobs();
|
|
828
|
-
const job = /^\d+$/.test(ref.trim()) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref) : resolveJobRef(all, ref);
|
|
829
|
+
const job = /^\d+$/.test(ref.trim()) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref, { ordinals: true }) : resolveJobRef(all, ref);
|
|
829
830
|
if (!job) ctx.ui.notify(ref ? `no cron job with id '${ref}'` : `usage: /cron ${sub} <id>`, "warning");
|
|
830
831
|
return job;
|
|
831
832
|
};
|
|
@@ -1025,10 +1026,19 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1025
1026
|
const byJob = [...spend.byJob.entries()].sort((a, b) => b[1].cost - a[1].cost);
|
|
1026
1027
|
const budget = scheduler.budgetState();
|
|
1027
1028
|
const label = window === "all" ? "since the run log was last rotated" : window === "today" ? "today" : `the last ${window}`;
|
|
1029
|
+
// The price beside what it bought: a loop's findings in the same window, and what a
|
|
1030
|
+
// person did with them. `$0.410 28 run(s) pr-watch — 6 findings · 6 dismissed` is
|
|
1031
|
+
// a line that answers itself.
|
|
1032
|
+
const runsInLog = scheduler.store.allRuns();
|
|
1033
|
+
const inboxAll = scheduler.inbox.list();
|
|
1034
|
+
const worthOf = (id: string) => signalSummary(loopSignal(id, runsInLog, inboxAll, since));
|
|
1028
1035
|
show(ctx, `Automation cost ${label}: $${spend.total.toFixed(3)} over ${spend.runs} run(s)`, [
|
|
1029
1036
|
...(spend.rotated > 0 ? [` including $${spend.rotated.toFixed(3)} from runs the log has already rotated away`] : []),
|
|
1030
1037
|
...(budget.cap > 0 ? [` today's budget: $${budget.spent.toFixed(2)} of $${budget.cap.toFixed(2)}${budget.over ? " — dispatching is paused" : ""}`] : [" no budget cap set ([limits] daily_budget_usd)"]),
|
|
1031
|
-
...byJob.slice(0, 12).map(([id, e]) =>
|
|
1038
|
+
...byJob.slice(0, 12).map(([id, e]) => {
|
|
1039
|
+
const worth = worthOf(id);
|
|
1040
|
+
return ` $${e.cost.toFixed(3)} ${e.runs} run(s) ${e.name ?? id}${worth ? ` — ${worth}` : ""}`;
|
|
1041
|
+
}),
|
|
1032
1042
|
...(byJob.length > 12 ? [` (+${byJob.length - 12} more)`] : []),
|
|
1033
1043
|
...(spend.runs ? [] : [" nothing has run in this window"]),
|
|
1034
1044
|
]);
|
|
@@ -1196,13 +1206,14 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1196
1206
|
pi.registerCommand("crontab", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
|
|
1197
1207
|
pi.registerCommand("loop", { description: "Alias of /cron", getArgumentCompletions: cronCompletions, handler: cronHandler });
|
|
1198
1208
|
|
|
1199
|
-
const INBOX_USAGE = "[list|all|claim <n>|dismiss <n
|
|
1209
|
+
const INBOX_USAGE = "[list|all|claim <n>|dismiss <n> [reason]|clear|help] [--all]";
|
|
1200
1210
|
|
|
1201
1211
|
const INBOX_HELP = [
|
|
1202
1212
|
"/inbox list new findings of this project /inbox --all every project on this machine",
|
|
1203
1213
|
"/inbox all [--all] include claimed/dismissed history",
|
|
1204
1214
|
"/inbox claim <n|id> mark claimed and hand it to the agent as a real turn",
|
|
1205
|
-
"/inbox dismiss <n|id> mark dismissed
|
|
1215
|
+
"/inbox dismiss <n|id> [reason] mark dismissed; a reason is shown to the loop's next run",
|
|
1216
|
+
"/inbox clear [--all] dismiss the ones listed",
|
|
1206
1217
|
];
|
|
1207
1218
|
|
|
1208
1219
|
/** The project a finding came from, as `/cron` shows a job's: the directory name is enough to tell them apart. */
|
|
@@ -1215,18 +1226,23 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1215
1226
|
// time pi-loops puts on a screen. It used to be the stored string with the `Z` sliced
|
|
1216
1227
|
// off — a UTC time wearing the shape of a local one, eight hours from the `next` on the
|
|
1217
1228
|
// `/cron` line above it, with nothing on either to say which was which.
|
|
1218
|
-
const when = formatLocal(Date.parse(e.
|
|
1219
|
-
|
|
1229
|
+
const when = formatLocal(Date.parse(e.created_at));
|
|
1230
|
+
// ⚑ is a checkpoint — a decision the finding asks of a person; ✓ is the checker's mark.
|
|
1231
|
+
const mark = `${e.kind === "checkpoint" ? "⚑ " : ""}${e.verified ? "✓ " : ""}`;
|
|
1220
1232
|
// The project comes first of the three: with loops running in several checkouts it is what
|
|
1221
1233
|
// decides whether a finding is this morning's problem, and claiming runs it in that cwd.
|
|
1222
|
-
|
|
1234
|
+
const why = e.dismiss_reason ? ` — dismissed: ${redact(e.dismiss_reason)}` : "";
|
|
1235
|
+
if (!numbered) return ` [${e.status}] ${mark}${redact(e.text)}${why} (${projectOf(e.cwd)}, ${e.source})`;
|
|
1223
1236
|
return ` ${i + 1}. [${e.id.slice(0, 12)}] ${mark}${redact(e.text)} (${projectOf(e.cwd)}, ${e.source}, ${when})`;
|
|
1224
1237
|
});
|
|
1225
1238
|
}
|
|
1226
1239
|
|
|
1227
1240
|
function claimPrompt(e: InboxEntry): string {
|
|
1228
|
-
const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.
|
|
1229
|
-
|
|
1241
|
+
const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verified_reason ? `: ${e.verified_reason}` : ""}.)` : "";
|
|
1242
|
+
// A checkpoint is a decision the loop prepared and left to a person; the claim is that
|
|
1243
|
+
// person's yes, so the turn carries the decision out rather than re-investigating it.
|
|
1244
|
+
const checkpoint = e.kind === "checkpoint" ? "\nThis finding is a checkpoint: claiming it is the person's approval of the decision it recommends — carry that decision out, and stop where the finding says a person acts." : "";
|
|
1245
|
+
return `A recurring loop (${e.source}, running in ${e.cwd}) reported this finding — investigate and address it:\n${e.text}${verified}${checkpoint}`;
|
|
1230
1246
|
}
|
|
1231
1247
|
|
|
1232
1248
|
/* ------------------------------------------------------------------ /goal */
|
|
@@ -1457,7 +1473,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1457
1473
|
const elsewhere = all.length - entries.length;
|
|
1458
1474
|
if (elsewhere) lines.push(`+ ${elsewhere} finding${elsewhere === 1 ? "" : "s"} in other projects — /inbox --all`);
|
|
1459
1475
|
lines.push("claim with /inbox claim <n>, dismiss with /inbox dismiss <n>");
|
|
1460
|
-
|
|
1476
|
+
const decisions = entries.filter((e) => e.kind === "checkpoint").length;
|
|
1477
|
+
show(ctx, `Inbox (${where}, ${entries.length} new${decisions ? `, ${decisions} need${decisions === 1 ? "s" : ""} a decision` : ""}, times ${localOffset(Date.now())}):`, lines);
|
|
1461
1478
|
return;
|
|
1462
1479
|
}
|
|
1463
1480
|
case "all": {
|
|
@@ -1470,19 +1487,45 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1470
1487
|
return;
|
|
1471
1488
|
case "claim":
|
|
1472
1489
|
case "dismiss": {
|
|
1490
|
+
// `/inbox dismiss 3 that file is generated`: the first word names the finding, the rest is
|
|
1491
|
+
// why, and the reason goes to the loop that reported it, in its next run's prompt. A
|
|
1492
|
+
// claim takes the whole argument as the ref, as it always has. For a dismiss, `--all`
|
|
1493
|
+
// only counts before the reason starts (`dismiss --all 3 …`, `dismiss 3 --all …`): a
|
|
1494
|
+
// reason that mentions `--all` in passing must not re-number the list the person is
|
|
1495
|
+
// looking at and dismiss another project's finding.
|
|
1496
|
+
let target = ref;
|
|
1497
|
+
let reason = "";
|
|
1498
|
+
let machineWide = everywhere;
|
|
1499
|
+
if (sub === "dismiss") {
|
|
1500
|
+
const words = rest.split(/\s+/).filter(Boolean);
|
|
1501
|
+
machineWide = words[0] === "--all";
|
|
1502
|
+
if (machineWide) words.shift();
|
|
1503
|
+
target = words.shift() ?? "";
|
|
1504
|
+
if (words[0] === "--all") {
|
|
1505
|
+
machineWide = true;
|
|
1506
|
+
words.shift();
|
|
1507
|
+
}
|
|
1508
|
+
reason = words.join(" ");
|
|
1509
|
+
}
|
|
1473
1510
|
// Numbers are the numbers on screen — this project's list, as `/cron` does it; an id
|
|
1474
1511
|
// or a prefix still resolves machine-wide, so a finding can be claimed from anywhere.
|
|
1475
|
-
const
|
|
1476
|
-
const
|
|
1512
|
+
const all = scheduler.inbox.listNew();
|
|
1513
|
+
const entries = /^\d+$/.test(target) && !machineWide ? inProject(all, session.cwd, sameProject) : all;
|
|
1514
|
+
const entry = resolveInboxRef(entries, target, { ordinals: true });
|
|
1477
1515
|
if (!entry) {
|
|
1478
|
-
const n = Number(
|
|
1479
|
-
ctx.ui.notify(!
|
|
1516
|
+
const n = Number(target);
|
|
1517
|
+
ctx.ui.notify(!target ? "usage: /inbox claim <n or inb-id> | /inbox dismiss <n or inb-id> [reason]" : Number.isInteger(n) ? `no inbox entry #${n} in ${where} (have ${entries.length}; /inbox --all lists every project)` : `no new inbox entry matching '${target}'`, "warning");
|
|
1480
1518
|
return;
|
|
1481
1519
|
}
|
|
1482
|
-
await scheduler.inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? session.sessionId : undefined);
|
|
1520
|
+
await scheduler.inbox.setStatus(entry.id, sub === "claim" ? "claimed" : "dismissed", sub === "claim" ? session.sessionId : undefined, reason || undefined);
|
|
1483
1521
|
refreshBadge();
|
|
1484
1522
|
if (sub === "dismiss") {
|
|
1485
|
-
|
|
1523
|
+
const loop = scheduler.store.load().find((j) => j.id === entry.job_id);
|
|
1524
|
+
// A trigger's finding carries its source label where a loop's carries a job id; a
|
|
1525
|
+
// trigger has no notes and no next run to tell.
|
|
1526
|
+
const gone = entry.job_id?.startsWith("cron-") ? "the loop that reported it is gone, so nobody is told" : "a trigger reported it, and a trigger keeps no notes, so nobody is told";
|
|
1527
|
+
const told = reason ? ` — ${loop ? `${loop.name ?? loop.id} is told why on its next run` : gone}` : "";
|
|
1528
|
+
ctx.ui.notify(`dismissed: ${previewRedacted(entry.text, 80)}${told}`, "info");
|
|
1486
1529
|
return;
|
|
1487
1530
|
}
|
|
1488
1531
|
pi.sendUserMessage(claimPrompt(entry), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
@@ -1553,7 +1596,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1553
1596
|
const store = triggers.store;
|
|
1554
1597
|
const pickRule = (ref: string) => {
|
|
1555
1598
|
const all = store.load();
|
|
1556
|
-
const rule = /^\d+$/.test(ref.trim()) ? resolveRuleRef(all.filter((r) => sameProject(r.cwd, session.cwd)), ref) : resolveRuleRef(all, ref);
|
|
1599
|
+
const rule = /^\d+$/.test(ref.trim()) ? resolveRuleRef(all.filter((r) => sameProject(r.cwd, session.cwd)), ref, { ordinals: true }) : resolveRuleRef(all, ref);
|
|
1557
1600
|
if (!rule) ctx.ui.notify(ref ? `no dynamic trigger rule with id '${ref}'` : `usage: /triggers ${sub} <id>${sub === "remove" || sub === "rm" || sub === "delete" ? "|--all" : ""}`, "warning");
|
|
1558
1601
|
return rule;
|
|
1559
1602
|
};
|
|
@@ -1841,7 +1884,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1841
1884
|
|
|
1842
1885
|
const RECIPE_HELP = [
|
|
1843
1886
|
"/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",
|
|
1887
|
+
"/recipe show <name|path> what one would install: playbooks, jobs, levels, what the runs may never do, setup script",
|
|
1845
1888
|
"/recipe add <name|path> install it in this project: copies the playbooks to .agents/skills/<name>/,",
|
|
1846
1889
|
" runs the setup script after showing it, creates the jobs. --level report|propose|act skips the question",
|
|
1847
1890
|
"/recipe update <name> merge the packaged playbooks into the installed copies; your edits are kept",
|
|
@@ -1858,6 +1901,44 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1858
1901
|
act: "also promote, close and merge — the terminal states",
|
|
1859
1902
|
};
|
|
1860
1903
|
|
|
1904
|
+
/**
|
|
1905
|
+
* What the playbooks forbid, as `/recipe show` and the install confirmation print it: one
|
|
1906
|
+
* block per playbook that has a `## Never` section. It is the safety envelope of an unattended
|
|
1907
|
+
* run, and the one part of a playbook worth reading before saying yes to the install.
|
|
1908
|
+
*/
|
|
1909
|
+
/**
|
|
1910
|
+
* What the project has and lacks for this recipe's runs, checked now rather than found out by
|
|
1911
|
+
* the first run at three in the morning. Reported, never enforced: the install goes through
|
|
1912
|
+
* whatever the lines say, because a missing `gh` login is fixed in a minute and a job that is
|
|
1913
|
+
* not there is not.
|
|
1914
|
+
*/
|
|
1915
|
+
async function preflightLines(recipe: Recipe, project: string, level: AutonomyLevel): Promise<string[]> {
|
|
1916
|
+
let tracker: string | undefined;
|
|
1917
|
+
try {
|
|
1918
|
+
tracker = fs.readFileSync(path.join(project, TRACKER_FILE), "utf8");
|
|
1919
|
+
} catch {
|
|
1920
|
+
tracker = undefined;
|
|
1921
|
+
}
|
|
1922
|
+
return renderPreflight(await runPreflight(preflightChecks(recipe.manifest, level, tracker), project));
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function neverLines(recipe: Recipe): string[] {
|
|
1926
|
+
const out: string[] = [];
|
|
1927
|
+
for (const file of playbookFiles(recipe.manifest)) {
|
|
1928
|
+
if (!file.endsWith(".md")) continue;
|
|
1929
|
+
let text: string;
|
|
1930
|
+
try {
|
|
1931
|
+
text = fs.readFileSync(path.join(recipe.dir, file), "utf8");
|
|
1932
|
+
} catch {
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
const never = neverSection(text);
|
|
1936
|
+
if (!never.length) continue;
|
|
1937
|
+
out.push(` never (${file}):`, ...never.map((l) => ` ${l}`));
|
|
1938
|
+
}
|
|
1939
|
+
return out;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1861
1942
|
|
|
1862
1943
|
/** The prompt that hands the tracker description to the session; the text lives beside the recipes. */
|
|
1863
1944
|
function trackerSetupPrompt(recipe: Recipe, project: string): string {
|
|
@@ -1939,6 +2020,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1939
2020
|
return;
|
|
1940
2021
|
}
|
|
1941
2022
|
const plan = planInstall(recipe, project, level);
|
|
2023
|
+
const preflight = await preflightLines(recipe, project, level);
|
|
1942
2024
|
let overwrite = false;
|
|
1943
2025
|
if (plan.changed.length) {
|
|
1944
2026
|
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"));
|
|
@@ -1960,10 +2042,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1960
2042
|
`Files → ${homeRel(plan.targetDir)}/ (listed in .git/info/exclude, so nothing enters the repository):`,
|
|
1961
2043
|
...plan.files.map((f) => ` ${f}${plan.changed.includes(f) ? (overwrite ? " (overwritten)" : " (kept as edited)") : ""}`),
|
|
1962
2044
|
...(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.`] : []),
|
|
2045
|
+
...(neverLines(recipe).length ? ["", "What the playbooks forbid a run to do, at every level:", ...neverLines(recipe)] : []),
|
|
1963
2046
|
"",
|
|
1964
2047
|
"Jobs:",
|
|
1965
2048
|
...plan.addLines.map((l) => ` /cron add ${l}`),
|
|
1966
2049
|
...(m.budgetHintUsd ? ["", `Budget hint: about $${m.budgetHintUsd}/day — cap it with [limits] daily_budget_usd in config.toml`] : []),
|
|
2050
|
+
...(preflight.length ? ["", "Before the first run (checked now; nothing here blocks the install):", ...preflight] : []),
|
|
1967
2051
|
];
|
|
1968
2052
|
const ok = await ctx.ui.confirm(`Install recipe ${m.name} at level "${level}"?`, body.join("\n"));
|
|
1969
2053
|
if (!ok) {
|
|
@@ -2021,11 +2105,20 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2021
2105
|
const packaged = listRecipes();
|
|
2022
2106
|
const installed = new Map(installedRecipes(project).map((r) => [r.recipe, r]));
|
|
2023
2107
|
const jobs = scheduler.store.load().filter((j) => j.recipe && sameProject(j.cwd, project));
|
|
2024
|
-
const
|
|
2108
|
+
const line = (r: Recipe) => {
|
|
2025
2109
|
const rec = installed.get(r.manifest.name);
|
|
2026
2110
|
const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
|
|
2027
2111
|
return ` ${r.manifest.name.padEnd(17)} ${(rec ? `installed: ${rec.level}, ${n} job(s)` : "—").padEnd(28)} ${r.manifest.summary}`;
|
|
2028
|
-
}
|
|
2112
|
+
};
|
|
2113
|
+
// Starters first: they read and file findings, and are the ones to install before
|
|
2114
|
+
// anything that writes to a tracker or opens a pull request.
|
|
2115
|
+
const lines: string[] = [];
|
|
2116
|
+
for (const tier of RECIPE_TIERS) {
|
|
2117
|
+
const group = packaged.filter((r) => r.manifest.tier === tier);
|
|
2118
|
+
if (!group.length) continue;
|
|
2119
|
+
lines.push(tier === "starter" ? " starter — reads, and files findings; install one of these first:" : " advanced — writes to a worktree, a tracker or a pull request; read the playbooks first:");
|
|
2120
|
+
lines.push(...group.map(line));
|
|
2121
|
+
}
|
|
2029
2122
|
for (const rec of installed.values()) {
|
|
2030
2123
|
if (packaged.some((r) => r.manifest.name === rec.recipe)) continue;
|
|
2031
2124
|
lines.push(` ${rec.recipe.padEnd(17)} installed from ${homeRel(rec.source)} (${rec.level})`);
|
|
@@ -2043,8 +2136,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2043
2136
|
const m = recipe.manifest;
|
|
2044
2137
|
const rel = path.join(INSTALL_ROOT, m.name);
|
|
2045
2138
|
const record = readRecord(installDirFor(project, m.name));
|
|
2139
|
+
const topLevel = m.levels[m.levels.length - 1];
|
|
2140
|
+
const showPreflight = await preflightLines(recipe, project, topLevel);
|
|
2046
2141
|
const lines = [
|
|
2047
2142
|
` ${m.summary}`,
|
|
2143
|
+
` tier: ${m.tier}${m.tier === "starter" ? " (reads, and files findings)" : " (writes to a worktree, a tracker or a pull request)"}`,
|
|
2144
|
+
...(m.usefulWhen.length ? [" useful when:", ...m.usefulWhen.map((u) => ` ${u}`)] : []),
|
|
2048
2145
|
` levels: ${m.levels.map((l) => `${l} (${LEVEL_TEXT[l]})`).join("; ")}`,
|
|
2049
2146
|
` 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
2147
|
` files → ${rel}/: ${playbookFiles(m).join(", ")}`,
|
|
@@ -2052,6 +2149,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2052
2149
|
...(m.budgetHintUsd ? [` budget hint: about $${m.budgetHintUsd}/day at the default schedules — [limits] daily_budget_usd in config.toml`] : []),
|
|
2053
2150
|
" jobs:",
|
|
2054
2151
|
...m.jobs.map((j) => ` /cron add ${addLineFor(j, rel)}`),
|
|
2152
|
+
...neverLines(recipe),
|
|
2153
|
+
...(showPreflight.length ? [` before the first run (at ${topLevel}, checked now):`, ...showPreflight] : []),
|
|
2055
2154
|
...(record ? [` installed here: ${record.level}, ${record.installedAt}, pi-loops ${record.version}`] : []),
|
|
2056
2155
|
];
|
|
2057
2156
|
show(ctx, `recipe ${m.name} (${homeRel(recipe.dir)})`, lines);
|
|
@@ -2233,12 +2332,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2233
2332
|
const outputPath = path.resolve(session.cwd, pathArgs[0] ?? defaultExportPath(session.cwd, sessionId));
|
|
2234
2333
|
ctx.ui.notify(ARCHIVE_WARNING, "warning"); // Printed before the attempt, success or not
|
|
2235
2334
|
try {
|
|
2236
|
-
|
|
2237
|
-
// created a job or rule is what scopes the archive; jobs from before `createdBy`
|
|
2238
|
-
// existed fall back to the project.
|
|
2239
|
-
const mine = (owner: { sessionId?: string } | undefined, cwd: string) => (owner?.sessionId ? owner.sessionId === sessionId : sameProject(cwd, session.cwd));
|
|
2240
|
-
const jobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && mine(j.createdBy, j.cwd));
|
|
2241
|
-
const rules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && mine(r.createdBy, r.cwd));
|
|
2335
|
+
const { jobs, rules } = automationOf(scheduler.store.load(), triggers.store.load(), session.cwd, sessionId);
|
|
2242
2336
|
const states: Record<string, string> = {};
|
|
2243
2337
|
for (const job of jobs) {
|
|
2244
2338
|
const loopState = job.stateful ? scheduler.store.readState(job.id) : undefined;
|
|
@@ -2259,26 +2353,26 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2259
2353
|
handler: async (args, ctx) => {
|
|
2260
2354
|
lastCtx = ctx;
|
|
2261
2355
|
const parts = args.split(/\s+/).filter(Boolean);
|
|
2262
|
-
let
|
|
2356
|
+
let mode: ActivateMode = "ask";
|
|
2263
2357
|
let resume = false;
|
|
2264
2358
|
let targetCwd = session.cwd;
|
|
2265
2359
|
const positional: string[] = [];
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
if (
|
|
2272
|
-
else if (
|
|
2273
|
-
else
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2360
|
+
try {
|
|
2361
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2362
|
+
const arg = parts[i];
|
|
2363
|
+
if (arg === "--resume") resume = true;
|
|
2364
|
+
// Both forms, as the command line takes them.
|
|
2365
|
+
else if (arg.startsWith("--activate-triggers=")) mode = parseActivateMode(arg.slice("--activate-triggers=".length));
|
|
2366
|
+
else if (arg === "--activate-triggers") mode = parseActivateMode(parts[++i]);
|
|
2367
|
+
else if (arg === "--cwd") targetCwd = path.resolve(session.cwd, parts[++i] ?? ".");
|
|
2368
|
+
else positional.push(arg);
|
|
2369
|
+
}
|
|
2370
|
+
} catch (err: any) {
|
|
2371
|
+
ctx.ui.notify(err?.message ?? String(err), "warning");
|
|
2372
|
+
return;
|
|
2279
2373
|
}
|
|
2280
2374
|
if (positional.length !== 1) {
|
|
2281
|
-
ctx.ui.notify("usage: /session-import <path> [--activate-triggers=on
|
|
2375
|
+
ctx.ui.notify("usage: /session-import <path> [--activate-triggers=off|ask|on] [--cwd <dir>] [--resume]", "warning");
|
|
2282
2376
|
return;
|
|
2283
2377
|
}
|
|
2284
2378
|
const archivePath = path.resolve(session.cwd, positional[0]);
|
|
@@ -2289,32 +2383,13 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2289
2383
|
archivePath,
|
|
2290
2384
|
sessionDir: ctx.sessionManager.getSessionDir(),
|
|
2291
2385
|
targetCwd,
|
|
2292
|
-
activate,
|
|
2386
|
+
activate: mode === "on",
|
|
2293
2387
|
existingJobs: store.load(),
|
|
2294
2388
|
existingRules: triggers.store.load(),
|
|
2295
2389
|
existingJobIds: new Set(store.load().map((j) => j.id)),
|
|
2296
2390
|
existingRuleIds: new Set(triggers.store.load().map((r) => r.id)),
|
|
2297
2391
|
});
|
|
2298
|
-
|
|
2299
|
-
if (imp.jobs.length) await store.mutate((jobs) => ({ jobs: [...jobs, ...imp.jobs], result: undefined }));
|
|
2300
|
-
for (const [id, text] of Object.entries(imp.states)) store.writeState(id, text);
|
|
2301
|
-
if (imp.rules.length) await triggers.store.mutate((rules) => rules.push(...imp.rules));
|
|
2302
|
-
} catch (err) {
|
|
2303
|
-
// Roll back, best effort and in this order: a half-imported archive must leave neither
|
|
2304
|
-
// an orphan session nor a partial store, and the original error is what gets reported.
|
|
2305
|
-
const ids = new Set(imp.jobs.map((j) => j.id));
|
|
2306
|
-
const attempt = (fn: () => void) => {
|
|
2307
|
-
try {
|
|
2308
|
-
fn();
|
|
2309
|
-
} catch {
|
|
2310
|
-
/* best effort */
|
|
2311
|
-
}
|
|
2312
|
-
};
|
|
2313
|
-
attempt(() => fs.rmSync(imp.sessionPath, { force: true }));
|
|
2314
|
-
await store.mutate((jobs) => ({ jobs: jobs.filter((j) => !ids.has(j.id)), result: undefined })).catch(() => {});
|
|
2315
|
-
for (const id of ids) attempt(() => fs.rmSync(store.statePath(id), { force: true }));
|
|
2316
|
-
throw err;
|
|
2317
|
-
}
|
|
2392
|
+
await applyImport(imp, store, triggers.store);
|
|
2318
2393
|
for (const job of imp.jobs) cronControlAudit("add", "slash", undefined, job);
|
|
2319
2394
|
refreshBadge();
|
|
2320
2395
|
const skipped = (imp.skippedJobs ?? 0) + (imp.skippedRules ?? 0);
|
|
@@ -2324,22 +2399,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2324
2399
|
...(imp.notes ?? []).map((n: string) => `note: ${n}`),
|
|
2325
2400
|
`resume with: pi --session ${imp.sessionPath}`,
|
|
2326
2401
|
]);
|
|
2327
|
-
// Offer to switch originally-enabled automation back on.
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
const before = store.load().find((j) => j.id === id);
|
|
2334
|
-
const after = await store.update(id, (j) => {
|
|
2335
|
-
j.enabled = true;
|
|
2336
|
-
});
|
|
2337
|
-
if (before && after) cronControlAudit("enable", "slash", before, after);
|
|
2338
|
-
}
|
|
2339
|
-
for (const id of imp.originallyEnabledRules) await triggers.store.setEnabled(id, true);
|
|
2340
|
-
refreshBadge();
|
|
2341
|
-
ctx.ui.notify(`enabled ${imp.originallyEnabledJobs.length} cron job(s) and ${imp.originallyEnabledRules.length} trigger rule(s)`, "info");
|
|
2342
|
-
}
|
|
2402
|
+
// Offer to switch originally-enabled automation back on. Nothing is asked with no UI to
|
|
2403
|
+
// ask in, and `off` means nothing is asked at all.
|
|
2404
|
+
const enabled = await offerToEnable(imp, store, triggers.store, mode === "ask" && ctx.hasUI ? (question, detail) => ctx.ui.confirm(question, detail) : undefined, (before, after) => cronControlAudit("enable", "slash", before, after));
|
|
2405
|
+
if (enabled.jobs || enabled.rules) {
|
|
2406
|
+
refreshBadge();
|
|
2407
|
+
ctx.ui.notify(`enabled ${enabled.jobs} cron job(s) and ${enabled.rules} trigger rule(s)`, "info");
|
|
2343
2408
|
}
|
|
2344
2409
|
if (resume) await ctx.switchSession(imp.sessionPath);
|
|
2345
2410
|
} catch (err: any) {
|
|
@@ -2365,12 +2430,6 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2365
2430
|
|
|
2366
2431
|
/* ------------------------------------------------------------ tools */
|
|
2367
2432
|
|
|
2368
|
-
/**
|
|
2369
|
-
* The same project, whichever path this pi was opened through: a worktree, a symlink or a
|
|
2370
|
-
* subdirectory of the project root all belong to the rule or job that names the root.
|
|
2371
|
-
*/
|
|
2372
|
-
const sameProject = (a: string, b: string): boolean => withinProject(b, a) || withinProject(a, b);
|
|
2373
|
-
|
|
2374
2433
|
/**
|
|
2375
2434
|
* Where a diagnostic goes: the log file always, and the chat as a warning — pi's `showStatus`
|
|
2376
2435
|
* replaces the previous status line in place, so several info-level notices in one tick collapse
|
|
@@ -2506,7 +2565,16 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2506
2565
|
goalMessages = event.messages ?? [];
|
|
2507
2566
|
const last: any = (event.messages ?? []).at(-1);
|
|
2508
2567
|
lastTurnStopReason = typeof last?.stopReason === "string" ? last.stopReason : undefined;
|
|
2509
|
-
if (lastTurnStopReason === "aborted")
|
|
2568
|
+
if (lastTurnStopReason === "aborted") {
|
|
2569
|
+
goalAbort?.abort();
|
|
2570
|
+
// Esc pauses the goal, not only this turn's judgement: the person said stop, and a goal
|
|
2571
|
+
// that went on pursuing would send them back to work on their next message.
|
|
2572
|
+
const stopped = goal && abortedTurn(goal);
|
|
2573
|
+
if (stopped) {
|
|
2574
|
+
persistGoal(ctx, stopped.state);
|
|
2575
|
+
notifyOrLog(ctx, `[goal] paused: ${stopped.action.kind === "pause" ? stopped.action.reason : ""}`, "warning");
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2510
2578
|
await fireHook({ event: "agent_end" }, ctx);
|
|
2511
2579
|
refreshBadge();
|
|
2512
2580
|
});
|
package/src/presence.ts
CHANGED
|
@@ -107,6 +107,11 @@ export function withinProject(root: string, p: string): boolean {
|
|
|
107
107
|
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
|
|
111
|
+
export function sameProject(a: string, b: string): boolean {
|
|
112
|
+
return withinProject(b, a) || withinProject(a, b);
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
/**
|
|
111
116
|
* The process that should act for the project rooted at `cwd` on `host`: a live pi opened in it —
|
|
112
117
|
* or in a subdirectory of it, or through a symlink to it — lowest pid as the tie-break so every
|
package/src/protocol.ts
CHANGED
|
@@ -21,6 +21,30 @@ export const FIRST_RUN_MARKER = "(first run)";
|
|
|
21
21
|
*/
|
|
22
22
|
export const LOOP_STATE_CLOSE = "[/loop-state]";
|
|
23
23
|
export const OUTPUT_PROTOCOL_HEADING = "Output protocol (mandatory):";
|
|
24
|
+
/** The block between the notes and the job text that carries what a person said when dismissing (`/inbox dismiss <n> <reason>`). */
|
|
25
|
+
export const DISMISSED_OPEN = "[dismissed]";
|
|
26
|
+
export const DISMISSED_CLOSE = "[/dismissed]";
|
|
27
|
+
/** How many dismissed-with-reason findings one run is shown; the newest survive. */
|
|
28
|
+
export const DISMISSED_PER_RUN = 8;
|
|
29
|
+
|
|
30
|
+
/** One finding a person dismissed with a reason, as the loop that reported it is told. */
|
|
31
|
+
export interface DismissedFeedback {
|
|
32
|
+
text: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A checkpoint is a finding that asks a person to decide, and docs/recipes.md fixes its shape:
|
|
38
|
+
* `<the decision> · waits: <what waits on it> · if not: <what happens if nobody acts>`. The
|
|
39
|
+
* middle dot before `waits:` is the mark — the word alone is prose ("the deploy waits: on DNS"),
|
|
40
|
+
* and a loop that wants its finding read as a decision writes the shape the playbooks write.
|
|
41
|
+
* Everything else is news: a merged pull request, a red check, a digest line. Every finding is one
|
|
42
|
+
* of the two, so the answer is always a kind.
|
|
43
|
+
*/
|
|
44
|
+
export type FindingKind = "checkpoint" | "news";
|
|
45
|
+
export function findingKind(text: string): FindingKind {
|
|
46
|
+
return /(^|\s)·\s*waits:/i.test(text) ? "checkpoint" : "news";
|
|
47
|
+
}
|
|
24
48
|
|
|
25
49
|
export function capChars(text: string, max: number): string {
|
|
26
50
|
const chars = Array.from(text.trim());
|
|
@@ -28,8 +52,23 @@ export function capChars(text: string, max: number): string {
|
|
|
28
52
|
return `${chars.slice(0, max).join("")}…`;
|
|
29
53
|
}
|
|
30
54
|
|
|
31
|
-
export function composeLoopPrompt(action: string, previousState: string | undefined, meta?: { name?: string; runAt?: string }): string {
|
|
55
|
+
export function composeLoopPrompt(action: string, previousState: string | undefined, meta?: { name?: string; runAt?: string; dismissed?: DismissedFeedback[] }): string {
|
|
32
56
|
const state = previousState && previousState.trim() ? capChars(previousState, LOOP_STATE_MAX_CHARS) : FIRST_RUN_MARKER;
|
|
57
|
+
/**
|
|
58
|
+
* Feedback, not permission. A reason is shown to the one run after it was given and then only
|
|
59
|
+
* lives on in that run's notes: the person spoke once, and the notes are the only thing a loop
|
|
60
|
+
* carries between runs — a second channel that the notes cap does not bound would be a memory the
|
|
61
|
+
* loop could not edit or forget. Newest last, so the most recent word is nearest the task.
|
|
62
|
+
*/
|
|
63
|
+
const dismissed = (meta?.dismissed ?? []).slice(-DISMISSED_PER_RUN);
|
|
64
|
+
const feedback = dismissed.length
|
|
65
|
+
? [
|
|
66
|
+
`${DISMISSED_OPEN} (findings of yours a person dismissed since your previous run, and why — do not report these again unless what they describe has changed; put what you need to remember that in your notes)`,
|
|
67
|
+
...dismissed.map((d) => `- "${capChars(d.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS)}" — ${capChars(d.reason.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS)}`),
|
|
68
|
+
DISMISSED_CLOSE,
|
|
69
|
+
"",
|
|
70
|
+
]
|
|
71
|
+
: [];
|
|
33
72
|
const header = meta?.name ? `You are running the recurring loop "${meta.name}"` : "You are running a recurring loop";
|
|
34
73
|
/**
|
|
35
74
|
* The run time, and how to write one.
|
|
@@ -50,6 +89,7 @@ export function composeLoopPrompt(action: string, previousState: string | undefi
|
|
|
50
89
|
state,
|
|
51
90
|
LOOP_STATE_CLOSE,
|
|
52
91
|
"",
|
|
92
|
+
...feedback,
|
|
53
93
|
action.trim(),
|
|
54
94
|
"",
|
|
55
95
|
OUTPUT_PROTOCOL_HEADING,
|
|
@@ -111,7 +151,10 @@ export function stripProtocolTags(text: string): string {
|
|
|
111
151
|
* what a display wants — a prompt from somewhere else is still the best thing to show.
|
|
112
152
|
*/
|
|
113
153
|
export function jobTextOf(prompt: string): string {
|
|
114
|
-
|
|
154
|
+
const afterState = prompt.split(LOOP_STATE_CLOSE).pop()?.split(OUTPUT_PROTOCOL_HEADING)[0] ?? prompt;
|
|
155
|
+
// The dismissed block sits between the notes and the job text; it is the person's words, not the job's.
|
|
156
|
+
const afterFeedback = afterState.trimStart().startsWith(DISMISSED_OPEN) ? afterState.split(DISMISSED_CLOSE).slice(1).join(DISMISSED_CLOSE) : afterState;
|
|
157
|
+
return afterFeedback;
|
|
115
158
|
}
|
|
116
159
|
|
|
117
160
|
/* ---------------------------------------------------------- maker/checker */
|