@alphacoder-v0/pi-loops 0.22.4 → 0.22.5

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,49 @@ All notable changes to pi-loops are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.5] - 2026-09-28
9
+
10
+ ### Changed
11
+ - **Both READMEs say what `/cron remove` reaches.** The command tables put `remove` in one row
12
+ with `enable` and `disable`, which resolve a name across the machine; removal stopped doing
13
+ that in 0.22.4 and the row never said so. It now states the rule: a name of this project, or
14
+ the exact id of a job anywhere.
15
+ - **The design principles say when a job disappears.** Principle 11 said a job goes when you
16
+ remove it "and not before", which a one-shot that has fired and a job `/cron gc` collects
17
+ both contradict. It now names all three, and says none of them is time passing.
18
+ - **`/cron` says `skipped overlaps`.** The listing called the counter `overlap skips`, while the
19
+ terminal panel and docs/loops.md call it `skipped overlaps`. The listing now uses the same name.
20
+ - **The `/cron` listing is in `src/job-lines.ts`.** It was composed inside the extension's default
21
+ export, where no test can reach it. The lines are unchanged; each marker and column now has a test.
22
+ - **docs/design.md counts eight recipes** and names `deps-sweeper`, which ships but was not listed.
23
+
24
+ ### Fixed
25
+ - **A long session with non-ASCII text is listed as truncated again.** The session-list window is
26
+ 64 KB of *bytes*, but the check asked whether the decoded string was that many UTF-16 code units
27
+ long. With CJK text the decoded length is smaller, so a session well past the window read as
28
+ complete and the resume picker printed a floor (`314 message(s)`) as an exact count. Both copies —
29
+ `src/session-head.ts` and the one inside `src/web.mjs` — now compare the bytes actually read.
30
+ - **`pi-loops sessions --limit` refuses a value it cannot use.** `--limit abc` and `--limit 0`
31
+ printed nothing and exited 0, the same output as a project with no sessions, and `--limit -1`
32
+ printed all but the oldest. The value must now be a whole number of at least 1.
33
+ - **`/triggers audit` refuses a limit it cannot read.** `alll`, `0` and `25x` quietly became the
34
+ default of 10 or 25, and `-5` printed almost the whole audit file. Anything other than one whole
35
+ number of at least 1 and `--all` now prints the usage line.
36
+ - **The `/goal` evaluator gets the 40 000 characters docs/goal.md promises.** The cap counted UTF-16
37
+ code units, so an emoji-heavy transcript was cut to about half and could begin on half a character.
38
+ - **The browser shows pi's last words with the cut marked.** When pi exits, the server cut its
39
+ stderr to 4000 UTF-16 code units and said nothing, so the tail looked whole and could begin on half
40
+ a character. The cut now counts characters and starts with `… (N chars dropped)`.
41
+ - **The browser panel shows an MCP server's last error whole.** The Runtime box cut it at 120
42
+ characters with no ellipsis, although the error it receives is already capped at 160 and marked.
43
+ - **The browser job card shows a loop's skipped overlaps,** as `/cron` and the terminal panel do.
44
+ - **The browser panel keeps a job whose checkout is gone in this project.** It resolved a missing
45
+ path only one directory up, so a removed worktree two levels down counted as another project's,
46
+ while `/cron` still listed it with `[orphan: cwd missing]`. It now resolves the deepest ancestor
47
+ that exists, as `src/paths.ts` does.
48
+ - **docs/web-ui-parity.md names a test that exists.** One line of the front-end gate named a test
49
+ that had been renamed. A new test checks every test name that a document gives.
50
+
8
51
  ## [0.22.4] - 2026-09-20
9
52
 
10
53
  ### Fixed
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.4 # or from GitHub, at a pinned tag
41
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.22.5 # 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
  ```
@@ -276,7 +276,7 @@ State stays in `~/.pi/agent/loops` until you delete it.
276
276
  |---|---|
277
277
  | `/cron add [--stateful] [--verify] "<schedule>" <prompt>` | Schedule a job: a plain job belongs to this chat and injects its result here (it sleeps while this session is not open), `--stateful` makes a loop that runs wherever the clock is, with memory and inbox routing, `--verify` adds the checker |
278
278
  | `/cron`, `/cron all` | This project's jobs, or every project on this machine |
279
- | `/cron enable\|disable\|remove <ref>` | Pause, resume or delete one job — and write an audit entry into the session, as `add` does |
279
+ | `/cron enable\|disable\|remove <ref>` | Pause, resume or delete one job — and write an audit entry into the session, as `add` does. `remove` takes a name of this project or the exact id of a job anywhere, so a name never reaches a checkout you are not in |
280
280
  | `/cron disable --all` | Pause every job in this project (`--all-projects` for the machine); `/cron enable --all` resumes |
281
281
  | `/cron run <ref>` | Fire one job now instead of waiting for its schedule |
282
282
  | `/cron state <ref>` | The loop's notes — the Markdown it carries from one run to the next |
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.4 # 或者从 GitHub 装,钉住一个 tag
48
+ pi install git:github.com/alphacoder-v0/pi-loops@v0.22.5 # 或者从 GitHub 装,钉住一个 tag
49
49
  pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
50
50
  ```
