@alphacoder-v0/pi-loops 0.22.1 → 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 +34 -0
- package/README.md +8 -6
- package/README.zh-CN.md +5 -5
- package/docs/cli.md +13 -4
- package/docs/design.md +25 -0
- package/docs/web-ui-parity.md +12 -0
- package/package.json +1 -1
- package/src/cli.ts +139 -19
- package/src/pi-loops.ts +50 -1
- package/src/ui-prefs.ts +22 -0
- package/src/web.mjs +133 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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
|
+
|
|
19
|
+
## [0.22.2] - 2026-09-16
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **The extension puts `pi-loops` on your PATH, instead of you typing a node path out of the
|
|
23
|
+
README.** `pi install` leaves this package under pi's directory and nothing on your `PATH`, so
|
|
24
|
+
`pi-loops` only existed after `install-launcher` had been run once — and the first time there was
|
|
25
|
+
no `pi-loops` to run it with, which is why the install instructions ended in a bare `node
|
|
26
|
+
~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops/src/cli-entry.mjs install-launcher`. The
|
|
27
|
+
launcher that wrote then went stale by itself when pi reinstalled the package by the other route
|
|
28
|
+
(0.22.1). pi loads this extension at every session start and knows, at that moment, both where it
|
|
29
|
+
runs and where a launcher would go: so the first session that finds none anywhere on your `PATH`
|
|
30
|
+
asks once — the same question `/pi-loops install-launcher` asks — and a no is remembered in
|
|
31
|
+
`ui.json` and never asked again. A launcher of ours is rewritten at the next start in two cases
|
|
32
|
+
and only two — the copy it names is not on disk any more, or the copy it names is this one and the
|
|
33
|
+
node or the shape of the script changed — with one line saying that it was and why; one naming
|
|
34
|
+
another copy that is still there belongs to that copy, so an install and a checkout on the same
|
|
35
|
+
machine never trade the file back and forth. Nothing else is touched: a `pi-loops` this project
|
|
36
|
+
did not write is left alone, a checkout is never asked for a launcher that is not there (one
|
|
37
|
+
written from a checkout is still kept current), and `/pi-loops install-launcher` still writes one
|
|
38
|
+
whenever you want it, clearing the remembered no. The script itself now also lists the copy it
|
|
39
|
+
runs once rather than twice, which is what an installed copy — where the entry it records is one
|
|
40
|
+
of pi's own two locations — used to get.
|
|
41
|
+
|
|
8
42
|
## [0.22.1] - 2026-09-16
|
|
9
43
|
|
|
10
44
|
### 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.
|
|
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
|
```
|
|
@@ -184,22 +184,24 @@ you are at the machine, and hands it back the next time you open pi (`/cron host
|
|
|
184
184
|
## The browser window, and the command line
|
|
185
185
|
|
|
186
186
|
`pi-loops` is the part that needs a launcher: `pi install` puts the package under pi's managed
|
|
187
|
-
directory rather than on your `PATH`, so the command does not exist yet
|
|
188
|
-
`install
|
|
187
|
+
directory rather than on your `PATH`, so the command does not exist yet. You are not asked to fix
|
|
188
|
+
that by hand — the first pi session after `pi install` offers to put `pi-loops` on your `PATH`, and
|
|
189
|
+
keeps that launcher working afterwards if pi ever moves the package. Say no and nothing is written,
|
|
190
|
+
and you are not asked again; `/pi-loops install-launcher` writes one whenever you change your mind:
|
|
189
191
|
|
|
190
192
|
```text
|
|
191
193
|
/pi-loops install-launcher # from inside pi, where the extension is already loaded
|
|
192
194
|
```
|
|
193
195
|
|
|
194
196
|
```bash
|
|
195
|
-
# or from a shell,
|
|
197
|
+
# or from a shell, on a machine where pi is never opened — the same command, by absolute path
|
|
196
198
|
cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # installed from npm
|
|
197
199
|
cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # installed from GitHub
|
|
198
200
|
node src/cli-entry.mjs install-launcher
|
|
199
201
|
```
|
|
200
202
|
|
|
201
|
-
|
|
202
|
-
your `PATH`, and refuses rather than guessing if neither is — pass `--dir <dir>` to say where.
|
|
203
|
+
Any of them writes a launcher into the first of `~/.local/bin` and `/usr/local/bin` that is already
|
|
204
|
+
on your `PATH`, and refuses rather than guessing if neither is — pass `--dir <dir>` to say where.
|
|
203
205
|
After that, `pi-loops` works from anywhere:
|
|
204
206
|
|
|
205
207
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -45,28 +45,28 @@ 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
|
|
|
52
52
|
**几种装法挑一种,不要装两份。** 两份副本注册同名工具,pi 会拒绝加载第二份并直接退出(`Tool "cron_create" conflicts with …`)。如果你在改这份代码,留本地检出那份。
|
|
53
53
|
|
|
54
|
-
重启 pi
|
|
54
|
+
重启 pi,装到这里就够了——那四个斜杠命令现在就能用。下面这段只跟 `pi-loops` 这个命令有关:浏览器窗口和几个 shell 子命令靠它,不要浏览器窗口可以先跳过。
|
|
55
55
|
|
|
56
|
-
`pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops`
|
|
56
|
+
`pi install` 把包放在 pi 自己的托管目录里、**不进 `PATH`**,所以此刻 `pi-loops` 这个命令还不存在。这一步不用你自己动手:`pi install` 之后的第一个 pi 会话会问你要不要把 `pi-loops` 放进 `PATH`,写下之后也一直替你维护着——包被 pi 挪到另一个位置,下一个会话会把启动器改回能用的样子,并用一行告诉你改了什么。答 no 就什么都不写,也不会再问第二次;改了主意,`/pi-loops install-launcher` 随时给你写一个:
|
|
57
57
|
|
|
58
58
|
```text
|
|
59
59
|
/pi-loops install-launcher # 在 pi 里面,扩展本来就加载着
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
# 或者在 shell
|
|
63
|
+
# 或者在 shell 里用绝对路径跑同一个命令——给那种根本不开 pi 的机器留的路
|
|
64
64
|
cd ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops # 从 npm 装的
|
|
65
65
|
cd ~/.pi/agent/git/github.com/alphacoder-v0/pi-loops # 从 GitHub 装的
|
|
66
66
|
node src/cli-entry.mjs install-launcher
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
几条路都会往 `~/.local/bin`(或其它已在 `PATH` 里的目录,用 `--dir` 指定)写一个启动器。之后 `pi-loops` 在任何目录都能用。
|
|
70
70
|
|
|
71
71
|
### 3. 开一个会话
|
|
72
72
|
|
package/docs/cli.md
CHANGED
|
@@ -153,16 +153,25 @@ by the id in its header.
|
|
|
153
153
|
## Getting the command onto your PATH
|
|
154
154
|
|
|
155
155
|
`pi install` puts this package under pi's managed directory rather than on your `PATH`, so the
|
|
156
|
-
command that is meant to start your sessions is at first reachable only by absolute path
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
command that is meant to start your sessions is at first reachable only by absolute path. The
|
|
157
|
+
extension does that step for you: the first pi session that finds no `pi-loops` anywhere on your
|
|
158
|
+
`PATH` asks once whether to write one, and a no is remembered — nothing is written and you are not
|
|
159
|
+
asked again. Afterwards it keeps that launcher current: if the copy it runs is not there any more
|
|
160
|
+
(pi installed the package by the other route), or that copy is the one now running and the node has
|
|
161
|
+
changed, the next session rewrites it and says so in one line. A `pi-loops` this project did not
|
|
162
|
+
write — your own wrapper — is left alone, and so is one that runs another copy of this package still
|
|
163
|
+
on disk, which is that copy's to keep. A checkout is never asked to write a launcher that is not
|
|
164
|
+
there, though one written from a checkout is still kept working.
|
|
165
|
+
|
|
166
|
+
To write one yourself, or after a no, run it from inside pi, where the extension is already loaded:
|
|
159
167
|
|
|
160
168
|
```text
|
|
161
169
|
/pi-loops install-launcher
|
|
162
170
|
```
|
|
163
171
|
|
|
164
172
|
or from the directory pi installed the package into — `~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops`
|
|
165
|
-
for a `pi install npm:` package, `~/.pi/agent/git/<host>/<owner>/<repo>` for a `pi install git:` one
|
|
173
|
+
for a `pi install npm:` package, `~/.pi/agent/git/<host>/<owner>/<repo>` for a `pi install git:` one,
|
|
174
|
+
which is the way in on a machine where pi itself is never opened:
|
|
166
175
|
|
|
167
176
|
```bash
|
|
168
177
|
node src/cli-entry.mjs install-launcher
|
package/docs/design.md
CHANGED
|
@@ -174,6 +174,31 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
174
174
|
The cost: two machines syncing one `$HOME` both run every job. That is not supported, and it
|
|
175
175
|
is said rather than half-handled.
|
|
176
176
|
|
|
177
|
+
20. **The extension keeps the launcher.** `pi install` puts this package under pi's directory and
|
|
178
|
+
nothing on your PATH, so `pi-loops` existed only after someone ran `install-launcher` once — and
|
|
179
|
+
the first time there was no `pi-loops` to run it with, which is why the README handed people a
|
|
180
|
+
bare `node ~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops/src/cli-entry.mjs
|
|
181
|
+
install-launcher`. The launcher then went stale by itself: a package reinstalled by the other
|
|
182
|
+
route moved, and every `pi-loops` after that ended in Node's `Cannot find module` (0.22.1). Both
|
|
183
|
+
endings were a path a person had to type out. pi loads this extension at every session start,
|
|
184
|
+
and at that moment it knows where it runs and where a launcher would go — so `session_start`
|
|
185
|
+
does it. A launcher of ours is rewritten in two cases and only two: the copy it names is not on
|
|
186
|
+
disk any more (the package moved, which is the reading that ends in `Cannot find module`), or
|
|
187
|
+
the copy it names is this one and the node or the shape of the script has changed. Either way
|
|
188
|
+
one line says that it was rewritten and why. One naming another copy that is still there is that
|
|
189
|
+
copy's to keep current, so an install and a checkout both being opened never trade the file back
|
|
190
|
+
and forth. With no launcher anywhere, the question `install-launcher` already asks is asked once,
|
|
191
|
+
and a no is remembered in `ui.json` and never asked again; `/pi-loops install-launcher` still
|
|
192
|
+
writes one whenever you want it, and clears that mark. A `pi-loops` carrying no marker of ours
|
|
193
|
+
is somebody's own wrapper and is left alone, and a checkout is never asked for one that is not
|
|
194
|
+
there — nobody ran `pi install`. The decision is `launcherState` / `refreshLauncher` in
|
|
195
|
+
[../src/cli.ts](../src/cli.ts), beside the one function that renders the script, so it is
|
|
196
|
+
testable without pi; the extension is the caller.
|
|
197
|
+
|
|
198
|
+
The cost: a write outside the loops directory, which the Non-invasive invariant now names as its
|
|
199
|
+
one exception, and a question on somebody's first start — an interruption this project otherwise
|
|
200
|
+
does not make.
|
|
201
|
+
|
|
177
202
|
## Where the browser front end came from
|
|
178
203
|
|
|
179
204
|
pi owns the `pi` command and its terminal, so a second front end cannot replace the first one. It
|
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/cli.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { FINDING_FIELDS, Inbox, type InboxEntry, inProject, resolveInboxRef } fr
|
|
|
21
21
|
import { sameProject } from "./presence.ts";
|
|
22
22
|
import { redact } from "./redact.ts";
|
|
23
23
|
import { liveHost, stopHost } from "./host-control.ts";
|
|
24
|
-
import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
|
|
24
|
+
import { type UiPrefs, clearUiPref, readUiPrefs } from "./ui-prefs.ts";
|
|
25
25
|
import { JobStore, defaultLoopsDir } from "./store.ts";
|
|
26
26
|
import { automationBadge, previewText, readSessionHead } from "./session-head.ts";
|
|
27
27
|
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
|
|
@@ -828,18 +828,48 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
|
|
|
828
828
|
return 1;
|
|
829
829
|
}
|
|
830
830
|
if (dir && !isOnPath(target)) out(`note: ${target} is not on your PATH, so the command will not be found there yet`);
|
|
831
|
-
const
|
|
831
|
+
const { entryDir, node, agentDir } = launcherSource();
|
|
832
832
|
const file = path.join(target, "pi-loops");
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
833
|
+
try {
|
|
834
|
+
fs.mkdirSync(target, { recursive: true });
|
|
835
|
+
fs.writeFileSync(file, launcherScript(entryDir, node, agentDir), { mode: 0o755 });
|
|
836
|
+
fs.chmodSync(file, 0o755);
|
|
837
|
+
} catch (err: any) {
|
|
838
|
+
out(`could not write ${file}: ${err?.message ?? err}`);
|
|
839
|
+
return 1;
|
|
840
|
+
}
|
|
841
|
+
// Whatever a session start was told once, this is the answer now: a launcher exists because
|
|
842
|
+
// someone asked for it, so the offer is live again if the file ever goes.
|
|
843
|
+
clearUiPref(defaultLoopsDir(getAgentDir()), "launcher");
|
|
844
|
+
out(`wrote ${file}`);
|
|
845
|
+
out(` it runs ${path.join(entryDir, "cli-entry.mjs")}`);
|
|
846
|
+
out(" `pi-loops` now starts a session; `pi-loops --tui` opens the terminal one instead");
|
|
847
|
+
return 0;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** The marker line that says a `pi-loops` on someone's PATH is one this project wrote. */
|
|
851
|
+
const LAUNCHER_MARKER = "# pi-loops launcher, written by";
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The launcher, as text. The one place the script is spelled: `installLauncher` writes this, and a
|
|
855
|
+
* session start compares what is on disk against it, so the two cannot drift into scripts that
|
|
856
|
+
* differ by a tab and call each other stale for ever.
|
|
857
|
+
*
|
|
858
|
+
* A launcher rather than a symlink: it names the node that is running now, which is the one known
|
|
859
|
+
* to be new enough for this code. What it survives is this package being installed again by the
|
|
860
|
+
* other route — `pi install npm:` over a `git:` copy moves it, and a launcher that knew only where
|
|
861
|
+
* it used to be died in Node's `Cannot find module`. So the entry it recorded is the first place it
|
|
862
|
+
* looks and not the only one, and the recorded node is a path it checks rather than trusts. On a
|
|
863
|
+
* copy pi installed, that recorded entry *is* one of the two it falls back to, so the duplicate is
|
|
864
|
+
* dropped rather than printed twice in a file people read.
|
|
865
|
+
*/
|
|
866
|
+
export function launcherScript(entryDir: string, node: string, agentDir: string): string {
|
|
867
|
+
const entry = path.join(entryDir, "cli-entry.mjs");
|
|
868
|
+
const candidates = [entry, ...piInstallEntries(agentDir).filter((e) => e !== entry)].map((e) => JSON.stringify(e)).join(" ");
|
|
869
|
+
return [
|
|
840
870
|
"#!/bin/sh",
|
|
841
|
-
|
|
842
|
-
`node=${JSON.stringify(
|
|
871
|
+
`${LAUNCHER_MARKER} \`pi-loops install-launcher\`.`,
|
|
872
|
+
`node=${JSON.stringify(node)}`,
|
|
843
873
|
'[ -x "$node" ] || node=$(command -v node) || node=node',
|
|
844
874
|
`for entry in ${candidates}; do`,
|
|
845
875
|
'\t[ -f "$entry" ] || continue',
|
|
@@ -849,18 +879,108 @@ export async function installLauncher(dir: string | undefined, out: (line: strin
|
|
|
849
879
|
"exit 1",
|
|
850
880
|
"",
|
|
851
881
|
].join("\n");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** What a launcher for *this* copy is written from, so a caller needs to know none of the three. */
|
|
885
|
+
export function launcherSource(): { entryDir: string; node: string; agentDir: string } {
|
|
886
|
+
return { entryDir: path.dirname(fileURLToPath(import.meta.url)), node: process.execPath, agentDir: getAgentDir() };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Whether pi installed this copy, by either route. The same two locations `upgradeSpec` reads — it
|
|
891
|
+
* is asked through it so the two can never disagree — and the tag is a placeholder, only the
|
|
892
|
+
* yes-or-no is used. A checkout is not an install: nobody ran `pi install`, so nobody is waiting
|
|
893
|
+
* for a command that was never put on their PATH.
|
|
894
|
+
*/
|
|
895
|
+
export function installedByPi(): boolean {
|
|
896
|
+
return upgradeSpec(packageRoot(), manifest().name ?? "", getAgentDir(), "v0.0.0") !== undefined;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export type LauncherState = "absent" | "current" | "stale" | "other" | "foreign";
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* What is at `<target>/pi-loops`, as far as this copy is concerned: nothing at all (`absent`), a
|
|
903
|
+
* launcher this project wrote that matches this node and this copy (`current`), one of ours that
|
|
904
|
+
* this copy should rewrite (`stale`), one of ours that belongs to another copy still on disk
|
|
905
|
+
* (`other`), or a file carrying no marker of ours — someone's own wrapper, which is not ours to
|
|
906
|
+
* rewrite either (`foreign`).
|
|
907
|
+
*
|
|
908
|
+
* It takes the three things a launcher is written from rather than reading them, so the decision is
|
|
909
|
+
* a function of its arguments and the session-start caller is a few lines.
|
|
910
|
+
*/
|
|
911
|
+
export function launcherState(entryDir: string, node: string, agentDir: string, target: string): { file: string; state: LauncherState } {
|
|
912
|
+
const file = path.join(target, "pi-loops");
|
|
913
|
+
const found = readLauncher(file);
|
|
914
|
+
if (found === undefined) return { file, state: "absent" };
|
|
915
|
+
if (!found.includes(LAUNCHER_MARKER)) return { file, state: "foreign" };
|
|
916
|
+
if (found === launcherScript(entryDir, node, agentDir)) return { file, state: "current" };
|
|
917
|
+
return { file, state: oursToRewrite(found, entryDir) ? "stale" : "other" };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Whether a launcher of ours that no longer matches is this copy's to rewrite. Two cases, and only
|
|
922
|
+
* these two: the entry it recorded is gone — the package moved, which is the reading that ends in
|
|
923
|
+
* `Cannot find module` — or that entry is this copy's own, so the node or the shape of the script
|
|
924
|
+
* changed under it. A launcher recording another copy that is still there belongs to that copy: an
|
|
925
|
+
* install and a checkout both get opened, and two live copies each rewriting the file to point at
|
|
926
|
+
* itself would trade it back and forth for ever, with a line about it every time.
|
|
927
|
+
*/
|
|
928
|
+
function oursToRewrite(script: string, entryDir: string): boolean {
|
|
929
|
+
const recorded = launcherRecords(script).entries[0];
|
|
930
|
+
// One of ours recording nothing this can read is nobody else's either, so it is ours to fix.
|
|
931
|
+
if (recorded === undefined) return true;
|
|
932
|
+
return recorded === path.join(entryDir, "cli-entry.mjs") || !fs.existsSync(recorded);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** The file, or undefined when there is nothing there to read — which is the same news. */
|
|
936
|
+
function readLauncher(file: string): string | undefined {
|
|
852
937
|
try {
|
|
853
|
-
fs.
|
|
854
|
-
|
|
938
|
+
return fs.readFileSync(file, "utf8");
|
|
939
|
+
} catch {
|
|
940
|
+
// Missing, a directory, unreadable: in all three there is no launcher of ours here.
|
|
941
|
+
return undefined;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Keep a launcher of ours current, and say what changed. Undefined when there is nothing to do —
|
|
947
|
+
* no launcher, somebody else's, another copy's, or already this one — so this is safe to call on
|
|
948
|
+
* every start.
|
|
949
|
+
*
|
|
950
|
+
* It writes exactly one file, and only one that is already a launcher of ours: nothing new appears
|
|
951
|
+
* anywhere from a start nobody answered a question at.
|
|
952
|
+
*/
|
|
953
|
+
export function refreshLauncher(entryDir: string, node: string, agentDir: string, target: string): { message: string; ok: boolean } | undefined {
|
|
954
|
+
const { file, state } = launcherState(entryDir, node, agentDir, target);
|
|
955
|
+
if (state !== "stale") return undefined;
|
|
956
|
+
const why = staleReason(readLauncher(file) ?? "", entryDir, node);
|
|
957
|
+
try {
|
|
958
|
+
fs.writeFileSync(file, launcherScript(entryDir, node, agentDir), { mode: 0o755 });
|
|
855
959
|
fs.chmodSync(file, 0o755);
|
|
856
960
|
} catch (err: any) {
|
|
857
|
-
|
|
858
|
-
return 1;
|
|
961
|
+
return { ok: false, message: `could not refresh the \`pi-loops\` launcher at ${file}: ${err?.message ?? err}` };
|
|
859
962
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
963
|
+
return { ok: true, message: `refreshed the \`pi-loops\` launcher at ${file}: ${why}` };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** The node and the entries a launcher records, from this script or from the one 0.22.0 wrote. */
|
|
967
|
+
function launcherRecords(script: string): { node?: string; entries: string[] } {
|
|
968
|
+
const quoted = (s: string): string[] => (s.match(/"(?:[^"\\]|\\.)*"/g) ?? []).map((q) => JSON.parse(q) as string);
|
|
969
|
+
const node = /^node=("(?:[^"\\]|\\.)*")$/m.exec(script)?.[1];
|
|
970
|
+
const list = /^for entry in (.*); do$/m.exec(script)?.[1];
|
|
971
|
+
if (node !== undefined && list !== undefined) return { node: JSON.parse(node) as string, entries: quoted(list) };
|
|
972
|
+
// Older launchers are one `exec <node> <entry> "$@"` line and nothing else.
|
|
973
|
+
const exec = quoted(/^exec (.*)$/m.exec(script)?.[1] ?? "");
|
|
974
|
+
return { node: exec[0], entries: exec[1] ? [exec[1]] : [] };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Why a launcher of ours no longer matches — the half-sentence a person gets told. */
|
|
978
|
+
function staleReason(old: string, entryDir: string, node: string): string {
|
|
979
|
+
const rec = launcherRecords(old);
|
|
980
|
+
if (rec.node === undefined && !rec.entries.length) return "this version of pi-loops writes it differently";
|
|
981
|
+
if (rec.entries[0] !== path.join(entryDir, "cli-entry.mjs")) return "the package moved";
|
|
982
|
+
if (rec.node !== node) return "the node it runs changed";
|
|
983
|
+
return "this version of pi-loops writes it differently";
|
|
864
984
|
}
|
|
865
985
|
|
|
866
986
|
/**
|
package/src/pi-loops.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { McpSource, PI_BUILTIN_TOOL_NAMES, type McpServerConfig, type McpToolDef
|
|
|
33
33
|
import { capRedacted, previewRedacted, redact } from "./redact.ts";
|
|
34
34
|
import { shouldEmitSnapshot, snapshotFingerprint } from "./snapshot.ts";
|
|
35
35
|
import { type ShareMessage, renderShare, shareSummary } from "./share.ts";
|
|
36
|
-
import { installLauncherWithConfirm } from "./cli.ts";
|
|
36
|
+
import { installLauncherWithConfirm, installedByPi, launcherSource, launcherState, launcherTarget, refreshLauncher } from "./cli.ts";
|
|
37
37
|
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";
|
|
@@ -2484,6 +2484,52 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2484
2484
|
|
|
2485
2485
|
/* -------------------------------------------------------- lifecycle */
|
|
2486
2486
|
|
|
2487
|
+
/**
|
|
2488
|
+
* The `pi-loops` command, kept on the PATH of whoever asked for it there.
|
|
2489
|
+
*
|
|
2490
|
+
* `pi install` puts this package under pi's directory and nothing on the PATH, so until 0.22.1
|
|
2491
|
+
* the first thing a new user had to do was type a bare `node …/cli-entry.mjs install-launcher`
|
|
2492
|
+
* out of the README — the one step that only existed because the command could not yet run
|
|
2493
|
+
* itself. pi loads this extension at every session start, and at that moment it knows both where
|
|
2494
|
+
* it runs and where a launcher would go, so the step belongs here.
|
|
2495
|
+
*
|
|
2496
|
+
* This is the extension's only write outside the loops directory (AGENTS.md). Its limit is one
|
|
2497
|
+
* file, `pi-loops` in a directory already on the PATH, and only where a launcher of ours already
|
|
2498
|
+
* is or after a yes. A file with no marker of ours is somebody's own wrapper and is left alone;
|
|
2499
|
+
* one naming another copy of this package that is still on disk is that copy's to keep current,
|
|
2500
|
+
* so an install and a checkout both being opened never trade the file back and forth; a no is
|
|
2501
|
+
* remembered in `ui.json`, so it is asked once rather than in every window; and a checkout is
|
|
2502
|
+
* never asked for a launcher that is not there at all — nobody ran `pi install`, so nothing is
|
|
2503
|
+
* missing from their PATH — though one written from a checkout is still kept working.
|
|
2504
|
+
*/
|
|
2505
|
+
async function keepLauncher(ctx: ExtensionContext): Promise<void> {
|
|
2506
|
+
const { entryDir, node, agentDir } = launcherSource();
|
|
2507
|
+
const target = launcherTarget(undefined);
|
|
2508
|
+
// Nothing on the PATH to write into. `/pi-loops install-launcher`, run by hand, is where that
|
|
2509
|
+
// is explained; a session start saying it unprompted would be a warning about nothing.
|
|
2510
|
+
if (!target) return;
|
|
2511
|
+
const { state } = launcherState(entryDir, node, agentDir, target);
|
|
2512
|
+
if (state === "stale") {
|
|
2513
|
+
const done = refreshLauncher(entryDir, node, agentDir, target);
|
|
2514
|
+
if (!done) return;
|
|
2515
|
+
log.info(`launcher: ${done.message}`);
|
|
2516
|
+
// No question: they asked for a launcher once, and keeping that one working is what they
|
|
2517
|
+
// asked for. The line is so that a file changing under them is never a silent change.
|
|
2518
|
+
if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${done.message}`, done.ok ? "info" : "warning");
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
if (state !== "absent") return; // ours and current, another copy's, or somebody else's entirely
|
|
2522
|
+
if (!ctx.hasUI || readUiPrefs(dir).launcher === "declined" || !installedByPi()) return;
|
|
2523
|
+
const lines: string[] = [];
|
|
2524
|
+
const code = await installLauncherWithConfirm([], (title, body) => ctx.ui.confirm(title, body), (l) => lines.push(l));
|
|
2525
|
+
if (code === undefined) {
|
|
2526
|
+
writeUiPref(dir, "launcher", "declined");
|
|
2527
|
+
ctx.ui.notify("[pi-loops] not installed — `/pi-loops install-launcher` writes one whenever you want it", "info");
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
ctx.ui.notify(`[pi-loops] ${lines.map((l) => l.trim()).join(" · ")}`, code === 0 ? "info" : "warning");
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2487
2533
|
pi.on("session_start", async (_event, ctx) => {
|
|
2488
2534
|
lastCtx = ctx;
|
|
2489
2535
|
if (startHandled) return; // the same start, delivered a second time by a second rebind
|
|
@@ -2506,6 +2552,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2506
2552
|
loadMcpConfig(ctx.isProjectTrusted());
|
|
2507
2553
|
for (const d of mcpDiagnostics) if (ctx.hasUI) ctx.ui.notify(`[pi-loops] ${d}`, "warning");
|
|
2508
2554
|
await startMcpSources(); // tools for every process; pushes are consumed by interactive processes only
|
|
2555
|
+
// Not awaited: the question below is a dialog, and the clock must not wait behind someone who
|
|
2556
|
+
// has walked away from it.
|
|
2557
|
+
void keepLauncher(ctx).catch((err: any) => log.info(`launcher: ${err?.message ?? err}`));
|
|
2509
2558
|
// tui and rpc processes stay alive and host the timer; `PI_LOOPS_HOST=1` lets a `pi -p`
|
|
2510
2559
|
// run (a long headless prompt) host it too.
|
|
2511
2560
|
const hostMode = ctx.mode === "tui" || ctx.mode === "rpc" || envFlag("LOOPS_HOST");
|
package/src/ui-prefs.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface UiPrefs {
|
|
|
25
25
|
/** The model and thinking level last chosen in the browser front end. */
|
|
26
26
|
model?: string;
|
|
27
27
|
thinking?: string;
|
|
28
|
+
/**
|
|
29
|
+
* `"declined"` once someone has said no to the launcher a session start offers, so the offer is
|
|
30
|
+
* made once and not in every window. `/pi-loops install-launcher` clears it when it writes one.
|
|
31
|
+
*/
|
|
32
|
+
launcher?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
const fileIn = (dir: string): string => path.join(dir, "ui.json");
|
|
@@ -52,6 +57,7 @@ export function readUiPrefs(dir: string): UiPrefs {
|
|
|
52
57
|
if (typeof doc.panel === "boolean") prefs.panel = doc.panel;
|
|
53
58
|
if (typeof doc.model === "string") prefs.model = doc.model;
|
|
54
59
|
if (typeof doc.thinking === "string") prefs.thinking = doc.thinking;
|
|
60
|
+
if (typeof doc.launcher === "string") prefs.launcher = doc.launcher;
|
|
55
61
|
return prefs;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -75,6 +81,22 @@ export function writeUiPref<K extends keyof UiPrefs>(dir: string, key: K, value:
|
|
|
75
81
|
const doc = readRaw(dir);
|
|
76
82
|
if (doc[key] === value) return;
|
|
77
83
|
doc[key] = value;
|
|
84
|
+
save(dir, doc);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Forget one preference, keeping every other key. A remembered "no" has to be forgettable by the
|
|
89
|
+
* thing that answers it — a launcher written by hand is the yes the session-start question was
|
|
90
|
+
* asking for — and removing the key is what "nobody has answered" is spelled as here.
|
|
91
|
+
*/
|
|
92
|
+
export function clearUiPref(dir: string, key: keyof UiPrefs): void {
|
|
93
|
+
const doc = readRaw(dir);
|
|
94
|
+
if (!(key in doc)) return;
|
|
95
|
+
delete doc[key];
|
|
96
|
+
save(dir, doc);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function save(dir: string, doc: Record<string, unknown>): void {
|
|
78
100
|
try {
|
|
79
101
|
writeFileAtomic(fileIn(dir), `${JSON.stringify(doc, null, 2)}\n`);
|
|
80
102
|
} catch {
|
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";
|