@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/src/store.ts
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent job store: one JSON file shared by every pi process on the machine,
|
|
3
|
+
* plus per-job loop-state Markdown files and a run log.
|
|
4
|
+
*
|
|
5
|
+
* Layout (default root ~/.pi/agent/loops, override with PI_LOOPS_DIR):
|
|
6
|
+
* jobs.json all jobs (global, survives pi restarts)
|
|
7
|
+
* state/<job-id>.md loop state — the "state spine", human-readable
|
|
8
|
+
* inbox.jsonl the triage inbox (see inbox.ts)
|
|
9
|
+
* runs.jsonl run log, bounded
|
|
10
|
+
* scheduler.<host>.json which process on this machine currently owns the timer
|
|
11
|
+
* *.lock mkdir locks
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { withFileLock, withFileLockSync, writeFileAtomic } from "./lock.ts";
|
|
18
|
+
import { LOOP_STATE_MAX_CHARS, capChars } from "./protocol.ts";
|
|
19
|
+
import type { Schedule } from "./schedule.ts";
|
|
20
|
+
|
|
21
|
+
export const MAX_PROMPT_BYTES = 8192;
|
|
22
|
+
|
|
23
|
+
export interface RunningMarker {
|
|
24
|
+
/** The machine that started it: another host's pid table says nothing about this run. */
|
|
25
|
+
host?: string;
|
|
26
|
+
runId: string;
|
|
27
|
+
pid: number;
|
|
28
|
+
startedAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LoopJob {
|
|
32
|
+
id: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
schedule: Schedule;
|
|
35
|
+
/** false = inject-and-run into the owning session; true = loop (fresh sub-agent + state spine + inbox). */
|
|
36
|
+
stateful: boolean;
|
|
37
|
+
prompt: string;
|
|
38
|
+
/** Working directory the sub-agent runs in. */
|
|
39
|
+
cwd: string;
|
|
40
|
+
/** "provider/model-id" for the sub-agent; undefined → inherit from the hosting session at run time. */
|
|
41
|
+
model?: string;
|
|
42
|
+
thinking?: string;
|
|
43
|
+
tools?: string[];
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
/** Maker/checker: a second sub-agent reviews findings before they enter the inbox. */
|
|
46
|
+
verify?: boolean;
|
|
47
|
+
/** "provider/model-id" for the checker; undefined → same as the maker. */
|
|
48
|
+
checkerModel?: string;
|
|
49
|
+
/** Fire once on startup if a tick was missed while no pi was running (default true). */
|
|
50
|
+
catchUp: boolean;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
createdBy?: { sessionId?: string; cwd: string };
|
|
54
|
+
/** Host the job belongs to (shared $HOME across machines): other hosts ignore it. Missing = any host (pre-0.1.3). */
|
|
55
|
+
host?: string;
|
|
56
|
+
/** non-stateful jobs only: the session that receives the message. */
|
|
57
|
+
sessionId?: string;
|
|
58
|
+
lastDueAt?: string;
|
|
59
|
+
lastFiredAt?: string;
|
|
60
|
+
lastCompletedAt?: string;
|
|
61
|
+
lastError?: string;
|
|
62
|
+
running?: RunningMarker;
|
|
63
|
+
runCount: number;
|
|
64
|
+
skippedOverlap: number;
|
|
65
|
+
/** Failures in a row. Cleared by a success; drives the backoff in the scheduler. */
|
|
66
|
+
consecutiveFailures?: number;
|
|
67
|
+
/**
|
|
68
|
+
* When this job's `cwd` was first found missing, if it still is.
|
|
69
|
+
*
|
|
70
|
+
* A directory that is not there at this instant is usually not a directory that is gone: a
|
|
71
|
+
* network mount, an external disk or an encrypted volume comes up *after* the first pi does at
|
|
72
|
+
* boot. Disabling on the first miss killed jobs for being twenty seconds early. Cleared the
|
|
73
|
+
* moment the directory is back — so this is a transient marker like `lastError`, which is why
|
|
74
|
+
* it does not bump `JOBS_FILE_VERSION`: an older build sharing this directory drops it on its
|
|
75
|
+
* next write, and the only consequence is that the grace period starts again.
|
|
76
|
+
*/
|
|
77
|
+
cwdMissingSince?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RunRecord {
|
|
81
|
+
runId: string;
|
|
82
|
+
jobId: string;
|
|
83
|
+
jobName?: string;
|
|
84
|
+
stateful: boolean;
|
|
85
|
+
cwd: string;
|
|
86
|
+
pid: number;
|
|
87
|
+
startedAt: string;
|
|
88
|
+
finishedAt: string;
|
|
89
|
+
ok: boolean;
|
|
90
|
+
exitCode?: number;
|
|
91
|
+
error?: string;
|
|
92
|
+
findings: number;
|
|
93
|
+
droppedFindings: number;
|
|
94
|
+
stateUpdated: boolean;
|
|
95
|
+
model?: string;
|
|
96
|
+
usage?: { input: number; output: number; cost: number; turns: number; cacheRead?: number; cacheWrite?: number };
|
|
97
|
+
/** Something the run survived but the user should know about (a pinned model that fell back). */
|
|
98
|
+
warning?: string;
|
|
99
|
+
/** Provider retries and context compactions inside the run: a quiet retry storm looks clean without these. */
|
|
100
|
+
retries?: number;
|
|
101
|
+
compactions?: number;
|
|
102
|
+
summary?: string;
|
|
103
|
+
/** Child session id / transcript file (inspect with /cron trace, resume with `pi --session <file>`). */
|
|
104
|
+
sessionId?: string;
|
|
105
|
+
sessionFile?: string;
|
|
106
|
+
/** Present when the job has verify=true and the maker reported findings. */
|
|
107
|
+
checker?: CheckerRecord;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CheckerRecord {
|
|
111
|
+
ok: boolean;
|
|
112
|
+
error?: string;
|
|
113
|
+
/** Findings the checker kept (after rewrite) — these entered the inbox. */
|
|
114
|
+
kept: number;
|
|
115
|
+
/** Findings the checker dropped, with its reasons. */
|
|
116
|
+
dropped: Array<{ text: string; reason: string }>;
|
|
117
|
+
/** Findings the checker gave no verdict for; they enter the inbox marked unverified. */
|
|
118
|
+
unreviewed: number;
|
|
119
|
+
durationMs: number;
|
|
120
|
+
cost: number;
|
|
121
|
+
model?: string;
|
|
122
|
+
sessionFile?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Transcripts kept per loop; older ones are deleted when a run completes. */
|
|
126
|
+
export const SESSIONS_KEPT_PER_JOB = 20;
|
|
127
|
+
|
|
128
|
+
/** Past this the run log is halved (docs/configuration.md). */
|
|
129
|
+
export const RUNS_ROTATE_BYTES = 1_000_000;
|
|
130
|
+
|
|
131
|
+
export function defaultLoopsDir(agentDir?: string): string {
|
|
132
|
+
if (process.env.PI_LOOPS_DIR) return process.env.PI_LOOPS_DIR;
|
|
133
|
+
const base = agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
134
|
+
return path.join(base, "loops");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Which session a plain (inject) job belongs to. A sub-agent that schedules one is acting for the
|
|
139
|
+
* session that runs it, so the parent's id wins over the child's throwaway one. Loops are
|
|
140
|
+
* machine-global and belong to no session.
|
|
141
|
+
*/
|
|
142
|
+
export function owningSessionId(stateful: boolean, sessionId: string | undefined, parentSessionId?: string): string | undefined {
|
|
143
|
+
if (stateful) return undefined;
|
|
144
|
+
return parentSessionId || sessionId;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Does pi still have a session with this id? A plain job whose session is gone can never inject
|
|
149
|
+
* again, so this is what parks one.
|
|
150
|
+
*
|
|
151
|
+
* pi names the sessions it starts `<sessionsRoot>/<encoded cwd>/<timestamp>_<id>.jsonl`, and that
|
|
152
|
+
* name is the cheap answer. It is not the authority, though: a session started from the browser
|
|
153
|
+
* front end is created by asking pi to switch to a path that does not exist yet, and the id pi
|
|
154
|
+
* mints for it cannot be known in time to put in the name. Believing the name disabled every
|
|
155
|
+
* inject-and-run job in such a session ten minutes after it was made — and `/cron gc` deletes what
|
|
156
|
+
* this parks. The header is what `listSessions` already reads, and it is what decides here too.
|
|
157
|
+
*/
|
|
158
|
+
export function sessionExists(sessionsRoot: string, sessionId: string): boolean {
|
|
159
|
+
let projects: string[];
|
|
160
|
+
try {
|
|
161
|
+
projects = fs.readdirSync(sessionsRoot);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const suffix = `_${sessionId}.jsonl`;
|
|
166
|
+
const rest: Array<[string, string[]]> = [];
|
|
167
|
+
for (const p of projects) {
|
|
168
|
+
const dir = path.join(sessionsRoot, p);
|
|
169
|
+
let files: string[];
|
|
170
|
+
try {
|
|
171
|
+
files = fs.readdirSync(dir);
|
|
172
|
+
} catch {
|
|
173
|
+
continue; // not a directory
|
|
174
|
+
}
|
|
175
|
+
if (files.some((f) => f.endsWith(suffix) || f === `${sessionId}.jsonl`)) return true;
|
|
176
|
+
rest.push([dir, files]);
|
|
177
|
+
}
|
|
178
|
+
// Nothing is named after it, which is the usual answer for a session that is really gone — so
|
|
179
|
+
// this second pass runs on the way to "no", and the scan that calls it runs every ten minutes.
|
|
180
|
+
for (const [dir, files] of rest) {
|
|
181
|
+
for (const f of files) {
|
|
182
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
183
|
+
if (sessionIdOf(path.join(dir, f)) === sessionId) return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The id in a session file's header, without reading the transcript behind it. */
|
|
190
|
+
function sessionIdOf(file: string): string | undefined {
|
|
191
|
+
let head: string;
|
|
192
|
+
try {
|
|
193
|
+
// A regular file, checked before it is opened. Anything else in this directory is not a
|
|
194
|
+
// session — and a fifo named like one never returns from `openSync`, which would hang the
|
|
195
|
+
// scan this is called from, on the leader's tick, for ever.
|
|
196
|
+
if (!fs.statSync(file).isFile()) return undefined;
|
|
197
|
+
const fd = fs.openSync(file, "r");
|
|
198
|
+
try {
|
|
199
|
+
const buf = Buffer.alloc(4096);
|
|
200
|
+
head = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, buf.length, 0));
|
|
201
|
+
} finally {
|
|
202
|
+
fs.closeSync(fd);
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const header = JSON.parse(head.split("\n", 1)[0] ?? "");
|
|
209
|
+
return header?.type === "session" && typeof header.id === "string" ? header.id : undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `<prefix>-<uuid simple>` (32 hex). Prefixes, names and ordinals still resolve (`resolveJobRef`). */
|
|
216
|
+
export function newId(prefix: string): string {
|
|
217
|
+
return `${prefix}-${randomBytes(16).toString("hex")}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A hostname as one filename component: `scheduler.<tag>.json`, `next-runs.<tag>.json`, one
|
|
222
|
+
* presence file per pi. Two machines sharing a `$HOME` must not write over each other, and a
|
|
223
|
+
* hostname may contain characters a path segment may not.
|
|
224
|
+
*
|
|
225
|
+
* `src/web.mjs` spells the same expression out again because it has no imports, and `src/presence.ts`
|
|
226
|
+
* has its own copy; `test/store.test.ts` pins those spellings to this one.
|
|
227
|
+
*/
|
|
228
|
+
export function hostFileTag(host: string = os.hostname()): string {
|
|
229
|
+
return host.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* On-disk shape of `jobs.json`, bumped whenever a field becomes load-bearing, so an older pi-loops sharing this directory refuses
|
|
234
|
+
* the file instead of silently rewriting it without that field. Version 2 adds `host` (which gates
|
|
235
|
+
* dispatch), `verify`/`checkerModel`, `timeoutMs` and `consecutiveFailures`.
|
|
236
|
+
*/
|
|
237
|
+
export const JOBS_FILE_VERSION = 2;
|
|
238
|
+
|
|
239
|
+
/** Local `YYYY-MM-DD`, so a day boundary means the same thing here as it does to the user. */
|
|
240
|
+
function localDay(ms: number): string {
|
|
241
|
+
return new Date(ms).toLocaleDateString("en-CA");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface JobsFile {
|
|
245
|
+
version: number;
|
|
246
|
+
jobs: LoopJob[];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export class JobStore {
|
|
250
|
+
readonly dir: string;
|
|
251
|
+
readonly jobsFile: string;
|
|
252
|
+
readonly stateDir: string;
|
|
253
|
+
readonly runsFile: string;
|
|
254
|
+
readonly sessionsDir: string;
|
|
255
|
+
private readonly lockPath: string;
|
|
256
|
+
private readonly runsLockPath: string;
|
|
257
|
+
|
|
258
|
+
constructor(dir: string) {
|
|
259
|
+
this.dir = dir;
|
|
260
|
+
this.jobsFile = path.join(dir, "jobs.json");
|
|
261
|
+
this.stateDir = path.join(dir, "state");
|
|
262
|
+
this.runsFile = path.join(dir, "runs.jsonl");
|
|
263
|
+
this.sessionsDir = path.join(dir, "sessions");
|
|
264
|
+
this.lockPath = path.join(dir, "jobs.lock");
|
|
265
|
+
this.runsLockPath = path.join(dir, "runs.lock");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Where a job's sub-agent transcripts live. Created on demand. */
|
|
269
|
+
sessionDirFor(jobId: string): string {
|
|
270
|
+
const dir = path.join(this.sessionsDir, jobId);
|
|
271
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
272
|
+
return dir;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Keep only the newest `keep` transcripts of a job. Best effort. */
|
|
276
|
+
pruneSessions(jobId: string, keep = SESSIONS_KEPT_PER_JOB): void {
|
|
277
|
+
const dir = path.join(this.sessionsDir, jobId);
|
|
278
|
+
try {
|
|
279
|
+
const files = fs
|
|
280
|
+
.readdirSync(dir)
|
|
281
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
282
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
283
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
284
|
+
for (const { f } of files.slice(keep)) fs.rmSync(path.join(dir, f), { force: true });
|
|
285
|
+
} catch {
|
|
286
|
+
/* nothing to prune */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Read all jobs. A missing file is an empty store; a corrupt or too-new file throws. */
|
|
291
|
+
load(): LoopJob[] {
|
|
292
|
+
return this.parse(this.read());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The file exactly as it is on disk, or undefined when it does not exist yet. */
|
|
296
|
+
private read(): string | undefined {
|
|
297
|
+
try {
|
|
298
|
+
return fs.readFileSync(this.jobsFile, "utf8");
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
if (err?.code === "ENOENT") return undefined;
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private parse(text: string | undefined): LoopJob[] {
|
|
306
|
+
if (text === undefined) return []; // no file yet: a machine with no jobs
|
|
307
|
+
// An existing but empty file is damage (a torn write, an external truncation), not "no jobs".
|
|
308
|
+
// Returning [] here would let the next tick's write erase every job with no error anywhere.
|
|
309
|
+
if (!text.trim()) throw new Error(`${this.jobsFile} is empty; restore it from ${this.jobsFile}.bak or delete it to start over`);
|
|
310
|
+
const parsed = JSON.parse(text) as JobsFile;
|
|
311
|
+
// A file stamped by a newer pi-loops may carry fields this build does not know and would
|
|
312
|
+
// drop on its next write. Refuse it instead: two versions sharing a $HOME must not silently
|
|
313
|
+
// downgrade each other's jobs.
|
|
314
|
+
if (typeof parsed?.version === "number" && parsed.version > JOBS_FILE_VERSION) {
|
|
315
|
+
throw new Error(`${this.jobsFile}: version ${parsed.version} was written by a newer pi-loops (this build understands ${JOBS_FILE_VERSION}); upgrade pi-loops`);
|
|
316
|
+
}
|
|
317
|
+
if (!Array.isArray(parsed?.jobs)) throw new Error(`${this.jobsFile}: missing "jobs" array`);
|
|
318
|
+
return parsed.jobs;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The last content that parsed, so a truncated `jobs.json` has something to restore from. */
|
|
322
|
+
private backup(text: string): void {
|
|
323
|
+
try {
|
|
324
|
+
writeFileAtomic(`${this.jobsFile}.bak`, text);
|
|
325
|
+
} catch {
|
|
326
|
+
/* a backup is a courtesy, never a reason to fail a write */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private serialize(jobs: LoopJob[]): string {
|
|
331
|
+
const file: JobsFile = { version: JOBS_FILE_VERSION, jobs };
|
|
332
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Read-modify-write under the cross-process lock. `fn` returns the new job list.
|
|
337
|
+
*
|
|
338
|
+
* A pass that changes nothing writes nothing: every pi window runs this on every 30s tick, so an
|
|
339
|
+
* unconditional save means three idle windows rewriting the file 8640 times a day — and a file
|
|
340
|
+
* whose mtime moves constantly tells a reader nothing about when the automation last changed.
|
|
341
|
+
*/
|
|
342
|
+
async mutate<T>(fn: (jobs: LoopJob[]) => { jobs: LoopJob[]; result: T }): Promise<T> {
|
|
343
|
+
return withFileLock(this.lockPath, () => {
|
|
344
|
+
const before = this.read();
|
|
345
|
+
const { jobs, result } = fn(this.parse(before));
|
|
346
|
+
if (before === undefined && !jobs.length) return result; // no file and no jobs: create nothing
|
|
347
|
+
const next = this.serialize(jobs);
|
|
348
|
+
if (next !== before) {
|
|
349
|
+
// The content that just parsed is worth keeping: it is what a truncated file is restored from.
|
|
350
|
+
if (before !== undefined) this.backup(before);
|
|
351
|
+
writeFileAtomic(this.jobsFile, next);
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Update one job in place. Returns the updated job, or undefined when it no longer exists. */
|
|
358
|
+
async update(id: string, patch: (job: LoopJob) => void): Promise<LoopJob | undefined> {
|
|
359
|
+
return this.mutate((jobs) => {
|
|
360
|
+
const job = jobs.find((j) => j.id === id);
|
|
361
|
+
if (job) patch(job);
|
|
362
|
+
return { jobs, result: job };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async add(job: LoopJob): Promise<LoopJob> {
|
|
367
|
+
return this.mutate((jobs) => ({ jobs: [...jobs, job], result: job }));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Remove every job matching `pred` (state files and transcripts included); returns them. */
|
|
371
|
+
async removeWhere(pred: (job: LoopJob) => boolean): Promise<LoopJob[]> {
|
|
372
|
+
const removed = await this.mutate((jobs) => ({ jobs: jobs.filter((j) => !pred(j)), result: jobs.filter(pred) }));
|
|
373
|
+
for (const job of removed) {
|
|
374
|
+
try {
|
|
375
|
+
fs.rmSync(this.statePath(job.id), { force: true });
|
|
376
|
+
fs.rmSync(path.join(this.sessionsDir, job.id), { recursive: true, force: true });
|
|
377
|
+
} catch {
|
|
378
|
+
/* best effort */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return removed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Remove a job. Its loop state — the notes it accumulated over months, and the whole reason a
|
|
386
|
+
* stateful loop can say "only what changed" — is kept unless `purge` is set, because the usual
|
|
387
|
+
* way to change a job's schedule or prompt is to remove it and add it again. Nothing deletes
|
|
388
|
+
* the state file from any production path either. Transcripts follow the state.
|
|
389
|
+
*/
|
|
390
|
+
async remove(id: string, opts: { purge?: boolean } = {}): Promise<LoopJob | undefined> {
|
|
391
|
+
const removed = await this.mutate((jobs) => {
|
|
392
|
+
const job = jobs.find((j) => j.id === id);
|
|
393
|
+
return { jobs: jobs.filter((j) => j.id !== id), result: job };
|
|
394
|
+
});
|
|
395
|
+
if (removed && opts.purge) {
|
|
396
|
+
try {
|
|
397
|
+
fs.rmSync(this.statePath(id), { force: true });
|
|
398
|
+
fs.rmSync(path.join(this.sessionsDir, id), { recursive: true, force: true });
|
|
399
|
+
} catch {
|
|
400
|
+
/* best effort */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return removed;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Delete one loop's state and transcripts (`/cron gc --purge`). */
|
|
407
|
+
purgeState(id: string): void {
|
|
408
|
+
try {
|
|
409
|
+
fs.rmSync(this.statePath(id), { force: true });
|
|
410
|
+
fs.rmSync(path.join(this.sessionsDir, id), { recursive: true, force: true });
|
|
411
|
+
} catch {
|
|
412
|
+
/* best effort */
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Loop state left behind by removed jobs, so `/cron gc` can offer to clear it. */
|
|
417
|
+
orphanStates(): Array<{ id: string; bytes: number }> {
|
|
418
|
+
const live = new Set(this.load().map((j) => j.id));
|
|
419
|
+
try {
|
|
420
|
+
return fs
|
|
421
|
+
.readdirSync(path.join(this.dir, "state"))
|
|
422
|
+
.filter((f) => f.endsWith(".md") && !live.has(f.slice(0, -3)))
|
|
423
|
+
.map((f) => ({ id: f.slice(0, -3), bytes: fs.statSync(path.join(this.dir, "state", f)).size }));
|
|
424
|
+
} catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ---------------------------------------------------- loop state */
|
|
430
|
+
|
|
431
|
+
statePath(jobId: string): string {
|
|
432
|
+
return path.join(this.stateDir, `${jobId}.md`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
readState(jobId: string): string | undefined {
|
|
436
|
+
try {
|
|
437
|
+
const text = fs.readFileSync(this.statePath(jobId), "utf8");
|
|
438
|
+
return text.trim() ? capChars(text, LOOP_STATE_MAX_CHARS) : undefined;
|
|
439
|
+
} catch (err: any) {
|
|
440
|
+
if (err?.code === "ENOENT") return undefined;
|
|
441
|
+
throw err;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
writeState(jobId: string, state: string): void {
|
|
446
|
+
writeFileAtomic(this.statePath(jobId), `${capChars(state, LOOP_STATE_MAX_CHARS)}\n`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* ------------------------------------------------------- run log */
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Append one run record, then rotate if the log grew past its cap. Both under `runs.lock`:
|
|
453
|
+
* rotation rewrites the whole file, so an append that walked in between another process's read
|
|
454
|
+
* and its write would simply be lost. Its own lock rather than `jobs.lock` — this is a
|
|
455
|
+
* synchronous spin, and `mutate` holds `jobs.lock` across an await.
|
|
456
|
+
*/
|
|
457
|
+
appendRun(record: RunRecord): void {
|
|
458
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
459
|
+
withFileLockSync(this.runsLockPath, () => {
|
|
460
|
+
fs.appendFileSync(this.runsFile, `${JSON.stringify(record)}\n`, "utf8");
|
|
461
|
+
this.rotateRuns();
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
listRuns(jobId?: string, limit = 20): RunRecord[] {
|
|
466
|
+
let text: string;
|
|
467
|
+
try {
|
|
468
|
+
text = fs.readFileSync(this.runsFile, "utf8");
|
|
469
|
+
} catch (err: any) {
|
|
470
|
+
if (err?.code === "ENOENT") return [];
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
const out: RunRecord[] = [];
|
|
474
|
+
for (const line of text.split("\n")) {
|
|
475
|
+
if (!line.trim()) continue;
|
|
476
|
+
try {
|
|
477
|
+
const rec = JSON.parse(line) as RunRecord;
|
|
478
|
+
if (!jobId || rec.jobId === jobId) out.push(rec);
|
|
479
|
+
} catch {
|
|
480
|
+
/* skip corrupt line */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return out.slice(-limit);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* What automation has cost since `sinceMs`. A loop that dies with its session never needs this;
|
|
488
|
+
* one that outlives it does, because nobody is watching the bill. A headless host runs for days,
|
|
489
|
+
* so the only way a user learns the cost is if something adds it up.
|
|
490
|
+
*
|
|
491
|
+
* `total` includes `rotated` — costs whose individual records the log has already
|
|
492
|
+
* dropped — because a cap that forgets what rotation ate stops capping. `byJob` covers only the
|
|
493
|
+
* records still in the log, and `sinceMs` is rounded down to its local day for the rotated part.
|
|
494
|
+
*/
|
|
495
|
+
spend(sinceMs: number): { total: number; runs: number; rotated: number; byJob: Map<string, { cost: number; runs: number; name?: string }> } {
|
|
496
|
+
const byJob = new Map<string, { cost: number; runs: number; name?: string }>();
|
|
497
|
+
let total = 0;
|
|
498
|
+
let runs = 0;
|
|
499
|
+
for (const rec of this.allRuns()) {
|
|
500
|
+
const at = Date.parse(rec.finishedAt || rec.startedAt);
|
|
501
|
+
// An unparseable stamp must not count toward today forever: one such record carrying a
|
|
502
|
+
// real cost above the cap would pause every job on the machine, permanently.
|
|
503
|
+
if (!Number.isFinite(at) || at < sinceMs) continue;
|
|
504
|
+
const cost = (rec.usage?.cost ?? 0) + (rec.checker?.cost ?? 0);
|
|
505
|
+
total += cost;
|
|
506
|
+
runs++;
|
|
507
|
+
const entry = byJob.get(rec.jobId) ?? { cost: 0, runs: 0, name: rec.jobName };
|
|
508
|
+
entry.cost += cost;
|
|
509
|
+
entry.runs++;
|
|
510
|
+
if (rec.jobName) entry.name = rec.jobName;
|
|
511
|
+
byJob.set(rec.jobId, entry);
|
|
512
|
+
}
|
|
513
|
+
const rotated = this.rotatedSpend(sinceMs);
|
|
514
|
+
return { total: total + rotated, runs, rotated, byJob };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Every record still in the log (it is rotated, so this is bounded). */
|
|
518
|
+
allRuns(): RunRecord[] {
|
|
519
|
+
return this.listRuns(undefined, Number.MAX_SAFE_INTEGER);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Halve the log once it passes `RUNS_ROTATE_BYTES`, after folding what is about to be dropped
|
|
524
|
+
* into the daily totals. The log rotates by size, so on a busy machine the morning's records can
|
|
525
|
+
* be gone before the day is over — and a spend cap reading only the log would then see the day as
|
|
526
|
+
* cheap and resume dispatching. `spend.json` is a few hundred bytes and rotation never touches
|
|
527
|
+
* it. Caller holds `runs.lock`.
|
|
528
|
+
*/
|
|
529
|
+
private rotateRuns(): void {
|
|
530
|
+
try {
|
|
531
|
+
const size = fs.statSync(this.runsFile).size;
|
|
532
|
+
if (size < RUNS_ROTATE_BYTES) return;
|
|
533
|
+
const lines = fs.readFileSync(this.runsFile, "utf8").split("\n").filter(Boolean);
|
|
534
|
+
const keep = Math.floor(lines.length / 2);
|
|
535
|
+
// One record can be over the limit on its own, and `slice(-0)` is the whole array — so
|
|
536
|
+
// without this the file would never shrink and its cost would be counted twice.
|
|
537
|
+
this.foldSpend(keep === 0 ? lines : lines.slice(0, lines.length - keep));
|
|
538
|
+
writeFileAtomic(this.runsFile, keep === 0 ? "" : `${lines.slice(-keep).join("\n")}\n`);
|
|
539
|
+
} catch {
|
|
540
|
+
/* best effort */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Daily totals that outlive rotation: `{ "2026-09-09": 1.23 }`, kept for a week. */
|
|
545
|
+
private foldSpend(dropped: string[]): void {
|
|
546
|
+
const totals = this.rotatedSpendByDay();
|
|
547
|
+
for (const line of dropped) {
|
|
548
|
+
try {
|
|
549
|
+
const rec = JSON.parse(line) as RunRecord;
|
|
550
|
+
const at = Date.parse(rec.finishedAt || rec.startedAt);
|
|
551
|
+
if (!Number.isFinite(at)) continue;
|
|
552
|
+
const day = localDay(at);
|
|
553
|
+
totals[day] = (totals[day] ?? 0) + (rec.usage?.cost ?? 0) + (rec.checker?.cost ?? 0);
|
|
554
|
+
} catch {
|
|
555
|
+
/* skip corrupt line */
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const cutoff = localDay(Date.now() - 7 * 86_400_000);
|
|
559
|
+
for (const day of Object.keys(totals)) if (day < cutoff) delete totals[day];
|
|
560
|
+
try {
|
|
561
|
+
writeFileAtomic(path.join(this.dir, "spend.json"), JSON.stringify(totals));
|
|
562
|
+
} catch {
|
|
563
|
+
/* best effort */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private rotatedSpendByDay(): Record<string, number> {
|
|
568
|
+
try {
|
|
569
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(this.dir, "spend.json"), "utf8")) as unknown;
|
|
570
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
571
|
+
const out: Record<string, number> = {};
|
|
572
|
+
for (const [day, cost] of Object.entries(parsed as Record<string, unknown>)) {
|
|
573
|
+
if (typeof cost === "number" && Number.isFinite(cost)) out[day] = cost;
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
} catch {
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** What rotation already folded away on or after `sinceMs`, so a cap still counts it. */
|
|
582
|
+
rotatedSpend(sinceMs: number): number {
|
|
583
|
+
const from = localDay(sinceMs);
|
|
584
|
+
let total = 0;
|
|
585
|
+
for (const [day, cost] of Object.entries(this.rotatedSpendByDay())) if (day >= from) total += cost;
|
|
586
|
+
return total;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Resolve "<n>" (1-based position in `jobs`) or an id / unique id prefix / exact name. */
|
|
591
|
+
export function resolveJobRef(jobs: LoopJob[], ref: string): LoopJob | undefined {
|
|
592
|
+
const trimmed = ref.trim();
|
|
593
|
+
if (!trimmed) return undefined;
|
|
594
|
+
if (/^\d+$/.test(trimmed)) return jobs[Number(trimmed) - 1];
|
|
595
|
+
const exact = jobs.find((j) => j.id === trimmed || j.name === trimmed);
|
|
596
|
+
if (exact) return exact;
|
|
597
|
+
const byPrefix = jobs.filter((j) => j.id.startsWith(trimmed));
|
|
598
|
+
return byPrefix.length === 1 ? byPrefix[0] : undefined;
|
|
599
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool-call gate every sub-session runs under: `src/danger.ts`'s policy, applied to each
|
|
3
|
+
* command a loop run, checker or trigger action tries to execute.
|
|
4
|
+
*
|
|
5
|
+
* pi-loops' own extension is deliberately not loaded in a sub-session (nothing may nest), so there
|
|
6
|
+
* is no extension there to hang a `tool_call` handler on. The gate is injected as a hidden
|
|
7
|
+
* synthetic extension instead — the one place where nobody is watching is the place that needs it.
|
|
8
|
+
*/
|
|
9
|
+
import { createSyntheticSourceInfo, type Extension } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { dangerousCommandReason } from "./danger.ts";
|
|
11
|
+
|
|
12
|
+
export const GUARD_PATH = "<pi-loops sub-agent guard>";
|
|
13
|
+
|
|
14
|
+
/** The shell command an event carries, for the tools that run one. */
|
|
15
|
+
function commandOf(event: any): string | undefined {
|
|
16
|
+
if (event?.toolName === "bash" || event?.toolName === "powershell") return typeof event.input?.command === "string" ? event.input.command : undefined;
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A hidden extension that blocks the dangerous-command corpus. `block` + `reason` is pi's
|
|
22
|
+
* documented way to refuse a call and tell the model why (ToolCallEventResult).
|
|
23
|
+
*/
|
|
24
|
+
export function subagentGuardExtension(log?: (message: string) => void, allow: readonly string[] = []): Extension {
|
|
25
|
+
const handler = async (...args: unknown[]) => {
|
|
26
|
+
const event: any = args[0];
|
|
27
|
+
const command = commandOf(event);
|
|
28
|
+
if (!command) return undefined;
|
|
29
|
+
const reason = dangerousCommandReason(command, undefined, allow);
|
|
30
|
+
if (!reason) return undefined;
|
|
31
|
+
log?.(`blocked a ${event.toolName} call: ${reason}`);
|
|
32
|
+
return { block: true, reason: `refused by pi-loops: ${reason}. This run is unattended, so commands that can destroy data or the machine are not allowed; do the safe part and report what you would need a human for.` };
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
path: GUARD_PATH,
|
|
36
|
+
resolvedPath: GUARD_PATH,
|
|
37
|
+
hidden: true,
|
|
38
|
+
sourceInfo: createSyntheticSourceInfo(GUARD_PATH, { source: "pi-loops" }),
|
|
39
|
+
handlers: new Map([["tool_call", [handler]]]),
|
|
40
|
+
tools: new Map(),
|
|
41
|
+
messageRenderers: new Map(),
|
|
42
|
+
entryRenderers: new Map(),
|
|
43
|
+
commands: new Map(),
|
|
44
|
+
flags: new Map(),
|
|
45
|
+
shortcuts: new Map(),
|
|
46
|
+
} as unknown as Extension;
|
|
47
|
+
}
|
package/src/thinking.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which thinking levels exist, and the one place that says so.
|
|
3
|
+
*
|
|
4
|
+
* A level is a string until it reaches `createAgentSession`, which casts it into pi's
|
|
5
|
+
* `ThinkingLevel` union — so `--thinking hgih` was stored, dispatched, and only noticed when the run
|
|
6
|
+
* failed hours later with pi's own error. The environment boundary validated it (the host's
|
|
7
|
+
* `PI_LOOPS_HOST_THINKING`); the command line and the job store did not.
|
|
8
|
+
*
|
|
9
|
+
* Its own module because both sides need it and neither wants the other: `args.ts` parses commands
|
|
10
|
+
* with no host code in reach, and `host.ts` reads an environment variable with no command parsing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The thinking levels pi knows (`ThinkingLevel` in pi's agent core). */
|
|
14
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
15
|
+
|
|
16
|
+
/** The value if it is a thinking level, undefined if it is anything else (the caller says so and falls back). */
|
|
17
|
+
export function thinkingLevelOrUndefined(value: string | undefined): string | undefined {
|
|
18
|
+
return value && (THINKING_LEVELS as readonly string[]).includes(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The value, or a refusal that lists what is accepted. For the paths where there is a person to tell
|
|
23
|
+
* and nothing sensible to fall back to: they asked for a level, and silently using another one is
|
|
24
|
+
* worse than saying it does not exist.
|
|
25
|
+
*/
|
|
26
|
+
export function requireThinkingLevel(value: string): string {
|
|
27
|
+
const level = thinkingLevelOrUndefined(value);
|
|
28
|
+
if (!level) throw new Error(`unknown thinking level "${value}"; pick one of ${THINKING_LEVELS.join(", ")}`);
|
|
29
|
+
return level;
|
|
30
|
+
}
|