@alphacoder-v0/pi-loops 0.22.4 → 0.22.6
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 +58 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -1
- package/docs/cli.md +3 -1
- package/docs/design.md +7 -5
- package/docs/web-ui-parity.md +6 -3
- package/package.json +1 -1
- package/src/cli.ts +7 -1
- package/src/goal.ts +5 -1
- package/src/job-lines.ts +68 -0
- package/src/pi-loops.ts +15 -40
- package/src/sdk-runner.ts +6 -9
- package/src/session-head.ts +6 -2
- package/src/triggers.ts +24 -0
- package/src/web.mjs +51 -17
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,64 @@ All notable changes to pi-loops are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.22.6] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **A finished loop run no longer leaves the shared extensions stale.** Every run in one project
|
|
12
|
+
shares one loaded extension set, and a finished run's `session.dispose()` invalidated the
|
|
13
|
+
runtime those extensions captured at load time. The restore that was meant to undo it read a
|
|
14
|
+
flag pi keeps in a closure, so it never did anything: from the second run on, every `pi.*`
|
|
15
|
+
call from a shared extension threw "This extension ctx is stale", an extension that gates
|
|
16
|
+
tool calls (pi-permission-system, say) blocked every tool call, and the event-bus listeners
|
|
17
|
+
the extensions registered at load were gone. The shared runtime is now never invalidated; each
|
|
18
|
+
run's own ExtensionRunner still is. The cost: a listener an extension adds inside a per-run
|
|
19
|
+
handler is kept for the life of the host. `test/sdk-runner.test.ts`: *a finished run does not
|
|
20
|
+
leave the shared extensions stale for the next run*. [#81](https://github.com/alphacoder-v0/pi-loops/pull/81)
|
|
21
|
+
by [@advaitpaliwal](https://github.com/advaitpaliwal).
|
|
22
|
+
|
|
23
|
+
## [0.22.5] - 2026-09-28
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Both READMEs say what `/cron remove` reaches.** The command tables put `remove` in one row
|
|
27
|
+
with `enable` and `disable`, which resolve a name across the machine; removal stopped doing
|
|
28
|
+
that in 0.22.4 and the row never said so. It now states the rule: a name of this project, or
|
|
29
|
+
the exact id of a job anywhere.
|
|
30
|
+
- **The design principles say when a job disappears.** Principle 11 said a job goes when you
|
|
31
|
+
remove it "and not before", which a one-shot that has fired and a job `/cron gc` collects
|
|
32
|
+
both contradict. It now names all three, and says none of them is time passing.
|
|
33
|
+
- **`/cron` says `skipped overlaps`.** The listing called the counter `overlap skips`, while the
|
|
34
|
+
terminal panel and docs/loops.md call it `skipped overlaps`. The listing now uses the same name.
|
|
35
|
+
- **The `/cron` listing is in `src/job-lines.ts`.** It was composed inside the extension's default
|
|
36
|
+
export, where no test can reach it. The lines are unchanged; each marker and column now has a test.
|
|
37
|
+
- **docs/design.md counts eight recipes** and names `deps-sweeper`, which ships but was not listed.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **A long session with non-ASCII text is listed as truncated again.** The session-list window is
|
|
41
|
+
64 KB of *bytes*, but the check asked whether the decoded string was that many UTF-16 code units
|
|
42
|
+
long. With CJK text the decoded length is smaller, so a session well past the window read as
|
|
43
|
+
complete and the resume picker printed a floor (`314 message(s)`) as an exact count. Both copies —
|
|
44
|
+
`src/session-head.ts` and the one inside `src/web.mjs` — now compare the bytes actually read.
|
|
45
|
+
- **`pi-loops sessions --limit` refuses a value it cannot use.** `--limit abc` and `--limit 0`
|
|
46
|
+
printed nothing and exited 0, the same output as a project with no sessions, and `--limit -1`
|
|
47
|
+
printed all but the oldest. The value must now be a whole number of at least 1.
|
|
48
|
+
- **`/triggers audit` refuses a limit it cannot read.** `alll`, `0` and `25x` quietly became the
|
|
49
|
+
default of 10 or 25, and `-5` printed almost the whole audit file. Anything other than one whole
|
|
50
|
+
number of at least 1 and `--all` now prints the usage line.
|
|
51
|
+
- **The `/goal` evaluator gets the 40 000 characters docs/goal.md promises.** The cap counted UTF-16
|
|
52
|
+
code units, so an emoji-heavy transcript was cut to about half and could begin on half a character.
|
|
53
|
+
- **The browser shows pi's last words with the cut marked.** When pi exits, the server cut its
|
|
54
|
+
stderr to 4000 UTF-16 code units and said nothing, so the tail looked whole and could begin on half
|
|
55
|
+
a character. The cut now counts characters and starts with `… (N chars dropped)`.
|
|
56
|
+
- **The browser panel shows an MCP server's last error whole.** The Runtime box cut it at 120
|
|
57
|
+
characters with no ellipsis, although the error it receives is already capped at 160 and marked.
|
|
58
|
+
- **The browser job card shows a loop's skipped overlaps,** as `/cron` and the terminal panel do.
|
|
59
|
+
- **The browser panel keeps a job whose checkout is gone in this project.** It resolved a missing
|
|
60
|
+
path only one directory up, so a removed worktree two levels down counted as another project's,
|
|
61
|
+
while `/cron` still listed it with `[orphan: cwd missing]`. It now resolves the deepest ancestor
|
|
62
|
+
that exists, as `src/paths.ts` does.
|
|
63
|
+
- **docs/web-ui-parity.md names a test that exists.** One line of the front-end gate named a test
|
|
64
|
+
that had been renamed. A new test checks every test name that a document gives.
|
|
65
|
+
|
|
8
66
|
## [0.22.4] - 2026-09-20
|
|
9
67
|
|
|
10
68
|
### 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.6 # 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.
|
|
48
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.22.6 # 或者从 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.
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
ecosystem —
|
|
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.
|
package/docs/web-ui-parity.md
CHANGED
|
@@ -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`: *
|
|
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.
|
|
3
|
+
"version": "0.22.6",
|
|
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,
|
|
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
|
-
|
|
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 {
|
package/src/job-lines.ts
ADDED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
1806
|
-
|
|
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)",
|
package/src/sdk-runner.ts
CHANGED
|
@@ -111,15 +111,12 @@ export function subSessionResources(req: Pick<SubagentRequest, "cwd">, deps: Pic
|
|
|
111
111
|
// staleness, which is what that message is really about, is untouched. The cost is that
|
|
112
112
|
// a `pi.*` action between runs reaches the most recently created session instead of
|
|
113
113
|
// throwing; pi rebinds those to the live session on the next run (`runner.js:160-175`).
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
invalidate(message);
|
|
121
|
-
(base.runtime as any).staleMessage = before;
|
|
122
|
-
};
|
|
114
|
+
// pi keeps the runtime's stale flag in a closure (`createExtensionRuntime`'s `state`, dist/core/extensions/
|
|
115
|
+
// loader.js), not on the runtime object, so it cannot be read back and restored after `invalidate()`.
|
|
116
|
+
// And `invalidate()` also unsubscribes every event-bus listener the shared extensions made when they
|
|
117
|
+
// loaded, which the next run needs (pi-mcp-adapter's runtime tool calls, say). The shared runtime lives
|
|
118
|
+
// as long as this loaded set, so it is never invalidated; each run's own ExtensionRunner still is.
|
|
119
|
+
base.runtime.invalidate = () => {};
|
|
123
120
|
return { ...base, extensions: [...base.extensions.filter((e) => !isInsideDir(ownDir, e.resolvedPath) && !isInsideDir(ownDir, e.path)), subagentGuardExtension(deps.log, deps.allowCommands?.() ?? [])] };
|
|
124
121
|
},
|
|
125
122
|
});
|
package/src/session-head.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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.
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
506
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2059
|
+
if (!file) return "";
|
|
2060
|
+
let at = path.resolve(file);
|
|
2061
|
+
const tail = [];
|
|
2062
|
+
for (;;) {
|
|
2032
2063
|
try {
|
|
2033
|
-
return path.join(fs.realpathSync(
|
|
2064
|
+
return path.join(fs.realpathSync(at), ...tail);
|
|
2034
2065
|
} catch {
|
|
2035
|
-
|
|
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)
|
|
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 || {};
|