51
51
 
@@ -242,6 +242,7 @@ playbook 拷到 `.agents/skills/<name>/`,通过 `.git/info/exclude` 排除在
242
242
  /cron add in 10m 提醒我看一下测试结果 # 会话级闹钟
243
243
  /cron · /cron list|ls|status 本项目的任务,[stateful] 标记;/cron all 看整台机器
244
244
  /cron enable|resume|disable|pause|remove <n|id|name>
245
+ remove 只认本项目的名字和编号,别的项目的任务要给完整 id(删除撤不回来)
245
246
  /cron set <ref> … 改已有任务而不换 id(笔记因此留着):--prompt、--schedule,
246
247
  以及 --model、--thinking、--timeout、--name 这几个钉子(`-` 解掉)
247
248
  /cron run 1 立刻跑一次(once 跑完即删,every 的间隔从现在重算,cron 的下次不变;停用的普通任务会被拒绝)
package/docs/cli.md CHANGED
@@ -238,7 +238,9 @@ in the session's control-plane audit.
238
238
  characters of the id, when the session started, the automation it
239
239
  has (`[2 cron, 1 trigger]`, or `[automation off]` when all of it is disabled), and what was first
240
240
  said in it — the session's name if it was given one, otherwise the first message, cut at eighty
241
- characters. `--all` lists every project and puts the cwd after the id. The id is what
241
+ characters. `--all` lists every project and puts the cwd after the id. `--limit <n>` shows at most
242
+ `n` (default 20); a whole number of at least 1 is required, and anything else is refused rather than
243
+ read as an offset. The id is what
242
244
  `export --session` accepts; a unique prefix will do. `inspect` prints an archive's schedules, prompts and rules without
243
245
  writing anything, which is what you want before restoring on a machine you care about.
244
246
 
package/docs/design.md CHANGED
@@ -102,7 +102,9 @@ machine. Everything here follows from that, and each one has a price.
102
102
  not otherwise need.
103
103
 
104
104
  11. **Bounded everywhere, and no expiry.** Loop state ≤ 2000 characters, a finding ≤ 500, at most
105
- 16 per run, prompts ≤ 8 KB. A job disappears when you remove it and not before.
105
+ 16 per run, prompts ≤ 8 KB. A job disappears when you remove it, when a one-shot has fired, or
106
+ when `/cron gc` collects one whose session is gone — never because time passed. Each of the
107
+ three is recorded in the control-plane audit, the self-removals included.
106
108
 
107
109
  12. **Cycle suppression by hop count.** Sub-agents run at hop 1 and keep the cron and trigger tools
108
110
  (`cron_create`, `cron_remove`, listing, disabling), so a loop can manage automation; the
@@ -122,10 +124,10 @@ machine. Everything here follows from that, and each one has a price.
122
124
  15. **Recipes are files, not code.** A recipe (`CONTEXT.md` has the vocabulary) is a directory:
123
125
  a `recipe.toml` manifest whose `[[job]]` fields are the arguments of `/cron add` and nothing
124
126
  else, one playbook per job, an optional setup script. Anything the wizard does with one is
125
- something a person could have done by hand with `/cron add` and `cp`. The first catalogue is
126
- seven: issue-loop, daily-digest, changelog-draft, pr-watch, ci-sweeper, autoresearch, and
127
- ecosystem — the last capped at `propose`, because every outward word it drafts is a finding a
128
- person claims before it is said.
127
+ something a person could have done by hand with `/cron add` and `cp`. The catalogue is eight:
128
+ issue-loop, daily-digest, changelog-draft, pr-watch, ci-sweeper, autoresearch, deps-sweeper,
129
+ and ecosystem — `ecosystem` capped at `propose`, because every outward word it drafts is a
130
+ finding a person claims before it is said.
129
131
 
