@alphacoder-v0/pi-loops 0.18.0 → 0.19.2
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 +52 -0
- package/README.md +6 -6
- package/README.zh-CN.md +6 -6
- package/docs/cli.md +6 -2
- package/docs/configuration.md +1 -1
- package/docs/design.md +20 -6
- package/docs/loops.md +25 -17
- package/docs/triggers.md +1 -1
- package/docs/troubleshooting.md +3 -2
- package/docs/web-ui-parity.md +7 -3
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -1
- package/src/archive.ts +5 -5
- package/src/args.ts +1 -8
- package/src/cli.ts +17 -5
- package/src/job-edit.ts +0 -3
- package/src/job-health.ts +6 -7
- package/src/job-owner.ts +29 -0
- package/src/pi-loops.ts +18 -25
- package/src/protocol.ts +2 -3
- package/src/scheduler.ts +13 -13
- package/src/session-head.ts +98 -0
- package/src/store.ts +7 -17
- package/src/tools.ts +14 -19
- package/src/trigger-runtime.ts +3 -5
- package/src/triggers.ts +3 -4
- package/src/web.mjs +35 -16
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,58 @@
|
|
|
3
3
|
All notable changes to pi-loops are documented here. The format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
|
5
5
|
|
|
6
|
+
## [0.19.2] - 2026-09-14
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **A plain job of another session was promised a next run, could be run into the wrong chat, and
|
|
10
|
+
was called dormant as if that were a fault** (#15). A plain job belongs to the session that
|
|
11
|
+
created it — its result is a message in that conversation — and dispatch has always honoured
|
|
12
|
+
that; the lists did not. `cron_list` printed a `next_run` for a job this process would never
|
|
13
|
+
dispatch (the same class the `[other host]` fix closed for the machine half of the predicate),
|
|
14
|
+
the browser panel showed the time it read from `next-runs.json`, and `/cron run` injected the job
|
|
15
|
+
into whatever chat was open. On 2026-09-14 a daily digest missed its slot exactly this way, and
|
|
16
|
+
the only surface that knew was the terminal's `[dormant …]` marker. Now one predicate
|
|
17
|
+
(`src/job-owner.ts`) answers "does it run here" for dispatch, `/cron`, `cron_list`, the panel and
|
|
18
|
+
`/cron run`: another session lists it as `[session <id> — resume it to run]` with no next run,
|
|
19
|
+
`cron_list` says the same and sets `owner_session` / `asleep`, the panel says what would wake it,
|
|
20
|
+
and `/cron run` refuses it with the same sentence. "Asleep" replaces "dormant" in the vocabulary:
|
|
21
|
+
it is waiting for its conversation, not broken. The `catch_up` description now says when a
|
|
22
|
+
plain job's missed slot is honoured (when its session is next opened); the skill tells the model
|
|
23
|
+
that anything meant to run unattended is a loop. Parking stays what it was and is now documented
|
|
24
|
+
as such: a plain job is disabled once its session's file has been deleted, not before.
|
|
25
|
+
|
|
26
|
+
## [0.19.1] - 2026-09-14
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **`pi-loops sessions` says what a session is, not only which.** It printed the id, the mtime and
|
|
30
|
+
the cwd, so telling two sessions apart meant opening them. It now prints one line a person can
|
|
31
|
+
tell them apart by: the short id, when the session started, the automation it has
|
|
32
|
+
(`[2 cron, 1 trigger]`, `[automation off]`), and what was first said in it — the name it was
|
|
33
|
+
given, or the first message cut at eighty characters, by characters and never inside one.
|
|
34
|
+
`--all` puts the cwd after the id. `src/session-head.ts` reads the head of a session file for
|
|
35
|
+
it; the browser front end's own copy of that reading is unchanged.
|
|
36
|
+
- **The session commands are two words in the browser front end.** `/sessions` lists this
|
|
37
|
+
project's sessions in the feed, and `/session export [path]` / `/session import <path>` are the
|
|
38
|
+
archive commands, delivered as `/session-export` / `/session-import`. pi's built-in `/session` is
|
|
39
|
+
a terminal command that does not exist over rpc — that is why the extension's commands are
|
|
40
|
+
hyphenated in the terminal, and why the page can spell them plainly.
|
|
41
|
+
|
|
42
|
+
## [0.19.0] - 2026-09-14
|
|
43
|
+
|
|
44
|
+
### Removed
|
|
45
|
+
- **The machine, as a concept.** Every job and rule used to carry the hostname that created it;
|
|
46
|
+
another machine sharing the `$HOME` ignored it and listed it as `[other host: <name>]`,
|
|
47
|
+
`/cron set <ref> --host here|-` and `/triggers set --host` re-homed it, `cron_list` printed
|
|
48
|
+
`other_host:`, the leader and next-runs files were named `scheduler.<host>.json` and
|
|
49
|
+
`next-runs.<host>.json`, and an imported archive was re-stamped for the importing machine. None
|
|
50
|
+
of it matches the way pi-loops is used: pi runs on one machine, the loops run
|
|
51
|
+
there, and a phone or a laptop reaches the browser front end over a tailnet without moving the
|
|
52
|
+
execution anywhere. All of it is gone. A `host` field an older build left in `jobs.json`,
|
|
53
|
+
`triggers.json` or a `.pisession` archive is ignored when read and dropped on the next write; the
|
|
54
|
+
leader file is `scheduler.json`, the next-runs file `next-runs.json`. Two machines syncing one
|
|
55
|
+
`$HOME` now both run every job — not supported, and said so rather than half-handled
|
|
56
|
+
(`docs/design.md` §19). "Host" means one thing here now: the headless process.
|
|
57
|
+
|
|
6
58
|
## [0.18.0] - 2026-09-14
|
|
7
59
|
|
|
8
60
|
### Added
|
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.
|
|
41
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.19.2 # 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
|
```
|
|
@@ -268,7 +268,7 @@ State stays in `~/.pi/agent/loops` until you delete it.
|
|
|
268
268
|
|
|
269
269
|
| Command | What it does |
|
|
270
270
|
|---|---|
|
|
271
|
-
| `/cron add [--stateful] [--verify] "<schedule>" <prompt>` | Schedule a job: plain
|
|
271
|
+
| `/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 |
|
|
272
272
|
| `/cron`, `/cron all` | This project's jobs, or every project on this machine |
|
|
273
273
|
| `/cron enable\|disable\|remove <ref>` | Pause, resume or delete one job — and write an audit entry into the session, as `add` does |
|
|
274
274
|
| `/cron disable --all` | Pause every job in this project (`--all-projects` for the machine); `/cron enable --all` resumes |
|
|
@@ -289,10 +289,10 @@ State stays in `~/.pi/agent/loops` until you delete it.
|
|
|
289
289
|
| `/new-trigger <natural language>` | Create a condition-based rule ("when ~/build.done exists, run cargo test") |
|
|
290
290
|
| `/triggers [status\|rules\|enable\|disable\|remove\|running\|audit [N]\|abort]` | Dynamic rules: what exists, what is running, what happened |
|
|
291
291
|
| `/triggers run <id>` | Check one rule now instead of waiting for its poll slot |
|
|
292
|
-
| `/triggers set <id> --model\|--thinking\|--timeout
|
|
292
|
+
| `/triggers set <id> --model\|--thinking\|--timeout` | Change what a rule runs with — the settings that decide how an unattended action behaves |
|
|
293
293
|
| `/triggers sources`, `/triggers hooks` | Every source feeding the trigger runtime: each connected MCP server, whose notifications can fire a rule; the local crontab; the dynamic checker |
|
|
294
294
|
| `/triggers panel [on\|off]` | The same panel, toggled from the trigger side |
|
|
295
|
-
| `/session-export [path]`, `/session-import <path>` | Portable `.pisession` archive: transcript + jobs + rules + loop state |
|
|
295
|
+
| `/session-export [path]`, `/session-import <path>` | Portable `.pisession` archive: transcript + jobs + rules + loop state (in the browser front end, `/session export` and `/session import` too; `/sessions` lists) |
|
|
296
296
|
| `/session-share [--public]` | Upload a redacted transcript as a GitHub gist via `gh`, after showing you what it contains. (pi has its own `/share`, which sends the raw session elsewhere first — see [docs/session-archive.md](docs/session-archive.md)) |
|
|
297
297
|
| `/recipe [list\|show\|add\|update\|remove <name>]` | Install a packaged way of running this project on loops — the issue tracker as a state machine, or one research experiment per run — with one question and one confirmation ([docs/recipes.md](docs/recipes.md)) |
|
|
298
298
|
| `/pi-loops [install-launcher]` | Version and paths; `install-launcher` puts the `pi-loops` command on your `PATH` |
|
|
@@ -344,7 +344,7 @@ notifications, and lifecycle hooks — each has a page here.
|
|
|
344
344
|
| `~/.pi/agent/loops/logs/pi-<pid>.log` | what each pi process's automation did |
|
|
345
345
|
| `~/.pi/agent/loops/triggers.json`, `triggers-audit.jsonl` | dynamic rules and trigger audit |
|
|
346
346
|
| `~/.pi/agent/loops/{config,mcp,hooks}.toml` | configuration |
|
|
347
|
-
| `~/.pi/agent/loops/scheduler
|
|
347
|
+
| `~/.pi/agent/loops/scheduler.json` | which pi process currently owns the timer |
|
|
348
348
|
|
|
349
349
|
Set `PI_LOOPS_DIR` to relocate all of it. The notes are yours to edit when the agent got something
|
|
350
350
|
wrong, and `logs/pi-<pid>.log` is the file to read after an overnight failure.
|
|
@@ -354,7 +354,7 @@ wrong, and `logs/pi-<pid>.log` is the file to read after an overnight failure.
|
|
|
354
354
|
Scheduled work is only worth trusting if it survives you closing the editor. So "pi was restarted"
|
|
355
355
|
is treated here as the normal case rather than the exception.
|
|
356
356
|
|
|
357
|
-
Jobs are machine-global
|
|
357
|
+
Jobs are machine-global and never expire. Any open pi can own the timer:
|
|
358
358
|
leadership is a file with a heartbeat, and when the process holding it exits or dies, the next tick
|
|
359
359
|
in another window picks it up. A project's trigger checks run in a pi that is open in that project,
|
|
360
360
|
so a result that belongs in a conversation lands in the right one. A tick a loop missed while
|
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.
|
|
48
|
+
pi install git:github.com/alphacoder-v0/pi-loops@v0.19.2 # 或者从 GitHub 装,钉住一个 tag
|
|
49
49
|
pi install /path/to/pi-loops # 或者本地检出;本仓库里就是 pi install .
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -241,7 +241,7 @@ playbook 拷到 `.agents/skills/<name>/`,通过 `.git/info/exclude` 排除在
|
|
|
241
241
|
/cron · /cron list|ls|status 本项目的任务,[stateful] 标记;/cron all 看整台机器
|
|
242
242
|
/cron enable|resume|disable|pause|remove <n|id|name>
|
|
243
243
|
/cron set <ref> … 改已有任务而不换 id(笔记因此留着):--prompt、--schedule,
|
|
244
|
-
以及 --model、--thinking、--timeout、--name
|
|
244
|
+
以及 --model、--thinking、--timeout、--name 这几个钉子(`-` 解掉)
|
|
245
245
|
/cron run 1 立刻跑一次(once 跑完即删,every 的间隔从现在重算,cron 的下次不变;停用的普通任务会被拒绝)
|
|
246
246
|
/cron state ci loop 的笔记(状态脊柱)
|
|
247
247
|
/cron runs [ci] 最近运行,最新在前
|
|
@@ -312,7 +312,7 @@ at 2026-09-08 → UTC 零点 ← 只给日期按 UTC,在北京就
|
|
|
312
312
|
|
|
313
313
|
要表示时间就把时间写出来。另外 `in 10m` / `at` 在**创建那一刻**就被算成一个绝对时刻存下来,之后改时区不影响它;`every 30m` 是纯间隔,和时区、夏令时都无关。
|
|
314
314
|
|
|
315
|
-
|
|
315
|
+
**一台机器**:pi-loops 在运行 pi 的那台机器上跑,没有跨机器的概念(0.19.0 去掉了任务上的主机名戳;两台机器同步一个 `$HOME` 会各跑一遍,不支持)。要在别处看它,走 tailnet 开浏览器前端。stateful loop 的笔记是模型自己写的自由文本,里面常有 watermark(「看到这里为止」),而机器的时区会变(设了 `TZ`、带着笔记本出差、容器重建成 UTC),所以 prompt 会要求模型写时间时带上偏移。0.14.1 之前写下的笔记没有这个保障,时区变过又带 watermark 的 loop 值得用 `/cron state <id>` 看一眼。
|
|
316
316
|
|
|
317
317
|
## Maker/checker:先质疑每条 finding,再给你看
|
|
318
318
|
|
|
@@ -486,8 +486,8 @@ Output protocol (mandatory):
|
|
|
486
486
|
|
|
487
487
|
定时任务值不值得信,取决于你关掉编辑器之后会发生什么。所以这里把「pi 重启过」当成常态:
|
|
488
488
|
|
|
489
|
-
1.
|
|
490
|
-
2. **loop 归本机唯一的 leader 跑;动态检查归「开在那个项目里的那个 pi」跑。** `scheduler
|
|
489
|
+
1. **任务是机器全局的**,存在 `~/.pi/agent/loops/jobs.json`,不绑会话、不绑目录(每个任务记住自己的 `cwd`,子代理在那里跑)。任何目录里打开的任何 pi 都能看到并执行 stateful 任务;普通任务因为要注入对话,只在创建它的那个会话里触发(`--resume` 回来就继续),会话没了,leader 会把它停用,`/cron gc` 清掉;子代理创建的普通任务归它所服务的那个父会话。
|
|
490
|
+
2. **loop 归本机唯一的 leader 跑;动态检查归「开在那个项目里的那个 pi」跑。** `scheduler.json` 里放 pid 加心跳,30 秒一 tick;leader 退出或者崩了,其它 pi 在下一 tick 接手。
|
|
491
491
|
|
|
492
492
|
每个进程每 tick 往 `presence/` 登记自己的 pid、会话和 cwd。一个项目的规则检查和推送评估,就交给开在那个项目里的 pi——优先创建规则的那个会话,其次 pid 最小的那个——所以 promote_to_chat 一定落在对的对话里。项目里一个 pi 都没开,才轮到 leader 代跑,结果进 inbox。
|
|
493
493
|
|
|
@@ -516,7 +516,7 @@ Output protocol (mandatory):
|
|
|
516
516
|
| `~/.pi/agent/loops/spend.json` | 轮转掉的那部分花费按天留一份,预算上限不会因为 run log 被截断而失效 |
|
|
517
517
|
| `~/.pi/agent/loops/logs/pi-<pid>.log` | 每个 pi 进程的自动化诊断,超 2 MB 保留后半,只留最近五个进程(进程还活着的那份不算) |
|
|
518
518
|
| `~/.pi/agent/loops/sessions/<id>/*.jsonl` | 子代理完整 transcript,每个 loop 保留最近 20 份 |
|
|
519
|
-
| `~/.pi/agent/loops/scheduler
|
|
519
|
+
| `~/.pi/agent/loops/scheduler.json` | 当前 leader 的 pid / 心跳 |
|
|
520
520
|
| `~/.pi/agent/loops/triggers.json` | 动态 trigger 规则(全局,带 cwd) |
|
|
521
521
|
| `~/.pi/agent/loops/triggers-audit.jsonl` | trigger audit,超 2 MB 保留后半 |
|
|
522
522
|
| `~/.pi/agent/loops/sessions/triggers-<项目>-<哈希>/*.jsonl` | 动态检查子代理的 transcript,每个项目一个目录(目录名带项目路径的哈希,同名的两个项目不会共用),保留最近 40 份 |
|
package/docs/cli.md
CHANGED
|
@@ -213,8 +213,12 @@ pi-loops host status | abort <run-id|trace-id> | stop
|
|
|
213
213
|
|
|
214
214
|
## sessions, inspect
|
|
215
215
|
|
|
216
|
-
`sessions` lists
|
|
217
|
-
|
|
216
|
+
`sessions` lists this project's sessions newest first, one line each: the first sixteen
|
|
217
|
+
characters of the id, when the session started, the automation it
|
|
218
|
+
has (`[2 cron, 1 trigger]`, or `[automation off]` when all of it is disabled), and what was first
|
|
219
|
+
said in it — the session's name if it was given one, otherwise the first message, cut at eighty
|
|
220
|
+
characters. `--all` lists every project and puts the cwd after the id. The id is what
|
|
221
|
+
`export --session` accepts; a unique prefix will do. `inspect` prints an archive's schedules, prompts and rules without
|
|
218
222
|
writing anything, which is what you want before restoring on a machine you care about.
|
|
219
223
|
|
|
220
224
|
## export
|
package/docs/configuration.md
CHANGED
|
@@ -15,7 +15,7 @@ Everything lives under `~/.pi/agent/loops/` (override: `PI_LOOPS_DIR`).
|
|
|
15
15
|
| `runs.jsonl`, `triggers-audit.jsonl` | run log (halved past 1 MB) and trigger audit (2 MB) |
|
|
16
16
|
| `spend.json` | per-day totals of what rotation dropped from the run log, so a daily budget still counts it |
|
|
17
17
|
| `sessions/<job-id>/`, `sessions/triggers-<project>-<hash>/` | sub-agent transcripts (20 per job, 40 per project's checks; the hash of the project's path keeps two projects named alike apart) |
|
|
18
|
-
| `scheduler
|
|
18
|
+
| `scheduler.json` | timer owner on this machine: pid, heartbeat |
|
|
19
19
|
| `presence/` | one file per live pi process: pid, session, cwd (who acts for which project) |
|
|
20
20
|
| `host.json`, `host.log` | the headless host that keeps the clock while no pi is open: pid, and its log |
|
|
21
21
|
| `polls.json` | last dynamic check per project (shared, so a hand-over never double-checks) |
|
package/docs/design.md
CHANGED
|
@@ -24,19 +24,21 @@ The premise is that **"pi was restarted" is the normal case**. Automation that d
|
|
|
24
24
|
it was configured in is automation you cannot rely on, so jobs and rules live on disk for the whole
|
|
25
25
|
machine. Everything here follows from that, and each one has a price.
|
|
26
26
|
|
|
27
|
-
1. **Machine-global jobs and rules, each carrying a `cwd
|
|
27
|
+
1. **Machine-global jobs and rules, each carrying a `cwd`.** `/cron` and
|
|
28
28
|
`/triggers rules` show the current project by default, and say how many are elsewhere. A plain
|
|
29
|
-
(inject) job belongs to the session that created it
|
|
30
|
-
|
|
29
|
+
(inject) job belongs to the session that created it — its result is a message in that
|
|
30
|
+
conversation — and everything that lists it agrees with dispatch about that: another session
|
|
31
|
+
shows it as `[session <id> — resume it to run]` with no next run, `cron_list` says the same to
|
|
32
|
+
the model, `/cron run` refuses it. Asleep is not broken. It is parked as disabled only once its
|
|
33
|
+
session's file has been deleted (`/cron gc` removes it). A loop
|
|
31
34
|
whose `cwd` disappeared waits half an hour — a mount can be late at boot — and is disabled after
|
|
32
|
-
that.
|
|
33
|
-
with whose they are; leader election is per host.
|
|
35
|
+
that.
|
|
34
36
|
|
|
35
37
|
The cost: a list has to be scoped, and a job you cannot see reads as a job that is gone. Hence
|
|
36
38
|
the counts, the markers, and `/cron all`.
|
|
37
39
|
|
|
38
40
|
2. **Who runs what.** Every process ticks. Loops run in the machine leader
|
|
39
|
-
(`scheduler
|
|
41
|
+
(`scheduler.json`, a pid and a heartbeat). A project's dynamic checks and push
|
|
40
42
|
evaluations run in a pi that is *open in that project* — preferring the session that created the
|
|
41
43
|
rules, lowest pid otherwise (`presence/`) — so a promotion lands in the conversation it belongs
|
|
42
44
|
to. Only a project with no pi open falls to the leader, and its results go to the inbox
|
|
@@ -160,6 +162,18 @@ machine. Everything here follows from that, and each one has a price.
|
|
|
160
162
|
not a permission, and the permissions that exist (the danger policy, the daily budget, branch
|
|
161
163
|
protection on the remote) are the ones that hold.
|
|
162
164
|
|
|
165
|
+
19. **One machine.** Until 0.19.0 every job and rule carried the hostname that created it, another
|
|
166
|
+
machine sharing the `$HOME` ignored it and listed it as `[other host]`, `/cron set --host`
|
|
167
|
+
re-homed it, and the leader file was named per host. None of it matched the way pi-loops is
|
|
168
|
+
used: pi runs on one machine, the loops run there, and a phone or a
|
|
169
|
+
laptop reaches the browser front end over a tailnet (`tailscale serve`) without moving the
|
|
170
|
+
execution anywhere. So the concept is gone: no `host` on a job or a rule (a stamp an older
|
|
171
|
+
build left is dropped when the file is read), no `--host`, no marker, `scheduler.json` and
|
|
172
|
+
`next-runs.json` plain. "Host" now means one thing here, the headless process.
|
|
173
|
+
|
|
174
|
+
The cost: two machines syncing one `$HOME` both run every job. That is not supported, and it
|
|
175
|
+
is said rather than half-handled.
|
|
176
|
+
|
|
163
177
|
## Where the browser front end came from
|
|
164
178
|
|
|
165
179
|
pi owns the `pi` command and its terminal, so a second front end cannot replace the first one. It
|
package/docs/loops.md
CHANGED
|
@@ -10,8 +10,14 @@ Everything here is `/cron`; `/crontab` and `/loop` are the same command under ot
|
|
|
10
10
|
|
|
11
11
|
When due, the prompt is injected into the session that created the job as a user message with the
|
|
12
12
|
engine prefix `[Trigger <run-id>] `, and the agent runs one turn (an *inject-and-run* job). If the agent
|
|
13
|
-
is busy the message is queued as a follow-up.
|
|
14
|
-
|
|
13
|
+
is busy the message is queued as a follow-up. A plain job belongs to the session that created it:
|
|
14
|
+
its result is a message in that conversation, so it fires only in the process holding that session.
|
|
15
|
+
Open another session and `/cron` lists it as `[session <id> — resume it to run]` with no next run,
|
|
16
|
+
`cron_list` says the same to the model, and `/cron run` refuses it; `--resume` the session it
|
|
17
|
+
belongs to and it wakes, catching up a missed slot if it was created with `--catchup`. It is not
|
|
18
|
+
broken while it sleeps — it is waiting for its conversation. Something that should run whether or
|
|
19
|
+
not a window is open (a nightly digest, a watch) is a loop: `--stateful`, with the inbox as its
|
|
20
|
+
outlet.
|
|
15
21
|
|
|
16
22
|
Schedules are local time: 5-field cron (`*/n`, ranges, lists, `mon-fri`, `jan`), the aliases
|
|
17
23
|
(`hourly` / `every hour` / `once an hour` → `0 * * * *`; `daily` / `every day` → `0 9 * * *`;
|
|
@@ -106,8 +112,8 @@ and answer with one `<verdict n="i">keep|drop — reason</verdict>` per finding
|
|
|
106
112
|
|
|
107
113
|
## Restart behavior
|
|
108
114
|
|
|
109
|
-
Jobs live in `~/.pi/agent/loops/jobs.json` and survive pi restarts. Exactly one pi process
|
|
110
|
-
|
|
115
|
+
Jobs live in `~/.pi/agent/loops/jobs.json` and survive pi restarts. Exactly one pi process on the
|
|
116
|
+
machine owns the timer (`scheduler.json`, 30-second ticks, 90-second heartbeat); when it exits
|
|
111
117
|
or dies another open pi takes over on its next tick. A tick that was missed while no pi was running
|
|
112
118
|
is fired once at startup (collapsed, not replayed) for stateful loops unless `[cron] catch_up = false`;
|
|
113
119
|
plain inject jobs do not catch up unless created with `--catchup` (`--no-catchup` turns it off for
|
|
@@ -116,12 +122,15 @@ loops). A run that died with its process is retried on the next tick; at most
|
|
|
116
122
|
a server pushing many distinct events cannot open one sub-agent per event.
|
|
117
123
|
Jobs run with the model and thinking level recorded
|
|
118
124
|
on them (`/cron set <id> --model … --thinking … --timeout …`, `-` to follow the running session).
|
|
119
|
-
`/cron scheduler` shows who owns the timer; `/cron` marks
|
|
120
|
-
is not open here, parks
|
|
121
|
-
|
|
122
|
-
mount can be late at boot. A job created by a sub-agent belongs to the session that ran it.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
`/cron scheduler` shows who owns the timer; `/cron` marks a plain job as `[session <id> — resume it
|
|
126
|
+
to run]` when its session is not open here, parks it as disabled once that session's file has been
|
|
127
|
+
deleted (`/cron gc` removes it), and marks a loop `[orphan: cwd missing]` when their checkout is gone — disabled half an hour later, since a
|
|
128
|
+
mount can be late at boot. A job created by a sub-agent belongs to the session that ran it.
|
|
129
|
+
|
|
130
|
+
pi-loops runs on one machine. Two machines syncing one `$HOME` would both run every job; that is
|
|
131
|
+
not supported (until 0.19.0 a hostname stamp on each job half-handled it, and is now ignored). To
|
|
132
|
+
reach a running pi from elsewhere, use the browser front end over your tailnet ([cli.md](cli.md));
|
|
133
|
+
the loops keep running where pi runs.
|
|
125
134
|
|
|
126
135
|
## Time, and which clock it is
|
|
127
136
|
|
|
@@ -147,14 +156,13 @@ Two things are deliberately still UTC, because neither is a time anybody reads:
|
|
|
147
156
|
session file, which cannot hold the `+` and `:` an offset brings, and pi's own session header, whose
|
|
148
157
|
format is pi's to decide.
|
|
149
158
|
|
|
150
|
-
The offset in the run line above is the offset of
|
|
151
|
-
the prompt.
|
|
152
|
-
|
|
153
|
-
between machines in different timezones. Its notes are free text the model writes — watermarks,
|
|
159
|
+
The offset in the run line above is the offset of the machine **at the time it ran**, not a fixed
|
|
160
|
+
part of the prompt. A machine's clock is not a constant: `TZ` gets set, a laptop travels, a
|
|
161
|
+
container is rebuilt in UTC. A loop's notes are free text the model writes — watermarks,
|
|
154
162
|
"everything up to here has been seen" — and a watermark without an offset is a watermark the next
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
`/cron state <id>`.
|
|
163
|
+
run cannot read once the clock has moved. The prompt asks for the offset for that reason. Notes
|
|
164
|
+
written before 0.14.1 do not have it; a loop that keeps a watermark on a machine whose timezone has
|
|
165
|
+
changed is worth one look at `/cron state <id>`.
|
|
158
166
|
|
|
159
167
|
### Daylight saving
|
|
160
168
|
|
package/docs/triggers.md
CHANGED
|
@@ -97,7 +97,7 @@ rule evaluation, per window for injected pushes).
|
|
|
97
97
|
/triggers sources local checker + each MCP server: state, queued/dropped/deduped, tools
|
|
98
98
|
(/triggers hooks is the same view)
|
|
99
99
|
/triggers enable <id> /triggers disable <id> /triggers remove <id> also --all | --all-projects
|
|
100
|
-
/triggers set <id> --model | --thinking | --timeout
|
|
100
|
+
/triggers set <id> --model | --thinking | --timeout: what the action runs with
|
|
101
101
|
/triggers run <id> check one rule now, without waiting for its poll slot
|
|
102
102
|
/triggers running sub-agents in flight (dynamic checks and cron runs) /triggers abort <trace>|--all
|
|
103
103
|
/triggers audit [N]
|
package/docs/troubleshooting.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Troubleshooting
|
|
2
2
|
|
|
3
3
|
**Nothing fires.** `/cron scheduler`: is this process the timer owner or standby? If the owner died
|
|
4
|
-
without cleanup, a standby takes over within 90 seconds.
|
|
5
|
-
job marked `[
|
|
4
|
+
without cleanup, a standby takes over within 90 seconds. A job shows `next …` when it will run
|
|
5
|
+
*here*. A plain job marked `[session <id> — resume it to run]` belongs to another session and fires
|
|
6
|
+
only there (`--resume` it, or `/cron add` it again in this chat); a loop marked
|
|
6
7
|
`[orphan: cwd missing]` was disabled because its checkout is gone.
|
|
7
8
|
|
|
8
9
|
**A run failed.** `/cron runs` shows the error; `/cron trace <job> 1` shows the sub-agent's
|
package/docs/web-ui-parity.md
CHANGED
|
@@ -116,9 +116,7 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
116
116
|
says so as a count — without it, a job somewhere else is indistinguishable from a job that is
|
|
117
117
|
gone. Jobs and rules are counted and labelled apart, each line naming the command that lists
|
|
118
118
|
them (`/cron all`, `/triggers rules --all`): one number covering both agrees with neither
|
|
119
|
-
command, and the line exists to send you to the command.
|
|
120
|
-
machine no longer has (a rebuilt container, a rename) is listed and marked, the way `/cron`
|
|
121
|
-
lists it, rather than filtered out of sight.
|
|
119
|
+
command, and the line exists to send you to the command.
|
|
122
120
|
What counts as "this project" is what the extension says it is — symlinks resolved, `$HOME`
|
|
123
121
|
too broad to be one — because a panel that disagrees with the command is worse than either.
|
|
124
122
|
`test/web.test.ts`: *the panel and /cron agree about what this project is*.
|
|
@@ -215,6 +213,12 @@ weaker, and worth converting whenever one of them breaks.
|
|
|
215
213
|
left is a file that `resume` lists, labelled by what was said in it rather than by a filename.
|
|
216
214
|
`test/web.test.ts`: *a new session is a path pi has not written yet, and going back is one it
|
|
217
215
|
has*. `test/web-page.test.ts`: *the session you are in is not offered as one to go back to*.
|
|
216
|
+
- [x] **The session commands are two words here.** `/sessions` prints this project's sessions into
|
|
217
|
+
the feed — short id, when, what was first said — and `/session export [path]` / `/session
|
|
218
|
+
import <path>` are the archive commands, sent as `/session-export` / `/session-import`. pi's
|
|
219
|
+
own `/session` is a terminal command that does not exist over rpc, which is why the extension
|
|
220
|
+
had to hyphenate its names in the terminal and why the page is free to spell them plainly.
|
|
221
|
+
`test/web-page.test.ts`: */sessions and /session export are typed here*.
|
|
218
222
|
- [x] A session started here is a session pi-loops recognises. pi names the sessions it starts after
|
|
219
223
|
their id and the front end cannot know that id in time, so anything identifying a session by
|
|
220
224
|
its file name is wrong about this one — which parked every inject-and-run cron job made in a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.2",
|
|
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/skills/pi-loops/SKILL.md
CHANGED
|
@@ -11,7 +11,8 @@ The user's pi has pi-loops installed. Prefer its tools over ad-hoc `sleep` loops
|
|
|
11
11
|
|
|
12
12
|
| The user wants… | Use |
|
|
13
13
|
|---|---|
|
|
14
|
-
| "every day at 9 / hourly / 每小时 …"
|
|
14
|
+
| "every day at 9 / hourly / 每小时 …" and the result belongs in this chat (a reminder, a question to answer here) | `cron_create` with `stateful: false` — it belongs to this session and runs only while this session is open; the user leaving the window means it sleeps |
|
|
15
|
+
| anything that must run whether or not a window is open — a nightly digest, a report, a watch | `cron_create` with `stateful: true`, even when the user did not say "loop": a plain job cannot run unattended |
|
|
15
16
|
| recurring watch/triage: "check X and tell me what changed", "report new issues" | `cron_create` with `stateful: true` — the loop keeps notes between runs and reports findings to `/inbox` instead of interrupting the chat |
|
|
16
17
|
| the findings must be double-checked before the user sees them | `cron_create` with `stateful: true, verify: true` (a second adversarial sub-agent reviews each finding) |
|
|
17
18
|
| "when <condition> happens, do <action>" (a file appears, a PR merges, a build finishes) | `new_trigger` — condition + action, fires once unless the user asks for repeating |
|
package/src/archive.ts
CHANGED
|
@@ -362,15 +362,14 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
362
362
|
}
|
|
363
363
|
if (!SAFE_ID.test(raw.id)) throw new Error("cron sidecar contains an invalid job id");
|
|
364
364
|
// Automation off unless activated, stale run bookkeeping cleared.
|
|
365
|
-
// `host`
|
|
366
|
-
// machine must be re-stamped or every job would look enabled and never fire.
|
|
365
|
+
// A `host` stamp from a pre-0.19.0 archive is dropped: pi-loops runs on one machine.
|
|
367
366
|
// `createdBy` is left as the archive carries it: it names the session that created the job,
|
|
368
367
|
// which is what lets a second import of the same archive recognise itself. Re-stamping it
|
|
369
368
|
// with the fresh import session would make every import look new and double the automation.
|
|
369
|
+
const { host: _legacyHost, ...rawJob } = raw as typeof raw & { host?: unknown };
|
|
370
370
|
const job: LoopJob = {
|
|
371
|
-
...
|
|
371
|
+
...rawJob,
|
|
372
372
|
cwd: input.targetCwd,
|
|
373
|
-
host: os.hostname(),
|
|
374
373
|
enabled: raw.enabled && input.activate,
|
|
375
374
|
running: undefined,
|
|
376
375
|
lastDueAt: undefined,
|
|
@@ -396,7 +395,8 @@ export function importSession(input: ImportInput): ImportSummary {
|
|
|
396
395
|
for (const raw of file.rules as DynamicTriggerRule[]) {
|
|
397
396
|
if (!raw || typeof raw.id !== "string" || typeof raw.condition !== "string" || typeof raw.action !== "string") throw new Error("trigger sidecar contains an invalid rule");
|
|
398
397
|
if (!SAFE_ID.test(raw.id)) throw new Error("trigger sidecar contains an invalid rule id");
|
|
399
|
-
const
|
|
398
|
+
const { host: _legacyRuleHost, ...rawRule } = raw as typeof raw & { host?: unknown };
|
|
399
|
+
const rule: DynamicTriggerRule = { ...rawRule, cwd: input.targetCwd, enabled: raw.enabled && input.activate, createdBy: raw.createdBy ?? { sessionId: parsed.header.id } };
|
|
400
400
|
if (dedup.hasRule(rule)) continue;
|
|
401
401
|
if (input.existingRuleIds.has(rule.id)) rule.id = newRuleId();
|
|
402
402
|
if (raw.enabled) originallyEnabledRules.push(rule.id);
|
package/src/args.ts
CHANGED
|
@@ -145,7 +145,6 @@ export interface SetArgs {
|
|
|
145
145
|
timeoutMs?: number | null;
|
|
146
146
|
name?: string | null;
|
|
147
147
|
/** `"here"` = this machine; `null` = any machine. */
|
|
148
|
-
host?: string | null;
|
|
149
148
|
/** Jobs only: the new prompt, verbatim. A loop's notes live under its id, so rewording it here keeps them. */
|
|
150
149
|
prompt?: string;
|
|
151
150
|
/** Jobs only: already parsed, so a typo is rejected before it can be stored. */
|
|
@@ -163,7 +162,7 @@ export interface SetOptions {
|
|
|
163
162
|
* `--model -` (or `current`) removes the pin.
|
|
164
163
|
*/
|
|
165
164
|
export function parseSetArgs(input: string, opts: SetOptions = {}): SetArgs {
|
|
166
|
-
const usage = `usage: /cron|/triggers set <id> [--model <provider/id>|-] [--thinking <level>|-] [--timeout <dur>|-] [--name <n>|-]
|
|
165
|
+
const usage = `usage: /cron|/triggers set <id> [--model <provider/id>|-] [--thinking <level>|-] [--timeout <dur>|-] [--name <n>|-]${opts.job ? ' [--prompt "<text>"] [--schedule "<expr>"]' : ""}`;
|
|
167
166
|
const tokens = tokenize(input);
|
|
168
167
|
const out: SetArgs = { ref: "" };
|
|
169
168
|
let touched = 0;
|
|
@@ -186,12 +185,6 @@ export function parseSetArgs(input: string, opts: SetOptions = {}): SetArgs {
|
|
|
186
185
|
else if (t === "--thinking") out.thinking = clear ? null : requireThinkingLevel(val);
|
|
187
186
|
else if (t === "--timeout") out.timeoutMs = clear ? null : parseDuration(val);
|
|
188
187
|
else if (t === "--name") out.name = clear ? null : val;
|
|
189
|
-
// `--host here` re-homes a job stamped with a machine that no longer exists (a renamed box,
|
|
190
|
-
// a rebuilt container, or one half of a synced $HOME); `-` unpins it for any machine.
|
|
191
|
-
else if (t === "--host") {
|
|
192
|
-
if (!clear && val !== "here") throw new Error("--host takes `here` (this machine) or `-` (any machine)");
|
|
193
|
-
out.host = clear ? null : "here";
|
|
194
|
-
}
|
|
195
188
|
// Rewording a loop or moving it to another hour used to mean remove-and-re-add, which mints a
|
|
196
189
|
// new id — and the notes a stateful loop has been accumulating live at `state/<id>.md`.
|
|
197
190
|
else if (opts.job && (t === "--prompt" || t === "--schedule")) {
|
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { askHost, renderHostSnapshot } from "./host-control-channel.ts";
|
|
|
20
20
|
import { liveHost, stopHost } from "./host-control.ts";
|
|
21
21
|
import { type UiPrefs, readUiPrefs } from "./ui-prefs.ts";
|
|
22
22
|
import { JobStore, defaultLoopsDir } from "./store.ts";
|
|
23
|
+
import { automationBadge, previewText, readSessionHead } from "./session-head.ts";
|
|
23
24
|
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, TRACKER_FILE, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, listRecipes, loadRecipe, planInstall, playbookFiles, readRecord, resolveRecipeRef } from "./recipe.ts";
|
|
24
25
|
import { TriggerStore } from "./triggers.ts";
|
|
25
26
|
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
@@ -98,7 +99,7 @@ export const CLI_USAGE = [
|
|
|
98
99
|
" Imported automation stays disabled unless --activate-triggers=on (ask: prompt on a terminal).",
|
|
99
100
|
"",
|
|
100
101
|
"pi-loops sessions [--all] [--limit <n>]",
|
|
101
|
-
" List
|
|
102
|
+
" List sessions, newest first: short id, when it started, its automation, what was first said in it.",
|
|
102
103
|
"",
|
|
103
104
|
"pi-loops inspect <file>",
|
|
104
105
|
" Show what an archive contains without writing anything.",
|
|
@@ -128,6 +129,10 @@ interface SessionFile {
|
|
|
128
129
|
file: string;
|
|
129
130
|
cwd: string;
|
|
130
131
|
mtimeMs: number;
|
|
132
|
+
/** The header's timestamp. */
|
|
133
|
+
startedAt?: string;
|
|
134
|
+
/** `/name`, or the first thing the person said — what tells two sessions apart. */
|
|
135
|
+
preview?: string;
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
/** pi keeps `<agentDir>/sessions/<encoded cwd>/<timestamp>_<uuid>.jsonl`; the header names the cwd. */
|
|
@@ -151,9 +156,9 @@ export function listSessions(sessionsRoot: string): SessionFile[] {
|
|
|
151
156
|
if (!name.endsWith(".jsonl")) continue;
|
|
152
157
|
const file = path.join(dir, name);
|
|
153
158
|
try {
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
out.push({ id:
|
|
159
|
+
const head = readSessionHead(file);
|
|
160
|
+
if (!head) continue;
|
|
161
|
+
out.push({ id: head.id, file, cwd: head.cwd, mtimeMs: fs.statSync(file).mtimeMs, startedAt: head.startedAt, preview: head.name ?? head.first });
|
|
157
162
|
} catch {
|
|
158
163
|
/* not a readable session */
|
|
159
164
|
}
|
|
@@ -254,7 +259,14 @@ export async function runCli(argv: string[], out: (line: string) => void = conso
|
|
|
254
259
|
out(flags.has("all") ? "no sessions recorded" : `no sessions recorded for ${cwd} (use --all)`);
|
|
255
260
|
return 1;
|
|
256
261
|
}
|
|
257
|
-
|
|
262
|
+
// One line a person can tell sessions apart by: short id, when it started, the automation the
|
|
263
|
+
// session has, what was first said in it. With --all the cwd sits after the id.
|
|
264
|
+
const jobs = new JobStore(loopsDir).load();
|
|
265
|
+
const rules = new TriggerStore(loopsDir).load();
|
|
266
|
+
for (const s of here.slice(0, Number(str("limit") ?? 20))) {
|
|
267
|
+
const badge = automationBadge(s.id, jobs, rules);
|
|
268
|
+
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());
|
|
269
|
+
}
|
|
258
270
|
return 0;
|
|
259
271
|
}
|
|
260
272
|
|
package/src/job-edit.ts
CHANGED
|
@@ -20,7 +20,6 @@ export interface JobEdit {
|
|
|
20
20
|
timeoutMs?: number | null;
|
|
21
21
|
name?: string | null;
|
|
22
22
|
/** "here" pins the job to this machine; null lets any machine run it. */
|
|
23
|
-
host?: string | null;
|
|
24
23
|
prompt?: string;
|
|
25
24
|
schedule?: Schedule;
|
|
26
25
|
}
|
|
@@ -38,7 +37,6 @@ export interface AppliedJobEdit {
|
|
|
38
37
|
|
|
39
38
|
export interface JobEditContext {
|
|
40
39
|
now: number;
|
|
41
|
-
hostName: string;
|
|
42
40
|
maxPromptBytes: number;
|
|
43
41
|
/** Called with the requested name and the other jobs; throws if it is not usable as a reference. */
|
|
44
42
|
checkName: (name: string, others: LoopJob[]) => void;
|
|
@@ -66,7 +64,6 @@ export function applyJobEdit(job: LoopJob, edit: JobEdit, ctx: JobEditContext):
|
|
|
66
64
|
if (edit.thinking !== undefined) patch.thinking = edit.thinking ?? undefined;
|
|
67
65
|
if (edit.timeoutMs !== undefined) patch.timeoutMs = edit.timeoutMs ?? undefined;
|
|
68
66
|
if (edit.name !== undefined) patch.name = edit.name ?? undefined;
|
|
69
|
-
if (edit.host !== undefined) patch.host = edit.host === "here" ? ctx.hostName : undefined;
|
|
70
67
|
if (edit.prompt !== undefined) {
|
|
71
68
|
patch.prompt = edit.prompt;
|
|
72
69
|
// The old wording is otherwise unrecoverable, and it is what ties "this loop started
|
package/src/job-health.ts
CHANGED
|
@@ -10,18 +10,17 @@ import { FAILURE_BACKOFF_AFTER } from "./scheduler.ts";
|
|
|
10
10
|
import type { LoopJob } from "./store.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Jobs failing
|
|
13
|
+
* Jobs failing, worst first. The threshold is the scheduler's backoff threshold, which
|
|
14
14
|
* is the honest place to draw the line: below it a failure is a bad night, at it the scheduler has
|
|
15
|
-
* already started widening the gap.
|
|
16
|
-
* report, not ours.
|
|
15
|
+
* already started widening the gap.
|
|
17
16
|
*/
|
|
18
|
-
export function failingJobs(jobs: LoopJob[]
|
|
19
|
-
return jobs.filter((j) => j.enabled && (
|
|
17
|
+
export function failingJobs(jobs: LoopJob[]): LoopJob[] {
|
|
18
|
+
return jobs.filter((j) => j.enabled && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/** `2 job(s) failing (check-issues ×7)` — one clause, the worst one named, whatever the count. */
|
|
23
|
-
export function failingSummary(jobs: LoopJob[]
|
|
24
|
-
const failing = failingJobs(jobs
|
|
22
|
+
export function failingSummary(jobs: LoopJob[]): string | undefined {
|
|
23
|
+
const failing = failingJobs(jobs);
|
|
25
24
|
const worst = failing[0];
|
|
26
25
|
if (!worst) return undefined;
|
|
27
26
|
const label = worst.name ?? worst.id;
|
package/src/job-owner.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a job runs, as one question the scheduler, the lists and `/cron run` all ask the same way.
|
|
3
|
+
*
|
|
4
|
+
* A stateful loop belongs to the machine: whichever process holds the clock runs it. A plain job
|
|
5
|
+
* belongs to the session that created it — its result is a message in that conversation, so it
|
|
6
|
+
* fires only in the process holding that session. Another session sees it, and sees that it is
|
|
7
|
+
* asleep; it is not broken, and nothing here promises it a next run it will not get.
|
|
8
|
+
*/
|
|
9
|
+
import type { LoopJob } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
/** Would the process holding `sessionId` dispatch this job? (For a loop, only if it is also the leader.) */
|
|
12
|
+
export function runsIn(job: Pick<LoopJob, "stateful" | "sessionId">, sessionId: string | undefined): boolean {
|
|
13
|
+
if (job.stateful) return true;
|
|
14
|
+
return !!sessionId && job.sessionId === sessionId;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The short form of the session a plain job belongs to. */
|
|
18
|
+
export function ownerSession(job: Pick<LoopJob, "stateful" | "sessionId">): string | undefined {
|
|
19
|
+
return job.stateful ? undefined : (job.sessionId ?? "?").slice(0, 8);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The line a list shows for a plain job that will not run here — worded as what to do, because
|
|
24
|
+
* "not open here" was read as an error. Undefined when the job runs here.
|
|
25
|
+
*/
|
|
26
|
+
export function asleepNote(job: Pick<LoopJob, "stateful" | "sessionId">, sessionId: string | undefined): string | undefined {
|
|
27
|
+
if (runsIn(job, sessionId)) return undefined;
|
|
28
|
+
return `session ${ownerSession(job)} — resume it to run`;
|
|
29
|
+
}
|