@alphacoder-v0/pi-loops 0.22.2 → 0.22.4

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 CHANGED
@@ -5,6 +5,65 @@ All notable changes to pi-loops are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.4] - 2026-09-20
9
+
10
+ ### Fixed
11
+ - **Removing a running job aborts the run, and the run leaves nothing behind.** `/cron remove`,
12
+ `cron_remove` and `/recipe remove` deleted the job and left its sub-agent going; the run then
13
+ wrote its state, filed a finding for a job that no longer existed and recreated `sessions/<id>/`.
14
+ Removal now aborts an in-flight run and gives it up to two seconds to write its record — enough for
15
+ a normal abort, not a wait that outlasts a wedged sub-agent — and a run that finds its job gone
16
+ writes the record only, discarding the state and the findings.
17
+ - **`/cron gc --purge` also collects the transcripts of jobs that are gone.** A removed job that
18
+ never wrote state but left `sessions/<id>/` behind was invisible to `orphanStates()`, so its
19
+ transcripts stayed for ever. The orphan scan now unions the state files with the transcript
20
+ directories and counts both in the size it reports.
21
+ - **`/recipe remove --purge` keeps a playbook you edited.** `purgeInstall` deleted every file in the
22
+ record without comparing it with its untouched copy under `.orig/`, so an edit that `/recipe
23
+ update` would have preserved was destroyed without a word. It now removes a file only while it
24
+ still matches its untouched copy, keeps and names the edited ones, and leaves the directory in
25
+ place when one remains.
26
+ - **A run whose `jobs.json` is torn mid-flight no longer disappears.** A run re-read the store to ask
27
+ whether its job was still there, and an empty or too-new file made that read throw: the record, the
28
+ findings and the run-finished callback were all skipped, so a run that had completed left no trace.
29
+ A store that cannot be read now counts as "the job is still there" — the record and the findings are
30
+ written, and only the job's own bookkeeping is skipped.
31
+ - **`/recipe remove --purge` keeps a setup script you edited.** The install copied the setup script
32
+ but kept no untouched copy of it, and purge deleted it unconditionally, so an edited script was
33
+ destroyed without a word. The install now writes the `.orig` copy and purge compares it like any
34
+ other file, keeping and naming the edited one.
35
+ - **The collection of a removed job's leftovers says what it does, and counts all of it.**
36
+ `removeWhere` deleted state and transcripts with no flag, so the next caller would have thrown away
37
+ a loop's notes unasked; it now keeps them unless the caller passes `purge`, which is what gc does.
38
+ The orphan scan also walks the whole transcript tree, so a nested file that `purgeState` deletes is
39
+ counted in the size `/cron gc` reports.
40
+ - **The command line finds a project's sessions and recipe jobs from a subdirectory.** Three listings
41
+ compared the recorded directory with `===`, so run from a subdirectory of the project they answered
42
+ "nothing here" or "0 job(s)". They now use the same realpath-and-containment match the rest of
43
+ pi-loops uses.
44
+ - **The docs say the command line does not delete.** There is no `cron remove`, `recipe remove` or
45
+ `inbox clear` on it: removal asks a person to confirm and records what it did in the session's
46
+ control-plane audit, so it stays in the slash commands.
47
+ - **`/cron remove <name>` no longer reaches into another project.** A name resolved machine-wide, so
48
+ a name this project does not have deleted another checkout's job and said so in the same words as
49
+ any other removal. It now follows the rule `cron_remove` already had — a name resolves here, and
50
+ another project's job needs its exact id — and a removal that did reach outside names the project
51
+ it reached into.
52
+ - **A one-shot that retires itself leaves a control-plane audit entry.** A one-shot was removed after
53
+ it fired or failed for the second time with nothing in the trail; the self-removal now goes through
54
+ the same audit as a removal a person asked for.
55
+
56
+ ## [0.22.3] - 2026-09-16
57
+
58
+ ### Changed
59
+ - **A job or rule card opens instead of being cut off.** The Automation panel sliced a job's prompt
60
+ to ninety characters, a rule's condition and action to eighty, and the last error to one hundred
61
+ and twenty, with no way to see the rest. Every card is a `<details>` now — closed it shows a
62
+ two-line preview, open it shows the whole text and the fields the terminal's `/cron` line has —
63
+ and it keeps that open or closed state across the panel's eight-second redraw. The full prompt,
64
+ condition, action and last error are masked with the same secret patterns as the stderr tail
65
+ before they leave the server.
66
+
8
67
  ## [0.22.2] - 2026-09-16
9
68
 
10
69
  ### Changed
package/README.md CHANGED
@@ -38,7 +38,7 @@ the first sign of credentials that do not work should not be an empty inbox tomo
38
38
 
39
39
  ```bash
40
40
  pi install npm:@alphacoder-v0/pi-loops # from npm, following new releases
41
- pi install git:github.com/alphacoder-v0/pi-loops@v0.22.2 # or from GitHub, at a pinned tag
41
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.22.4 # or from GitHub, at a pinned tag
42
42
  pi install /path/to/pi-loops # or a local checkout — `pi install .` in this repo
43
43
  pi -e /path/to/pi-loops # or none of them: try it for one run, installing nothing
44
44
  ```
package/README.zh-CN.md CHANGED
@@ -45,7 +45,7 @@ pi-loops 自己没有任何运行时依赖。
45
45
 
46
46
  ```bash
47
47
  pi install npm:@alphacoder-v0/pi-loops # 从 npm 装,跟着新版本走
48
- pi install git:github.com/alphacoder-v0/pi-loops@v0.22.2 # 或者从 GitHub 装,钉住一个 tag
48
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.22.4 # 或者从 GitHub 装,钉住一个 tag
49
49
  pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
50
50
  ```
51
51
 
package/docs/cli.md CHANGED
@@ -227,6 +227,11 @@ pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level
227
227
  pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>] [--json]
228
228
  ```
229
229
 
230
+ The command line reads, triages one finding at a time and moves sessions. It never creates or
231
+ removes: there is no `cron remove`, no `recipe remove` and no `inbox clear`. Creating and removing
232
+ jobs, rules and recipes are slash commands, because each asks a person first and records what it did
233
+ in the session's control-plane audit.
234
+
230
235
  ## sessions, inspect
231
236
 
232
237
  `sessions` lists this project's sessions newest first, one line each: the first sixteen
@@ -16,6 +16,7 @@ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
16
16
  | `spend.json` | per-day totals of what rotation dropped from the run log, so a daily budget still counts it |
17
17
  | `sessions/<job-id>/`, `sessions/triggers-<project>-<hash>/` | sub-agent transcripts (20 per job, 40 per project's checks; the hash of the project's path keeps two projects named alike apart) |
18
18
  | `scheduler.json` | timer owner on this machine: pid, heartbeat |
19
+ | `next-runs.json` | job id → when it runs next, as the timer owner computed it (`{ at, next }`). Written only when an answer changes, so it can be older than the last tick; the browser panel reads it rather than parsing schedules a second time |
19
20
  | `presence/` | one file per live pi process: pid, session, cwd (who acts for which project) |
20
21
  | `host.json`, `host.log` | the headless host that keeps the clock while no pi is open: pid, and its log |
21
22
  | `polls.json` | last dynamic check per project (shared, so a hand-over never double-checks) |
package/docs/loops.md CHANGED
@@ -240,7 +240,8 @@ cron job's clock restarts at the edit, so slots that only exist retroactively un
240
240
  expression are not owed. An `every <dur>` job is still measured from its last run — if it last ran
241
241
  longer ago than the new interval, it is genuinely overdue and the confirmation says `due now`
242
242
  instead of promising a later time. One-shots (`in 10m`, `at <ISO>`) are refused here: the scheduler
243
- deletes a `once` job after it fires, notes included.
243
+ deletes a `once` job after it fires, notes included, and records the removal in the control-plane
244
+ audit like any other.
244
245
 
245
246
  `/cron run <ref>` fires a job now instead of at its next due time, and that run counts as a run: a
246
247
  `once` job is fired and then removed (an enabled job with nothing left to fire is worse than none),
@@ -300,9 +301,14 @@ anything, so this is how you actually go quiet.
300
301
  If a loop is stuck showing `running` after a process was killed and its pid reused, `/cron clear
