@alphacoder-v0/pi-loops 0.22.7 → 0.24.0

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +681 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -41
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +598 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Requests addressed to a session: `requests/<sessionId>.jsonl`, private state. The file holds live
3
+ * rows only; a row leaves it once its delivery is in the conversation, or as a dead letter in the
4
+ * inbox. Rows are appended under that session's own lock (`requests/<sessionId>.lock`), and a
5
+ * removal or an update is an atomic rewrite under it, so one conversation's rewrite never holds
6
+ * another's writers. A person row's image bytes are in a file beside the queue, one per row, and
7
+ * the row carries their digests. The engine (src/engine.ts) decides what each row gets; this file
8
+ * only keeps them.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { sessionFileName } from "./lease.ts";
14
+ import { type LockOptions, withFileLockSync, writeFileAtomic } from "./lock.ts";
15
+
16
+ /** pi's image part, as `pi.sendUserMessage` takes it; pi does not export the type from its package. */
17
+ export interface ImageContent {
18
+ type: "image";
19
+ data: string;
20
+ mimeType: string;
21
+ }
22
+
23
+ /**
24
+ * An image of a person row as the queue file carries it: its type and a digest of its bytes. The
25
+ * bytes are in the row's image file (`RequestStore.images`), so three screenshots are a line in the
26
+ * queue, not thirty megabytes parsed at every listing and rewritten and synced at every update.
27
+ */
28
+ export interface ImageRef {
29
+ type: "image";
30
+ mimeType: string;
31
+ sha256: string;
32
+ }
33
+
34
+ export function imageRef(image: ImageContent): ImageRef {
35
+ return { type: "image", mimeType: image.mimeType, sha256: createHash("sha256").update(image.data).digest("hex") };
36
+ }
37
+
38
+ export type RequestKind = "input" | "stop" | "cancel";
39
+ export type RequestMode = "attended" | "unattended" | "none";
40
+ export type RequestSource =
41
+ | `cron:${string}` // a plain job: the clock
42
+ | `cron-run:${string}` // `/cron run`, typed in the job's own conversation
43
+ | `rule:${string}`
44
+ | `push:${string}`
45
+ | "goal"
46
+ | `inbox:${string}`
47
+ | `recipe:${string}`
48
+ | "new-trigger"
49
+ | "person"
50
+ | "cli"
51
+ | "window";
52
+
53
+ export interface SessionRequest {
54
+ v: 2;
55
+ /** pi-loops rows: the id inside `[Trigger <id>] `, the proof of delivery. Person rows: a UUID the page mints. */
56
+ id: string;
57
+ session_id: string;
58
+ kind: RequestKind;
59
+ source: RequestSource;
60
+ /** modeOf(kind, source, wake), written by the producer, recomputed by the executor; a wider row is refused. */
61
+ mode: RequestMode;
62
+ /** input only: `turn` runs a model turn, `append` writes an entry without one. */
63
+ wake?: "turn" | "append";
64
+ payload?: {
65
+ text: string; // pi-loops sources: ≤ 8 KB (docs/design.md decision 11), redacted, prefixed
66
+ images?: ImageRef[]; // person rows only; the bytes are in the row's image file
67
+ cwd: string;
68
+ model?: string;
69
+ thinking?: string;
70
+ tools?: string[];
71
+ builtin_tools?: string[];
72
+ timeout_ms?: number;
73
+ /** MCP servers the turn needs: the row waits for a connection. */
74
+ requires?: string[];
75
+ };
76
+ /** stop, cancel. Never "whatever runs next". */
77
+ target?: { id: string } | { current: true; started_before: string };
78
+ collapse_key?: string;
79
+ created_at: string;
80
+ /**
81
+ * For a person row, the producer that must be alive for it to be sent; for a local row (a claim, a
82
+ * goal continuation), the window that runs it while it lives. `left` is the stamp at which that
83
+ * window left the conversation (`/new`, `/resume`, quitting) with the row still queued: the pid
84
+ * lives on, bound elsewhere, and the row is the holder's to run.
85
+ */
86
+ created_by: { pid: number; instance: string; procStart?: string; version: string; left?: string };
87
+ not_before?: string; // backoff after a failure only; scheduling stays in jobs.json
88
+ attempts: number;
89
+ last_error?: string;
90
+ /**
91
+ * Rows proved by their text (a person's message, a claim, a recipe hand-off, `/new-trigger`, a goal
92
+ * continuation: src/engine.ts `carriesPrefix`): when the holder last handed the row to pi, written
93
+ * under the lock just before the call. The proof of delivery is a user message with the row's text
94
+ * written at or after this; a row never handed over has none and is never done. Not part of the
95
+ * fingerprint.
96
+ */
97
+ handed_at?: string;
98
+ }
99
+
100
+ /** What `RequestStore.removeIf` did with the row it was asked about. */
101
+ export type Taken = { outcome: "removed" | "kept"; row: SessionRequest } | { outcome: "missing" };
102
+
103
+ export const REQUEST_VERSION = 2;
104
+ const KINDS: ReadonlySet<string> = new Set<RequestKind>(["input", "stop", "cancel"]);
105
+
106
+ /**
107
+ * One line as a row, or undefined for a line this version does not act on: torn, not JSON, a newer
108
+ * `v`, or an unknown `kind`. Such a line is kept in the file for the version that wrote it.
109
+ */
110
+ export function parseRequestLine(line: string): SessionRequest | undefined {
111
+ let value: unknown;
112
+ try {
113
+ value = JSON.parse(line);
114
+ } catch {
115
+ return undefined; // a torn last line, or not ours
116
+ }
117
+ if (!value || typeof value !== "object") return undefined;
118
+ const row = value as Partial<SessionRequest>;
119
+ if (row.v !== REQUEST_VERSION || typeof row.id !== "string" || typeof row.session_id !== "string" || typeof row.kind !== "string" || !KINDS.has(row.kind)) return undefined;
120
+ // The file is ours, but anything could have written it: a row whose fields are not the shape the
121
+ // engine reads is skipped like a newer one, rather than thrown on in the middle of a drain.
122
+ if (typeof row.source !== "string" || typeof row.mode !== "string") return undefined;
123
+ const by = row.created_by as unknown as Record<string, unknown> | undefined;
124
+ if (!by || typeof by !== "object" || typeof by.pid !== "number" || typeof by.instance !== "string" || (by.left !== undefined && typeof by.left !== "string")) return undefined;
125
+ if (row.payload !== undefined && (!row.payload || typeof row.payload !== "object" || typeof row.payload.text !== "string")) return undefined;
126
+ const images = row.payload?.images as unknown;
127
+ if (images !== undefined && (!Array.isArray(images) || !images.every((i) => !!i && typeof i === "object" && typeof (i as ImageRef).mimeType === "string" && typeof (i as ImageRef).sha256 === "string"))) return undefined;
128
+ if (row.handed_at !== undefined && typeof row.handed_at !== "string") return undefined;
129
+ const requires = row.payload?.requires;
130
+ if (requires !== undefined && (!Array.isArray(requires) || !requires.every((s) => typeof s === "string"))) return undefined;
131
+ const target = row.target as unknown;
132
+ if (target !== undefined && !(target && typeof target === "object" && (typeof (target as { id?: unknown }).id === "string" || ((target as { current?: unknown }).current === true && typeof (target as { started_before?: unknown }).started_before === "string")))) return undefined;
133
+ return { ...row, attempts: typeof row.attempts === "number" ? row.attempts : 0 } as SessionRequest;
134
+ }
135
+
136
+ /** Whether `file` ends in the middle of a line. */
137
+ function endsTorn(file: string): boolean {
138
+ let fd: number | undefined;
139
+ try {
140
+ fd = fs.openSync(file, "r");
141
+ const size = fs.fstatSync(fd).size;
142
+ if (size === 0) return false;
143
+ const last = Buffer.alloc(1);
144
+ fs.readSync(fd, last, 0, 1, size - 1);
145
+ return last[0] !== 0x0a;
146
+ } catch {
147
+ return false; // no file yet
148
+ } finally {
149
+ if (fd !== undefined) fs.closeSync(fd);
150
+ }
151
+ }
152
+
153
+ export class RequestStore {
154
+ readonly dir: string;
155
+
156
+ constructor(dir: string) {
157
+ this.dir = dir;
158
+ }
159
+
160
+ file(sessionId: string): string {
161
+ return path.join(this.dir, "requests", `${sessionFileName(sessionId)}.jsonl`);
162
+ }
163
+
164
+ /** One conversation's lock, beside its file: a rewrite of one queue never holds another's writers. A `requests.lock` an older version left is nobody's. */
165
+ private lockPath(sessionId: string): string {
166
+ return path.join(this.dir, "requests", `${sessionFileName(sessionId)}.lock`);
167
+ }
168
+
169
+ /** Where a row's image bytes are: one file per row, its name derived from a digest of the row id, never from the id as written. */
170
+ imageFile(sessionId: string, rowId: string): string {
171
+ return path.join(this.dir, "requests", `${sessionFileName(sessionId)}.${createHash("sha256").update(rowId).digest("hex").slice(0, 32)}.images.json`);
172
+ }
173
+
174
+ /** Every line of the file, parsed where this version can: a removal keeps the lines it cannot. */
175
+ private lines(sessionId: string): Array<{ raw: string; row?: SessionRequest }> {
176
+ let data: string;
177
+ try {
178
+ // A regular file only: a FIFO or a device put here would block every reader on it.
179
+ if (!fs.lstatSync(this.file(sessionId)).isFile()) return [];
180
+ data = fs.readFileSync(this.file(sessionId), "utf8");
181
+ } catch {
182
+ return []; // no requests for this session
183
+ }
184
+ return data
185
+ .split("\n")
186
+ .filter((raw) => raw.length > 0)
187
+ .map((raw) => ({ raw, row: parseRequestLine(raw) }));
188
+ }
189
+
190
+ /**
191
+ * Append `row`; `images` are the bytes its `payload.images` digests name, written beside the queue
192
+ * first, so a row never names a file that is not there. An append that fails (a lock that timed
193
+ * out, a file that cannot be written) takes the image file with it: a file no row names is an orphan.
194
+ */
195
+ append(row: SessionRequest, images?: ImageContent[]): void {
196
+ const imageFile = images?.length ? this.imageFile(row.session_id, row.id) : undefined;
197
+ if (imageFile) writeFileAtomic(imageFile, JSON.stringify(images));
198
+ try {
199
+ withFileLockSync(this.lockPath(row.session_id), () => this.appendLocked(row));
200
+ } catch (err) {
201
+ if (imageFile) fs.rmSync(imageFile, { force: true });
202
+ throw err;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Image files no row names any more, older than `olderThanMs`: a crash between the image write and
208
+ * the append leaves one, and so would an older version's removal. Younger ones may belong to an
209
+ * append in flight, whose row is not in the file yet. Returns the files removed.
210
+ */
211
+ sweepImages(now = Date.now(), olderThanMs = 60_000): string[] {
212
+ const dir = path.join(this.dir, "requests");
213
+ let names: string[];
214
+ try {
215
+ names = fs.readdirSync(dir);
216
+ } catch {
217
+ return []; // no requests directory yet
218
+ }
219
+ const live = new Set<string>();
220
+ for (const sessionId of this.sessions()) for (const row of this.list(sessionId)) if (row.payload?.images?.length) live.add(this.imageFile(sessionId, row.id));
221
+ const removed: string[] = [];
222
+ for (const name of names) {
223
+ if (!/^[^/]+\.[0-9a-f]{32}\.images\.json$/.test(name)) continue;
224
+ const file = path.join(dir, name);
225
+ if (live.has(file)) continue;
226
+ try {
227
+ if (now - fs.statSync(file).mtimeMs < olderThanMs) continue;
228
+ fs.rmSync(file);
229
+ removed.push(file);
230
+ } catch {
231
+ /* gone meanwhile, or not ours to remove: the next sweep looks again */
232
+ }
233
+ }
234
+ return removed;
235
+ }
236
+
237
+ /**
238
+ * The bytes of a person row's images, checked against the digests the row carries: a file swapped
239
+ * under the row, or gone, is an error for the caller to report, never other bytes sent as the
240
+ * person's. The fingerprint the page rang with covers the digests, so this is the same check.
241
+ */
242
+ images(row: SessionRequest): ImageContent[] {
243
+ const refs = row.payload?.images ?? [];
244
+ if (!refs.length) return [];
245
+ let value: unknown;
246
+ try {
247
+ value = JSON.parse(fs.readFileSync(this.imageFile(row.session_id, row.id), "utf8"));
248
+ } catch (err: any) {
249
+ throw new Error(`the images of request ${row.id} cannot be read: ${err?.message ?? err}`);
250
+ }
251
+ const images = Array.isArray(value) ? (value as Array<Partial<ImageContent>>) : [];
252
+ const same = images.length === refs.length && refs.every((ref, i) => typeof images[i]?.data === "string" && images[i].mimeType === ref.mimeType && imageRef(images[i] as ImageContent).sha256 === ref.sha256);
253
+ if (!same) throw new Error(`the images of request ${row.id} are not the ones it was written with`);
254
+ return images.map((image) => ({ type: "image", data: image.data!, mimeType: image.mimeType! }));
255
+ }
256
+
257
+ /**
258
+ * Append `row` unless a row with its id is already queued; returns whether it was written. Two
259
+ * producers of one slot (a window's clock and the one before it crashed) derive the same id, so
260
+ * the second append is a no-op instead of a second prompt. With `collapse`, a queued input row
261
+ * with the same `collapse_key` also makes it a no-op (collapse, never replay); the check is under
262
+ * the same lock as the write, so two producers cannot both find the key free. A row `done` says
263
+ * is already in the conversation does not collapse anything: it is in the file only until the
264
+ * next drain removes it. `lock` bounds the wait for this queue's lock: a caller that holds another
265
+ * lock meanwhile passes a timeout shorter than that lock's stale point, and a timeout throws.
266
+ */
267
+ appendIfAbsent(row: SessionRequest, opts: { collapse?: boolean; done?: (queued: SessionRequest) => boolean } = {}, lock?: LockOptions): boolean {
268
+ return withFileLockSync(this.lockPath(row.session_id), () => {
269
+ const queued = this.lines(row.session_id);
270
+ if (queued.some(({ row: q }) => q?.id === row.id)) return false;
271
+ if (opts.collapse && row.collapse_key !== undefined && queued.some(({ row: q }) => q?.kind === "input" && q.collapse_key === row.collapse_key && !opts.done?.(q))) return false;
272
+ this.appendLocked(row);
273
+ return true;
274
+ }, lock);
275
+ }
276
+
277
+ private appendLocked(row: SessionRequest): void {
278
+ const file = this.file(row.session_id);
279
+ fs.mkdirSync(path.dirname(file), { recursive: true });
280
+ // A write cut short by a crash leaves a last line with no newline; appended to, it would take
281
+ // this row down with it.
282
+ fs.appendFileSync(file, `${endsTorn(file) ? "\n" : ""}${JSON.stringify(row)}\n`, "utf8");
283
+ }
284
+
285
+ /** The rows of `sessionId` this version acts on, in file order. A row naming another session is not this file's to act on. */
286
+ list(sessionId: string): SessionRequest[] {
287
+ return this.lines(sessionId).flatMap(({ row }) => (row && row.session_id === sessionId ? [row] : []));
288
+ }
289
+
290
+ /** Rewrite the file without `ids`, under the lock; every line not ours stays. The file goes when nothing is left, and each removed row's image file with it. */
291
+ remove(sessionId: string, ids: Iterable<string>): void {
292
+ const gone = new Set(ids);
293
+ this.rewrite(sessionId, (lines) => lines.filter(({ row }) => !row || !gone.has(row.id)));
294
+ for (const id of gone) fs.rmSync(this.imageFile(sessionId, id), { force: true });
295
+ }
296
+
297
+ /**
298
+ * Rewrite one row with `patch` applied, under the lock; a row removed meanwhile stays removed, and
299
+ * the result says so: false when no row `id` was in the file, so a hand-over whose stamp did not
300
+ * land knows the row is gone (cancelled, or sent from the page) and hands nothing over.
301
+ */
302
+ update(sessionId: string, id: string, patch: Partial<Pick<SessionRequest, "attempts" | "not_before" | "last_error" | "created_by" | "handed_at">>): boolean {
303
+ let found = false;
304
+ this.rewrite(sessionId, (lines) =>
305
+ lines.map(({ raw, row }) => {
306
+ if (!row || row.id !== id) return { raw, row };
307
+ found = true;
308
+ return { raw: JSON.stringify({ ...row, ...patch }), row };
309
+ }),
310
+ );
311
+ return found;
312
+ }
313
+
314
+ /**
315
+ * Remove row `id` under the lock, but only if `when` holds for the row as it is in the file at that
316
+ * moment: the page takes a message back only while no holder has stamped it handed over, and the
317
+ * check and the removal are one critical section, so a stamp cannot land between them. Says what
318
+ * happened: `removed` with the row, `kept` with the row `when` refused, or `missing`.
319
+ */
320
+ removeIf(sessionId: string, id: string, when: (row: SessionRequest) => boolean): Taken {
321
+ // Assigned inside the rewrite's callback, which the type checker does not follow: no narrowing here.
322
+ let result = { outcome: "missing" } as Taken;
323
+ this.rewrite(sessionId, (lines) =>
324
+ lines.filter(({ row }) => {
325
+ if (!row || row.id !== id) return true;
326
+ result = when(row) ? { outcome: "removed", row } : { outcome: "kept", row };
327
+ return result.outcome !== "removed";
328
+ }),
329
+ );
330
+ if (result.outcome === "removed") fs.rmSync(this.imageFile(sessionId, id), { force: true });
331
+ return result;
332
+ }
333
+
334
+ private rewrite(sessionId: string, change: (lines: Array<{ raw: string; row?: SessionRequest }>) => Array<{ raw: string }>): void {
335
+ const file = this.file(sessionId);
336
+ withFileLockSync(this.lockPath(sessionId), () => {
337
+ const remaining = change(this.lines(sessionId));
338
+ if (remaining.length) writeFileAtomic(file, `${remaining.map(({ raw }) => raw).join("\n")}\n`);
339
+ else fs.rmSync(file, { force: true });
340
+ });
341
+ }
342
+
343
+ /** The sessions with a request file. */
344
+ sessions(): string[] {
345
+ try {
346
+ return fs
347
+ .readdirSync(path.join(this.dir, "requests"))
348
+ .filter((f) => f.endsWith(".jsonl"))
349
+ .map((f) => f.slice(0, -".jsonl".length));
350
+ } catch {
351
+ return []; // no requests directory yet
352
+ }
353
+ }
354
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What `/triggers` prints, out of the unimportable extension so a test can read the lines a person
3
+ * reads: the rule rows and the status block. Everything it reads is a parameter.
4
+ */
5
+ import { projectLabel } from "./conversation-rows.ts";
6
+ import { ruleFailureMark } from "./job-health.ts";
7
+ import { previewRedacted } from "./redact.ts";
8
+ import { formatLocal } from "./schedule.ts";
9
+ import { shortSessionId } from "./session-head.ts";
10
+ import type { DynamicTriggerRule } from "./triggers.ts";
11
+
12
+ export interface RuleLinesOptions {
13
+ /** `/triggers rules` numbers this project's rules; `--all` lists every project's with a dash. */
14
+ numbered: boolean;
15
+ /** The conversation listing them: a rule another conversation created says which. */
16
+ sessionId?: string;
17
+ cwd: string;
18
+ home: string;
19
+ sameProject: (a: string, b: string) => boolean;
20
+ }
21
+
22
+ /**
23
+ * One row a rule. The marks say only where the rule differs from the Watch card's first answers
24
+ * (shown here, once): `disabled`, `every time`, `to inbox`, and when it fired.
25
+ */
26
+ export function ruleLines(rules: readonly DynamicTriggerRule[], opts: RuleLinesOptions): string[] {
27
+ return rules.map((r, i) => {
28
+ const marks = [r.enabled ? "" : "disabled", r.fireOnce ? "" : "every time", r.promoteToChat ? "" : "to inbox", r.firedAt ? `fired ${formatLocal(Date.parse(r.firedAt))}` : ""].filter(Boolean).join(", ");
29
+ const head = opts.numbered ? `${String(i + 1).padStart(2)}. ` : " - ";
30
+ const failing = ruleFailureMark(r);
31
+ const elsewhere = opts.sameProject(r.cwd, opts.cwd) ? "" : ` (${projectLabel(r.cwd, opts.home)})`;
32
+ const other = r.createdBy?.sessionId && r.createdBy.sessionId !== opts.sessionId ? ` (session ${shortSessionId(r.createdBy.sessionId)})` : "";
33
+ return `${head}${r.id}${marks ? ` [${marks}]` : ""}${failing ? ` ${failing}` : ""} when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${elsewhere}${other}`;
34
+ });
35
+ }
36
+
37
+ export interface TriggerStatusInput {
38
+ rules: readonly Pick<DynamicTriggerRule, "enabled">[];
39
+ pollIntervalSecs: number;
40
+ /** Whether pi-loops runs in the background: it is what checks the rules. */
41
+ running: boolean;
42
+ lastCheck?: { at: string; cwd: string; outcome: string };
43
+ home: string;
44
+ pushSources: number;
45
+ configError?: string;
46
+ sources: { total: number; connected: number; attention: number };
47
+ runningCount: number;
48
+ deduplicated: number;
49
+ cyclesStopped: number;
50
+ rulesFile: string;
51
+ auditFile: string;
52
+ persistenceError?: string;
53
+ }
54
+
55
+ /** `/triggers status`, in plain words. */
56
+ export function triggerStatusLines(i: TriggerStatusInput): string[] {
57
+ const disabled = i.rules.filter((r) => !r.enabled).length;
58
+ return [
59
+ `Rules: ${i.rules.length}${disabled ? `, ${disabled} disabled` : ""}`,
60
+ `Checks: every ${i.pollIntervalSecs}s while a rule is on, by pi-loops in the background${i.running ? "" : " — not running"}`,
61
+ i.lastCheck ? `Last check: ${formatLocal(Date.parse(i.lastCheck.at)).slice(11)} in ${projectLabel(i.lastCheck.cwd, i.home)} — ${i.lastCheck.outcome}` : "Last check: none yet",
62
+ `MCP push sources: ${i.pushSources}${i.configError ? ` (config error: ${i.configError})` : ""}`,
63
+ `Sources: ${i.sources.total}, ${i.sources.connected} connected${i.sources.attention ? `, ${i.sources.attention} need attention` : ""}`,
64
+ `Running ${i.runningCount} · deduplicated ${i.deduplicated} · cycles stopped ${i.cyclesStopped}`,
65
+ `Files: ${projectLabel(i.rulesFile, i.home)}, ${projectLabel(i.auditFile, i.home)} (/triggers audit [N])`,
66
+ ...(i.persistenceError ? [` ! ${i.persistenceError}`] : []),
67
+ ];
68
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * What a finished loop run writes into the conversation (docs/loops.md "Run cards"). A loop's first
3
+ * three runs, every failed run and every run a person asked for get a compact card; every other run
4
+ * is a line in the panel above the editor only. A run that was stopped is not a failure and gets no
5
+ * card.
6
+ */
7
+ import { screenText } from "./inbox-card.ts";
8
+ import { loopLabel } from "./job-lines.ts";
9
+ import { previewRedacted } from "./redact.ts";
10
+ import type { RunRecord } from "./store.ts";
11
+
12
+ /** The runs of a loop that card in the conversation: enough to see that it works and what it reports. */
13
+ export const CARDED_FIRST_RUNS = 3;
14
+ /** Cards shown for one look at the run log; the rest of a burst is one line. */
15
+ export const CARDS_PER_TICK = 5;
16
+ /** Run log records a window reads at each look: the cards of a burst are placed from these, not from the last 20. */
17
+ export const FRESH_RUNS_READ = 500;
18
+ /** A run line's error, and a card's reply line, clipped so each stays one line. */
19
+ const ERROR_CHARS = 120;
20
+
21
+ export function runCardFor(r: Pick<RunRecord, "ok" | "ordinal" | "aborted" | "requestedBy">): "card" | "quiet" {
22
+ if (r.aborted) return "quiet";
23
+ if (!r.ok || r.requestedBy) return "card";
24
+ return r.ordinal !== undefined && r.ordinal <= CARDED_FIRST_RUNS ? "card" : "quiet";
25
+ }
26
+
27
+ /**
28
+ * The fresh runs whose card belongs to this conversation. A run a person asked for cards where they
29
+ * asked, whichever loop or project it was; any other run cards in the conversation that created its
30
+ * loop, and a loop nobody's conversation made (imported, made from a shell) in any window of its
31
+ * project.
32
+ */
33
+ export function runCardsHere<R extends Pick<RunRecord, "cwd" | "forSession" | "requestedBy">>(fresh: readonly R[], here: { sessionId: string | undefined; inProject: (cwd: string) => boolean }): R[] {
34
+ return fresh.filter((r) => (r.requestedBy ? r.requestedBy.sessionId === here.sessionId : here.inProject(r.cwd) && (!r.forSession || r.forSession === here.sessionId)));
35
+ }
36
+
37
+ /** The lines under a card's title: for a run someone asked for that finished, the first line of its reply. */
38
+ export function runCardLines(r: Pick<RunRecord, "ok" | "requestedBy" | "summary">): string[] {
39
+ if (!r.requestedBy || !r.ok || !r.summary) return [];
40
+ const first = screenText(r.summary.split("\n").find((line) => line.trim()) ?? "").trim();
41
+ return first ? [first.length > ERROR_CHARS ? `${first.slice(0, ERROR_CHARS - 1)}…` : first] : [];
42
+ }
43
+
44
+ /** One line for a run: the card's title, the panel's row and the burst line say it the same way. `name` is the loop's, when it has one. */
45
+ export function runLine(r: RunRecord, name?: string): string {
46
+ const label = loopLabel({ id: r.jobId, name: name ?? r.jobName });
47
+ const secs = Math.max(0, Math.round((Date.parse(r.finishedAt) - Date.parse(r.startedAt)) / 1000)) || 0;
48
+ if (r.aborted) return `${label} stopped · ${secs}s`;
49
+ if (!r.ok) return `${label} FAILED · ${secs}s · ${previewRedacted(r.error ?? "unknown error", ERROR_CHARS)}`;
50
+ const cost = r.usage?.cost ? ` · $${r.usage.cost.toFixed(3)}` : "";
51
+ return `${label} · ${secs}s${cost} · ${r.findings} finding${r.findings === 1 ? "" : "s"}`;
52
+ }
53
+
54
+ /** The one line a burst's carded rest is folded into. */
55
+ export function burstLine(placed: { more: number; failed: number }): string {
56
+ return `+${placed.more} more run${placed.more === 1 ? "" : "s"} finished${placed.failed ? ` (${placed.failed} failed)` : ""} — /cron runs`;
57
+ }
58
+
59
+ /** The carded runs of `fresh`, oldest first, at most `max`; `more` counts the carded rest and `failed` the failures among them. */
60
+ export function placeRunCards(fresh: readonly RunRecord[], max = CARDS_PER_TICK): { cards: RunRecord[]; more: number; failed: number } {
61
+ const carded = fresh.filter((r) => runCardFor(r) === "card").sort((a, b) => a.finishedAt.localeCompare(b.finishedAt));
62
+ const rest = carded.slice(max);
63
+ return { cards: carded.slice(0, max), more: rest.length, failed: rest.filter((r) => !r.ok).length };
64
+ }
65
+
66
+ /**
67
+ * Where a finished run's card goes. A window that holds its conversation saves the card in it, a
68
+ * terminal and the page's pi alike (pi writes the entry, and the page draws it as it lands and when
69
+ * it reads the conversation again); a terminal also says a failure, for when the card has scrolled
70
+ * by. The page draws a notice as a line of the conversation, so there a notice would be the card
71
+ * twice. A window that does not hold the conversation, or has nowhere to save it, only says it.
72
+ */
73
+ export function runCardDelivery(r: Pick<RunRecord, "ok" | "ordinal" | "aborted" | "requestedBy">, where: { mode: string; mayAppend: boolean }): { save: boolean; notify?: "info" | "warning" } {
74
+ if (runCardFor(r) !== "card") return { save: false };
75
+ if (where.mayAppend && where.mode === "tui") return r.ok ? { save: true } : { save: true, notify: "warning" };
76
+ if (where.mayAppend && where.mode === "rpc") return { save: true };
77
+ return { save: false, notify: r.ok ? "info" : "warning" };
78
+ }
79
+
80
+ /** The runs the panel above the editor lists: the project's latest loop runs, and one more so it can say there are others. A plain job's turn and a `/goal` evaluator's are not loop runs. */
81
+ export function panelRuns(all: readonly RunRecord[], inProject: (cwd: string) => boolean, limit: number): RunRecord[] {
82
+ return all.filter((r) => r.stateful && inProject(r.cwd)).slice(-(limit + 1));
83
+ }
84
+
85
+ /**
86
+ * The loop runs a window has not carded yet: those that finished after `after`, the stamp of the
87
+ * last look, and the stamp to look after next time (the latest of theirs, else `after`). Times are
88
+ * compared as instants: a record written in another offset (UTC, or either side of a
89
+ * daylight-saving change) sorts wrongly as text, and was carded again on every restart. A record
90
+ * whose time does not parse is skipped.
91
+ */
92
+ export function freshRuns<R extends Pick<RunRecord, "stateful" | "finishedAt">>(records: readonly R[], after: string): { fresh: R[]; cursor: string } {
93
+ let cursor = after;
94
+ let latest = Date.parse(after);
95
+ const fresh = records.filter((r) => {
96
+ const at = Date.parse(r.finishedAt);
97
+ if (!r.stateful || !Number.isFinite(at) || !(at > Date.parse(after))) return false;
98
+ if (!(at <= latest)) {
99
+ latest = at;
100
+ cursor = r.finishedAt;
101
+ }
102
+ return true;
103
+ });
104
+ return { fresh, cursor };
105
+ }
package/src/runner.ts CHANGED
@@ -50,6 +50,13 @@ export interface SubagentRequest {
50
50
  thinking?: string;
51
51
  /** Allowlist of tool names (built-in, MCP and automation tools alike). */
52
52
  tools?: string[];
53
+ /** Without `tools`: the built-in tools to leave active; every other tool stays as it is. */
54
+ builtinTools?: string[];
55
+ /**
56
+ * A held turn: pi's own saved decision for its conversation's directory, or a parent of it, read
57
+ * for this run. Loops and rule checks leave it unset and need saved trust for that exact directory.
58
+ */
59
+ trusted?: boolean;
53
60
  /**
54
61
  * Called once the sub-session's transcript file exists, so a run in flight can be inspected
55
62
  * while it is still going (`/cron trace <job> live`) rather than only after it finishes.
@@ -64,7 +71,17 @@ export interface SubagentRequest {
64
71
  /** The interactive session this run acts for; plain cron jobs it schedules bind to it. */
65
72
  parentSessionId?: string;
66
73
  parentCwd?: string;
67
- kind: "loop" | "checker" | "trigger";
74
+ kind: "loop" | "checker" | "trigger" | "held";
75
+ /**
76
+ * A held turn: run `prompt` in this existing conversation instead of a new transcript. Its own
77
+ * model and thinking level are kept unless the request pins them.
78
+ */
79
+ sessionFile?: string;
80
+ /**
81
+ * Asked on every agent event but streaming deltas: false means the conversation was taken back
82
+ * (a window opened it), and the turn stops there.
83
+ */
84
+ stillHolds?: () => boolean;
68
85
  jobId?: string;
69
86
  runId?: string;
70
87
  traceId?: string;
package/src/schedule.ts CHANGED
@@ -255,8 +255,7 @@ function parseNumber(raw: string, field: string, min: number, max: number, names
255
255
  * One rule, called from two places, because the scans below skip whole days on the strength of it —
256
256
  * "this day cannot match, jump to midnight" is only sound while it says exactly what `cronMatches`
257
257
  * says, and it was a second copy free to drift. ("0 0 30 2 *" parses and never matches, and walking
258
- * five years of it one minute at a time cost ~300ms of the leader's tick, every 30 seconds, in every
259
- * pi window.)
258
+ * five years of it one minute at a time cost ~300ms of the scheduler's tick, every 30 seconds.)
260
259
  */
261
260
  function dayMatches(fields: CronFields, when: Date): boolean {
262
261
  if (!fields.months.has(when.getMonth() + 1)) return false;