@alphacoder-v0/pi-loops 0.22.2 → 0.22.3

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,17 @@ All notable changes to pi-loops are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.22.3] - 2026-09-16
9
+
10
+ ### Changed
11
+ - **A job or rule card opens instead of being cut off.** The Automation panel sliced a job's prompt
12
+ to ninety characters, a rule's condition and action to eighty, and the last error to one hundred
13
+ and twenty, with no way to see the rest. Every card is a `<details>` now — closed it shows a
14
+ two-line preview, open it shows the whole text and the fields the terminal's `/cron` line has —
15
+ and it keeps that open or closed state across the panel's eight-second redraw. The full prompt,
16
+ condition, action and last error are masked with the same secret patterns as the stderr tail
17
+ before they leave the server.
18
+
8
19
  ## [0.22.2] - 2026-09-16
9
20
 
10
21
  ### 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.3 # 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.3 # 或者从 GitHub 装,钉住一个 tag
49
49
  pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
50
50
  ```
51
51
 
@@ -143,6 +143,18 @@ weaker, and worth converting whenever one of them breaks.
143
143
  `test/web-page.test.ts`: *a next run in April does not render as a time of day*.
144
144
  - [x] The name of a job is never abbreviated: it is what `/cron set <name> …` takes, so a clipped
145
145
  one is a name you cannot act on. It wraps; the schedule beside it does not break in half.
146
+ - [x] **The whole prompt, condition and action are one click away, and a last error is one line
147
+ when closed and whole when open.** They used to be cut — a job's prompt at ninety characters,
148
+ a rule's condition and action at eighty, the error at one hundred and twenty — with no way to
149
+ see the rest, which made the panel a place where things looked shorter than they were. Each
150
+ card is a `<details>` now: closed, a two-line preview and, for a failing job, a one-line error
151
+ hint; open, the whole text and the fields the terminal's `/cron` line has. What is shown in
152
+ full is masked with the same secret patterns as the stderr tail, and a card keeps its open or
153
+ closed state across the panel's eight-second redraw.
154
+ `test/web-page.test.ts`: *a job's whole prompt is one click away, and stays open across a
155
+ redraw*; *a rule's condition and action are shown in full*; *a job with a last error says so
156
+ before it is opened*.
157
+ `test/web.test.ts`: *a key in a job's prompt does not reach the page*.
146
158
  - [x] Run a job now.
147
159
  - [x] Inbox count.
148
160
  - [x] Runtime: scheduler state and whether this pi owns the clock, MCP servers and their state,
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.3",
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/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);
@@ -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)}
@@ -3430,6 +3474,11 @@ function str(v) { return v === undefined || v === null ? "" : String(v); }
3430
3474
  */
3431
3475
  function safeText(v) { return esc(plain(str(v))); }
3432
3476
 
3477
+ // Which cards were open when the panel last drew. The panel is rebuilt as one string and assigned
3478
+ // every eight seconds, so the open flag has nowhere to live but here: read before the draw, restore
3479
+ // after it, and a card toggled between draws updates this at once through its own toggle event.
3480
+ const openCards = new Set();
3481
+
3433
3482
  function renderSidebar(s) {
3434
3483
  const a = s.automation || {};
3435
3484
  const box = $("auto");
@@ -3438,19 +3487,38 @@ function renderSidebar(s) {
3438
3487
  let html = "";
3439
3488
  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
3489
  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>" +
3490
+ // The prompt is shown twice — a two-line peek in the summary, the whole of it in the body —
3491
+ // rather than once and cut, which is what the 90-character slice was: a preview that hid the
3492
+ // rest with no way to see it. Both copies go through safeText; neither is sliced here.
3493
+ const prompt = safeText(j.prompt);
3494
+ html += '<details class="card job' + (j.enabled ? "" : " off") + '" data-key="job:' + safeText(j.id) + '">' +
3495
+ '<summary><span class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3442
3496
  '<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>" : "") +
3497
+ '<button data-run="' + safeText(j.id) + '">run</button></span>' +
3498
+ '<span class="m peek">' + prompt + "</span>" +
3499
+ '<span class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</span>" +
3500
+ // A one-line hint so a job that is failing says so before it is opened; the whole error is in
3501
+ // the body. It is a .peek like the prompt, hidden once the card is open.
3502
+ (j.lastError ? '<span class="m peek failing">' + safeText(j.lastError) + "</span>" : "") +
3503
+ (j.asleep ? '<span class="m" style="color:#c93">' + safeText(j.asleep) + "</span>" : "") +
3504
+ "</summary>" +
3505
+ '<div class="body">' +
3506
+ '<pre class="full">' + prompt + "</pre>" +
3507
+ '<button class="copy" data-copy="' + safeText(j.id) + '">copy</button>' +
3508
+ '<div class="m">id ' + safeText(j.id) + " · cwd " + safeText(j.cwd) + (j.lastFiredAt ? " · last fired " + safeText(j.lastFiredAt) : "") + "</div>" +
3447
3509
  // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
3448
3510
  // 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>";
3511
+ (j.lastError ? '<div class="m" style="color:#c66">' + safeText(j.lastError) + "</div>" : "") +
3512
+ "</div></details>";
3450
3513
  }
3451
3514
  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>";
3515
+ html += '<details class="card rule' + (r.enabled ? "" : " off") + '" data-key="rule:' + safeText(r.id) + '">' +
3516
+ '<summary><span class="t"><b>rule</b><span class="m">' + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
3517
+ '<span class="m peek">when ' + safeText(r.condition) + "</span></summary>" +
3518
+ '<div class="body">' +
3519
+ '<pre class="full">when ' + safeText(r.condition) + "</pre>" +
3520
+ '<pre class="full">→ ' + safeText(r.action) + "</pre>" +
3521
+ "</div></details>";
3454
3522
  }
3455
3523
  if (!a.jobs.length && !a.rules.length) html += '<div class="notice">no jobs or rules in this project</div>';
3456
3524
  // The store is machine-wide and this list is not: without these lines, a job made in another
@@ -3462,8 +3530,55 @@ function renderSidebar(s) {
3462
3530
  if (jobsAway) html += '<div class="notice">+ ' + jobsAway + " job" + (jobsAway === 1 ? "" : "s") + " in other projects — /cron all</div>";
3463
3531
  if (rulesAway) html += '<div class="notice">+ ' + rulesAway + " rule" + (rulesAway === 1 ? "" : "s") + " elsewhere — /triggers rules --all</div>";
3464
3532
  if (s.lastPoll) html += '<div class="notice">last check: ' + safeText(s.lastPoll.state || "") + " · " + esc(new Date(s.lastPoll.at).toLocaleTimeString()) + "</div>";
3533
+ // The open state survives the redraw by being read out before the panel is rebuilt and put back
3534
+ // after: the cards themselves are new elements each time, so the flag has to live in this set.
3535
+ box.querySelectorAll("details[data-key]").forEach((d) => {
3536
+ if (d.open) openCards.add(d.dataset.key); else openCards.delete(d.dataset.key);
3537
+ });
3465
3538
  box.innerHTML = html;
3466
- box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = () => api("/trigger/immediate", { id: b.dataset.run })));
3539
+ box.querySelectorAll("details[data-key]").forEach((d) => {
3540
+ if (openCards.has(d.dataset.key)) d.open = true;
3541
+ // The toggle event does not bubble, so it is heard per card, not once on the box: a card
3542
+ // closed by hand between draws stays closed when the next draw runs.
3543
+ d.addEventListener("toggle", () => {
3544
+ if (d.open) openCards.add(d.dataset.key); else openCards.delete(d.dataset.key);
3545
+ });
3546
+ });
3547
+ box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = (e) => {
3548
+ // A click anywhere in a summary toggles the card; the run button is a summary's own button,
3549
+ // not a way to open the prompt.
3550
+ e.preventDefault(); e.stopPropagation();
3551
+ api("/trigger/immediate", { id: b.dataset.run });
3552
+ }));
3553
+ box.querySelectorAll("[data-copy]").forEach((b) => {
3554
+ const card = b.closest?.("details[data-key]");
3555
+ b.type = "button";
3556
+ // Reads the prompt back from the DOM rather than holding a second copy in JS, the way the
3557
+ // feed's tool rows do, so the two can never fall out of step.
3558
+ b.onclick = async (e) => {
3559
+ e.stopPropagation();
3560
+ const text = card?.querySelector?.("pre.full")?.textContent ?? "";
3561
+ try {
3562
+ if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(text);
3563
+ else {
3564
+ const ta = document.createElement("textarea");
3565
+ ta.value = text;
3566
+ ta.style.position = "fixed";
3567
+ ta.style.opacity = "0";
3568
+ document.body.append(ta);
3569
+ ta.focus();
3570
+ ta.select();
3571
+ let ok = false;
3572
+ try { ok = document.execCommand("copy"); } finally { ta.remove(); }
3573
+ if (!ok) throw new Error("copy refused");
3574
+ }
3575
+ b.textContent = "copied";
3576
+ } catch (_) {
3577
+ b.textContent = "copy failed";
3578
+ }
3579
+ setTimeout(() => { b.textContent = "copy"; }, 1400);
3580
+ };
3581
+ });
3467
3582
  }
3468
3583
  renderRuntime(s.runtime);
3469
3584
  $("goal").textContent = s.goal ? plain((s.goal.condition || "") + " — " + (s.goal.status || "") + " (" + (s.goal.iterations ?? 0) + ")") : "none";