301
302
  <ref>` releases the marker (it asks first).
302
303
 
304
+ `/cron remove` takes a name of this project, a number from its listing, or the exact id of a job
305
+ anywhere — the rule `cron_remove` follows, because a removal cannot be undone and a name this
306
+ project does not have should not reach another checkout. A job removed by its exact id from outside
307
+ this project is named with the project it was in.
308
+
303
309
  `/cron remove` keeps the loop's notes (to change a prompt or a schedule, edit the job in place with
304
310
  `/cron set` instead); `--purge` deletes them. `/cron gc` collects this project's jobs whose session
305
- is gone — `--all` every project's, `--purge` also the loop state left behind by jobs that are gone.
311
+ is gone — `--all` every project's, `--purge` also the loop state and transcripts left behind by jobs that are gone.
306
312
 
307
313
  A job that fails three times in a row is retried on a widening gap (5 minutes, doubling, up to six
308
314
  hours) instead of at every due tick, and says so; one success clears the streak.
package/docs/recipes.md CHANGED
@@ -141,7 +141,8 @@ A playbook installed by hand, with no untouched copy, is left alone and named.
141
141
 
142
142
  `/recipe remove <name>` removes the recipe's jobs and keeps the files and the loops' notes;
143
143
  `--purge` deletes both — of the files, only what the install wrote, since `.agents/skills/` may
144
- hold the project's own. `/cron` shows recipe jobs like any other, and `/cron set` edits them like
144
+ hold the project's own, and only a file that still matches its untouched copy: a playbook you
145
+ edited is kept and named. `/cron` shows recipe jobs like any other, and `/cron set` edits them like
145
146
  any other — a recipe is how they were made, not what they are.
146
147
 
147
148
  ## Writing a recipe
@@ -25,13 +25,15 @@ weaker, and worth converting whenever one of them breaks.
25
25
  - [x] Busy, queued count, and "pi exited" are all visible without opening a terminal.
26
26
  `test/web-page.test.ts`: *a tool call and a dead pi both reach the page*.
27
27
  - [x] Prompt history: ArrowUp and ArrowDown, but only while the caret is on the first or last line,
28
- so they still navigate a multi-line draft.
28
+ so they still navigate a multi-line draft. `test/web-page.test.ts`: *prompt history recalls what
29
+ was sent, and steps aside on a middle line*.
29
30
 
30
31
  ## Composing
31
32
 
32
33
  - [x] `/` completes slash commands; `@` completes paths, and the mention is expanded before the
33
34
  prompt is sent — the expansion is anchored to the session's directory, not to anything the
34
- browser supplies.
35
+ browser supplies. `test/web.test.ts`: *an @ mention is expanded from the session's directory,
36
+ and one outside it is skipped*.
35
37
  - [x] A command with an argument can be typed straight through. The completion list closes on the
36
38
  space, and an answer still in flight does not put it back — which it did, so Enter accepted
37
39
  the completion instead of sending the line and anything taking an argument was mouse-only.
@@ -85,6 +87,8 @@ weaker, and worth converting whenever one of them breaks.
85
87
  `test/web-page.test.ts`: *a gap in the event stream reloads the conversation*.
86
88
  - [x] While you are reading back through the conversation, an update never yanks the page — and
87
89
  never arrives silently either: there is a way back to the newest.
90
+ `test/web-page.test.ts`: *an update while you are reading back does not yank the page, and the
91
+ pill is the way back*.
88
92
  - [x] Every block says when it happened.
89
93
  - [x] Every message, tool call and result can be copied, including over plain http where the
90
94
  clipboard API is unavailable. The button waits for a hover on a mouse and is simply always
@@ -92,7 +96,9 @@ weaker, and worth converting whenever one of them breaks.
92
96
  - [x] Tool results, an extension's messages and pi's dying words are capped, and every cap says how
93
97
  much was dropped — one helper for the three of them, because they used to report it three
94
98
  different ways and one of them not at all, so a result that stopped at 8000 characters looked
95
- like a result that ended there.
99
+ like a result that ended there. The count and the cut are characters, not UTF-16 code units, so
100
+ an astral character is counted once and a cut cannot end on half of one. `test/web-page.test.ts`:
101
+ *a capped result counts characters, not the units a string is counted in*.
96
102
  - [x] **You can see what the session made, not only read about it.** A picture in a reply is a
97
103
  picture — including one named in ordinary prose rather than as a link; a path is something to
98
104
  open; a page written into the reply has a preview; an image a tool returned is shown rather
@@ -143,6 +149,18 @@ weaker, and worth converting whenever one of them breaks.
143
149
  `test/web-page.test.ts`: *a next run in April does not render as a time of day*.
144
150
  - [x] The name of a job is never abbreviated: it is what `/cron set <name> …` takes, so a clipped
145
151
  one is a name you cannot act on. It wraps; the schedule beside it does not break in half.
152
+ - [x] **The whole prompt, condition and action are one click away, and a last error is one line
153
+ when closed and whole when open.** They used to be cut — a job's prompt at ninety characters,
154
+ a rule's condition and action at eighty, the error at one hundred and twenty — with no way to
155
+ see the rest, which made the panel a place where things looked shorter than they were. Each
156
+ card is a `<details>` now: closed, a two-line preview and, for a failing job, a one-line error
157
+ hint; open, the whole text and the fields the terminal's `/cron` line has. What is shown in
158
+ full is masked with the same secret patterns as the stderr tail, and a card keeps its open or
159
+ closed state across the panel's eight-second redraw.
160
+ `test/web-page.test.ts`: *a job's whole prompt is one click away, and stays open across a
161
+ redraw*; *a rule's condition and action are shown in full*; *a job with a last error says so
162
+ before it is opened*.
163
+ `test/web.test.ts`: *a key in a job's prompt does not reach the page*.
146
164
  - [x] Run a job now.
147
165
  - [x] Inbox count.
148
166
  - [x] Runtime: scheduler state and whether this pi owns the clock, MCP servers and their state,
@@ -180,7 +198,9 @@ weaker, and worth converting whenever one of them breaks.
180
198
  - [x] A confirmation shows what is about to happen apart from the reasoning about it, so it cannot
181
199
  be read as prose and waved through. `test/web-page.test.ts`: *a confirmation shows what is
182
200
  about to run*.
183
- - [x] Enter does not approve: the focus starts on cancel.
201
+ - [x] Enter does not approve: the focus starts on cancel, which is written before approve so the
202
+ first menu button is the one Enter hits. `test/web-page.test.ts`: *Enter in a confirmation
203
+ dialog hits cancel, not approve*.
184
204
  - [x] **A tab in the background does not hold one of the browser's six connections.** Every tab
185
205
  used to keep an event stream open for as long as it existed, so the sixth tab — or a few
186
206
  forgotten behind the first — left nothing for the request that sends what you typed, and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "description": "An automation layer for pi: cron + stateful loops with a triage inbox, maker/checker verification, dynamic triggers, MCP notifications and tools, lifecycle hooks, session archives. A non-invasive pi extension.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -184,7 +184,7 @@ export function pickSession(sessions: SessionFile[], opts: { id?: string; cwd: s
184
184
  if (byPrefix.length > 1) throw new Error(`session id ${opts.id} is ambiguous (${byPrefix.length} matches)`);
185
185
  throw new Error(`no session with id ${opts.id}`);
186
186
  }
187
- const here = sessions.filter((s) => s.cwd === opts.cwd);
187
+ const here = sessions.filter((s) => sameProject(s.cwd, opts.cwd));
188
188
  if (!here.length) throw new Error(`no sessions recorded for ${opts.cwd}; pass --session <id>`);
189
189
  return here[0];
190
190
  }
@@ -268,7 +268,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
268
268
  if (command === "sessions") {
269
269
  // `pickSession` refuses an unknown id, and there was no way to discover one.
270
270
  const sessions = listSessions(path.join(agentDir, "sessions"));
271
- const here = flags.has("all") ? sessions : sessions.filter((s) => s.cwd === cwd);
271
+ const here = flags.has("all") ? sessions : sessions.filter((s) => sameProject(s.cwd, cwd));
272
272
  if (!here.length) {
273
273
  out(flags.has("all") ? "no sessions recorded" : `no sessions recorded for ${cwd} (use --all)`);
274
274
  return 1;
@@ -493,7 +493,7 @@ async function recipeCommand(positional: string[], opts: { level?: string; overw
493
493
  if (sub === "list") {
494
494
  const packaged = listRecipes();
495
495
  const installed = new Map(installedRecipes(cwd).map((r) => [r.recipe, r]));
496
- const jobs = new JobStore(loopsDir).load().filter((j) => j.recipe && j.cwd === cwd);
496
+ const jobs = new JobStore(loopsDir).load().filter((j) => j.recipe && sameProject(j.cwd, cwd));
497
497
  for (const r of packaged) {
498
498
  const rec = installed.get(r.manifest.name);
499
499
  const n = jobs.filter((j) => j.recipe === r.manifest.name).length;
package/src/pi-loops.ts CHANGED
@@ -50,7 +50,7 @@ import { waitForHost, HOST_LOG, crashedHost, hostPushWork, liveHost, piPackageDi
50
50
  import { summarizeSessionFile } from "./transcript.ts";
51
51
  import { TriggerRuntime, type TriggerOutcome } from "./trigger-runtime.ts";
52
52
  import { auditCronFinish, auditCronStart, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
53
- import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob } from "./tools.ts";
53
+ import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob, resolveJobRefScoped } from "./tools.ts";
54
54
  import { panelEnabled, readUiPrefs, writeUiPref } from "./ui-prefs.ts";
55
55
  import * as fs from "node:fs";
56
56
  import * as os from "node:os";
@@ -197,6 +197,7 @@ export default function piLoops(pi: ExtensionAPI) {
197
197
  onCatchUp: (job, dueAt) => {
198
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");
199
199
  },
200
+ onJobRetired: (job) => cronControlAudit("remove", "scheduler", job, undefined),
200
201
  onRunFinished: ({ job, record, findings, result }) => {
201
202
  auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
202
203
  refreshBadge();
@@ -653,8 +654,9 @@ export default function piLoops(pi: ExtensionAPI) {
653
654
  const n = scheduler.inbox.newCount();
654
655
  const decisions = scheduler.inbox.decisionCount();
655
656
  if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${decisions} decision${decisions === 1 ? "" : "s"})` : ""}`);
656
- // Machine-wide like the inbox count and the running list above it: the clock is one per host,
657
- // and a loop failing in another checkout is still this machine's automation going quiet.
657
+ // Machine-wide like the inbox count and the running list above it: one process owns the
658
+ // machine's clock, and a loop failing in another checkout is still this machine's automation
659
+ // going quiet.
658
660
  const failing = failingSummary(scheduler.store.load());
659
661
  if (failing) parts.push(failing);
660
662
  const running = [...scheduler.runningLabels(), ...triggers.runningList().map((r) => (r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel))];
@@ -766,7 +768,7 @@ export default function piLoops(pi: ExtensionAPI) {
766
768
  * The `cron_control_plane` audit: every add / enable / disable / remove, from a slash
767
769
  * command or a tool, leaves a custom entry in the session (never in LLM context).
768
770
  */
769
- function cronControlAudit(op: "add" | "enable" | "disable" | "remove", actor: "slash" | "tool" | "sub-agent", before?: LoopJob, after?: LoopJob): string {
771
+ function cronControlAudit(op: "add" | "enable" | "disable" | "remove", actor: "slash" | "tool" | "sub-agent" | "scheduler", before?: LoopJob, after?: LoopJob): string {
770
772
  const job = after ?? before;
771
773
  const next = after?.enabled ? computeNext({ schedule: after.schedule, createdAt: Date.parse(after.createdAt), lastFiredAt: after.lastFiredAt ? Date.parse(after.lastFiredAt) : undefined }, Date.now()) : undefined;
772
774
  // pi.appendEntry returns no id; mint one so tool results can point at the entry.
@@ -952,12 +954,26 @@ export default function piLoops(pi: ExtensionAPI) {
952
954
  // remove-and-re-add is how a schedule or prompt gets changed, and that must not
953
955
  // throw away months of accumulated state.
954
956
  const purge = /(^|\s)--purge(\s|$)/.test(rest);
955
- const job = pick(rest.replace(/(^|\s)--purge(\s|$)/, " ").trim());
956
- if (!job) return;
957
- await scheduler.store.remove(job.id, { purge });
957
+ // `pick` resolves a name machine-wide, which is right for `/cron run ci` and wrong
958
+ // here: a removal cannot be undone, and a name this project does not have would
959
+ // reach into a checkout nobody was looking at and delete its job with a message
960
+ // that never says so. `resolveJobRefScoped` is the rule `cron_remove` already
961
+ // follows — a name resolves in this project, another project's job needs its
962
+ // exact id — so the two faces of the same deletion agree.
963
+ const ref = rest.replace(/(^|\s)--purge(\s|$)/, " ").trim();
964
+ const all = jobs();
965
+ const job = /^\d+$/.test(ref) ? resolveJobRef(all.filter((j) => sameProject(j.cwd, session.cwd)), ref, { ordinals: true }) : resolveJobRefScoped(all, ref, session.cwd);
966
+ if (!job) {
967
+ ctx.ui.notify(ref ? `no cron job with id '${ref}' in ${homeRel(session.cwd)} (another project's job needs its exact id)` : "usage: /cron remove <id> [--purge]", "warning");
968
+ return;
969
+ }
970
+ const { aborted } = await scheduler.removeJob(job.id, { purge });
958
971
  cronControlAudit("remove", "slash", job, undefined);
