@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/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, plural, queueLines, toolsCapNotice } from "./job-lines.ts";
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} decision${decisions === 1 ? "" : "s"})` : ""}`);
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} source${attention === 1 ? "" : "s"} down`);
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} queued item${gone === 1 ? "" : "s"}` : `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");
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} finding${r.findings === 1 ? "" : "s"}${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)}`;
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} finding${elsewhere === 1 ? "" : "s"} in other projects — /inbox --all`);
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} rule${elsewhere === 1 ? "" : "s"} in other projects — /triggers rules --all`);
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, kept on the PATH of whoever asked for it there.
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, and only where a launcher of ours already
3173
- * is or after a yes. A file with no marker of ours is somebody's own wrapper and is left alone;
3174
- * one naming another copy of this package that is still on disk is that copy's to keep current,
3175
- * so an install and a checkout both being opened never trade the file back and forth; a no is
3176
- * remembered in `ui.json`, so it is asked once rather than in every window; and a checkout is
3177
- * never asked for a launcher that is not there at all — nobody ran `pi install`, so nothing is
3178
- * missing from their PATH — though one written from a checkout is still kept working.
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
- // Nothing on the PATH to write into. `/pi-loops install-launcher`, run by hand, is where that
3184
- // is explained; a session start saying it unprompted would be a warning about nothing.
3185
- if (!target) return;
3186
- const { state } = launcherState(entryDir, node, agentDir, target);
3187
- if (state === "stale") {
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
- // No question: they asked for a launcher once, and keeping that one working is what they
3192
- // asked for. The line is so that a file changing under them is never a silent change.
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
- if (state !== "absent") return; // ours and current, another copy's, or somebody else's entirely
3197
- if (!ctx.hasUI || readUiPrefs(dir).launcher === "declined" || !installedByPi()) return;
3198
- const lines: string[] = [];
3199
- const code = await installLauncherWithConfirm([], (title, body) => ctx.ui.confirm(title, body), (l) => lines.push(l));
3200
- if (code === undefined) {
3201
- writeUiPref(dir, "launcher", "declined");
3202
- ctx.ui.notify("[pi-loops] not installed — `/pi-loops install-launcher` writes one whenever you want it", "info");
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
- ctx.ui.notify(`[pi-loops] ${lines.map((l) => l.trim()).join(" · ")}`, code === 0 ? "info" : "warning");
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
- // Not awaited: the question below is a dialog, and the clock must not wait behind someone who
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
@@ -0,0 +1,4 @@
1
+ /** `1 loop`, `2 loops`: a count a person reads, never `loop(s)`. */
2
+ export function plural(n: number, one: string, many = `${one}s`): string {
3
+ return `${n} ${n === 1 ? one : many}`;
4
+ }
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} finding${r.findings === 1 ? "" : "s"}`;
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} more run${placed.more === 1 ? "" : "s"} finished${placed.failed ? ` (${placed.failed} failed)` : ""} — /cron runs`;
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 "./job-lines.ts";
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
- const base = agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
200
- return path.join(base, "loops");
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 "./job-lines.ts";
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: "Fallback complete trigger rule text when condition and action cannot be supplied separately." })),
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 tick that was missed while nothing could run the job: a loop when pi-loops next starts, a plain job when its conversation is next opened or pi-loops next starts (default: true for stateful jobs, false for plain jobs)." })),
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
- "Preview or confirm removal of a scheduled job by id or name. Use confirm=false first when the user asks to delete, remove, or clear a scheduled job, cron job, crontab entry, or 定时任务. Call confirm=true only after the user explicitly confirms removal. 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.",
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, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
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 preview above stays
544
- // free: it is what the user is shown before deciding.
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, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
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, confirmation_required: false, audit_entry_id: auditEntryId, state_path: statePath } };
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 ("control-plane prompt required but no
467
- * on_control_plane_prompt hook configured"); a UI-less interactive process is refused; an
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 said no to the launcher a session start offers, so the offer is
29
- * made once and not in every window. `/pi-loops install-launcher` clears it when it writes one.
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;