130
132
  The cost: a project cannot add a recipe by writing TypeScript, only by writing a directory,
131
133
  and the wizard can only ask what a manifest can declare.
@@ -98,7 +98,8 @@ weaker, and worth converting whenever one of them breaks.
98
98
  different ways and one of them not at all, so a result that stopped at 8000 characters looked
99
99
  like a result that ended there. The count and the cut are characters, not UTF-16 code units, so
100
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*.
101
+ *a capped result counts characters, not the units a string is counted in*; `test/web.test.ts`:
102
+ *pi's dying words are cut by characters, and the cut says how much it dropped*.
102
103
  - [x] **You can see what the session made, not only read about it.** A picture in a reply is a
103
104
  picture — including one named in ordinary prose rather than as a link; a path is something to
104
105
  open; a page written into the reply has a preview; an image a tool returned is shown rather
@@ -136,7 +137,7 @@ weaker, and worth converting whenever one of them breaks.
136
137
  What counts as "this project" is what the extension says it is — symlinks resolved, `$HOME`
137
138
  too broad to be one — because a panel that disagrees with the command is worse than either.
138
139
  `test/web.test.ts`: *the panel and /cron agree about what this project is*.
139
- `test/web-page.test.ts`: *a job this machine no longer owns is listed, not hidden*.
140
+ `test/web-page.test.ts`: *what the machine has that this project does not is counted, apart*.
140
141
  - [x] **When each job runs next, whatever its schedule.** The page used to work this out itself and
141
142
  understood only `every <interval>`, so a job on `0 9 * * *` — the first example in the
142
143
  README — showed nothing. A second cron parser in a page with no dependencies was the wrong
@@ -194,7 +195,8 @@ weaker, and worth converting whenever one of them breaks.
194
195
  browser. A session whose only question is on a terminal you closed is a stuck session. One
195
196
  asked while no browser was attached is held for a browser that turns up, and the list of those
196
197
  is bounded the way the event backlog is: an abandoned dialog was otherwise re-offered every
197
- eight seconds for as long as the process lived.
198
+ eight seconds for as long as the process lived. `test/web.test.ts`: *dialogs held for a browser
199
+ that turns up are bounded, oldest first*.
198
200
  - [x] A confirmation shows what is about to happen apart from the reasoning about it, so it cannot
199
201
  be read as prose and waved through. `test/web-page.test.ts`: *a confirmation shows what is
200
202
  about to run*.
@@ -343,6 +345,7 @@ weaker, and worth converting whenever one of them breaks.
343
345
  - [x] Pairing survives an upgrade: the token is a file outside the package, so a device stays signed
344
346
  in across restarts and new versions. `test/web.test.ts`: *the token outlives the process*.
345
347
  - [x] Events are incremental. The backlog a late-joining browser replays is bounded.
348
+ `test/web.test.ts`: *the event backlog a late-joining browser replays is bounded*.
346
349
  - [x] Events carry no credential and no oversized tool payload; the image bytes a message already
347
350
  contains travel with it, and the page renders them rather than fetching them. The page itself
348
351
  is handed the token — in the cookie, and in the URL on a first visit — and nothing else is. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacoder-v0/pi-loops",
3
- "version": "0.22.4",
3
+ "version": "0.22.5",
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
@@ -266,6 +266,12 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
266
266
  if (command === "inbox") return inboxCommand(positional, { all: flags.has("all"), json: flags.has("json"), reason: flags.get("reason") }, cwd, loopsDir, out);
267
267
 