959
972
  const kept = job.stateful && !purge ? `; its loop state is kept (${homeRel(scheduler.store.statePath(job.id))}) — /cron gc --purge clears orphaned state` : purge && job.stateful ? " and its loop state" : "";
960
- ctx.ui.notify(`removed cron job ${job.id}${job.name ? ` "${job.name}"` : ""}${kept}`, "info");
973
+ // An exact id may still name another project's job. Say which, so a removal that
974
+ // reached outside this checkout is never a line that looks like any other.
975
+ const elsewhere = sameProject(job.cwd, session.cwd) ? "" : ` in ${homeRel(job.cwd)}`;
976
+ ctx.ui.notify(`removed cron job ${job.id}${job.name ? ` "${job.name}"` : ""}${elsewhere}${kept}${aborted > 0 ? "; aborted its running run" : ""}`, "info");
961
977
  return;
962
978
  }
963
979
  case "host": {
@@ -1056,9 +1072,9 @@ export default function piLoops(pi: ExtensionAPI) {
1056
1072
  if (purge && orphans.length) for (const o of orphans) scheduler.store.purgeState(o.id);
1057
1073
  show(ctx, `removed ${removed.length} job(s) whose session no longer exists${all ? " (every project)" : " in this project"}`, [
1058
1074
  ...removed.map((j) => ` - ${j.id}${j.name ? ` "${j.name}"` : ""} (session ${(j.sessionId ?? "?").slice(0, 8)})`),
1059
- ...(orphans.length && !purge ? [` ${orphans.length} loop state file(s) belong to jobs that are gone (${Math.round(orphans.reduce((n, o) => n + o.bytes, 0) / 1024)} KB) — /cron gc --purge deletes them`] : []),
1075
+ ...(orphans.length && !purge ? [` ${orphans.length} job(s) that are gone left loop state or transcripts behind (${Math.round(orphans.reduce((n, o) => n + o.bytes, 0) / 1024)} KB) — /cron gc --purge deletes them`] : []),
1060
1076
  ...(all ? [] : [" --all also collects other projects' dead jobs"]),
1061
- ...(purge && orphans.length ? [` deleted ${orphans.length} orphaned loop state file(s)`] : []),
1077
+ ...(purge && orphans.length ? [` deleted the loop state and transcripts of ${orphans.length} job(s) that are gone`] : []),
1062
1078
  ]);
1063
1079
  refreshBadge();
1064
1080
  return;
@@ -2205,19 +2221,24 @@ export default function piLoops(pi: ExtensionAPI) {
2205
2221
  }
2206
2222
  const ok = await ctx.ui.confirm(`Remove recipe ${name}?`, [
2207
2223
  jobs.length ? `${jobs.length} job(s): ${jobs.map((j) => j.name ?? j.id).join(", ")}` : "no jobs of its own here",
2208
- purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length} file(s), their untouched copies, the record` : "no record: only the directory if it is empty"}) and the loops' notes` : `the files in ${homeRel(dir)}/ and the loops' notes are kept (--purge deletes both)`,
2224
+ purge ? `and delete what the install wrote in ${homeRel(dir)}/ (${record ? `${record.files.length} file(s) as installed — an edited one is kept — their untouched copies, the record` : "no record: only the directory if it is empty"}) and the loops' notes` : `the files in ${homeRel(dir)}/ and the loops' notes are kept (--purge deletes both)`,
2209
2225
  ].join("\n"));
2210
2226
  if (!ok) {
2211
2227
  ctx.ui.notify("not removed", "info");
2212
2228
  return;
2213
2229
  }
2230
+ let aborted = 0;
2231
+ let keptNote = "";
2214
2232
  for (const job of jobs) {
2215
- await scheduler.store.remove(job.id, { purge });
2233
+ const r = await scheduler.removeJob(job.id, { purge });
2234
+ aborted += r.aborted;
2216
2235
  cronControlAudit("remove", "slash", job, undefined);
2217
2236
  }
2218
2237
  if (purge) {
2219
- if (record) purgeInstall(dir, record, listRecipes().find((r) => r.manifest.name === name)?.manifest.setup);
2220
- else {
2238
+ if (record) {
2239
+ const { kept } = purgeInstall(dir, record, listRecipes().find((r) => r.manifest.name === name)?.manifest.setup);
2240
+ if (kept.length > 0) keptNote = `; kept ${kept.length} edited file(s): ${kept.join(", ")}`;
2241
+ } else {
2221
2242
  try {
2222
2243
  fs.rmdirSync(dir);
2223
2244
  } catch {
@@ -2226,7 +2247,10 @@ export default function piLoops(pi: ExtensionAPI) {
2226
2247
  }
2227
2248
  }
2228
2249
  refreshBadge();
2229
- ctx.ui.notify(`removed recipe ${name}: ${jobs.length} job(s)${purge ? ", its files and notes" : `; files kept in ${homeRel(dir)}/`}`, "info");
2250
+ ctx.ui.notify(
2251
+ `removed recipe ${name}: ${jobs.length} job(s)${purge ? ", its files and notes" : `; files kept in ${homeRel(dir)}/`}${aborted > 0 ? `; aborted ${aborted} running run(s)` : ""}${keptNote}`,
2252
+ "info",
2253
+ );
2230
2254
  return;
2231
2255
  }
2232
2256
  case "help":
package/src/recipe.ts CHANGED
@@ -576,10 +576,12 @@ export function installFiles(recipe: Recipe, projectDir: string, level: Autonomy
576
576
  fs.writeFileSync(origPath(targetDir, f), content);
577
577
  }
578
578
  if (recipe.manifest.setup) {
579
- // The setup script is copied too, so what ran is what is there to read afterwards.
579
+ // The setup script is copied too, so what ran is what is there to read afterwards — and its
580
+ // untouched copy is kept the way a playbook's is, so `--purge` can tell an edit from the original.
580
581
  const dest = path.join(targetDir, recipe.manifest.setup);
581
582
  fs.mkdirSync(path.dirname(dest), { recursive: true });
582
583
  fs.copyFileSync(path.join(recipe.dir, recipe.manifest.setup), dest);
584
+ fs.writeFileSync(origPath(targetDir, recipe.manifest.setup), fs.readFileSync(dest));
583
585
  }
584
586
  const record: InstallRecord = { recipe: recipe.manifest.name, version: VERSION, level, installedAt: stamp(), files, source: opts.source };
585
587
  fs.writeFileSync(path.join(targetDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
@@ -610,13 +612,30 @@ export function requireRecipeName(name: string): string {
610
612
  }
611
613
 
612
614
  /**
613
- * `--purge`: only what the install wrote — the recorded files, their untouched copies, the setup
614
- * script and the record — then the directory if that emptied it. `.agents/skills/<name>/` may also
615
- * hold a project's own files, and those are not ours to delete.
615
+ * `--purge`: only what the install wrote — a recorded file or the setup script, and each only while
616
+ * it still matches its untouched copy, plus the untouched copies and the record — then the directory
617
+ * if that emptied it. A file the person edited, or one with no `.orig` to compare against, is kept
618
+ * and named. `.agents/skills/<name>/` may also hold a project's own files, and those are not ours to
619
+ * delete.
616
620
  */
617
- export function purgeInstall(targetDir: string, record: InstallRecord, setup?: string): void {
621
+ export function purgeInstall(targetDir: string, record: InstallRecord, setup?: string): { removed: string[]; kept: string[] } {
622
+ const removed: string[] = [];
623
+ const kept: string[] = [];
618
624
  for (const f of [...record.files, ...(setup ? [setup] : [])]) {
619
- fs.rmSync(path.join(targetDir, f), { force: true });
625
+ const dest = path.join(targetDir, f);
626
+ if (!fs.existsSync(dest)) continue;
627
+ let untouched = false;
628
+ try {
629
+ untouched = fs.readFileSync(path.join(targetDir, ".orig", f)).equals(fs.readFileSync(dest));
630
+ } catch {
631
+ untouched = false; // no untouched copy: leave the file alone
632
+ }
633
+ if (untouched) {
634
+ fs.rmSync(dest, { force: true });
635
+ removed.push(f);
636
+ } else {
637
+ kept.push(f);
638
+ }
620
639
  }
621
640
  fs.rmSync(path.join(targetDir, ".orig"), { recursive: true, force: true });
622
641
  fs.rmSync(path.join(targetDir, RECORD_FILE), { force: true });
@@ -634,6 +653,7 @@ export function purgeInstall(targetDir: string, record: InstallRecord, setup?: s
634
653
  } catch {
635
654
  // something else lives here; leave it
636
655
  }
656
+ return { removed, kept };
637
657
  }
638
658
 
639
659
  /** Every recipe installed in a project: the directories under `.agents/skills/` that carry a record. */
package/src/scheduler.ts CHANGED
@@ -33,6 +33,12 @@ export const FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60_000;
33
33
  export const FOREIGN_RUN_STALE_MS = 24 * 60 * 60 * 1000;
34
34
  export const DEFAULT_RUN_TIMEOUT_MS = 15 * 60_000;
35
35
  export const MAX_CONCURRENT_RUNS = 3;
36
+ /**
37
+ * How long `removeJob` waits for an aborted run to write its record before removing the job anyway.
38
+ * Two seconds is enough: the drain exists to let a normal abort write its record first, not to
39
+ * outlast a wedged sub-agent — the job is removed regardless, and `runOnce` discards its state.
40
+ */
41
+ export const REMOVE_DRAIN_MS = 2_000;
36
42
 
37
43
  export interface SessionSnapshot {
38
44
  sessionId?: string;
@@ -69,6 +75,8 @@ export interface SchedulerHooks {
69
75
  /** A run is being fired for a tick that was missed while no pi was open. */
70
76
  onCatchUp?: (job: LoopJob, dueAt: number) => void;
71
77
  onRunFinished?: (outcome: RunOutcome) => void;
78
+ /** A one-shot removed itself: the control plane records it the way a person's removal is recorded. */
79
+ onJobRetired?: (job: LoopJob, reason: string) => void;
72
80
  onInboxChanged?: () => void;
73
81
  /** Every tick, after leadership is settled. Other subsystems (dynamic triggers) piggyback on it. */
74
82
  onTick?: (now: number, leader: boolean) => void | Promise<void>;
@@ -172,12 +180,14 @@ export class LoopScheduler {
172
180
  this.dir = opts.dir;
173
181
  this.store = new JobStore(opts.dir);
174
182
  this.inbox = new Inbox(opts.dir);
175
- // One leader per host: machines sharing a $HOME must not elect each other.
183
+ // One leader at a time, for the machine: every process here elects through this one file. Two
184
+ // machines sharing a $HOME contend for it and both end up running every job, which is not
185
+ // supported (docs/design.md §19).
176
186
  this.leaderFile = path.join(opts.dir, "scheduler.json");
177
- // Per host, like the leader record beside it, and for the same reason. Leadership is per
178
- // host; two machines sharing a `$HOME` are both leaders, and a cron expression is matched
179
- // against local time — so one file would be two machines writing different answers over each
180
- // other, and a panel showing whichever wrote last.
187
+ // One plain file beside the leader record. A cron expression is matched against local time, so
188
+ // if two machines sharing a `$HOME` both ran, they would write different answers over one
189
+ // another and the panel would show whichever wrote last — which is why that arrangement is
190
+ // unsupported rather than handled (docs/design.md §19).
181
191
  this.nextRunsFile = path.join(opts.dir, "next-runs.json");
182
192
  this.leaderLock = path.join(opts.dir, "scheduler.lock");
183
193
  this.getSession = opts.getSession;
@@ -226,6 +236,26 @@ export class LoopScheduler {
226
236
  return true;
227
237
  }
228
238
 
239
+ /**
240
+ * Remove a job. A run of it that is in flight is aborted first and given up to
241
+ * REMOVE_DRAIN_MS to write its record, so the removal does not race the run's end.
242
+ * Returns the removed job (undefined when there was none) and how many runs were aborted.
243
+ */
244
+ async removeJob(id: string, opts: { purge?: boolean } = {}): Promise<{ job: LoopJob | undefined; aborted: number }> {
245
+ const runIds = [...this.inflight.entries()].filter(([, r]) => r.jobId === id).map(([runId]) => runId);
246
+ for (const runId of runIds) this.inflight.get(runId)?.ctrl.abort();
247
+ // The wall clock, not `this.now()`: this is a real wait, and a test that moved its injected
248
+ // clock must not be able to hold the removal open for ever.
249
+ const deadline = Date.now() + REMOVE_DRAIN_MS;
250
+ while (runIds.some((runId) => this.inflight.has(runId)) && Date.now() < deadline) {
251
+ await new Promise((r) => setTimeout(r, 25));
252
+ }
253
+ // If a run is still there past the drain the job is removed anyway; `runOnce` re-reads the
254
+ // store before it writes anything, so a late run leaves nothing behind.
255
+ const job = await this.store.remove(id, opts);
256
+ return { job, aborted: runIds.length };
257
+ }
258
+
229
259
  /** Idempotent. Starts the tick timer and takes leadership if free. */
230
260
  start(): void {
231
261
  if (this.timer) return;
@@ -500,9 +530,13 @@ export class LoopScheduler {
500
530
  }
501
531
  }
502
532
 
503
- /** Remove the jobs `disableDeadSessionJobs` parked; returns them. */
533
+ /**
534
+ * Remove the jobs `disableDeadSessionJobs` parked; returns them. These are plain jobs of deleted
535
+ * sessions with nothing left to run, so gc says yes to `purge`: their leftover state and
536
+ * transcripts go with them.
537
+ */
504
538
  async gc(inScope: (job: LoopJob) => boolean = () => true): Promise<LoopJob[]> {
505
- return this.store.removeWhere((j) => inScope(j) && !j.enabled && !!j.lastError?.endsWith(DEAD_SESSION_MARKER));
539
+ return this.store.removeWhere((j) => inScope(j) && !j.enabled && !!j.lastError?.endsWith(DEAD_SESSION_MARKER), { purge: true });
506
540
  }
507
541
 
508
542
  /** Clear running markers left behind by processes that are gone. Mutates `jobs` in place. */
@@ -668,7 +702,14 @@ export class LoopScheduler {
668
702
  // here, and the caller used to read it back out of the string this just formatted.
669
703
  const runId = newId("run");
670
704
  await this.hooks.onInject?.(job, `[Trigger ${runId}] ${job.prompt}${note}`, runId);
671
- if (job.schedule.kind === "once") await this.store.remove(job.id);
705
+ if (job.schedule.kind === "once") {
706
+ await this.store.remove(job.id);
707
+ try {
708
+ this.hooks.onJobRetired?.(job, "one-shot fired");
709
+ } catch (err: any) {
710
+ this.log(`onJobRetired hook failed: ${err?.message ?? err}`);
711
+ }
712
+ }
672
713
  return true;
673
714
  }
674
715
 
@@ -811,10 +852,24 @@ export class LoopScheduler {
811
852
  // make `clearStaleRunning` see "my pid, not in flight", clear the running marker, roll the
812
853
  // schedule back and re-fire the same job while its checker is still working.
813
854
 
855
+ // The job may have been removed while the run was in flight — by `/cron remove` here, by the
856
+ // tool, or by another pi window. Its state and findings belong to a job that is gone, so they
857
+ // are discarded; the run record is still written below, because the run did happen.
858
+ // A store that cannot be read (a torn write, a file from a newer build) must not cost a run its
859
+ // record and its findings: the question here is only whether the job was removed, and the safe
860
+ // answer to "cannot tell" is no.
861
+ let stillThere = true;
862
+ try {
863
+ stillThere = this.store.load().some((j) => j.id === job.id);
864
+ } catch (err: any) {
865
+ this.log(`loop ${job.id}: could not read the store after the run (${err?.message ?? err}); keeping its state and findings`);
866
+ }
867
+ if (!stillThere) this.log(`loop ${job.id}: removed during the run; its state and findings are discarded`);
868
+
814
869
  // Tag extraction never fails a run: malformed/missing tags leave the state untouched.
815
870
  const parsed = parseRunOutput(result.text);
816
871
  let stateUpdated = false;
817
- if (result.ok && parsed.state !== undefined) {
872
+ if (stillThere && result.ok && parsed.state !== undefined) {
818
873
  try {
819
874
  this.store.writeState(job.id, parsed.state);
820
875
  stateUpdated = true;
@@ -827,13 +882,13 @@ export class LoopScheduler {
827
882
  // still enter the inbox, but marked unverified — a broken checker must not silence the loop.
828
883
  let checker: CheckerRecord | undefined;
829
884
  let reviewed: Array<{ text: string; verified?: boolean; reason?: string }> = parsed.findings.map((text) => ({ text }));
830
- if (result.ok && job.verify && parsed.findings.length && !ctrl.signal.aborted) {
885
+ if (stillThere && result.ok && job.verify && parsed.findings.length && !ctrl.signal.aborted) {
831
886
  const outcome = await this.runChecker(job, runId, parsed.state ?? previousState, parsed.findings, ctrl.signal, job.checkerModel ?? model, thinking);
832
887
  checker = outcome.record;
833
888
  reviewed = outcome.reviewed;
834
889
  }
835
890
  const findings: string[] = [];
836
- if (result.ok) {
891
+ if (stillThere && result.ok) {
837
892
  // cron:<name>, else cron:<id prefix> — "cron-" plus 8 hex, the width /inbox shows.
838
893
  const source = `cron:${job.name ?? job.id.slice(0, "cron-".length + 8)}`;
839
894
  for (const f of reviewed) {
@@ -882,20 +937,28 @@ export class LoopScheduler {
882
937
  // slot it claimed is given back and the next tick re-fires it (a tick is never lost this
883
938
  // way because its runs die with the session that owned them).
884
939
  const aborted = !result.ok && (result.stopReason === "aborted" || ctrl.signal.aborted);
885
- const updated = await this.store.update(job.id, (j) => {
886
- if (j.running?.runId === runId) j.running = undefined;
887
- j.lastCompletedAt = finishedAt;
888
- j.lastError = result.ok ? undefined : record.error;
889
- if (aborted) {
890
- j.lastDueAt = prior.dueAt;
891
- j.lastFiredAt = prior.firedAt;
892
- } else {
893
- j.runCount++;
894
- // A job that fails every time costs money every time. Count the streak so `dispatch`
895
- // can back off, and clear it the moment one run works.
896
- j.consecutiveFailures = result.ok ? undefined : (j.consecutiveFailures ?? 0) + 1;
897
- }
898
- });
940
+ // The same unreadable store that was tolerated above must not cost the run its `onRunFinished`
941
+ // either: the record is already written, and the job's own bookkeeping is the one part that
942
+ // can be skipped when the file cannot be read.
943
+ let updated: LoopJob | undefined;
944
+ try {
945
+ updated = await this.store.update(job.id, (j) => {
946
+ if (j.running?.runId === runId) j.running = undefined;
947
+ j.lastCompletedAt = finishedAt;
948
+ j.lastError = result.ok ? undefined : record.error;
949
+ if (aborted) {
950
+ j.lastDueAt = prior.dueAt;
951
+ j.lastFiredAt = prior.firedAt;
952
+ } else {
953
+ j.runCount++;
954
+ // A job that fails every time costs money every time. Count the streak so `dispatch`
955
+ // can back off, and clear it the moment one run works.
956
+ j.consecutiveFailures = result.ok ? undefined : (j.consecutiveFailures ?? 0) + 1;
957
+ }
958
+ });
959
+ } catch (err: any) {
960
+ this.log(`loop ${job.id}: could not write the store after the run (${err?.message ?? err})`);
961
+ }
899
962
  if (updated && !result.ok && (updated.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER) {
900
963
  this.hooks.onSchedulerError?.(`${updated.name ?? updated.id} has failed ${updated.consecutiveFailures} times in a row; backing off (last: ${record.error ?? "unknown"})`);
901
964
  }
@@ -907,13 +970,20 @@ export class LoopScheduler {
907
970
  // not for a job — and is then retired too, with the error kept in the run log and reported by
908
971
  // `onRunFinished`. Anything else leaves a failed `in 10m` enabled forever with no next run.
909
972
  if (updated && job.schedule.kind === "once" && !aborted) {
910
- if (result.ok || updated.runCount >= 2) await this.store.remove(job.id);
911
- else
973
+ if (result.ok || updated.runCount >= 2) {
974
+ await this.store.remove(job.id);
975
+ try {
976
+ this.hooks.onJobRetired?.(job, result.ok ? "one-shot finished" : "one-shot failed twice");
977
+ } catch (err: any) {
978
+ this.log(`onJobRetired hook failed: ${err?.message ?? err}`);
979
+ }
980
+ } else {
912
981
  await this.store.update(job.id, (j) => {
913
982
  j.lastDueAt = undefined;
914
983
  j.lastFiredAt = undefined;
915
984
  j.lastError = `${record.error ?? "run failed"} (one-shot: retrying once)`;
916
985
  });
986
+ }
917
987
  }
918
988
 
919
989
  try {
package/src/store.ts CHANGED
@@ -359,9 +359,14 @@ export class JobStore {
359
359
  return this.mutate((jobs) => ({ jobs: [...jobs, job], result: job }));
360
360
  }
361
361
 
362
- /** Remove every job matching `pred` (state files and transcripts included); returns them. */
363
- async removeWhere(pred: (job: LoopJob) => boolean): Promise<LoopJob[]> {
362
+ /**
363
+ * Remove every job matching `pred`; returns them. Its state and transcripts are kept unless
364
+ * `purge` is set — the caller decides, as with `remove` — because the usual way to change a job
365
+ * is to remove it and add it again, and a stateful loop's notes are what it accumulated.
366
+ */
367
+ async removeWhere(pred: (job: LoopJob) => boolean, opts: { purge?: boolean } = {}): Promise<LoopJob[]> {
364
368
  const removed = await this.mutate((jobs) => ({ jobs: jobs.filter((j) => !pred(j)), result: jobs.filter(pred) }));
369
+ if (!opts.purge) return removed;
365
370
  for (const job of removed) {
366
371
  try {
367
372
  fs.rmSync(this.statePath(job.id), { force: true });
@@ -405,17 +410,59 @@ export class JobStore {
405
410
  }
406
411
  }
407
412
 
408
- /** Loop state left behind by removed jobs, so `/cron gc` can offer to clear it. */
413
+ /**
414
+ * Loop state and transcripts left behind by removed jobs, so `/cron gc` can offer to clear both.
415
+ * An id qualifies if a `state/<id>.md` or a `sessions/<id>/` directory is there and no job claims
416
+ * it; `bytes` is the state file plus the whole transcript tree, since that is what `purgeState` deletes.
417
+ */
409
418
  orphanStates(): Array<{ id: string; bytes: number }> {
410
419
  const live = new Set(this.load().map((j) => j.id));
420
+ const bytes = new Map<string, number>();
421
+ const add = (id: string, n: number) => {
422
+ if (!live.has(id)) bytes.set(id, (bytes.get(id) ?? 0) + n);
423
+ };
424
+ let states: string[] = [];
411
425
  try {
412
- return fs
413
- .readdirSync(path.join(this.dir, "state"))
414
- .filter((f) => f.endsWith(".md") && !live.has(f.slice(0, -3)))
415
- .map((f) => ({ id: f.slice(0, -3), bytes: fs.statSync(path.join(this.dir, "state", f)).size }));
426
+ states = fs.readdirSync(this.stateDir);
416
427
  } catch {
417
- return [];
428
+ /* no state directory yet */
429
+ }
430
+ for (const f of states) {
431
+ if (!f.endsWith(".md")) continue;
432
+ let size = 0;
433
+ try {
434
+ size = fs.statSync(path.join(this.stateDir, f)).size;
435
+ } catch {
436
+ /* a file that vanished between the read and the stat counts 0 */
437
+ }
438
+ add(f.slice(0, -3), size);
439
+ }
440
+ let transcripts: fs.Dirent[] = [];
441
+ try {
442
+ transcripts = fs.readdirSync(this.sessionsDir, { withFileTypes: true });
443
+ } catch {
444
+ /* no transcript directory yet */
445
+ }
446
+ for (const e of transcripts) {
447
+ if (!e.isDirectory()) continue;
448
+ let size = 0;
449
+ try {
450
+ // The whole tree: `purgeState` deletes the directory recursively, so counting only the
451
+ // files directly under it understated what `/cron gc` is about to remove.
452
+ for (const f of fs.readdirSync(path.join(this.sessionsDir, e.name), { recursive: true, withFileTypes: true })) {
453
+ if (!f.isFile()) continue;
454
+ try {
455
+ size += fs.statSync(path.join(f.parentPath, f.name)).size;
456
+ } catch {
457
+ /* a file that vanished between the read and the stat counts 0 */
458
+ }
459
+ }
460
+ } catch {
461
+ size = 0; // an unreadable directory counts 0
462
+ }
463
+ add(e.name, size);
418
464
  }
465
+ return [...bytes.entries()].map(([id, n]) => ({ id, bytes: n }));
419
466
  }
420
467
 
421
468
  /* ---------------------------------------------------- loop state */
package/src/tools.ts CHANGED
@@ -416,15 +416,16 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
416
416
  // free: it is what the user is shown before deciding.
417
417
  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);
418
418
  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 } };
419
- await host.scheduler.store.remove(job.id);
419
+ const { aborted } = await host.scheduler.removeJob(job.id);
420
420
  const auditEntryId = host.cronControlAudit("remove", scope.actor, job, undefined);
421
421
  // The slash command says where a loop's notes went; a tool-driven removal has to say it too,
422
422
  // or the person who asked the agent to delete a job is the only one left guessing. The prose
423
423
  // carries the fact and `details` carries the path, which is the division this tool already
424
424
  // uses elsewhere (`cron_list` reports `storage_path` the same way).
425
425
  const kept = job.stateful ? "\nits loop state and run transcripts are kept; /cron gc --purge clears them" : "";
426
+ const abortedNote = aborted > 0 ? "\naborted its running run" : "";
426
427
  const statePath = job.stateful ? host.scheduler.store.statePath(job.id) : undefined;
427
- return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}` }], details: { id: job.id as string | undefined, removed_count: 1, confirmation_required: false, audit_entry_id: auditEntryId, state_path: statePath } };
428
+ 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 } };
428
429
  },
429
430
  });
430
431
 
package/src/web.mjs CHANGED
@@ -237,16 +237,31 @@ const SECRET_SHAPES = [
237
237
  * reasons pi exits at all, and what it prints is the key it was refused with — so the one event
238
238
  * whose whole purpose is to explain a failure was the one event that could carry a credential.
239
239
  */
240
- function recentStderr() {
241
- // Redact the whole kept tail, then cut. The other order cut first, so a key that straddled the
242
- // 4000-character boundary lost the `sk-` its pattern starts at — nothing matched, and the second
243
- // half of it went out to every attached browser.
244
- let out = stderrTail.trim();
240
+ /**
241
+ * Redact every credential shape a line of output could carry.
242
+ *
243
+ * A job's prompt and last error, and a rule's condition and action, now leave this process in
244
+ * `/state` in full rather than cut short — and full is what makes a prompt with an API key in it,
245
+ * or an error a provider printed, reach every attached browser unless it is masked first. The same
246
+ * patterns that guard the stderr tail have to guard these, which is why this is one function and
247
+ * not two copies of the list.
248
+ */
249
+ function maskSecrets(text) {
250
+ // A field the store does not have stays absent: /state said undefined before and still does.
251
+ if (text === undefined || text === null) return text;
252
+ let out = String(text);
245
253
  for (const re of SECRET_SHAPES) {
246
254
  re.lastIndex = 0;
247
255
  out = out.replace(re, "[REDACTED]");
248
256
  }
249
- return out.slice(-STDERR_SEND);
257
+ return out;
258
+ }
259
+
260
+ function recentStderr() {
261
+ // Redact the whole kept tail, then cut. The other order cut first, so a key that straddled the
262
+ // 4000-character boundary lost the `sk-` its pattern starts at — nothing matched, and the second
263
+ // half of it went out to every attached browser.
264
+ return maskSecrets(stderrTail.trim()).slice(-STDERR_SEND);
250
265
  }
251
266
  pi.stderr.on("data", (d) => {
252
267
  process.stderr.write(d);
@@ -449,9 +464,9 @@ function formatSchedule(s) {
449
464
  * Missing file, older pi-loops, no leader yet: no next run shown, which is what was shown before.
450
465
  */
451
466
  function nextRuns() {
452
- // This machine's file. Leadership is per host, and so is the clock a cron expression is matched
453
- // against, so on a shared `$HOME` each machine keeps its own answers rather than overwriting the
454
- // other's with times computed in a different timezone.
467
+ // The scheduler's one file. If two machines shared a `$HOME` they would both run every job and
468
+ // overwrite one another's answers here; that is unsupported rather than handled
469
+ // (docs/design.md §19).
455
470
  const doc = readJson(path.join(LOOPS_DIR, "next-runs.json"), { next: {} });
456
471
  return doc?.next && typeof doc.next === "object" ? doc.next : {};
457
472
  }
@@ -483,10 +498,14 @@ function automation(cwd) {
483
498
  schedule: formatSchedule(j.schedule),
484
499
  stateful: !!j.stateful,
485
500
  enabled: !!j.enabled,
486
- prompt: j.prompt,
501
+ prompt: maskSecrets(j.prompt),
487
502
  runCount: j.runCount ?? 0,
488
- lastError: j.lastError,
503
+ lastError: maskSecrets(j.lastError),
489
504
  running: !!j.running,
505
+ // The open body of a card shows these two, which the terminal's `/cron` line has and the
506
+ // panel did not; they leave the store as they were written, no reshaping.
507
+ cwd: j.cwd,
508
+ lastFiredAt: j.lastFiredAt,
490
509
  // Set only when it is not this machine's: the panel says so, and nothing else has to guess.
491
510
  // What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
492
511
  // is the position in a numbered list — which this panel does not have, so telling someone to
@@ -504,7 +523,7 @@ function automation(cwd) {
504
523
  const allRules = readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? [];
505
524
  const rules = allRules
506
525
  .filter(inThisProject)
507
- .map((r) => ({ id: r.id, condition: r.condition, action: r.action, enabled: !!r.enabled, fireOnce: !!r.fireOnce, firedAt: r.firedAt }));
526
+ .map((r) => ({ id: r.id, condition: maskSecrets(r.condition), action: maskSecrets(r.action), enabled: !!r.enabled, fireOnce: !!r.fireOnce, firedAt: r.firedAt }));
508
527
  // Two counts, not one. They used to be added together, and `/cron` counts jobs only — so the
509
528
  // panel's number disagreed with the command's whenever a rule lived somewhere else, and the line
510
529
  // that exists to say "your job is not gone, it is elsewhere" pointed at `/cron all`, where a
@@ -2261,6 +2280,31 @@ aside h2{font-size:12px;font-weight:700;color:var(--ink);margin:0 0 9px;letter-s
2261
2280
  .card .t .m{white-space:nowrap}
2262
2281
  .card .m{color:var(--muted);font-size:12px}
2263
2282
  .card button{padding:2px 9px;font-size:12px;margin-left:auto;min-height:0}
2283
+ /* A job or rule is now a <details>: closed it is the name, a two-line preview and a one-line error
2284
+ hint, open it is the whole prompt and the fields the terminal /cron listing has. The summary is
2285
+ the only part shown closed, so the runs line, the preview and the error hint live there and the
2286
+ full text lives in the body. */
2287
+ details.card{padding:0;overflow:hidden}
2288
+ details.card>summary{cursor:pointer;list-style:none;padding:7px 11px;display:flex;flex-wrap:wrap;align-items:center;gap:8px}
2289
+ details.card>summary::-webkit-details-marker{display:none}
2290
+ details.card>summary::before{content:"▸";color:var(--faint);flex:0 0 auto}
2291
+ details.card[open]>summary::before{content:"▾"}
2292
+ details.card>summary .t{flex:1 1 auto;min-width:0}
2293
+ details.card>summary>.m{flex-basis:100%}
2294
+ /* A summary must hold phrasing content, so these are spans; the summary is a flex row, which
2295
+ blockifies its children anyway, and the block is stated here for the lines that need it. */
2296
+ details.card>summary>.m:not(.peek){display:block}
2297
+ details.card .peek{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-width:0}
2298
+ details.card[open] .peek{display:none}
2299
+ details.card .peek.failing{-webkit-line-clamp:1;color:#c66}
2300
+ details.card .body{position:relative;padding:0 11px 9px}
2301
+ /* The id, cwd and last error are one unbroken run each — a path, a URL in an error — and the card
2302
+ clips what it cannot wrap, so they break the way the full prompt does. */
2303
+ details.card .body .m{overflow-wrap:anywhere}
2304
+ details.card pre.full{margin:0;padding:3px 3.5em 6px 0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:22em;overflow:auto;color:var(--muted);font-size:12px;font-family:inherit}
2305
+ details.card button.copy{border:0;background:none;color:var(--faint);padding:0 5px;font-size:11px;min-height:0;opacity:0;position:absolute;top:4px;right:6px}
2306
+ details.card:hover button.copy,details.card:focus-within button.copy{opacity:1}
2307
+ @media (pointer:coarse){details.card button.copy{opacity:.7}}
2264
2308
  .off{opacity:.5}
2265
2309
  .dot{display:inline-block;width:7px;height:7px;border-radius:99px;margin-right:6px;vertical-align:middle}
2266
2310
  .up{background:var(--accent)}.down{background:var(--bad)}.idle{background:var(--faint)}
@@ -2994,10 +3038,15 @@ function copyBtn(getText) {
2994
3038
  * they reported the drop three different ways, one of them not at all: a result that stopped at 8000
2995
3039
  * characters looked like a result that ended there. A cap that hides its own existence is a lie
2996
3040
  * about the output.
3041
+ *
3042
+ * Counted and cut in characters, which is what the message says and what capChars in src/protocol.ts
3043
+ * and previewText in src/session-head.ts have always counted. String.length is code units, so an
3044
+ * emoji is two of them: the count came out larger than what was shown, and a cut between the two
3045
+ * halves of one left half a character, which a browser draws as a replacement glyph.
2997
3046
  */
2998
3047
  function capped(text, n) {
2999
- const s = String(text ?? "");
3000
- return s.length > n ? s.slice(0, n) + "\n… (" + s.length + " chars)" : s;
3048
+ const chars = Array.from(String(text ?? ""));
3049
+ return chars.length > n ? chars.slice(0, n).join("") + "\n… (" + chars.length + " chars)" : chars.join("");
3001
3050
  }
3002
3051
 
3003
3052
  /** Replace a row's plain text with its rendered Markdown, keeping the source for the copy button. */
@@ -3430,6 +3479,11 @@ function str(v) { return v === undefined || v === null ? "" : String(v); }
3430
3479
  */
3431
3480
  function safeText(v) { return esc(plain(str(v))); }
3432
3481
 
3482
+ // Which cards were open when the panel last drew. The panel is rebuilt as one string and assigned
3483
+ // every eight seconds, so the open flag has nowhere to live but here: read before the draw, restore
3484
+ // after it, and a card toggled between draws updates this at once through its own toggle event.
3485
+ const openCards = new Set();
3486
+
3433
3487
  function renderSidebar(s) {
3434
3488
  const a = s.automation || {};
3435
3489
  const box = $("auto");
@@ -3438,19 +3492,38 @@ function renderSidebar(s) {
3438
3492
  let html = "";
3439
3493
  html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new" + (a.inboxDecisions ? " (" + num(a.inboxDecisions) + " decision" + (a.inboxDecisions === 1 ? "" : "s") + ")" : "") + " · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
3440
3494
  for (const j of a.jobs) {
3441
- html += '<div class="card' + (j.enabled ? "" : " off") + '"><div class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3495
+ // The prompt is shown twice — a two-line peek in the summary, the whole of it in the body —
3496
+ // rather than once and cut, which is what the 90-character slice was: a preview that hid the
3497
+ // rest with no way to see it. Both copies go through safeText; neither is sliced here.
3498
+ const prompt = safeText(j.prompt);
3499
+ html += '<details class="card job' + (j.enabled ? "" : " off") + '" data-key="job:' + safeText(j.id) + '">' +
3500
+ '<summary><span class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3442
3501
  '<span class="m">' + safeText(j.schedule) + (j.stateful ? " · loop" : "") + "</span>" +
3443
- '<button data-run="' + safeText(j.id) + '">run</button></div>' +
3444
- '<div class="m">' + safeText(str(j.prompt).slice(0, 90)) + "</div>" +
3445
- '<div class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</div>" +
3446
- (j.asleep ? '<div class="m" style="color:#c93">' + safeText(j.asleep) + "</div>" : "") +
3502
+ '<button data-run="' + safeText(j.id) + '">run</button></span>' +
3503
+ '<span class="m peek">' + prompt + "</span>" +
3504
+ '<span class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</span>" +
3505
+ // A one-line hint so a job that is failing says so before it is opened; the whole error is in
3506
+ // the body. It is a .peek like the prompt, hidden once the card is open.
3507
+ (j.lastError ? '<span class="m peek failing">' + safeText(j.lastError) + "</span>" : "") +
3508
+ (j.asleep ? '<span class="m" style="color:#c93">' + safeText(j.asleep) + "</span>" : "") +
3509
+ "</summary>" +
3510
+ '<div class="body">' +
3511
+ '<pre class="full">' + prompt + "</pre>" +
3512
+ '<button class="copy" data-copy="' + safeText(j.id) + '">copy</button>' +
3513
+ '<div class="m">id ' + safeText(j.id) + " · cwd " + safeText(j.cwd) + (j.lastFiredAt ? " · last fired " + safeText(j.lastFiredAt) : "") + "</div>" +
3447
3514
  // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
3448
3515
  // and it says why nothing is happening rather than leaving you to find out from the silence.
3449
- (j.lastError ? '<div class="m" style="color:#c66">' + safeText(str(j.lastError).slice(0, 120)) + "</div>" : "") + "</div>";
3516
+ (j.lastError ? '<div class="m" style="color:#c66">' + safeText(j.lastError) + "</div>" : "") +
3517
+ "</div></details>";
3450
3518
  }
3451
3519
  for (const r of a.rules) {
3452
- html += '<div class="card' + (r.enabled ? "" : " off") + '"><div class="t"><b>rule</b><span class="m">' + (r.fireOnce ? "once" : "repeat") + "</span></div>" +
3453
- '<div class="m">when ' + safeText(str(r.condition).slice(0, 80)) + "</div><div class=\"m\">→ " + safeText(str(r.action).slice(0, 80)) + "</div></div>";
3520
+ html += '<details class="card rule' + (r.enabled ? "" : " off") + '" data-key="rule:' + safeText(r.id) + '">' +
3521
+ '<summary><span class="t"><b>rule</b><span class="m">' + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
3522
+ '<span class="m peek">when ' + safeText(r.condition) + "</span></summary>" +
3523
+ '<div class="body">' +
3524
+ '<pre class="full">when ' + safeText(r.condition) + "</pre>" +
3525
+ '<pre class="full">→ ' + safeText(r.action) + "</pre>" +
3526
+ "</div></details>";
3454
3527
  }
3455
3528
  if (!a.jobs.length && !a.rules.length) html += '<div class="notice">no jobs or rules in this project</div>';
3456
3529
  // The store is machine-wide and this list is not: without these lines, a job made in another
@@ -3462,8 +3535,55 @@ function renderSidebar(s) {
3462
3535
  if (jobsAway) html += '<div class="notice">+ ' + jobsAway + " job" + (jobsAway === 1 ? "" : "s") + " in other projects — /cron all</div>";
3463
3536
  if (rulesAway) html += '<div class="notice">+ ' + rulesAway + " rule" + (rulesAway === 1 ? "" : "s") + " elsewhere — /triggers rules --all</div>";
3464
3537
  if (s.lastPoll) html += '<div class="notice">last check: ' + safeText(s.lastPoll.state || "") + " · " + esc(new Date(s.lastPoll.at).toLocaleTimeString()) + "</div>";
3538
+ // The open state survives the redraw by being read out before the panel is rebuilt and put back
3539
+ // after: the cards themselves are new elements each time, so the flag has to live in this set.
3540
+ box.querySelectorAll("details[data-key]").forEach((d) => {
3541
+ if (d.open) openCards.add(d.dataset.key); else openCards.delete(d.dataset.key);
3542
+ });
3465
3543
  box.innerHTML = html;
3466
- box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = () => api("/trigger/immediate", { id: b.dataset.run })));
3544
+ box.querySelectorAll("details[data-key]").forEach((d) => {
3545
+ if (openCards.has(d.dataset.key)) d.open = true;
3546
+ // The toggle event does not bubble, so it is heard per card, not once on the box: a card
3547
+ // closed by hand between draws stays closed when the next draw runs.
3548
+ d.addEventListener("toggle", () => {
3549
+ if (d.open) openCards.add(d.dataset.key); else openCards.delete(d.dataset.key);
3550
+ });
3551
+ });
3552
+ box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = (e) => {
3553
+ // A click anywhere in a summary toggles the card; the run button is a summary's own button,
3554
+ // not a way to open the prompt.
3555
+ e.preventDefault(); e.stopPropagation();
3556
+ api("/trigger/immediate", { id: b.dataset.run });
3557
+ }));
3558
+ box.querySelectorAll("[data-copy]").forEach((b) => {
3559
+ const card = b.closest?.("details[data-key]");
3560
+ b.type = "button";
3561
+ // Reads the prompt back from the DOM rather than holding a second copy in JS, the way the
3562
+ // feed's tool rows do, so the two can never fall out of step.
3563
+ b.onclick = async (e) => {
3564
+ e.stopPropagation();
3565
+ const text = card?.querySelector?.("pre.full")?.textContent ?? "";
3566
+ try {
3567
+ if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(text);
3568
+ else {
3569
+ const ta = document.createElement("textarea");
3570
+ ta.value = text;
3571
+ ta.style.position = "fixed";
3572
+ ta.style.opacity = "0";
3573
+ document.body.append(ta);
3574
+ ta.focus();
3575
+ ta.select();
3576
+ let ok = false;
3577
+ try { ok = document.execCommand("copy"); } finally { ta.remove(); }
3578
+ if (!ok) throw new Error("copy refused");
3579
+ }
3580
+ b.textContent = "copied";
3581
+ } catch (_) {
3582
+ b.textContent = "copy failed";
3583
+ }
3584
+ setTimeout(() => { b.textContent = "copy"; }, 1400);
3585
+ };
3586
+ });
3467
3587
  }
3468
3588
  renderRuntime(s.runtime);
3469
3589
  $("goal").textContent = s.goal ? plain((s.goal.condition || "") + " — " + (s.goal.status || "") + " (" + (s.goal.iterations ?? 0) + ")") : "none";