@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 +11 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/web-ui-parity.md +12 -0
- package/package.json +1 -1
- package/src/web.mjs +133 -18
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.
|
|
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.
|
|
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
|
|
package/docs/web-ui-parity.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
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></
|
|
3444
|
-
'<
|
|
3445
|
-
'<
|
|
3446
|
-
|
|
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(
|
|
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 += '<
|
|
3453
|
-
'<
|
|
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-
|
|
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";
|