@alphacoder-v0/pi-loops 0.17.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 +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- package/src/web.mjs +4484 -0
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alphacoder-v0/pi-loops",
|
|
3
|
+
"version": "0.17.3",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alphacoder-v0/pi-loops.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/alphacoder-v0/pi-loops#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/alphacoder-v0/pi-loops/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi",
|
|
17
|
+
"coding-agent",
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"cron",
|
|
21
|
+
"loops",
|
|
22
|
+
"inbox",
|
|
23
|
+
"triggers",
|
|
24
|
+
"mcp",
|
|
25
|
+
"hooks"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.6"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"skills",
|
|
33
|
+
"docs",
|
|
34
|
+
"examples",
|
|
35
|
+
"README.md",
|
|
36
|
+
"README.zh-CN.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"piLoops": {
|
|
44
|
+
"publishedToNpm": false
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"ci": "npm run typecheck && npm run lint && npm run check:scripts && npm run check:docs && npm test",
|
|
48
|
+
"typecheck": "node scripts/typecheck.mjs",
|
|
49
|
+
"lint": "node scripts/lint.mjs",
|
|
50
|
+
"check:docs": "node scripts/check-docs.mjs",
|
|
51
|
+
"test": "node --import ./src/register-pi.mjs --test test/*.test.ts",
|
|
52
|
+
"check:scripts": "node --check src/web.mjs && node --check src/cli-entry.mjs && node --check src/host-entry.mjs && node --check src/ts-entry.mjs && node --check src/pi-resolver.mjs && node --check src/register-pi.mjs && node --check examples/mcp-notify-server.mjs"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
56
|
+
"@earendil-works/pi-tui": "*",
|
|
57
|
+
"typebox": "*"
|
|
58
|
+
},
|
|
59
|
+
"pi": {
|
|
60
|
+
"image": "https://raw.githubusercontent.com/alphacoder-v0/pi-loops/main/docs/screenshot.png",
|
|
61
|
+
"extensions": [
|
|
62
|
+
"./src/pi-loops.ts"
|
|
63
|
+
],
|
|
64
|
+
"skills": [
|
|
65
|
+
"./skills"
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"bin": {
|
|
69
|
+
"pi-loops": "./src/cli-entry.mjs"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-loops
|
|
3
|
+
description: Schedule recurring or one-off work (cron jobs, stateful loops with an inbox, maker/checker verification) and condition-based triggers with the pi-loops tools. Use when the user asks to check, watch, monitor, remind, or run something later or periodically.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-loops: cron, loops, triggers, inbox
|
|
7
|
+
|
|
8
|
+
The user's pi has pi-loops installed. Prefer its tools over ad-hoc `sleep` loops or shell cron.
|
|
9
|
+
|
|
10
|
+
## Pick the right primitive
|
|
11
|
+
|
|
12
|
+
| The user wants… | Use |
|
|
13
|
+
|---|---|
|
|
14
|
+
| "every day at 9 / hourly / 每小时 …" run something, result in this chat | `cron_create` with `stateful: false` |
|
|
15
|
+
| 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
|
+
| 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
|
+
| "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 |
|
|
18
|
+
| a reminder in N minutes | `cron_create` with schedule `in 10m` |
|
|
19
|
+
|
|
20
|
+
Never use `new_trigger` for time-based schedules; never use `cron_create` for conditions.
|
|
21
|
+
|
|
22
|
+
## Schedules
|
|
23
|
+
|
|
24
|
+
5-field cron (local time), `hourly` / `daily` / `weekly`, crontab's `@hourly` / `@daily` /
|
|
25
|
+
`@weekly` / `@monthly`, `every 30m`, `in 10m`, `at 2026-09-08T18:00`. `daily` means 09:00 local;
|
|
26
|
+
`@daily` means midnight.
|
|
27
|
+
|
|
28
|
+
Everything is the machine's own clock, and it has no timezone of its own to set: `0 9 * * *` is nine
|
|
29
|
+
in the morning where the machine is. With `at`, give a time and not only a date — `at 2026-09-08` is
|
|
30
|
+
midnight **UTC** by JavaScript's rule, while `at 2026-09-08T18:00` is local as you would expect. A
|
|
31
|
+
job that must run once a day and never twice is safer as `every 24h`: clocks go back one night a
|
|
32
|
+
year, and a job at `0 1 * * *` runs twice that night while one at `0 2 * * *` on the night they go
|
|
33
|
+
forward does not run at all.
|
|
34
|
+
|
|
35
|
+
## Writing a stateful loop prompt
|
|
36
|
+
|
|
37
|
+
The loop runs in a fresh sub-agent whose only memory is its own notes. Tell it what to track and
|
|
38
|
+
what counts as a finding, e.g. "check the GitHub issues of this repo; keep the ids you have already
|
|
39
|
+
reported in your notes; report only new or newly closed issues". Findings should be one line each.
|
|
40
|
+
|
|
41
|
+
## Triage
|
|
42
|
+
|
|
43
|
+
`/inbox` lists findings; `/inbox claim <n>` hands one to you as a real turn; `/inbox dismiss <n>`.
|
|
44
|
+
`/cron` lists jobs, `/cron runs`, `/cron trace <job> [k]` shows what a run did. `/triggers rules`,
|
|
45
|
+
`/triggers audit` for dynamic triggers.
|
|
46
|
+
|
|
47
|
+
## Confirmations
|
|
48
|
+
|
|
49
|
+
Creating or removing a trigger, and re-enabling a job or trigger, asks the user to confirm. If the
|
|
50
|
+
confirmation is declined, stop; do not retry with different arguments.
|
package/src/archive.ts
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session archives — a portable `.pisession`, carrying the loop-state files
|
|
3
|
+
* One uncompressed ustar tar, owner-only, never overwritten:
|
|
4
|
+
*
|
|
5
|
+
* manifest.json
|
|
6
|
+
* session.jsonl pi's session file, verbatim
|
|
7
|
+
* sidecars/cron.json this project's cron jobs (optional)
|
|
8
|
+
* sidecars/triggers.json this project's trigger rules (optional; --exclude-triggers)
|
|
9
|
+
* loops/<job-id>.md loop state per stateful job (optional)
|
|
10
|
+
*
|
|
11
|
+
* Import rewrites only what must be local: a fresh session id and the target cwd in the
|
|
12
|
+
* session header, and sidecar bookkeeping (automation disabled unless activated, running
|
|
13
|
+
* markers / errors / overlap counters cleared). Importing the same archive twice adds nothing
|
|
14
|
+
* the second time.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { LOOP_STATE_MAX_CHARS, capChars } from "./protocol.ts";
|
|
21
|
+
import { previewRedacted } from "./redact.ts";
|
|
22
|
+
import { computeNext, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
|
|
23
|
+
import { newId, type LoopJob } from "./store.ts";
|
|
24
|
+
import { type DynamicTriggerRule, newRuleId } from "./triggers.ts";
|
|
25
|
+
|
|
26
|
+
export const ARCHIVE_SCHEMA = "pi-loops.session_export.v1";
|
|
27
|
+
export const ARCHIVE_EXT = ".pisession";
|
|
28
|
+
const MANIFEST_PATH = "manifest.json";
|
|
29
|
+
const SESSION_PATH = "session.jsonl";
|
|
30
|
+
const CRON_PATH = "sidecars/cron.json";
|
|
31
|
+
const TRIGGERS_PATH = "sidecars/triggers.json";
|
|
32
|
+
const LOOPS_DIR = "loops/";
|
|
33
|
+
const MAX_MANIFEST_BYTES = 128 * 1024;
|
|
34
|
+
const MAX_SESSION_BYTES = 50 * 1024 * 1024;
|
|
35
|
+
const MAX_SIDECAR_BYTES = 2 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------------------------ tar */
|
|
38
|
+
|
|
39
|
+
function padOctal(n: number, len: number): string {
|
|
40
|
+
return n.toString(8).padStart(len - 1, "0") + "\0";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function header(name: string, size: number, mode: number, mtime: number): Buffer {
|
|
44
|
+
const buf = Buffer.alloc(512);
|
|
45
|
+
buf.write(name, 0, 100, "utf8");
|
|
46
|
+
buf.write(padOctal(mode, 8), 100);
|
|
47
|
+
buf.write(padOctal(0, 8), 108);
|
|
48
|
+
buf.write(padOctal(0, 8), 116);
|
|
49
|
+
buf.write(padOctal(size, 12), 124);
|
|
50
|
+
buf.write(padOctal(mtime, 12), 136);
|
|
51
|
+
buf.write(" ", 148); // checksum placeholder
|
|
52
|
+
buf.write("0", 156);
|
|
53
|
+
buf.write("ustar\0", 257);
|
|
54
|
+
buf.write("00", 263);
|
|
55
|
+
let sum = 0;
|
|
56
|
+
for (const b of buf) sum += b;
|
|
57
|
+
buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
|
|
58
|
+
return buf;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writeTar(entries: Array<{ name: string; data: Buffer; mode?: number }>, mtime = Math.floor(Date.now() / 1000)): Buffer {
|
|
62
|
+
const parts: Buffer[] = [];
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (Buffer.byteLength(e.name) > 100) throw new Error(`tar entry name too long: ${e.name}`);
|
|
65
|
+
parts.push(header(e.name, e.data.length, e.mode ?? 0o600, mtime), e.data);
|
|
66
|
+
const pad = (512 - (e.data.length % 512)) % 512;
|
|
67
|
+
if (pad) parts.push(Buffer.alloc(pad));
|
|
68
|
+
}
|
|
69
|
+
parts.push(Buffer.alloc(1024));
|
|
70
|
+
return Buffer.concat(parts);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readTar(buf: Buffer): Map<string, Buffer> {
|
|
74
|
+
const out = new Map<string, Buffer>();
|
|
75
|
+
let off = 0;
|
|
76
|
+
while (off + 512 <= buf.length) {
|
|
77
|
+
const block = buf.subarray(off, off + 512);
|
|
78
|
+
if (block.every((b) => b === 0)) break;
|
|
79
|
+
const name = block.toString("utf8", 0, 100).replace(/\0.*$/, "");
|
|
80
|
+
const size = parseInt(block.toString("utf8", 124, 136).replace(/\0.*$/, "").trim() || "0", 8);
|
|
81
|
+
const type = block.toString("utf8", 156, 157);
|
|
82
|
+
off += 512;
|
|
83
|
+
if (Number.isNaN(size) || off + size > buf.length) throw new Error("corrupt tar archive");
|
|
84
|
+
if (type === "0" || type === "\0" || type === "") out.set(name, Buffer.from(buf.subarray(off, off + size)));
|
|
85
|
+
off += size + ((512 - (size % 512)) % 512);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateArchivePath(name: string): void {
|
|
91
|
+
if (path.isAbsolute(name) || name.split("/").some((seg) => seg === ".." || seg === "") || name.includes("\\")) {
|
|
92
|
+
throw new Error(`archive contains an unsafe path: ${name}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ------------------------------------------------------------- manifest */
|
|
97
|
+
|
|
98
|
+
export interface Manifest {
|
|
99
|
+
schema: string;
|
|
100
|
+
created_at: string;
|
|
101
|
+
pi_version: string;
|
|
102
|
+
pi_loops_version: string;
|
|
103
|
+
source: { session_id: string; cwd: string; session_path: string };
|
|
104
|
+
content: { session_jsonl_sha256: string; entry_count: number; has_triggers: boolean; has_cron: boolean; loop_state_count: number };
|
|
105
|
+
sensitivity: { session_transcript_preserved: true; separate_auth_stores_included: false; provider_credentials_included: false; mcp_config_included: false; inbox_included: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sha256 = (b: Buffer) => createHash("sha256").update(b).digest("hex");
|
|
109
|
+
|
|
110
|
+
interface SessionHeader {
|
|
111
|
+
type: "session";
|
|
112
|
+
id: string;
|
|
113
|
+
cwd?: string;
|
|
114
|
+
[k: string]: unknown;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse and structurally validate a pi session transcript.
|
|
119
|
+
*
|
|
120
|
+
* Beyond "every line is JSON", three things make a transcript unopenable rather than merely odd,
|
|
121
|
+
* and they are checked at parse time so a broken archive is refused here instead of silently
|
|
122
|
+
* truncating history the first time the session is opened: duplicate entry ids, a `parentId` no
|
|
123
|
+
* earlier entry declared, and an entry pointing at a target that does not exist (pi's `label` and
|
|
124
|
+
* `leaf` entries carry `targetId`).
|
|
125
|
+
*/
|
|
126
|
+
function parseSessionJsonl(text: string): { header: SessionHeader; headerLine: string; rest: string[]; entryCount: number } {
|
|
127
|
+
const lines = text.split("\n");
|
|
128
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
129
|
+
if (!lines.length) throw new Error("session.jsonl is empty");
|
|
130
|
+
let header: SessionHeader;
|
|
131
|
+
try {
|
|
132
|
+
header = JSON.parse(lines[0]);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error("session.jsonl header is not JSON");
|
|
135
|
+
}
|
|
136
|
+
if (header?.type !== "session" || typeof header.id !== "string") throw new Error("session.jsonl does not start with a pi session header");
|
|
137
|
+
let entryCount = 0;
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
for (const line of lines.slice(1)) {
|
|
140
|
+
if (!line.trim()) continue;
|
|
141
|
+
let entry: { id?: unknown; parentId?: unknown; targetId?: unknown };
|
|
142
|
+
try {
|
|
143
|
+
entry = JSON.parse(line);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error("session.jsonl contains a line that is not JSON");
|
|
146
|
+
}
|
|
147
|
+
if (typeof entry?.id !== "string") throw new Error("session.jsonl contains an entry without an id");
|
|
148
|
+
if (seen.has(entry.id)) throw new Error("session.jsonl contains a duplicate entry id");
|
|
149
|
+
if (typeof entry.parentId === "string" && !seen.has(entry.parentId)) throw new Error("session.jsonl contains a dangling parentId");
|
|
150
|
+
if (typeof entry.targetId === "string" && !seen.has(entry.targetId)) throw new Error("session.jsonl contains a dangling entry target");
|
|
151
|
+
seen.add(entry.id);
|
|
152
|
+
entryCount++;
|
|
153
|
+
}
|
|
154
|
+
return { header, headerLine: lines[0], rest: lines.slice(1), entryCount };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* --------------------------------------------------------------- export */
|
|
158
|
+
|
|
159
|
+
export interface ExportInput {
|
|
160
|
+
sessionFile: string;
|
|
161
|
+
cwd: string;
|
|
162
|
+
jobs: LoopJob[];
|
|
163
|
+
rules: DynamicTriggerRule[];
|
|
164
|
+
/** loop state per job id */
|
|
165
|
+
states: Record<string, string>;
|
|
166
|
+
excludeTriggers?: boolean;
|
|
167
|
+
outputPath: string;
|
|
168
|
+
piVersion: string;
|
|
169
|
+
piLoopsVersion: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ExportSummary {
|
|
173
|
+
outputPath: string;
|
|
174
|
+
sessionId: string;
|
|
175
|
+
entryCount: number;
|
|
176
|
+
hasTriggers: boolean;
|
|
177
|
+
hasCron: boolean;
|
|
178
|
+
loopStateCount: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function defaultExportPath(cwd: string, sessionId: string): string {
|
|
182
|
+
return path.join(cwd, `pi-session-${sessionId.slice(0, 16)}${ARCHIVE_EXT}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function exportSession(input: ExportInput): ExportSummary {
|
|
186
|
+
const sessionBytes = fs.readFileSync(input.sessionFile);
|
|
187
|
+
if (sessionBytes.length > MAX_SESSION_BYTES) throw new Error("session file exceeds the 50 MiB archive cap");
|
|
188
|
+
const parsed = parseSessionJsonl(sessionBytes.toString("utf8"));
|
|
189
|
+
// --exclude-triggers drops every automation sidecar (trigger rules and cron jobs); loop
|
|
190
|
+
// state follows the jobs.
|
|
191
|
+
const rules = input.excludeTriggers ? [] : input.rules;
|
|
192
|
+
const jobs = input.excludeTriggers ? [] : input.jobs;
|
|
193
|
+
const states = Object.entries(input.states).filter(([id, text]) => jobs.some((j) => j.id === id && j.stateful) && text.trim());
|
|
194
|
+
const manifest: Manifest = {
|
|
195
|
+
schema: ARCHIVE_SCHEMA,
|
|
196
|
+
created_at: stamp(),
|
|
197
|
+
pi_version: input.piVersion,
|
|
198
|
+
pi_loops_version: input.piLoopsVersion,
|
|
199
|
+
source: { session_id: parsed.header.id, cwd: input.cwd, session_path: input.sessionFile },
|
|
200
|
+
content: { session_jsonl_sha256: sha256(sessionBytes), entry_count: parsed.entryCount, has_triggers: rules.length > 0, has_cron: jobs.length > 0, loop_state_count: states.length },
|
|
201
|
+
sensitivity: { session_transcript_preserved: true, separate_auth_stores_included: false, provider_credentials_included: false, mcp_config_included: false, inbox_included: false },
|
|
202
|
+
};
|
|
203
|
+
const entries: Array<{ name: string; data: Buffer }> = [
|
|
204
|
+
{ name: MANIFEST_PATH, data: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`) },
|
|
205
|
+
{ name: SESSION_PATH, data: sessionBytes },
|
|
206
|
+
];
|
|
207
|
+
if (jobs.length) entries.push({ name: CRON_PATH, data: Buffer.from(`${JSON.stringify({ version: 1, jobs }, null, 2)}\n`) });
|
|
208
|
+
if (rules.length) entries.push({ name: TRIGGERS_PATH, data: Buffer.from(`${JSON.stringify({ version: 1, rules }, null, 2)}\n`) });
|
|
209
|
+
for (const [id, text] of states) entries.push({ name: `${LOOPS_DIR}${id}.md`, data: Buffer.from(`${capChars(text, LOOP_STATE_MAX_CHARS)}\n`) });
|
|
210
|
+
for (const e of entries) if (e.name !== SESSION_PATH && e.data.length > MAX_SIDECAR_BYTES) throw new Error(`${e.name} exceeds the 2 MiB sidecar cap`);
|
|
211
|
+
// Owner-only, and never truncate an existing file.
|
|
212
|
+
const fd = fs.openSync(input.outputPath, "wx", 0o600);
|
|
213
|
+
try {
|
|
214
|
+
fs.writeFileSync(fd, writeTar(entries));
|
|
215
|
+
} finally {
|
|
216
|
+
fs.closeSync(fd);
|
|
217
|
+
}
|
|
218
|
+
return { outputPath: input.outputPath, sessionId: parsed.header.id, entryCount: parsed.entryCount, hasTriggers: rules.length > 0, hasCron: jobs.length > 0, loopStateCount: states.length };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* --------------------------------------------------------------- import */
|
|
222
|
+
|
|
223
|
+
export interface ImportInput {
|
|
224
|
+
archivePath: string;
|
|
225
|
+
/** Directory pi keeps this project's sessions in (`ctx.sessionManager.getSessionDir()`). */
|
|
226
|
+
sessionDir: string;
|
|
227
|
+
targetCwd: string;
|
|
228
|
+
/** --activate-triggers: automation stays disabled unless true. */
|
|
229
|
+
activate: boolean;
|
|
230
|
+
existingJobIds: Set<string>;
|
|
231
|
+
existingRuleIds: Set<string>;
|
|
232
|
+
/**
|
|
233
|
+
* What the store already holds. With them, a job/rule this archive was already imported from is
|
|
234
|
+
* recognised by provenance + payload, so re-importing adds nothing and importing the same archive
|
|
235
|
+
* into a second project still works; without them only a taken id can be recognised, which covers
|
|
236
|
+
* the plain re-import but treats a `--cwd` copy as a duplicate too.
|
|
237
|
+
*/
|
|
238
|
+
existingJobs?: LoopJob[];
|
|
239
|
+
existingRules?: DynamicTriggerRule[];
|
|
240
|
+
now?: () => Date;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What makes two imported jobs "the same job": the session that first created it (provenance that
|
|
245
|
+
* travels with the archive), the project it runs in, and what it runs. Ids alone are not enough —
|
|
246
|
+
* an older pi-loops regenerated them on collision, so a second copy carries a different id.
|
|
247
|
+
*/
|
|
248
|
+
function jobKey(job: { createdBy?: { sessionId?: string }; cwd: string; prompt: string }): string {
|
|
249
|
+
return [job.createdBy?.sessionId ?? "", job.cwd, job.prompt].join("\u0000");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ruleKey(rule: { createdBy?: { sessionId?: string }; cwd: string; condition: string; action: string }): string {
|
|
253
|
+
return [rule.createdBy?.sessionId ?? "", rule.cwd, rule.condition, rule.action].join("\u0000");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface ImportSummary {
|
|
257
|
+
sessionId: string;
|
|
258
|
+
sessionPath: string;
|
|
259
|
+
originalSessionId: string;
|
|
260
|
+
entryCount: number;
|
|
261
|
+
jobs: LoopJob[];
|
|
262
|
+
rules: DynamicTriggerRule[];
|
|
263
|
+
/** loop state per (possibly regenerated) job id */
|
|
264
|
+
states: Record<string, string>;
|
|
265
|
+
originallyEnabledJobs: string[];
|
|
266
|
+
originallyEnabledRules: string[];
|
|
267
|
+
automationEnabled: boolean;
|
|
268
|
+
manifest: Manifest;
|
|
269
|
+
/** Already present in the store and therefore not imported again (a second import of one archive). */
|
|
270
|
+
skippedJobs: number;
|
|
271
|
+
skippedRules: number;
|
|
272
|
+
/** What the import did that the caller should say out loud. */
|
|
273
|
+
notes: string[];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Ids become file and directory names (`state/<id>.md`, `sessions/<id>/`): plain tokens only. */
|
|
277
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* What an archive holds, without writing anything. A backup format you cannot look inside is one
|
|
281
|
+
* you have to trust blindly at exactly the moment you are least able to (restoring on a new
|
|
282
|
+
* machine); a summary is printed before importing, which makes it available on its own.
|
|
283
|
+
*/
|
|
284
|
+
export function inspectArchive(archivePath: string): { schema: string; createdAt: string; sourceCwd: string; entryCount: number; loopStateCount: number; jobs: Array<{ schedule: string; prompt: string; enabled: boolean }>; rules: Array<{ condition: string; action: string; enabled: boolean }> } {
|
|
285
|
+
const files = readTar(fs.readFileSync(archivePath));
|
|
286
|
+
for (const name of files.keys()) validateArchivePath(name);
|
|
287
|
+
const manifestBytes = files.get(MANIFEST_PATH);
|
|
288
|
+
if (!manifestBytes) throw new Error("archive has no manifest.json");
|
|
289
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8")) as Manifest;
|
|
290
|
+
const jobs: Array<{ schedule: string; prompt: string; enabled: boolean }> = [];
|
|
291
|
+
const rules: Array<{ condition: string; action: string; enabled: boolean }> = [];
|
|
292
|
+
const cron = files.get(CRON_PATH);
|
|
293
|
+
if (cron) {
|
|
294
|
+
for (const j of (JSON.parse(cron.toString("utf8"))?.jobs ?? []) as LoopJob[]) {
|
|
295
|
+
jobs.push({ schedule: isValidSchedule(j.schedule) ? formatSchedule(j.schedule) : "(invalid)", prompt: previewRedacted(j.prompt ?? "", 100), enabled: !!j.enabled });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const trig = files.get(TRIGGERS_PATH);
|
|
299
|
+
if (trig) {
|
|
300
|
+
for (const r of (JSON.parse(trig.toString("utf8"))?.rules ?? []) as DynamicTriggerRule[]) {
|
|
301
|
+
rules.push({ condition: previewRedacted(r.condition ?? "", 80), action: previewRedacted(r.action ?? "", 80), enabled: !!r.enabled });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
schema: manifest?.schema ?? "(unknown)",
|
|
306
|
+
createdAt: manifest?.created_at ?? "(unknown)",
|
|
307
|
+
sourceCwd: manifest?.source?.cwd ?? "(unknown)",
|
|
308
|
+
entryCount: manifest?.content?.entry_count ?? 0,
|
|
309
|
+
loopStateCount: manifest?.content?.loop_state_count ?? 0,
|
|
310
|
+
jobs,
|
|
311
|
+
rules,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function importSession(input: ImportInput): ImportSummary {
|
|
316
|
+
const files = readTar(fs.readFileSync(input.archivePath));
|
|
317
|
+
for (const name of files.keys()) validateArchivePath(name);
|
|
318
|
+
const manifestBytes = files.get(MANIFEST_PATH);
|
|
319
|
+
if (!manifestBytes) throw new Error("archive has no manifest.json");
|
|
320
|
+
if (manifestBytes.length > MAX_MANIFEST_BYTES) throw new Error("manifest.json exceeds cap");
|
|
321
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8")) as Manifest;
|
|
322
|
+
if (manifest?.schema !== ARCHIVE_SCHEMA) throw new Error(`unsupported archive schema ${JSON.stringify(manifest?.schema)} (expected ${ARCHIVE_SCHEMA})`);
|
|
323
|
+
const sessionBytes = files.get(SESSION_PATH);
|
|
324
|
+
if (!sessionBytes) throw new Error("archive has no session.jsonl");
|
|
325
|
+
if (sessionBytes.length > MAX_SESSION_BYTES) throw new Error("session.jsonl exceeds the 50 MiB cap");
|
|
326
|
+
if (sha256(sessionBytes) !== manifest.content?.session_jsonl_sha256) throw new Error("session.jsonl does not match the manifest checksum");
|
|
327
|
+
const parsed = parseSessionJsonl(sessionBytes.toString("utf8"));
|
|
328
|
+
|
|
329
|
+
// Fresh id + local cwd; provenance kept in the header as `importedFrom`.
|
|
330
|
+
const now = (input.now ?? (() => new Date()))();
|
|
331
|
+
const sessionId = randomUUID();
|
|
332
|
+
// UTC here, against the rule everywhere else: this goes into pi's own session header and into
|
|
333
|
+
// the file name beside it. The header's format is pi's to decide, and a file name cannot hold
|
|
334
|
+
// the `+` and `:` an offset brings.
|
|
335
|
+
const timestamp = now.toISOString();
|
|
336
|
+
const { parentSession: _parent, parentSessionPath: _parentPath, ...headerRest } = parsed.header as Record<string, unknown>;
|
|
337
|
+
const header = { ...headerRest, id: sessionId, cwd: input.targetCwd, timestamp, importedFrom: { session_id: parsed.header.id, cwd: manifest.source?.cwd, exported_at: manifest.created_at, pi_version: manifest.pi_version, pi_loops_version: manifest.pi_loops_version } };
|
|
338
|
+
const sessionPath = path.join(input.sessionDir, `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
|
|
339
|
+
|
|
340
|
+
// Every sidecar is staged and validated before anything is committed: a rejected archive
|
|
341
|
+
// must not leave an orphan session file behind.
|
|
342
|
+
const dedup = new Dedup(input);
|
|
343
|
+
const idMap = new Map<string, string>();
|
|
344
|
+
const jobs: LoopJob[] = [];
|
|
345
|
+
const originallyEnabledJobs: string[] = [];
|
|
346
|
+
const cronBytes = files.get(CRON_PATH);
|
|
347
|
+
if (cronBytes) {
|
|
348
|
+
if (cronBytes.length > MAX_SIDECAR_BYTES) throw new Error("cron sidecar exceeds cap");
|
|
349
|
+
const file = JSON.parse(cronBytes.toString("utf8"));
|
|
350
|
+
if (!Array.isArray(file?.jobs)) throw new Error("cron sidecar has no jobs array");
|
|
351
|
+
for (const raw of file.jobs as LoopJob[]) {
|
|
352
|
+
if (!raw || typeof raw.id !== "string" || typeof raw.prompt !== "string") throw new Error("cron sidecar contains an invalid job");
|
|
353
|
+
// The schedule drives `computeDue` on every tick in every pi: a malformed one from a
|
|
354
|
+
// hand-made archive would throw there, and the tick has no per-job recovery upstream.
|
|
355
|
+
if (!isValidSchedule(raw.schedule)) throw new Error(`cron sidecar contains an invalid schedule for job ${raw.id}`);
|
|
356
|
+
// And one that parses but can never match ("0 0 30 2 *"): the job would go quiet with nothing
|
|
357
|
+
// to see, and every scan for its next run walks the whole lookahead window on the leader's
|
|
358
|
+
// tick. `/cron add` and `/cron set` have refused it since they existed; an archive is user
|
|
359
|
+
// input at the same boundary. Only cron is asked — a `once` whose time has passed is due now.
|
|
360
|
+
if (raw.schedule.kind === "cron" && computeNext({ schedule: raw.schedule, createdAt: Date.parse(raw.createdAt) || Date.now() }, Date.now()) === undefined) {
|
|
361
|
+
throw new Error(`cron sidecar contains a schedule with no next run for job ${raw.id}: ${formatSchedule(raw.schedule)}`);
|
|
362
|
+
}
|
|
363
|
+
if (!SAFE_ID.test(raw.id)) throw new Error("cron sidecar contains an invalid job id");
|
|
364
|
+
// Automation off unless activated, stale run bookkeeping cleared.
|
|
365
|
+
// `host` is a hard run-time filter (scheduler.ts), so an archive restored on another
|
|
366
|
+
// machine must be re-stamped or every job would look enabled and never fire.
|
|
367
|
+
// `createdBy` is left as the archive carries it: it names the session that created the job,
|
|
368
|
+
// which is what lets a second import of the same archive recognise itself. Re-stamping it
|
|
369
|
+
// with the fresh import session would make every import look new and double the automation.
|
|
370
|
+
const job: LoopJob = {
|
|
371
|
+
...raw,
|
|
372
|
+
cwd: input.targetCwd,
|
|
373
|
+
host: os.hostname(),
|
|
374
|
+
enabled: raw.enabled && input.activate,
|
|
375
|
+
running: undefined,
|
|
376
|
+
lastDueAt: undefined,
|
|
377
|
+
lastError: undefined,
|
|
378
|
+
skippedOverlap: 0,
|
|
379
|
+
sessionId: raw.stateful ? undefined : sessionId,
|
|
380
|
+
createdBy: raw.createdBy ?? { sessionId: parsed.header.id, cwd: manifest.source?.cwd ?? input.targetCwd },
|
|
381
|
+
};
|
|
382
|
+
if (dedup.hasJob(job)) continue;
|
|
383
|
+
if (input.existingJobIds.has(job.id)) job.id = newId("cron");
|
|
384
|
+
idMap.set(raw.id, job.id);
|
|
385
|
+
if (raw.enabled) originallyEnabledJobs.push(job.id);
|
|
386
|
+
jobs.push(job);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const rules: DynamicTriggerRule[] = [];
|
|
390
|
+
const originallyEnabledRules: string[] = [];
|
|
391
|
+
const trigBytes = files.get(TRIGGERS_PATH);
|
|
392
|
+
if (trigBytes) {
|
|
393
|
+
if (trigBytes.length > MAX_SIDECAR_BYTES) throw new Error("trigger sidecar exceeds cap");
|
|
394
|
+
const file = JSON.parse(trigBytes.toString("utf8"));
|
|
395
|
+
if (!Array.isArray(file?.rules)) throw new Error("trigger sidecar has no rules array");
|
|
396
|
+
for (const raw of file.rules as DynamicTriggerRule[]) {
|
|
397
|
+
if (!raw || typeof raw.id !== "string" || typeof raw.condition !== "string" || typeof raw.action !== "string") throw new Error("trigger sidecar contains an invalid rule");
|
|
398
|
+
if (!SAFE_ID.test(raw.id)) throw new Error("trigger sidecar contains an invalid rule id");
|
|
399
|
+
const rule: DynamicTriggerRule = { ...raw, cwd: input.targetCwd, host: os.hostname(), enabled: raw.enabled && input.activate, createdBy: raw.createdBy ?? { sessionId: parsed.header.id } };
|
|
400
|
+
if (dedup.hasRule(rule)) continue;
|
|
401
|
+
if (input.existingRuleIds.has(rule.id)) rule.id = newRuleId();
|
|
402
|
+
if (raw.enabled) originallyEnabledRules.push(rule.id);
|
|
403
|
+
rules.push(rule);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const states: Record<string, string> = {};
|
|
407
|
+
for (const [name, data] of files) {
|
|
408
|
+
if (!name.startsWith(LOOPS_DIR) || !name.endsWith(".md")) continue;
|
|
409
|
+
if (data.length > MAX_SIDECAR_BYTES) throw new Error(`${name} exceeds cap`);
|
|
410
|
+
const original = name.slice(LOOPS_DIR.length, -".md".length);
|
|
411
|
+
const id = idMap.get(original);
|
|
412
|
+
if (id) states[id] = capChars(data.toString("utf8"), LOOP_STATE_MAX_CHARS);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
fs.mkdirSync(input.sessionDir, { recursive: true });
|
|
416
|
+
const fd = fs.openSync(sessionPath, "wx", 0o600);
|
|
417
|
+
try {
|
|
418
|
+
fs.writeFileSync(fd, `${[JSON.stringify(header), ...parsed.rest].join("\n")}\n`);
|
|
419
|
+
} finally {
|
|
420
|
+
fs.closeSync(fd);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
sessionId,
|
|
424
|
+
sessionPath,
|
|
425
|
+
originalSessionId: parsed.header.id,
|
|
426
|
+
entryCount: parsed.entryCount,
|
|
427
|
+
jobs,
|
|
428
|
+
rules,
|
|
429
|
+
states,
|
|
430
|
+
originallyEnabledJobs,
|
|
431
|
+
originallyEnabledRules,
|
|
432
|
+
automationEnabled: input.activate,
|
|
433
|
+
manifest,
|
|
434
|
+
skippedJobs: dedup.jobs,
|
|
435
|
+
skippedRules: dedup.rules,
|
|
436
|
+
notes: dedup.notes(),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Recognises automation this store already imported from the same archive. Without it, importing an
|
|
442
|
+
* archive twice leaves two live copies of every job and rule, each running and each billing.
|
|
443
|
+
*/
|
|
444
|
+
class Dedup {
|
|
445
|
+
jobs = 0;
|
|
446
|
+
rules = 0;
|
|
447
|
+
private readonly input: ImportInput;
|
|
448
|
+
private readonly jobKeys?: Set<string>;
|
|
449
|
+
private readonly ruleKeys?: Set<string>;
|
|
450
|
+
constructor(input: ImportInput) {
|
|
451
|
+
this.input = input;
|
|
452
|
+
if (input.existingJobs) this.jobKeys = new Set(input.existingJobs.map(jobKey));
|
|
453
|
+
if (input.existingRules) this.ruleKeys = new Set(input.existingRules.map(ruleKey));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
hasJob(job: LoopJob): boolean {
|
|
457
|
+
// Without the store's jobs, a taken id is the only evidence available — and an id in an archive
|
|
458
|
+
// is stable, so a taken one does mean "imported already".
|
|
459
|
+
const dup = this.jobKeys ? this.jobKeys.has(jobKey(job)) : this.input.existingJobIds.has(job.id);
|
|
460
|
+
if (dup) this.jobs++;
|
|
461
|
+
return dup;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
hasRule(rule: DynamicTriggerRule): boolean {
|
|
465
|
+
const dup = this.ruleKeys ? this.ruleKeys.has(ruleKey(rule)) : this.input.existingRuleIds.has(rule.id);
|
|
466
|
+
if (dup) this.rules++;
|
|
467
|
+
return dup;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
notes(): string[] {
|
|
471
|
+
const out: string[] = [];
|
|
472
|
+
if (this.jobs) out.push(`${this.jobs} cron job(s) already imported from this archive were skipped`);
|
|
473
|
+
if (this.rules) out.push(`${this.rules} trigger rule(s) already imported from this archive were skipped`);
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
}
|