@alphacoder-v0/pi-loops 0.24.0 → 0.24.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +37 -30
- package/README.zh-CN.md +4 -3
- package/docs/cli.md +104 -28
- package/docs/configuration.md +2 -2
- package/docs/design.md +12 -7
- package/docs/triggers.md +11 -7
- package/docs/troubleshooting.md +4 -2
- package/docs/web-ui-parity.md +18 -6
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -0
- package/src/activity.ts +4 -3
- package/src/browser-open.ts +169 -0
- package/src/cli.ts +300 -95
- package/src/engine.ts +4 -3
- package/src/host-control-channel.ts +2 -1
- package/src/host-control.ts +16 -1
- package/src/host-runtime.ts +11 -11
- package/src/inbox-card.ts +2 -2
- package/src/inbox.ts +7 -0
- package/src/job-lines.ts +1 -5
- package/src/job-signal.ts +2 -1
- package/src/mcp.ts +2 -0
- package/src/page-children.ts +3 -58
- package/src/page-server.ts +71 -24
- package/src/path-precheck.ts +2 -0
- package/src/paths.ts +15 -2
- package/src/pi-loops.ts +82 -35
- package/src/plural.ts +4 -0
- package/src/run-card.ts +3 -2
- package/src/schedule.ts +20 -0
- package/src/seen.ts +1 -1
- package/src/share.ts +1 -1
- package/src/store.ts +11 -2
- package/src/tools.ts +18 -19
- package/src/triggers.ts +2 -3
- package/src/trust.ts +1 -11
- package/src/ui-prefs.ts +6 -2
- package/src/web.mjs +70 -38
package/src/pi-loops.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { ProjectTrustStore, SessionManager, VERSION as PI_VERSION, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { ProjectTrustStore, SessionManager, VERSION as PI_VERSION, copyToClipboard, getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { ARCHIVE_EXT, type ActivateMode, applyImport, automationOf, defaultExportPath, exportSession, importSession, offerToEnable, parseActivateMode } from "./archive.ts";
|
|
21
21
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
22
22
|
import { Type } from "typebox";
|
|
@@ -38,16 +38,18 @@ import { cardLines, cardOrigin, claimPrompt, dismissOutcome, dismissTail, inboxC
|
|
|
38
38
|
import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
|
|
39
39
|
import { FRESH_RUNS_READ, burstLine, freshRuns, panelRuns, placeRunCards, runCardDelivery, runCardLines, runCardsHere, runLine } from "./run-card.ts";
|
|
40
40
|
import { type ShareMessage, renderShare, shareSummary, shareableMessages } from "./share.ts";
|
|
41
|
-
import { installLauncherWithConfirm, installedByPi, launcherSource, launcherState, launcherTarget, refreshLauncher, stopAndWait, stopService } from "./cli.ts";
|
|
41
|
+
import { UsageError, addLauncher, installLauncherWithConfirm, installedByPi, launcherAction, launcherSource, launcherState, launcherTarget, openPage, pageLaunchHost, refreshLauncher, slashDir, stopAndWait, stopService, uninstallLauncher, webPageRequest } from "./cli.ts";
|
|
42
|
+
import { copyText, isRemoteTty } from "./browser-open.ts";
|
|
42
43
|
import { execFile } from "node:child_process";
|
|
43
44
|
import { createHash } from "node:crypto";
|
|
44
45
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
45
46
|
import { applyJobEdit } from "./job-edit.ts";
|
|
46
47
|
import { jobLines as cronJobLines } from "./job-lines.ts";
|
|
48
|
+
import { plural } from "./plural.ts";
|
|
47
49
|
import { openWhere, runsHere } from "./job-owner.ts";
|
|
48
50
|
import { type AcquireOutcome, LeaseBinding, LeaseStore, TURN_EVENTS, type TurnMarks, closedConversation, markTurnEvent, openWindowPids, procStart, turnSinceOf } from "./lease.ts";
|
|
49
51
|
import { type DrainOptions, type EngineSinks, type HandOverDeps, type HandOverOutcome, type Producer, type RowAddress, armRetry, cancelRow, coalesced, drain, dropContinuations, goalRow, handOver as engineHandOver, handOverTarget, heldTurnFor, inboxClaimRow, newTriggerRow, promotionTarget, promotionView, pushRunRow, recipeRow, releaseLocalRows, ringProduced, ruleResultRow, stopRow, waitingLine } from "./engine.ts";
|
|
50
|
-
import { PANEL_RUN_LIMIT, heldJobsNotice, leftConversationLine, panelLines, pickQueued,
|
|
52
|
+
import { PANEL_RUN_LIMIT, heldJobsNotice, leftConversationLine, panelLines, pickQueued, queueLines, toolsCapNotice } from "./job-lines.ts";
|
|
51
53
|
import { RequestStore, type SessionRequest } from "./requests.ts";
|
|
52
54
|
import { WindowExecutor, unattendedTurn } from "./window-executor.ts";
|
|
53
55
|
import { type GuardAnswer, WINDOW_GUARD_CONFIRM_MS, askRefusingFirst, windowGuard } from "./window-guard.ts";
|
|
@@ -1119,7 +1121,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1119
1121
|
const parts: string[] = [];
|
|
1120
1122
|
const n = scheduler.inbox.newCount();
|
|
1121
1123
|
const decisions = scheduler.inbox.decisionCount();
|
|
1122
|
-
if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${decisions
|
|
1124
|
+
if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${plural(decisions, "decision")})` : ""}`);
|
|
1123
1125
|
// Machine-wide like the inbox count and the running list above it: one process owns the
|
|
1124
1126
|
// machine's clock, and a loop failing in another checkout is still this machine's automation
|
|
1125
1127
|
// going quiet.
|
|
@@ -1133,7 +1135,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1133
1135
|
const queued = here ? requests.list(here).filter((r) => r.kind === "input" && !executor.handedOver.has(r.id)).length : 0;
|
|
1134
1136
|
if (queued) parts.push(`queued ${queued}`);
|
|
1135
1137
|
const attention = mcpSources.filter((s) => s.status.state === "disconnected" || s.status.state === "auth_failed").length;
|
|
1136
|
-
if (attention) parts.push(`mcp: ${attention
|
|
1138
|
+
if (attention) parts.push(`mcp: ${plural(attention, "source")} down`);
|
|
1137
1139
|
const paused = hostPauseReason();
|
|
1138
1140
|
if (paused && hostHasWork()) parts.push(`loops paused: ${paused}`);
|
|
1139
1141
|
else if (hostState?.kind === "channel-broken") parts.push(HOST_STATE_BADGE["channel-broken"]);
|
|
@@ -1321,7 +1323,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1321
1323
|
for (const row of targets) requests.append(cancelRow(sessionId, { id: row.id }, "window", createdBy()));
|
|
1322
1324
|
const result = await drainRequests();
|
|
1323
1325
|
const gone = targets.filter((row) => result?.removed.includes(row.id)).length;
|
|
1324
|
-
ctx.ui.notify(gone === targets.length ? `cancelled ${gone
|
|
1326
|
+
ctx.ui.notify(gone === targets.length ? `cancelled ${plural(gone, "queued item")}` : `cancelled ${gone} of ${targets.length}; the rest are handed to pi already (Esc stops the turn) or are for the window that has this conversation to cancel`, "info");
|
|
1325
1327
|
refreshBadge();
|
|
1326
1328
|
return;
|
|
1327
1329
|
}
|
|
@@ -1617,7 +1619,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1617
1619
|
const who = job ? "" : `${r.jobName ?? r.jobId} `;
|
|
1618
1620
|
const cost = r.usage?.cost ? ` $${r.usage.cost.toFixed(3)}` : "";
|
|
1619
1621
|
const chk = r.checker ? (r.checker.ok ? `, checker kept ${r.checker.kept}/${r.checker.kept + r.checker.dropped.length + r.checker.unreviewed}` : ", checker FAILED") : "";
|
|
1620
|
-
const tail = r.ok ? `${r.findings
|
|
1622
|
+
const tail = r.ok ? `${plural(r.findings, "finding")}${chk}${r.stateUpdated ? ", state updated" : ""}` : r.stoppedBy === "person" ? `stopped by you ${r.stoppedFrom === "page" ? "from the page" : "from a terminal"}` : r.aborted ? "stopped" : `FAILED: ${previewRedacted(r.error ?? "?", 80)}`;
|
|
1621
1623
|
return `${String(i + 1).padStart(2)}. ${formatLocal(Date.parse(r.startedAt))} ${who}${secs}s${cost} · ${tail}${r.sessionFile ? "" : " (no transcript)"}`;
|
|
1622
1624
|
}),
|
|
1623
1625
|
job ? `transcript: /cron trace ${job.name ?? job.id} <k>` : "transcript: /cron trace <job> <k>",
|
|
@@ -2038,7 +2040,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2038
2040
|
}
|
|
2039
2041
|
const lines = inboxLines(entries, true);
|
|
2040
2042
|
const elsewhere = all.length - entries.length;
|
|
2041
|
-
if (elsewhere) lines.push(`+ ${elsewhere
|
|
2043
|
+
if (elsewhere) lines.push(`+ ${plural(elsewhere, "finding")} in other projects — /inbox --all`);
|
|
2042
2044
|
const decisions = entries.filter((e) => e.kind === "checkpoint").length;
|
|
2043
2045
|
show(ctx, `Inbox (${where}, ${entries.length} new${decisions ? `, ${decisions} need${decisions === 1 ? "s" : ""} a decision` : ""}, times ${localOffset(Date.now())}):`, lines);
|
|
2044
2046
|
markSeenIfShown(everywhere);
|
|
@@ -2209,7 +2211,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2209
2211
|
const here = all.filter((r) => sameProject(r.cwd, session.cwd));
|
|
2210
2212
|
const elsewhere = all.length - here.length;
|
|
2211
2213
|
const lines = here.length ? ruleLines(here, true) : ["none"];
|
|
2212
|
-
if (elsewhere) lines.push(` + ${elsewhere
|
|
2214
|
+
if (elsewhere) lines.push(` + ${plural(elsewhere, "rule")} in other projects — /triggers rules --all`);
|
|
2213
2215
|
if (rest.trim() === "--all") {
|
|
2214
2216
|
show(ctx, `Dynamic trigger rules (this machine, ${all.length}):`, all.length ? ruleLines(all, false) : ["none"]);
|
|
2215
2217
|
return;
|
|
@@ -2464,7 +2466,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2464
2466
|
const ARCHIVE_WARNING = `warning: ${ARCHIVE_EXT} archives include transcript and tool history. They do not include separate auth stores, provider credentials, OAuth tokens, MCP config, or the inbox.`;
|
|
2465
2467
|
|
|
2466
2468
|
pi.registerCommand("pi-loops", {
|
|
2467
|
-
description: "About this extension; start | stop | restart | status the part that runs loops while no pi is open; install-launcher puts the `pi-loops` command on your PATH",
|
|
2469
|
+
description: "About this extension; start | stop | restart | status the part that runs loops while no pi is open; web opens this conversation in the browser page; install-launcher puts the `pi-loops` command on your PATH, uninstall-launcher removes it",
|
|
2468
2470
|
handler: async (args, ctx) => {
|
|
2469
2471
|
lastCtx = ctx;
|
|
2470
2472
|
const sub = args.trim().split(/\s+/)[0] ?? "";
|
|
@@ -2491,6 +2493,37 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2491
2493
|
await drainRequests({ tick: true });
|
|
2492
2494
|
return;
|
|
2493
2495
|
}
|
|
2496
|
+
if (sub === "web") {
|
|
2497
|
+
// The page on this conversation with no `pi-loops` on PATH: the same page-open and opener
|
|
2498
|
+
// the command line uses. Shown as a notice, not written into the conversation: the link
|
|
2499
|
+
// carries a one-use key.
|
|
2500
|
+
const lines: string[] = [];
|
|
2501
|
+
const host = { ...pageLaunchHost(dir), packageDir: PACKAGE_DIR, piPackage: piPackageDir(), startedBy: `the pi window pid ${process.pid}`, myPiVersion: PI_VERSION };
|
|
2502
|
+
const req = webPageRequest({ cwd: session.cwd, sessionId: binding.here().sessionId ?? session.sessionId, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
2503
|
+
// The link is shown and copied before the browser is tried: the wait for a browser to spend
|
|
2504
|
+
// the key can take seconds, and the person may paste the link sooner.
|
|
2505
|
+
const onLink = async (link: string) => {
|
|
2506
|
+
// Said only when a clipboard command took the link. pi's copy can still get there through
|
|
2507
|
+
// OSC 52, which no terminal confirms, so that try says nothing either way.
|
|
2508
|
+
if (await copyText(link)) lines.push(" copied to the clipboard");
|
|
2509
|
+
else await copyToClipboard(link).catch((err: any) => log.info(`/pi-loops web: copy: ${err?.message ?? err}`));
|
|
2510
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
2511
|
+
};
|
|
2512
|
+
const opened = await openPage(dir, req, { key: true, browser: isRemoteTty() ? "remote" : "yes", host }, (l) => lines.push(l), undefined, onLink);
|
|
2513
|
+
// pi shows one notice at a time, so the last one repeats the link with the outcome.
|
|
2514
|
+
ctx.ui.notify(lines.join("\n"), opened.code === 0 ? "info" : "warning");
|
|
2515
|
+
refreshBadge();
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (sub === "install-launcher" || sub === "uninstall-launcher") {
|
|
2519
|
+
try {
|
|
2520
|
+
slashDir(tokenize(splitCommand(args).rest).map((t) => t.value));
|
|
2521
|
+
} catch (err: any) {
|
|
2522
|
+
if (!(err instanceof UsageError)) throw err;
|
|
2523
|
+
ctx.ui.notify(`${err.message}. usage: /pi-loops ${sub} [--dir <dir>]`, "warning");
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2494
2527
|
if (sub === "install-launcher") {
|
|
2495
2528
|
// The command line's own `install-launcher` cannot be reached until it has run once —
|
|
2496
2529
|
// `pi install` does not put the package on your PATH. You are already inside pi with
|
|
@@ -2505,12 +2538,20 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2505
2538
|
show(ctx, code === 0 ? "installed the pi-loops launcher" : "could not install the launcher", lines.map((l) => ` ${l.trim()}`));
|
|
2506
2539
|
return;
|
|
2507
2540
|
}
|
|
2541
|
+
if (sub === "uninstall-launcher") {
|
|
2542
|
+
const lines: string[] = [];
|
|
2543
|
+
const code = uninstallLauncher(slashDir(tokenize(splitCommand(args).rest).map((t) => t.value)), (l) => lines.push(l));
|
|
2544
|
+
show(ctx, code === 0 ? "the pi-loops launcher is gone" : "left the launcher alone", lines.map((l) => ` ${l.trim()}`));
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2508
2547
|
show(ctx, `pi-loops ${PI_LOOPS_VERSION}`, [
|
|
2509
2548
|
` loops directory: ${homeRel(dir)}`,
|
|
2510
2549
|
` package: ${homeRel(PACKAGE_DIR)}`,
|
|
2511
2550
|
"",
|
|
2512
2551
|
" /pi-loops start | stop | restart | status",
|
|
2552
|
+
" /pi-loops web open this conversation in the browser page",
|
|
2513
2553
|
" /pi-loops install-launcher [--dir <dir>] put the `pi-loops` command on your PATH",
|
|
2554
|
+
" /pi-loops uninstall-launcher [--dir <dir>] remove it, so no session writes it again",
|
|
2514
2555
|
" /cron · /triggers · /inbox · /goal",
|
|
2515
2556
|
]);
|
|
2516
2557
|
},
|
|
@@ -3160,7 +3201,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
3160
3201
|
/* -------------------------------------------------------- lifecycle */
|
|
3161
3202
|
|
|
3162
3203
|
/**
|
|
3163
|
-
* The `pi-loops` command,
|
|
3204
|
+
* The `pi-loops` command, put on the PATH and kept working there.
|
|
3164
3205
|
*
|
|
3165
3206
|
* `pi install` puts this package under pi's directory and nothing on the PATH, so until 0.22.1
|
|
3166
3207
|
* the first thing a new user had to do was type a bare `node …/cli-entry.mjs install-launcher`
|
|
@@ -3169,40 +3210,47 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
3169
3210
|
* it runs and where a launcher would go, so the step belongs here.
|
|
3170
3211
|
*
|
|
3171
3212
|
* This is the extension's only write outside the loops directory (AGENTS.md). Its limit is one
|
|
3172
|
-
* file, `pi-loops` in a directory already on the PATH
|
|
3173
|
-
*
|
|
3174
|
-
*
|
|
3175
|
-
*
|
|
3176
|
-
*
|
|
3177
|
-
*
|
|
3178
|
-
*
|
|
3213
|
+
* file, `pi-loops` in a directory already on the PATH. With nothing there, the first interactive
|
|
3214
|
+
* session of a copy `pi install` put in place writes it with no question and says so in one line,
|
|
3215
|
+
* as pi's own installer does for `pi` (#180); after that only a launcher of ours is rewritten. A
|
|
3216
|
+
* file that does not open with our header is somebody's own wrapper and is left alone; one naming another
|
|
3217
|
+
* copy of this package that is still on disk is that copy's to keep current, so an install and a
|
|
3218
|
+
* checkout both being opened never trade the file back and forth; `uninstall-launcher` is
|
|
3219
|
+
* remembered in `ui.json`, so no later session writes it again; a write that failed is
|
|
3220
|
+
* remembered with its directory, so it is not tried in every window; and a checkout never writes
|
|
3221
|
+
* a launcher that is not there at all — nobody ran `pi install`, so nothing is missing from their
|
|
3222
|
+
* PATH — though one written from a checkout is still kept working. The decision is
|
|
3223
|
+
* `launcherAction` in src/cli.ts.
|
|
3179
3224
|
*/
|
|
3180
3225
|
async function keepLauncher(ctx: ExtensionContext): Promise<void> {
|
|
3181
3226
|
const { entryDir, node, agentDir } = launcherSource();
|
|
3182
3227
|
const target = launcherTarget(undefined);
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
if (
|
|
3228
|
+
const prefs = readUiPrefs(dir);
|
|
3229
|
+
const state = target ? launcherState(entryDir, node, agentDir, target).state : undefined;
|
|
3230
|
+
const action = launcherAction({ target, state, hasUI: ctx.hasUI, declined: prefs.launcher === "declined", failedIn: prefs.launcher === "failed" ? prefs.launcherFailedIn : undefined, installedByPi: state === "absent" && installedByPi() });
|
|
3231
|
+
if (!target || action === "none") return;
|
|
3232
|
+
if (action === "refresh") {
|
|
3188
3233
|
const done = refreshLauncher(entryDir, node, agentDir, target);
|
|
3189
3234
|
if (!done) return;
|
|
3190
3235
|
log.info(`launcher: ${done.message}`);
|
|
3191
|
-
//
|
|
3192
|
-
//
|
|
3236
|
+
// They have a launcher of ours, and keeping that one working is what it is for. The line is
|
|
3237
|
+
// so that a file changing under them is never a silent change.
|
|
3193
3238
|
if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${done.message}`, done.ok ? "info" : "warning");
|
|
3194
3239
|
return;
|
|
3195
3240
|
}
|
|
3196
|
-
|
|
3197
|
-
if (
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3241
|
+
const added = addLauncher(entryDir, node, agentDir, target);
|
|
3242
|
+
if (added.kind === "none") return;
|
|
3243
|
+
if (added.kind === "added") {
|
|
3244
|
+
// A failed write into another directory is not news any more.
|
|
3245
|
+
clearUiPref(dir, "launcher");
|
|
3246
|
+
clearUiPref(dir, "launcherFailedIn");
|
|
3247
|
+
log.info(`launcher: wrote ${added.file}`);
|
|
3248
|
+
ctx.ui.notify(`[pi-loops] added pi-loops to ${homeRel(target)}; /pi-loops uninstall-launcher removes it`, "info");
|
|
3203
3249
|
return;
|
|
3204
3250
|
}
|
|
3205
|
-
|
|
3251
|
+
writeUiPref(dir, "launcher", "failed");
|
|
3252
|
+
writeUiPref(dir, "launcherFailedIn", target);
|
|
3253
|
+
ctx.ui.notify(`[pi-loops] ${added.message}; /pi-loops install-launcher --dir <dir> writes it elsewhere`, "warning");
|
|
3206
3254
|
}
|
|
3207
3255
|
|
|
3208
3256
|
/**
|
|
@@ -3277,8 +3325,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
3277
3325
|
loadMcpConfig(windowTrusted(getAgentDir(), ctx.cwd, ctx.isProjectTrusted(), APPROVED));
|
|
3278
3326
|
for (const d of mcpDiagnostics) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${d}`, "warning");
|
|
3279
3327
|
await startMcpSources(); // tools for every process; pushes are consumed by interactive processes only
|
|
3280
|
-
//
|
|
3281
|
-
// has walked away from it.
|
|
3328
|
+
// A launcher that cannot be written or kept is logged, and never stops the session starting.
|
|
3282
3329
|
void keepLauncher(ctx).catch((err: any) => log.info(`launcher: ${err?.message ?? err}`));
|
|
3283
3330
|
// tui and rpc processes stay alive and run the clock; `PI_LOOPS_HOST=1` starts it in a `pi -p`
|
|
3284
3331
|
// run too, which queues its conversation's due prompts but hands none to pi (the window
|
package/src/plural.ts
ADDED
package/src/run-card.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { screenText } from "./inbox-card.ts";
|
|
8
8
|
import { loopLabel } from "./job-lines.ts";
|
|
9
|
+
import { plural } from "./plural.ts";
|
|
9
10
|
import { previewRedacted } from "./redact.ts";
|
|
10
11
|
import type { RunRecord } from "./store.ts";
|
|
11
12
|
|
|
@@ -48,12 +49,12 @@ export function runLine(r: RunRecord, name?: string): string {
|
|
|
48
49
|
if (r.aborted) return `${label} stopped · ${secs}s`;
|
|
49
50
|
if (!r.ok) return `${label} FAILED · ${secs}s · ${previewRedacted(r.error ?? "unknown error", ERROR_CHARS)}`;
|
|
50
51
|
const cost = r.usage?.cost ? ` · $${r.usage.cost.toFixed(3)}` : "";
|
|
51
|
-
return `${label} · ${secs}s${cost} · ${r.findings
|
|
52
|
+
return `${label} · ${secs}s${cost} · ${plural(r.findings, "finding")}`;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** The one line a burst's carded rest is folded into. */
|
|
55
56
|
export function burstLine(placed: { more: number; failed: number }): string {
|
|
56
|
-
return `+${placed.more
|
|
57
|
+
return `+${plural(placed.more, "more run")} finished${placed.failed ? ` (${placed.failed} failed)` : ""} — /cron runs`;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The carded runs of `fresh`, oldest first, at most `max`; `more` counts the carded rest and `failed` the failures among them. */
|
package/src/schedule.ts
CHANGED
|
@@ -127,6 +127,26 @@ export function formatSchedule(schedule: Schedule): string {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A schedule as the page's Loops pane says it: the common cron shapes in words (`every hour`,
|
|
134
|
+
* `daily 09:00`, `weekdays 09:00`, `weekly Mon 09:00`), anything else as `formatSchedule` says it.
|
|
135
|
+
* A person who typed "daily" reads that back, not `0 9 * * *`.
|
|
136
|
+
*/
|
|
137
|
+
export function scheduleWords(schedule: Schedule): string {
|
|
138
|
+
if (schedule.kind !== "cron") return formatSchedule(schedule);
|
|
139
|
+
const [m, h, dom, mon, dow, ...rest] = schedule.expr.trim().split(/\s+/);
|
|
140
|
+
if (rest.length || dom !== "*" || mon !== "*") return schedule.expr;
|
|
141
|
+
if (m === "0" && h === "*" && dow === "*") return "every hour";
|
|
142
|
+
if (!/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(h)) return schedule.expr;
|
|
143
|
+
const at = `${h.padStart(2, "0")}:${m.padStart(2, "0")}`;
|
|
144
|
+
if (dow === "*") return `daily ${at}`;
|
|
145
|
+
if (dow === "1-5") return `weekdays ${at}`;
|
|
146
|
+
if (/^[0-7]$/.test(dow)) return `weekly ${DAY_NAMES[Number(dow)]} ${at}`;
|
|
147
|
+
return schedule.expr;
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
/**
|
|
131
151
|
* The machine's offset from UTC at that instant, as `+08:00`.
|
|
132
152
|
*
|
package/src/seen.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Inbox, type InboxEntry } from "./inbox.ts";
|
|
|
5
5
|
import { stamp } from "./schedule.ts";
|
|
6
6
|
import { JobStore, type RunRecord } from "./store.ts";
|
|
7
7
|
import { realpathish, sameProject } from "./paths.ts";
|
|
8
|
+
import { plural } from "./plural.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* When `/inbox` was last looked at, per project: the terminal's `/inbox`, and the page's Mark all
|
|
@@ -232,7 +233,6 @@ export function sinceWords(sinceMs: number, nowMs: number): string {
|
|
|
232
233
|
return a.getFullYear() === b.getFullYear() ? `${date} ${time}` : `${date} ${a.getFullYear()} ${time}`;
|
|
233
234
|
}
|
|
234
235
|
|
|
235
|
-
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
236
236
|
|
|
237
237
|
/** How many findings came from this conversation, worth saying only when that is not all of them. */
|
|
238
238
|
const fromHere = (s: AwaySummary): number => (s.fromThisConversation !== s.findings ? s.fromThisConversation : 0);
|
package/src/share.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* taken back, so the summary comes first and the confirmation after it.
|
|
10
10
|
*/
|
|
11
11
|
import { redact } from "./redact.ts";
|
|
12
|
-
import { plural } from "./
|
|
12
|
+
import { plural } from "./plural.ts";
|
|
13
13
|
import { stamp } from "./schedule.ts";
|
|
14
14
|
|
|
15
15
|
export interface ShareMessage {
|
package/src/store.ts
CHANGED
|
@@ -196,8 +196,17 @@ export function homeLoopsDir(agentDir: string): string {
|
|
|
196
196
|
|
|
197
197
|
export function defaultLoopsDir(agentDir?: string): string {
|
|
198
198
|
if (process.env.PI_LOOPS_DIR) return process.env.PI_LOOPS_DIR;
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
return path.join(agentDir ?? piAgentDir(), "loops");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* pi's agent directory, by pi's rule (its `getAgentDir`): `PI_CODING_AGENT_DIR` with a leading `~`
|
|
204
|
+
* expanded, else `~/.pi/agent`. Here so that `pi-loops` does not load pi's SDK, about a second, for
|
|
205
|
+
* a path; test/store.test.ts holds it to the SDK's answer.
|
|
206
|
+
*/
|
|
207
|
+
export function piAgentDir(): string {
|
|
208
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
209
|
+
return env ? env.replace(/^~(?=$|\/)/, os.homedir()) : path.join(os.homedir(), ".pi", "agent");
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
/**
|
package/src/tools.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* SetCronJobState and NewTrigger / ListTriggers / RemoveTrigger / SetTriggerState — as pi tool
|
|
4
4
|
* definitions. One factory serves the interactive session (hop 0, registered with pi), every
|
|
5
5
|
* in-process sub-session (hop 1, passed as customTools) and the headless host (hop 1, no UI).
|
|
6
|
+
* Hop 1 does not get `new_trigger`, `remove_trigger` or `cron_remove`: nobody can approve any of them there.
|
|
6
7
|
*/
|
|
7
8
|
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal } from "./job-signal.ts";
|
|
8
9
|
import { formatWait, queuedPrompt, type QueuedPrompt } from "./engine.ts";
|
|
@@ -16,7 +17,7 @@ import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
|
16
17
|
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
17
18
|
import { asleepNote, ownerSession, runsSomewhere, whereRuns, type OwnerView } from "./job-owner.ts";
|
|
18
19
|
import { inProject } from "./inbox.ts";
|
|
19
|
-
import { plural } from "./
|
|
20
|
+
import { plural } from "./plural.ts";
|
|
20
21
|
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
21
22
|
import { sameProject, withinProject } from "./paths.ts";
|
|
22
23
|
import { type TriggerStore, cleanCondition, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
@@ -323,11 +324,16 @@ export interface ToolScope {
|
|
|
323
324
|
parentSessionId?: string;
|
|
324
325
|
parentCwd?: string;
|
|
325
326
|
}
|
|
327
|
+
// Every call of these is a prompt-class operation, denied at hop > 0 (controlPlanePreflight): a
|
|
328
|
+
// tool that can only fail is one an agent tries first.
|
|
329
|
+
const HOP0_ONLY = new Set(["new_trigger", "remove_trigger", "cron_remove"]);
|
|
330
|
+
|
|
326
331
|
/**
|
|
327
332
|
* The cron/trigger tools are registered in sub-agents too (Prompt-class ones are denied there)
|
|
328
333
|
* and bounds cycles with a hop count. The same definitions serve the interactive session
|
|
329
334
|
* (hop 0, registered with pi) and every in-process sub-session (hop 1, passed as customTools).
|
|
330
335
|
*/
|
|
336
|
+
|
|
331
337
|
export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinition<any, any>[] {
|
|
332
338
|
const defs: ToolDefinition<any, any>[] = [];
|
|
333
339
|
// Only where a person's message can arrive: a sub-agent is told nothing about it.
|
|
@@ -341,9 +347,9 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
341
347
|
"Create an event/condition-based dynamic trigger rule. Use this for future events such as a browser tab, file, MCP notification, webhook, or other condition becoming true. Do not use this for fixed time, recurring, scheduled, hourly, daily, weekly, cron, crontab, 定时任务, 每小时, or similar time-based jobs; use cron_create instead.",
|
|
342
348
|
parameters: Type.Object(
|
|
343
349
|
{
|
|
344
|
-
condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events." }),
|
|
350
|
+
condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events. For 'when <path> exists', keep the person's words: '<path> exists', with nothing added (no aside in parentheses) — a condition like that is checked with a local stat, no model call, while the path is absent." }),
|
|
345
351
|
action: Type.String({ description: "The action to perform when the condition matches. This may be a shell command or a natural-language instruction." }),
|
|
346
|
-
spec: Type.Optional(Type.String({ description: "
|
|
352
|
+
spec: Type.Optional(Type.String({ description: "The whole rule as one sentence, only when condition and action cannot be given apart." })),
|
|
347
353
|
fire_once: Type.Optional(Type.Boolean({ description: "Whether to disable the rule after the first successful match. Defaults to true unless the user explicitly asks for a repeating trigger." })),
|
|
348
354
|
promote_to_chat: Type.Optional(Type.Boolean({ description: "Whether a match's result goes into this conversation (true) or the inbox (false). With a person present the question starts at this conversation whatever this says; with nobody to ask, the default is false." })),
|
|
349
355
|
},
|
|
@@ -456,14 +462,14 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
456
462
|
"Create a scheduled job. Use when the user asks for a fixed time, recurring, scheduled, hourly, daily, weekly, crontab, 定时任务, 每小时, 每天, or similar time-based job. Jobs persist across pi restarts. A plain job runs its prompt in this chat when due. Set stateful=true for loop mode: a fresh sub-agent runs it, keeps persistent notes across runs (injected each time), and routes findings to the user's /inbox instead of the chat — use that for recurring watch/triage jobs like \"check for new issues and report only what changed\"." + watchSentence,
|
|
457
463
|
parameters: Type.Object({
|
|
458
464
|
schedule: Type.String({
|
|
459
|
-
description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "@daily", "every 30m", "in 10m", or "at 2026-09-08T18:00".',
|
|
465
|
+
description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "hourly", "daily" (09:00), "weekly" (Monday 09:00), "@daily" (midnight), "every 30m", "in 10m", or "at 2026-09-08T18:00".',
|
|
460
466
|
}),
|
|
461
467
|
action: Type.String({ description: "Natural-language instruction to run when the schedule is due. For stateful jobs, write it for a fresh agent whose only memory is its own notes." }),
|
|
462
468
|
stateful: Type.Optional(Type.Boolean({ description: "Loop mode: sub-agent + notes between runs + findings to the inbox. With a person present the question starts at the inbox unless this is false; with nobody to ask, the default is false." })),
|
|
463
469
|
verify: Type.Optional(Type.Boolean({ description: "Maker/checker: a second adversarial sub-agent verifies each finding before it enters the inbox (stateful jobs only, default false). Use when the user asks for verified, double-checked, or high-precision findings." })),
|
|
464
470
|
name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
|
|
465
471
|
cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
|
|
466
|
-
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a
|
|
472
|
+
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a missed tick (default: true for stateful jobs, false for plain jobs)." })),
|
|
467
473
|
}),
|
|
468
474
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
469
475
|
let schedule: LoopJob["schedule"];
|
|
@@ -521,29 +527,22 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
521
527
|
name: "cron_remove",
|
|
522
528
|
label: "Remove cron job",
|
|
523
529
|
description:
|
|
524
|
-
"
|
|
530
|
+
"Remove a scheduled job by id or name. Use when the user asks to delete, remove, or clear a scheduled job, cron job, crontab entry, or 定时任务; the user is asked to confirm in the same call. Removal deletes the job itself; a loop's saved notes and run transcripts are kept on disk, and `/cron gc --purge` is what clears them.",
|
|
525
531
|
parameters: Type.Object({
|
|
526
532
|
ref: Type.String({ description: "Job id (for example cron-abc123), unique id prefix, or name." }),
|
|
527
|
-
confirm: Type.Optional(Type.Boolean({ description: "false to preview the removal; true only after explicit user confirmation." })),
|
|
528
533
|
}),
|
|
529
534
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
530
535
|
const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, host.session().cwd);
|
|
531
|
-
if (!job) return { content: [{ type: "text", text: `no cron job matching '${params.ref}' in this project; cron_list shows the ids and names` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0,
|
|
536
|
+
if (!job) return { content: [{ type: "text", text: `no cron job matching '${params.ref}' in this project; cron_list shows the ids and names` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, audit_entry_id: undefined as string | undefined } };
|
|
532
537
|
const label = `${job.id}${job.name ? ` "${job.name}"` : ""}`;
|
|
533
|
-
if (!params.confirm) {
|
|
534
|
-
return {
|
|
535
|
-
content: [{ type: "text", text: `remove cron job ${label} requires confirmation\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\ncall cron_remove again with confirm=true only after the user confirms` }],
|
|
536
|
-
details: { id: job.id as string | undefined, removed_count: 0, confirmation_required: true, audit_entry_id: undefined as string | undefined },
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
538
|
// The only control-plane tool that used to skip this gate, so a sub-agent (hop > 0) could
|
|
540
539
|
// delete any project's job unapproved — and what that costs is the automation itself: the
|
|
541
540
|
// loop stops running and its id is gone from jobs.json. Its notes and transcripts stay
|
|
542
541
|
// behind (under an id nothing schedules any more; `/cron gc --purge` clears those), which
|
|
543
|
-
// is why the gate is about what stops, not about data destroyed. The
|
|
544
|
-
//
|
|
542
|
+
// is why the gate is about what stops, not about data destroyed. The dialog shows the
|
|
543
|
+
// schedule and the action: one question, where a preview call used to make it two.
|
|
545
544
|
const denied = await host.confirmTool(ctx, { label: `remove cron job ${label}`, tool: "cron_remove", reason: "remove a scheduled job", preview: `${formatSchedule(job.schedule)} · ${previewRedacted(job.prompt, 120)}`, args: params }, scope.hop);
|
|
546
|
-
if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0,
|
|
545
|
+
if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, audit_entry_id: undefined as string | undefined } };
|
|
547
546
|
const { aborted } = await host.scheduler.removeJob(job.id);
|
|
548
547
|
const auditEntryId = host.cronControlAudit("remove", scope.actor, job, undefined);
|
|
549
548
|
// The slash command says where a loop's notes went; a tool-driven removal has to say it too,
|
|
@@ -553,7 +552,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
553
552
|
const kept = job.stateful ? "\nits loop state and run transcripts are kept; /cron gc --purge clears them" : "";
|
|
554
553
|
const abortedNote = aborted > 0 ? "\naborted its running run" : "";
|
|
555
554
|
const statePath = job.stateful ? host.scheduler.store.statePath(job.id) : undefined;
|
|
556
|
-
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1,
|
|
555
|
+
return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1, audit_entry_id: auditEntryId, state_path: statePath } };
|
|
557
556
|
},
|
|
558
557
|
});
|
|
559
558
|
|
|
@@ -587,5 +586,5 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
587
586
|
};
|
|
588
587
|
},
|
|
589
588
|
});
|
|
590
|
-
return defs;
|
|
589
|
+
return scope.hop === 0 ? defs : defs.filter((d) => !HOP0_ONLY.has(d.name));
|
|
591
590
|
}
|
package/src/triggers.ts
CHANGED
|
@@ -463,9 +463,8 @@ export function auditArgs(rest: string): { limit: number; all: boolean } | { usa
|
|
|
463
463
|
/**
|
|
464
464
|
* The control-plane prompt gate, pre-flight part. Prompt-class tool calls (create or remove
|
|
465
465
|
* a trigger, re-enable a trigger or a cron job) need a human. Sub-agents have no prompt channel
|
|
466
|
-
* and are denied fail-closed
|
|
467
|
-
*
|
|
468
|
-
* interactive UI gets to ask. Returns the denial text, or undefined when the caller should ask.
|
|
466
|
+
* and are denied fail-closed; a UI-less interactive process is refused; an interactive UI gets
|
|
467
|
+
* to ask. Returns the denial text, or undefined when the caller should ask.
|
|
469
468
|
*/
|
|
470
469
|
export function controlPlanePreflight(proc: { hop: number; hasUI: boolean; held?: boolean }, reason: string): string | undefined {
|
|
471
470
|
// A plain job's turn in a closed conversation: the person's own turn, run where they were not.
|
package/src/trust.ts
CHANGED
|
@@ -10,17 +10,7 @@
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { ProjectTrustStore, hasTrustRequiringProjectResources } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { withinProject } from "./paths.ts";
|
|
14
|
-
|
|
15
|
-
/** pi's own normalisation: resolved, then through symlinks where the path exists. */
|
|
16
|
-
export function canonicalDir(dir: string): string {
|
|
17
|
-
const resolved = path.resolve(dir);
|
|
18
|
-
try {
|
|
19
|
-
return fs.realpathSync(resolved);
|
|
20
|
-
} catch {
|
|
21
|
-
return resolved;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
13
|
+
import { canonicalDir, withinProject } from "./paths.ts";
|
|
24
14
|
|
|
25
15
|
/**
|
|
26
16
|
* pi's own saved decision for `cwd`: the nearest entry, this directory's or a parent's, so a
|
package/src/ui-prefs.ts
CHANGED
|
@@ -25,10 +25,13 @@ export interface UiPrefs {
|
|
|
25
25
|
model?: string;
|
|
26
26
|
thinking?: string;
|
|
27
27
|
/**
|
|
28
|
-
* `"declined"` once someone has
|
|
29
|
-
*
|
|
28
|
+
* `"declined"` once someone has removed the launcher with `uninstall-launcher`, so no session
|
|
29
|
+
* start writes it again; `"failed"` once a session start could not write it. `/pi-loops
|
|
30
|
+
* install-launcher` clears it when it writes one.
|
|
30
31
|
*/
|
|
31
32
|
launcher?: string;
|
|
33
|
+
/** The directory a session start could not write the launcher into: tried again only elsewhere. */
|
|
34
|
+
launcherFailedIn?: string;
|
|
32
35
|
/** `"stopped"` once a person stopped pi-loops by hand: no window starts it again until `pi-loops start`. */
|
|
33
36
|
host?: string;
|
|
34
37
|
/** `"shown"` once a session start has said that closed conversations' plain jobs now run in the host. */
|
|
@@ -65,6 +68,7 @@ export function readUiPrefs(dir: string): UiPrefs {
|
|
|
65
68
|
if (typeof doc.model === "string") prefs.model = doc.model;
|
|
66
69
|
if (typeof doc.thinking === "string") prefs.thinking = doc.thinking;
|
|
67
70
|
if (typeof doc.launcher === "string") prefs.launcher = doc.launcher;
|
|
71
|
+
if (typeof doc.launcherFailedIn === "string") prefs.launcherFailedIn = doc.launcherFailedIn;
|
|
68
72
|
if (typeof doc.host === "string") prefs.host = doc.host;
|
|
69
73
|
if (typeof doc.heldJobsNotice === "string") prefs.heldJobsNotice = doc.heldJobsNotice;
|
|
70
74
|
if (typeof doc.toolsCapNotice === "string") prefs.toolsCapNotice = doc.toolsCapNotice;
|