268
268
  if (command === "sessions") {
269
+ // A count, so anything that is not a whole number ≥ 1 would reach `slice` as NaN, a
270
+ // negative offset or a fraction, and change what is listed without ever saying so.
271
+ const limitFlag = flags.get("limit");
272
+ if (limitFlag !== undefined && (typeof limitFlag !== "string" || !/^\d+$/.test(limitFlag) || Number(limitFlag) < 1))
273
+ throw new Error(`--limit takes a whole number of sessions ≥ 1; got ${limitFlag === true ? "nothing" : JSON.stringify(limitFlag)}`);
274
+ const limit = limitFlag === undefined ? 20 : Number(limitFlag);
269
275
  // `pickSession` refuses an unknown id, and there was no way to discover one.
270
276
  const sessions = listSessions(path.join(agentDir, "sessions"));
271
277
  const here = flags.has("all") ? sessions : sessions.filter((s) => sameProject(s.cwd, cwd));
@@ -277,7 +283,7 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
277
283
  // session has, what was first said in it. With --all the cwd sits after the id.
278
284
  const jobs = new JobStore(loopsDir).load();
279
285
  const rules = new TriggerStore(loopsDir).load();
280
- for (const s of here.slice(0, Number(str("limit") ?? 20))) {
286
+ for (const s of here.slice(0, limit)) {
281
287
  const badge = automationBadge(s.id, jobs, rules);
282
288
  out(`${s.id.slice(0, 16)} ${flags.has("all") ? `${s.cwd} ` : ""}${(s.startedAt ?? stamp(s.mtimeMs)).slice(0, 16)}${badge ? ` [${badge}]` : ""} ${s.preview ? previewText(s.preview) : ""}`.trimEnd());
283
289
  }
package/src/goal.ts CHANGED
@@ -69,7 +69,11 @@ export function transcriptFromMessages(messages: Array<{ role?: string; content?
69
69
  if (text) lines.push(`${role}: ${text}`);
70
70
  }
71
71
  const joined = lines.join("\n");
72
- return joined.length <= limit ? joined : `…\n${joined.slice(joined.length - limit)}`;
72
+ // Characters, not UTF-16 code units: one emoji is two units, so counting units cut an emoji-heavy
73
+ // transcript to about half of the 40 000 docs/goal.md promises and could land between the halves
74
+ // of one character. `tailChars` is the character-correct tail already used a few lines below.
75
+ const tail = tailChars(joined, limit);
76
+ return tail === joined ? joined : `…\n${tail}`;
73
77
  }
74
78
 
75
79
  function renderContent(content: unknown): string {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The `/cron` listing, as lines.
3
+ *
4
+ * It lives here rather than in `src/pi-loops.ts` — the extension's default export, which nothing can
5
+ * import — because docs/loops.md states these markers and columns as the behaviour, and until this
6
+ * was importable no test could see one go. Everything it reads is a parameter, so a test can hand it
7
+ * a job, a run log and an inbox and read the lines a person reads.
8
+ */
9
+ import type { InboxEntry } from "./inbox.ts";
10
+ import { asleepNote, runsIn } from "./job-owner.ts";
11
+ import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
12
+ import { previewRedacted } from "./redact.ts";
13
+ import { computeNext, formatLocal, formatSchedule } from "./schedule.ts";
14
+ import type { LoopJob, RunRecord } from "./store.ts";
15
+
16
+ export interface JobLinesInput {
17
+ /** The whole run log, read once by the caller; only this listing's loops are filtered out of it. */
18
+ runs: RunRecord[];
19
+ /** The whole inbox, read once by the caller. */
20
+ inbox: InboxEntry[];
21
+ /** The session reading the list: a plain job of another session has no next run where it is. */
22
+ sessionId?: string;
23
+ now: number;
24
+ /** `$HOME`, for `~` shortening; passed rather than read so a test's paths are its own. */
25
+ home: string;
26
+ /** Injected because the answer decides `[orphan: cwd missing]`, and a test should not make directories. */
27
+ cwdExists: (cwd: string) => boolean;
28
+ }
29
+
30
+ /** The caller's `homeRel`, with `home` passed in: this module has no `process.env`. */
31
+ const homeRel = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p);
32
+
33
+ export function jobLines(jobs: LoopJob[], opts: JobLinesInput): string[] {
34
+ const { runs, inbox, sessionId, now, home, cwdExists } = opts;
35
+ return jobs
36
+ .map((job, i) => {
37
+ // A next run is shown where it will happen: a plain job of a session not open here has none.
38
+ const next =
39
+ job.enabled && runsIn(job, sessionId)
40
+ ? computeNext(
41
+ {
42
+ schedule: job.schedule,
43
+ createdAt: Date.parse(job.createdAt),
44
+ lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
45
+ },
46
+ now,
47
+ )
48
+ : undefined;
49
+ const asleepMark = asleepNote(job, sessionId);
50
+ const dormant = asleepMark ? `[${asleepMark}]` : undefined;
51
+ const orphan = job.stateful && !cwdExists(job.cwd) ? "[orphan: cwd missing]" : undefined;
52
+ // What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
53
+ // runs are the newest, and the findings line — filed, claimed, dismissed — over 30 days.
54
+ // A loop was, until now, judged by whether it ran; this is whether it was worth running.
55
+ const signal = job.stateful ? loopSignal(job.id, runs, inbox, now - SIGNAL_WINDOW_MS) : undefined;
56
+ const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
57
+ const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
58
+ .filter(Boolean)
59
+ .join(" ");
60
+ const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
61
+ const action = ` action: ${previewRedacted(job.prompt, 120)}`;
62
+ const worth = signal ? signalSummary(signal) : undefined;
63
+ const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · skipped overlaps ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${homeRel(job.cwd, home)}`;
64
+ const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
65
+ return [head, action, meta, err].filter((l): l is string => !!l);
66
+ })
67
+ .flat();
68
+ }
package/src/pi-loops.ts CHANGED
@@ -25,7 +25,7 @@ import { sameProject } from "./presence.ts";
25
25
  import { isExactlyTrusted, sessionTrustCovers } from "./trust.ts";
26
26
  import { HookRunner, type HookEventData, messageKind, messageSummary, resultSummary, runEndEvent, runStartEvent, truncateSummary } from "./hooks.ts";
27
27
  import { failingSummary } from "./job-health.ts";
28
- import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
28
+ import { loopSignal, signalSummary } from "./job-signal.ts";
29
29
  import { LoopsLog, pruneLogs } from "./log.ts";
30
30
  import { type InboxEntry, belongsToProject, inProject, resolveInboxRef } from "./inbox.ts";
31
31
  import { McpPool } from "./mcp-pool.ts";
@@ -38,7 +38,8 @@ import { execFile } from "node:child_process";
38
38
  import { createHash } from "node:crypto";
39
39
  import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
40
40
  import { applyJobEdit } from "./job-edit.ts";
41
- import { asleepNote, runsIn } from "./job-owner.ts";
41
+ import { jobLines as cronJobLines } from "./job-lines.ts";
42
+ import { runsIn } from "./job-owner.ts";
42
43
  import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, RECIPE_TIERS, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, neverSection, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, preflightChecks, purgeInstall, readRecord, renderPreflight, requireRecipeName, resolveRecipeRef, runPreflight, updateFiles } from "./recipe.ts";
43
44
  import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
44
45
  import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
@@ -49,7 +50,7 @@ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
49
50
  import { waitForHost, HOST_LOG, crashedHost, hostPushWork, liveHost, piPackageDir, shouldHandOff, spawnHost, stopHost } from "./host-control.ts";
50
51
  import { summarizeSessionFile } from "./transcript.ts";
51
52
  import { TriggerRuntime, type TriggerOutcome } from "./trigger-runtime.ts";
52
- import { auditCronFinish, auditCronStart, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
53
+ import { auditArgs, auditCronFinish, auditCronStart, TriggerStore, buildPeriodicCheckTrigger, controlPlanePreflight, resolveRuleRef } from "./triggers.ts";
53
54
  import { type ControlPlaneRequest, type CreateJobInput, type JobScope, type ToolHost, automationTools, checkJobName, createLoopJob, resolveJobRefScoped } from "./tools.ts";
54
55
  import { panelEnabled, readUiPrefs, writeUiPref } from "./ui-prefs.ts";
55
56
  import * as fs from "node:fs";
@@ -723,41 +724,12 @@ export default function piLoops(pi: ExtensionAPI) {
723
724
  };
724
725
 
725
726
  function jobLines(jobs: LoopJob[]): string[] {
726
- const now = Date.now();
727
727
  // Read once for the whole listing: the run log and the inbox are each one file, and a
728
- // project with a dozen loops would otherwise parse them a dozen times per `/cron`.
729
- const allRuns = jobs.some((j) => j.stateful) ? scheduler.store.allRuns() : [];
730
- const allInbox = jobs.some((j) => j.stateful) ? scheduler.inbox.list() : [];
731
- return jobs.map((job, i) => {
732
- // A next run is shown where it will happen: a plain job of a session not open here has none.
733
- const next = job.enabled && runsIn(job, session.sessionId)
734
- ? computeNext(
735
- {
736
- schedule: job.schedule,
737
- createdAt: Date.parse(job.createdAt),
738
- lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined,
739
- },
740
- now,
741
- )
742
- : undefined;
743
- const asleepMark = asleepNote(job, session.sessionId);
744
- const dormant = asleepMark ? `[${asleepMark}]` : undefined;
745
- const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
746
- // What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
747
- // runs are the newest, and the findings line — filed, claimed, dismissed — over 30 days.
748
- // A loop was, until now, judged by whether it ran; this is whether it was worth running.
749
- const signal = job.stateful ? loopSignal(job.id, allRuns, allInbox, now - SIGNAL_WINDOW_MS) : undefined;
750
- const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
751
- const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
752
- .filter(Boolean)
753
- .join(" ");
754
- const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
755
- const action = ` action: ${previewRedacted(job.prompt, 120)}`;
756
- const worth = signal ? signalSummary(signal) : undefined;
757
- const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · overlap skips ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${homeRel(job.cwd)}`;
758
- const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
759
- return [head, action, meta, err].filter((l): l is string => !!l);
760
- }).flat();
728
+ // project with a dozen loops would otherwise parse them a dozen times per `/cron`. The shape
729
+ // of the lines is src/job-lines.ts, where a test can see a marker go.
730
+ const runs = jobs.some((j) => j.stateful) ? scheduler.store.allRuns() : [];
731
+ const inbox = jobs.some((j) => j.stateful) ? scheduler.inbox.list() : [];
732
+ return cronJobLines(jobs, { runs, inbox, sessionId: session.sessionId, now: Date.now(), home: process.env.HOME ?? "", cwdExists: (cwd) => fs.existsSync(cwd) });
761
733
  }
762
734
 
763
735
  /* --------------------------------------------------------- creation */
@@ -1802,10 +1774,13 @@ export default function piLoops(pi: ExtensionAPI) {
1802
1774
  return;
1803
1775
  }
1804
1776
  case "audit": {
1805
- const all = /\s--all\b|^--all\b/.test(rest);
1806
- const limit = Number.parseInt(rest.replace("--all", ""), 10) || 10;
1777
+ const args = auditArgs(rest);
1778
+ if ("usage" in args) {
1779
+ ctx.ui.notify(args.usage, "warning");
1780
+ return;
1781
+ }
1807
1782
  // This project's rows by default (rows without a cwd predate 0.1.3 and are shown too).
1808
- const rows = store.listAudit(limit, all ? undefined : (r) => !r.cwd || sameProject(r.cwd, session.cwd));
1783
+ const rows = store.listAudit(args.limit, args.all ? undefined : (r) => !r.cwd || sameProject(r.cwd, session.cwd));
1809
1784
  show(
1810
1785
  ctx,
1811
1786
  rows.length ? `Recent trigger audit (${rows.length}):` : "(no trigger audit entries)",
@@ -27,11 +27,15 @@ const HEAD_BYTES = 64 * 1024;
27
27
 
28
28
  export function readSessionHead(file: string, bytes: number = HEAD_BYTES): SessionHead | undefined {
29
29
  let text: string;
30
+ let readBytes = 0;
30
31
  try {
31
32
  const fd = fs.openSync(file, "r");
32
33
  try {
33
34
  const buf = Buffer.alloc(bytes);
34
- text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, bytes, 0));
35
+ // `bytes` is a byte window, so the decision to cut is the byte count read, not the
36
+ // decoded string's length: a multi-byte character makes those differ.
37
+ readBytes = fs.readSync(fd, buf, 0, bytes, 0);
38
+ text = buf.toString("utf8", 0, readBytes);
35
39
  } finally {
36
40
  fs.closeSync(fd);
37
41
  }
@@ -46,7 +50,7 @@ export function readSessionHead(file: string, bytes: number = HEAD_BYTES): Sessi
46
50
  return undefined;
47
51
  }
48
52
  if (header?.type !== "session" || typeof header.id !== "string") return undefined;
49
- const truncated = text.length >= bytes;
53
+ const truncated = readBytes >= bytes;
50
54
  let name: string | undefined;
51
55
  let first: string | undefined;
52
56
  let messages = 0;
package/src/triggers.ts CHANGED
@@ -406,6 +406,30 @@ export class TriggerStore {
406
406
  }
407
407
  }
408
408
 
409
+ export const AUDIT_USAGE = "usage: /triggers audit [N] [--all] (N is the number of rows, default 10)";
410
+
411
+ /**
412
+ * `/triggers audit [N] [--all]`, parsed where it can be tested: `src/pi-loops.ts` is the extension's
413
+ * default export, so nothing can import the handler. A limit the command cannot read is refused
414
+ * rather than silently replaced with the default, and a negative one is refused rather than handed
415
+ * to `listAudit`, where `slice(-limit)` stops meaning "limit" and prints the file from the top.
416
+ * `--all` lifts the project filter and may sit before or after the number.
417
+ */
418
+ export function auditArgs(rest: string): { limit: number; all: boolean } | { usage: string } {
419
+ let limit = 10;
420
+ let all = false;
421
+ let numbered = false;
422
+ for (const part of rest.split(/\s+/).filter(Boolean)) {
423
+ if (part === "--all") all = true;
424
+ else if (!numbered && /^\d+$/.test(part)) {
425
+ limit = Number(part);
426
+ numbered = true;
427
+ } else return { usage: AUDIT_USAGE };
428
+ }
429
+ if (limit < 1) return { usage: AUDIT_USAGE };
430
+ return { limit, all };
431
+ }
432
+
409
433
  /**
410
434
  * The control-plane prompt gate, pre-flight part. Prompt-class tool calls (create or remove
411
435
  * a trigger, re-enable a trigger or a cron job) need a human. Sub-agents have no prompt channel
package/src/web.mjs CHANGED
@@ -210,6 +210,8 @@ const STDERR_KEEP = 8000;
210
210
  /** What actually leaves this process: the tail, which is the part that says why pi stopped. */
211
211
  const STDERR_SEND = 4000;
212
212
  let stderrTail = "";
213
+ /** Characters the keep cut has already thrown away, so the send cut can count everything dropped. */
214
+ let stderrDropped = 0;
213
215
  /**
214
216
  * The shapes a credential takes in a line of output.
215
217
  *
@@ -258,14 +260,25 @@ function maskSecrets(text) {
258
260
  }
259
261
 
260
262
  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);
263
+ // Redact the whole kept tail, then cut by characters. `slice` counted UTF-16 code units, so the
264
+ // served tail could begin on the low half of an astral character — a replacement glyph where the
265
+ // error should start — and the page's own cap at 4000 could never fire: it was always handed 4000
266
+ // code units or fewer, and code points are never more numerous than code units, so nothing ever
267
+ // told the person anything was dropped. The count has to be put here rather than in the page,
268
+ // because `capped` keeps the first n characters and a tail keeps the last.
269
+ const kept = Array.from(maskSecrets(stderrTail.trim()));
270
+ if (!stderrDropped && kept.length <= STDERR_SEND) return kept.join("");
271
+ const room = STDERR_SEND - 40; // the marker line below is shorter than this
272
+ const shown = kept.slice(-room);
273
+ return `… (${stderrDropped + kept.length - shown.length} chars dropped)\n${shown.join("")}`;
265
274
  }
266
275
  pi.stderr.on("data", (d) => {
267
276
  process.stderr.write(d);
268
- stderrTail = (stderrTail + d.toString("utf8")).slice(-STDERR_KEEP);
277
+ // Characters, not code units: a cut on the code-unit half of an astral character leaves a lone
278
+ // surrogate in the tail, which `textContent` draws as a replacement glyph.
279
+ const all = Array.from(stderrTail + d.toString("utf8"));
280
+ stderrDropped += Math.max(0, all.length - STDERR_KEEP);
281
+ stderrTail = all.slice(-STDERR_KEEP).join("");
269
282
  });
270
283
 
271
284
  let nextId = 1;
@@ -502,10 +515,13 @@ function automation(cwd) {
502
515
  runCount: j.runCount ?? 0,
503
516
  lastError: maskSecrets(j.lastError),
504
517
  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.
518
+ // The open body of a card shows these, which the terminal's `/cron` line has and the panel
519
+ // did not; they leave the store as they were written, no reshaping.
507
520
  cwd: j.cwd,
508
521
  lastFiredAt: j.lastFiredAt,
522
+ // Due ticks a run was too slow to take. The `lastError` a skip writes is cleared by the next
523
+ // run; this count is not, so it is the last evidence a loop is losing cadence.
524
+ skippedOverlap: j.skippedOverlap ?? 0,
509
525
  // Set only when it is not this machine's: the panel says so, and nothing else has to guess.
510
526
  // What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
511
527
  // is the position in a numbered list — which this panel does not have, so telling someone to
@@ -822,11 +838,15 @@ function listSessionsHere(limit = 40) {
822
838
  /** The header, a name if one was set early, and the first thing a person said. */
823
839
  function readHead(file) {
824
840
  let text;
841
+ let readBytes = 0;
825
842
  try {
826
843
  const fd = fs.openSync(file, "r");
827
844
  try {
828
845
  const buf = Buffer.alloc(SESSION_HEAD);
829
- text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, SESSION_HEAD, 0));
846
+ // `SESSION_HEAD` is a byte window, so the decision to cut is the byte count read, not the
847
+ // decoded string's length: a multi-byte character makes those differ.
848
+ readBytes = fs.readSync(fd, buf, 0, SESSION_HEAD, 0);
849
+ text = buf.toString("utf8", 0, readBytes);
830
850
  } finally {
831
851
  fs.closeSync(fd);
832
852
  }
@@ -841,7 +861,7 @@ function readHead(file) {
841
861
  return undefined;
842
862
  }
843
863
  if (header?.type !== "session" || typeof header.id !== "string") return undefined;
844
- const cut = text.length >= SESSION_HEAD;
864
+ const cut = readBytes >= SESSION_HEAD;
845
865
  let name;
846
866
  let first;
847
867
  let messages = 0;
@@ -2024,15 +2044,29 @@ function previewRoot(file) {
2024
2044
  return undefined;
2025
2045
  }
2026
2046
 
2027
- /** The path the filesystem actually means: symlinks followed as far as anything exists. */
2047
+ /**
2048
+ * The path the filesystem actually means: every symlink above it resolved, whether or not the path
2049
+ * itself exists. The deepest ancestor that does exist is resolved and the rest re-attached.
2050
+ *
2051
+ * One level up was not enough. A job whose cwd is a removed worktree or a checkout that moved is two
2052
+ * or more levels below the deepest thing that exists, and a parent-only fallback returned the
2053
+ * unresolved path — so `sameProject` said "another project" about a directory plainly inside this
2054
+ * one, and the panel dropped a job `/cron` still lists with `[orphan: cwd missing]`. This is the
2055
+ * walk `src/paths.ts` does as `realpathish`, copied because the front end is one file with no
2056
+ * imports on purpose.
2057
+ */
2028
2058
  function realOf(file) {
2029
- try {
2030
- return fs.realpathSync(file);
2031
- } catch {
2059
+ if (!file) return "";
2060
+ let at = path.resolve(file);
2061
+ const tail = [];
2062
+ for (;;) {
2032
2063
  try {
2033
- return path.join(fs.realpathSync(path.dirname(file)), path.basename(file));
2064
+ return path.join(fs.realpathSync(at), ...tail);
2034
2065
  } catch {
2035
- return path.resolve(file);
2066
+ const parent = path.dirname(at);
2067
+ if (parent === at) return path.resolve(file); // reached the root and nothing resolved
2068
+ tail.unshift(path.basename(at));
2069
+ at = parent;
2036
2070
  }
2037
2071
  }
2038
2072
  }
@@ -3510,7 +3544,7 @@ function renderSidebar(s) {
3510
3544
  '<div class="body">' +
3511
3545
  '<pre class="full">' + prompt + "</pre>" +
3512
3546
  '<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>" +
3547
+ '<div class="m">id ' + safeText(j.id) + " · cwd " + safeText(j.cwd) + (j.lastFiredAt ? " · last fired " + safeText(j.lastFiredAt) : "") + (j.skippedOverlap ? " · skipped overlaps " + num(j.skippedOverlap) : "") + "</div>" +
3514
3548
  // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
3515
3549
  // and it says why nothing is happening rather than leaving you to find out from the silence.
3516
3550
  (j.lastError ? '<div class="m" style="color:#c66">' + safeText(j.lastError) + "</div>" : "") +
@@ -3750,7 +3784,7 @@ function renderRuntime(rt) {
3750
3784
  // An MCP server's name, its state and its last error are all that server's own words.
3751
3785
  html += "<div>" + dot(m.state) + safeText(m.name) + " <span class=\"m\">" + safeText(m.state) + " · " + countOf((m.tools || []).length, "tools", m.tools) +
3752
3786
  (m.injects ? " · injects" : "") + (m.queued ? " · " + num(m.queued) + " queued" : "") + "</span></div>" +
3753
- (m.lastError ? '<div class="m" style="color:#c66"> ' + safeText(str(m.lastError).slice(0, 120)) + "</div>" : "");
3787
+ (m.lastError ? '<div class="m" style="color:#c66"> ' + safeText(str(m.lastError)) + "</div>" : "");
3754
3788
  }
3755
3789
  if (rt.mcpConfigError) html += '<div class="m" style="color:#c66">mcp.toml: ' + safeText(rt.mcpConfigError) + "</div>";
3756
3790
  const h = rt.hooks || {};