@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Dynamic triggers and the `Trigger` envelope every source hands the runtime, as plain data +
3
+ * pure functions. A rule is a natural-language condition and
4
+ * action; a periodic check (or a pushed notification) hands every enabled rule to a
5
+ * fresh sub-agent that evaluates conditions with tools, executes matching actions and
6
+ * reports the matched `dyn-…` ids, which the runtime marks fired.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { randomBytes, randomUUID } from "node:crypto";
11
+ import { clampFuture, stamp } from "./schedule.ts";
12
+ import { withFileLock, writeFileAtomic } from "./lock.ts";
13
+ import { capRedacted, previewRedacted } from "./redact.ts";
14
+
15
+ export const DEFAULT_TRIGGER_POLL_INTERVAL_SECS = 10 * 60;
16
+ export const DEDUP_WINDOW_MS = 5 * 60_000;
17
+ export const SUMMARY_CAP_CHARS = 4096;
18
+ /** Past this the audit is halved (docs/configuration.md). */
19
+ export const AUDIT_ROTATE_BYTES = 2_000_000;
20
+ export const NO_MATCH_SENTINEL = "no dynamic trigger rule matched";
21
+
22
+ export interface DynamicTriggerRule {
23
+ id: string;
24
+ condition: string;
25
+ action: string;
26
+ enabled: boolean;
27
+ fireOnce: boolean;
28
+ firedAt?: string;
29
+ promoteToChat: boolean;
30
+ createdAt: string;
31
+ /** Project the rule belongs to; the check sub-agent runs here. */
32
+ cwd: string;
33
+ /** Model / thinking of the session that created the rule; the check sub-agent uses them. */
34
+ model?: string;
35
+ thinking?: string;
36
+ /** Per-rule cap on the check/action sub-agent; default `[triggers] run_timeout_secs`. */
37
+ timeoutMs?: number;
38
+ /** Host the rule belongs to (shared $HOME): other hosts ignore it. Missing = any host (pre-0.1.3). */
39
+ host?: string;
40
+ createdBy?: { sessionId?: string };
41
+ /** Checks that failed in a row (a check covers every rule it evaluated). Cleared by one that completes; drives the poll backoff in the trigger runtime, as `JobStore`'s field of the same name drives the scheduler's. */
42
+ consecutiveFailures?: number;
43
+ /** When the last failing check ran: the backoff is measured from here, so a rule that has never been checked is never held back. */
44
+ lastCheckFailedAt?: string;
45
+ }
46
+
47
+ export type SourceKind = "local" | "mcp";
48
+ export type ReplacementPolicy = "drop" | "latest_replaces";
49
+
50
+ /** The boundary type between sources (periodic checker, MCP push) and the runtime. */
51
+ export interface Trigger {
52
+ source: { kind: "local"; subkind: string } | { kind: "mcp"; serverName: string; method: string };
53
+ sourceKind: SourceKind;
54
+ sourceLabel: string;
55
+ eventLabel: string;
56
+ payloadSummary?: string;
57
+ idempotencyKey: string;
58
+ replacementPolicy: ReplacementPolicy;
59
+ traceId: string;
60
+ receivedAt: string;
61
+ /** Which project's rules this event is evaluated against (undefined = every project). */
62
+ cwd?: string;
63
+ /** How long a push waited for a free slot before this evaluation; unset when it was never held. */
64
+ deferredMs?: number;
65
+ }
66
+
67
+ export function newTraceId(): string {
68
+ return randomUUID();
69
+ }
70
+
71
+ export function newRuleId(): string {
72
+ return `dyn-${randomBytes(16).toString("hex")}`;
73
+ }
74
+
75
+ /* -------------------------------------------------------------- parsing */
76
+
77
+ const ZH_WHEN = "当";
78
+ const ZH_IF = "如果";
79
+ const ZH_TIME_SUFFIX_LONG = "的时候";
80
+ const ZH_TIME_SUFFIX_SHORT = "时";
81
+ const ZH_EXECUTE = "执行";
82
+
83
+ const MARKERS: string[] = [
84
+ "的时候,执行", "的时候,执行", "的时候 执行", "的时候执行", "的时候,", "的时候,",
85
+ "时,执行", "时,执行", "时 执行", "时执行", "时,", "时,",
86
+ ",则", ", 则", ",则", " 则 ", "则", ",就", ", 就", ",就", " 就 ",
87
+ ",执行", ", 执行", ",执行", " 执行 ",
88
+ " then ", " then run ", " then execute ", ", run ", ", execute ", ", do ", " run ", " execute ",
89
+ ];
90
+
91
+ export interface ParsedTriggerRule {
92
+ condition: string;
93
+ action: string;
94
+ }
95
+
96
+ /** Split "when X, run Y" / "当 X 时,执行 Y" into condition + action. Throws on malformed input. */
97
+ export function parseTriggerRule(spec: string): ParsedTriggerRule {
98
+ const text = spec.trim();
99
+ if (!text) throw new Error("usage: /new-trigger <when condition, run action>");
100
+ const lower = text.toLowerCase();
101
+ let split: { idx: number; marker: string } | undefined;
102
+ for (const marker of MARKERS) {
103
+ const haystack = /^[\x00-\x7f]*$/.test(marker) ? lower : text;
104
+ const idx = haystack.indexOf(marker);
105
+ if (idx >= 0) {
106
+ split = { idx, marker };
107
+ break;
108
+ }
109
+ }
110
+ if (!split) throw new Error("could not split the trigger into a condition and action. In normal chat, ask pi to create the trigger so the model can extract them, or use `/new-trigger if condition, then action`.");
111
+ const condition = cleanCondition(text.slice(0, split.idx));
112
+ const action = cleanAction(text.slice(split.idx + split.marker.length));
113
+ if (!condition || !action) throw new Error("condition and action must both be non-empty");
114
+ return { condition, action };
115
+ }
116
+
117
+ function cleanCondition(raw: string): string {
118
+ let s = raw.trim();
119
+ if (s.startsWith(ZH_WHEN)) s = s.slice(ZH_WHEN.length).trim();
120
+ if (s.startsWith(ZH_IF)) s = s.slice(ZH_IF.length).trim();
121
+ const lower = s.toLowerCase();
122
+ if (lower.startsWith("when ")) s = s.slice(5).trim();
123
+ else if (lower.startsWith("if ")) s = s.slice(3).trim();
124
+ if (s.endsWith(ZH_TIME_SUFFIX_LONG)) s = s.slice(0, -ZH_TIME_SUFFIX_LONG.length);
125
+ else if (s.endsWith(ZH_TIME_SUFFIX_SHORT)) s = s.slice(0, -ZH_TIME_SUFFIX_SHORT.length);
126
+ return s.trim();
127
+ }
128
+
129
+ function cleanAction(raw: string): string {
130
+ let s = raw.trim();
131
+ if (s.startsWith(ZH_EXECUTE)) s = s.slice(ZH_EXECUTE.length).trim();
132
+ const lower = s.toLowerCase();
133
+ if (lower.startsWith("run ")) s = s.slice(4).trim();
134
+ else if (lower.startsWith("execute ")) s = s.slice(8).trim();
135
+ return s;
136
+ }
137
+
138
+ /** A time-based request belongs to cron, not here. */
139
+ export function looksLikeFixedScheduleRequest(text: string): boolean {
140
+ const lower = text.toLowerCase();
141
+ const english = ["every hour", "hourly", "every day", "daily", "every week", "weekly", "scheduled job", "cron", "crontab"];
142
+ if (english.some((n) => lower.includes(n))) return true;
143
+ return ["定时任务", "定時任務", "每小时", "每小時", "每天", "每日", "每周", "每週"].some((n) => text.includes(n));
144
+ }
145
+
146
+ /* --------------------------------------------------------------- prompt */
147
+
148
+ export function renderDynamicTriggerPrompt(trigger: Trigger, rules: DynamicTriggerRule[]): string {
149
+ const rulesJson = JSON.stringify(
150
+ rules.map((r) => ({ id: r.id, condition: r.condition, action: r.action, enabled: r.enabled, fire_once: r.fireOnce, fired_at: r.firedAt ?? null, promote_to_chat: r.promoteToChat, created_at: r.createdAt })),
151
+ null,
152
+ 2,
153
+ );
154
+ const triggerJson = JSON.stringify(
155
+ {
156
+ source_kind: trigger.sourceKind,
157
+ source: trigger.source,
158
+ source_label: trigger.sourceLabel,
159
+ event_label: trigger.eventLabel,
160
+ payload_visibility: "local",
161
+ payload_summary: trigger.payloadSummary ?? null,
162
+ payload: null,
163
+ received_at: trigger.receivedAt,
164
+ idempotency_key: trigger.idempotencyKey,
165
+ trace_id: trigger.traceId,
166
+ authority: { principal_id: trigger.sourceLabel, principal_label: trigger.sourceLabel, credential_scope: "user" },
167
+ ...(trigger.deferredMs === undefined ? {} : { deferred_ms: trigger.deferredMs }),
168
+ },
169
+ null,
170
+ 2,
171
+ );
172
+ // A push that waited for a free slot is evaluated against a world that has moved on. Only the
173
+ // rule's author knows whether that matters, so say it plainly instead of deciding for them.
174
+ const held =
175
+ trigger.deferredMs === undefined
176
+ ? ""
177
+ : `This event was held ${Math.round(trigger.deferredMs / 1000)}s while other checks were running: \`received_at\` is when it happened, which is not now. Re-check anything time-sensitive before acting on it.\n\n`;
178
+ return (
179
+ `A trigger check event arrived.\n\nEvent:\n${triggerJson}\n\n${held}Dynamic trigger rules:\n${rulesJson}\n\n` +
180
+ "Evaluate each rule's natural-language condition. For source-specific events, compare the rule against the event. For `local:dynamic` periodic checks, inspect current local or remote state with the available tools whenever the condition depends on filesystem state, paths, environment variables, shell expansion, command output, clock time, network/API state, or any fact not already present in the Event JSON. Do not report no match for those conditions until after the needed inspection. " +
181
+ `If no enabled rule matches after any required inspection, reply with exactly: ${NO_MATCH_SENTINEL}.\n\n` +
182
+ "If one or more rules match, execute each matching rule's action. Treat the action as an instruction from the user. If it asks to read or print a file, use the read tool or a safe shell command, then include the requested file contents in your final response. If it asks to run a local program or shell command, use the bash tool. Keep the final response concise and include the exact matched rule id(s), for example `matched dyn-...`."
183
+ );
184
+ }
185
+
186
+ /** Every well-formed `dyn-<32 hex>` id in the text, in order, de-duplicated. */
187
+ export function extractDynamicRuleIds(text: string): string[] {
188
+ const out: string[] = [];
189
+ for (const m of text.matchAll(/dyn-[0-9a-f]{32}/g)) if (!out.includes(m[0])) out.push(m[0]);
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * `evaluator` distinguishes the processes that check one project: a rule belongs to the session
195
+ * that created it, so two pi windows in one repo each raise their own check and must not collapse
196
+ * into one another in the shared dedup window when they land in the same millisecond.
197
+ */
198
+ export function buildPeriodicCheckTrigger(cwd: string, ruleCount: number, now = new Date(), evaluator = ""): Trigger {
199
+ return {
200
+ source: { kind: "local", subkind: "dynamic" },
201
+ sourceKind: "local",
202
+ sourceLabel: "local:dynamic",
203
+ eventLabel: "dynamic periodic check",
204
+ payloadSummary: `Periodic dynamic trigger check at ${stamp(now.getTime())} with ${ruleCount} enabled rule(s); cwd: ${cwd}`,
205
+ idempotencyKey: `local:dynamic:${cwd}${evaluator ? `#${evaluator}` : ""}:${now.getTime()}`,
206
+ replacementPolicy: "drop",
207
+ traceId: newTraceId(),
208
+ receivedAt: stamp(now.getTime()),
209
+ cwd,
210
+ };
211
+ }
212
+
213
+ /* ---------------------------------------------------------------- store */
214
+
215
+ interface RulesFile {
216
+ version: 1;
217
+ rules: DynamicTriggerRule[];
218
+ }
219
+
220
+ export type AuditType = "trigger" | "trigger_result" | "trigger_promotion" | "cron_control_plane";
221
+
222
+ export interface AuditRecord {
223
+ ts: string;
224
+ type: AuditType;
225
+ traceId: string;
226
+ /** Project the trigger belonged to; `/triggers audit` shows this project's rows by default. */
227
+ cwd?: string;
228
+ /** The state this row records; docs/triggers.md ("Promotion and audit") lists every one of them. */
229
+ state: string;
230
+ sourceLabel?: string;
231
+ eventLabel?: string;
232
+ summary?: string;
233
+ details?: Record<string, unknown>;
234
+ }
235
+
236
+ export class TriggerStore {
237
+ readonly dir: string;
238
+ readonly rulesFile: string;
239
+ readonly auditFile: string;
240
+ private readonly lockPath: string;
241
+ /** Last audit write that failed: the trigger still ran. */
242
+ lastPersistenceError: string | undefined;
243
+ onPersistenceError: ((message: string) => void) | undefined;
244
+ /** Second sink: trigger audit is kept as session entries too, so it resumes and exports with the session. */
245
+ onAudit: ((record: AuditRecord) => void) | undefined;
246
+
247
+ constructor(dir: string) {
248
+ this.dir = dir;
249
+ this.rulesFile = path.join(dir, "triggers.json");
250
+ this.auditFile = path.join(dir, "triggers-audit.jsonl");
251
+ this.lockPath = path.join(dir, "triggers.lock");
252
+ }
253
+
254
+ load(): DynamicTriggerRule[] {
255
+ let text: string;
256
+ try {
257
+ text = fs.readFileSync(this.rulesFile, "utf8");
258
+ } catch (err: any) {
259
+ if (err?.code === "ENOENT") return [];
260
+ throw err;
261
+ }
262
+ if (!text.trim()) return [];
263
+ const parsed = JSON.parse(text) as RulesFile;
264
+ if (!Array.isArray(parsed?.rules)) throw new Error(`${this.rulesFile}: missing "rules" array`);
265
+ return parsed.rules;
266
+ }
267
+
268
+ async mutate<T>(fn: (rules: DynamicTriggerRule[]) => T): Promise<T> {
269
+ return withFileLock(this.lockPath, () => {
270
+ const rules = this.load();
271
+ const result = fn(rules);
272
+ writeFileAtomic(this.rulesFile, `${JSON.stringify({ version: 1, rules } satisfies RulesFile, null, 2)}\n`);
273
+ return result;
274
+ });
275
+ }
276
+
277
+ async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string; host?: string }): Promise<DynamicTriggerRule> {
278
+ const condition = input.condition.trim();
279
+ const action = input.action.trim();
280
+ if (!condition || !action) throw new Error("trigger rule needs both a condition and an action");
281
+ const rule: DynamicTriggerRule = {
282
+ id: newRuleId(),
283
+ condition,
284
+ action,
285
+ enabled: true,
286
+ fireOnce: input.fireOnce ?? true,
287
+ promoteToChat: input.promoteToChat ?? false,
288
+ createdAt: stamp(),
289
+ cwd: input.cwd,
290
+ model: input.model,
291
+ thinking: input.thinking,
292
+ host: input.host,
293
+ createdBy: { sessionId: input.sessionId },
294
+ };
295
+ await this.mutate((rules) => rules.push(rule));
296
+ return rule;
297
+ }
298
+
299
+ async remove(id: string): Promise<DynamicTriggerRule | undefined> {
300
+ return this.mutate((rules) => {
301
+ const idx = rules.findIndex((r) => r.id === id.trim());
302
+ return idx < 0 ? undefined : rules.splice(idx, 1)[0];
303
+ });
304
+ }
305
+
306
+ /** `cwd` clears that project's rules (a worktree or subdirectory counts as the same project). */
307
+ async clear(cwd?: string, sameProject: (a: string, b: string) => boolean = (a, b) => a === b): Promise<number> {
308
+ return this.mutate((rules) => {
309
+ const keep = cwd ? rules.filter((r) => !sameProject(r.cwd, cwd)) : [];
310
+ const removed = rules.length - keep.length;
311
+ rules.splice(0, rules.length, ...keep);
312
+ return removed;
313
+ });
314
+ }
315
+
316
+ /** Patch one rule in place under the lock; undefined when it no longer exists. */
317
+ async update(id: string, patch: (rule: DynamicTriggerRule) => void): Promise<DynamicTriggerRule | undefined> {
318
+ return this.mutate((rules) => {
319
+ const rule = rules.find((r) => r.id === id.trim());
320
+ if (rule) patch(rule);
321
+ return rule;
322
+ });
323
+ }
324
+
325
+ async setEnabled(id: string, enabled: boolean): Promise<DynamicTriggerRule | undefined> {
326
+ return this.mutate((rules) => {
327
+ const rule = rules.find((r) => r.id === id.trim());
328
+ if (!rule) return undefined;
329
+ rule.enabled = enabled;
330
+ if (enabled) rule.firedAt = undefined;
331
+ return rule;
332
+ });
333
+ }
334
+
335
+ /** fire-once rules in `ids` become disabled with `firedAt`; returns the changed rules. */
336
+ async markFired(ids: string[]): Promise<DynamicTriggerRule[]> {
337
+ if (!ids.length) return [];
338
+ return this.mutate((rules) => {
339
+ const now = stamp();
340
+ const changed: DynamicTriggerRule[] = [];
341
+ for (const rule of rules) {
342
+ if (!rule.enabled || !ids.includes(rule.id)) continue;
343
+ rule.firedAt = now;
344
+ if (rule.fireOnce) {
345
+ rule.enabled = false;
346
+ changed.push(rule);
347
+ }
348
+ }
349
+ return changed;
350
+ });
351
+ }
352
+
353
+ /** Best effort: a failed audit write is remembered and reported, never thrown. */
354
+ appendAudit(record: Omit<AuditRecord, "ts">): AuditRecord {
355
+ // Capped, but the text keeps its lines: this row is the only
356
+ // durable copy of what a check produced (`/triggers audit`, session entries, exports).
357
+ const full: AuditRecord = { ts: stamp(), ...record, summary: record.summary ? capRedacted(record.summary, SUMMARY_CAP_CHARS) : undefined };
358
+ try {
359
+ fs.mkdirSync(this.dir, { recursive: true });
360
+ fs.appendFileSync(this.auditFile, `${JSON.stringify(full)}\n`, "utf8");
361
+ } catch (err: any) {
362
+ const message = `trigger audit write failed: ${err?.message ?? err}`;
363
+ this.lastPersistenceError = message;
364
+ this.onPersistenceError?.(message);
365
+ }
366
+ try {
367
+ this.onAudit?.(full);
368
+ } catch {
369
+ /* the session sink is best effort too */
370
+ }
371
+ try {
372
+ if (fs.statSync(this.auditFile).size > AUDIT_ROTATE_BYTES) {
373
+ const lines = fs.readFileSync(this.auditFile, "utf8").split("\n").filter(Boolean);
374
+ writeFileAtomic(this.auditFile, `${lines.slice(-Math.floor(lines.length / 2)).join("\n")}\n`);
375
+ }
376
+ } catch {
377
+ /* best effort */
378
+ }
379
+ return full;
380
+ }
381
+
382
+ /** Newest first. */
383
+ listAudit(limit = 10, filter?: (r: AuditRecord) => boolean): AuditRecord[] {
384
+ let text: string;
385
+ try {
386
+ text = fs.readFileSync(this.auditFile, "utf8");
387
+ } catch (err: any) {
388
+ if (err?.code !== "ENOENT") {
389
+ // Unreadable (permissions, a directory in the way): empty audit, but say why in /triggers status.
390
+ const message = `trigger audit read failed: ${err?.message ?? err}`;
391
+ this.lastPersistenceError = message;
392
+ this.onPersistenceError?.(message);
393
+ }
394
+ return [];
395
+ }
396
+ const out: AuditRecord[] = [];
397
+ for (const line of text.split("\n")) {
398
+ if (!line.trim()) continue;
399
+ try {
400
+ const rec = JSON.parse(line) as AuditRecord;
401
+ if (!filter || filter(rec)) out.push(rec);
402
+ } catch {
403
+ /* skip */
404
+ }
405
+ }
406
+ return out.slice(-limit).reverse();
407
+ }
408
+ }
409
+
410
+ /**
411
+ * The control-plane prompt gate, pre-flight part. Prompt-class tool calls (create or remove
412
+ * a trigger, re-enable a trigger or a cron job) need a human. Sub-agents have no prompt channel
413
+ * and are denied fail-closed ("control-plane prompt required but no
414
+ * on_control_plane_prompt hook configured"); a UI-less interactive process is refused; an
415
+ * interactive UI gets to ask. Returns the denial text, or undefined when the caller should ask.
416
+ */
417
+ export function controlPlanePreflight(proc: { hop: number; hasUI: boolean }, reason: string): string | undefined {
418
+ if (proc.hop > 0) return `${reason} requires user confirmation; sub-agents have no control-plane prompt channel (fail-closed deny)`;
419
+ if (!proc.hasUI) return `${reason} requires interactive confirmation; use the slash command instead`;
420
+ return undefined;
421
+ }
422
+
423
+ /**
424
+ * The audit rows a cron run leaves. Both the interactive extension and the headless host call these
425
+ * — the host used to only write to `host.log`, so `/triggers audit` was blank for exactly the hours
426
+ * nobody was watching, which is when a user most wants to know what ran.
427
+ */
428
+ export function auditCronStart(store: TriggerStore, job: { id: string; name?: string; cwd: string; prompt: string; stateful: boolean; lastDueAt?: string; lastFiredAt?: string }, runId: string): void {
429
+ const summary = `cron \`${job.id}\`${job.name ? ` "${job.name}"` : ""} due at ${job.lastDueAt ?? job.lastFiredAt ?? stamp()}: ${previewRedacted(job.prompt, 120)}`;
430
+ store.appendAudit({ cwd: job.cwd, type: "trigger", traceId: runId, state: "accepted", sourceLabel: "Cron", eventLabel: job.id, summary, details: { delivery: job.stateful ? "sub_agent" : "inject_and_run", evaluator_decision: { outcome: "accept", permission: "allow" } } });
431
+ store.appendAudit({ cwd: job.cwd, type: "trigger_result", traceId: runId, state: "running", sourceLabel: "Cron", eventLabel: job.id, details: { cwd: job.cwd } });
432
+ }
433
+
434
+ export function auditCronFinish(store: TriggerStore, job: { id: string; cwd: string }, record: { runId: string; ok: boolean; summary?: string; error?: string; findings: number; stateUpdated: boolean; exitCode?: number; sessionFile?: string; usage?: { cost: number }; checker?: { ok: boolean; kept: number; dropped: unknown[] } }, aborted: boolean): void {
435
+ store.appendAudit({
436
+ cwd: job.cwd,
437
+ type: "trigger_result",
438
+ traceId: record.runId,
439
+ state: aborted ? "aborted" : record.ok ? "completed" : "failed",
440
+ sourceLabel: "Cron",
441
+ eventLabel: job.id,
442
+ summary: record.ok ? record.summary : record.error,
443
+ details: { findings: record.findings, state_updated: record.stateUpdated, cost_usd: record.usage?.cost ?? 0, exit_code: record.exitCode, session_file: record.sessionFile, checker: record.checker ? { ok: record.checker.ok, kept: record.checker.kept, dropped: record.checker.dropped.length } : undefined },
444
+ });
445
+ }
446
+
447
+ /** Resolve an id, a unique id prefix, or "<n>" in `rules`. */
448
+ export function resolveRuleRef(rules: DynamicTriggerRule[], ref: string): DynamicTriggerRule | undefined {
449
+ const t = ref.trim();
450
+ if (!t) return undefined;
451
+ if (/^\d+$/.test(t)) return rules[Number(t) - 1];
452
+ const exact = rules.find((r) => r.id === t);
453
+ if (exact) return exact;
454
+ const hits = rules.filter((r) => r.id.startsWith(t));
455
+ return hits.length === 1 ? hits[0] : undefined;
456
+ }
457
+
458
+ /* ------------------------------------------------------------- polls */
459
+
460
+ /** A slot nobody has claimed for a day is a session that is gone; drop it on the next write. */
461
+ export const POLL_LEDGER_TTL_MS = 24 * 60 * 60_000;
462
+
463
+ /**
464
+ * When each project was last checked, shared by every pi process on the machine so a check
465
+ * runs once per poll interval no matter which process owns the project at that moment.
466
+ */
467
+ export class PollLedger {
468
+ private readonly file?: string;
469
+ private readonly mem = new Map<string, number>();
470
+ constructor(file?: string) {
471
+ this.file = file;
472
+ }
473
+
474
+ /**
475
+ * True (and the slot is taken) when `key` has not been claimed inside `intervalMs`. The key is a
476
+ * composite ownership key, not a directory: host, project and — when the creating session is
477
+ * still open — that session, so two windows in one repo each check their own rules once per
478
+ * interval while a hand-over of a project's own rules still never double-checks.
479
+ */
480
+ async claim(key: string, now: number, intervalMs: number): Promise<boolean> {
481
+ if (!this.file) return this.claimMap(this.mem, key, now, intervalMs);
482
+ const file = this.file;
483
+ return withFileLock(`${file}.lock`, () => {
484
+ let map = new Map<string, number>();
485
+ try {
486
+ map = new Map(Object.entries(JSON.parse(fs.readFileSync(file, "utf8"))).map(([k, v]) => [k, Number(v)]));
487
+ } catch {
488
+ /* fresh */
489
+ }
490
+ const ok = this.claimMap(map, key, now, intervalMs);
491
+ if (ok) {
492
+ // Slots embed the creating session id, so every session that ever owned a rule left a
493
+ // permanent entry in a file that is read, parsed and rewritten on every tick.
494
+ for (const [k, v] of map) if (now - v > POLL_LEDGER_TTL_MS) map.delete(k);
495
+ writeFileAtomic(file, JSON.stringify(Object.fromEntries(map)));
496
+ }
497
+ return ok;
498
+ });
499
+ }
500
+
501
+ private claimMap(map: Map<string, number>, key: string, now: number, intervalMs: number): boolean {
502
+ // A stamp from the future (a corrected clock, another machine on a synced $HOME) would block
503
+ // every future poll; treat it as "just now" so the next interval is honoured and no more.
504
+ const last = clampFuture(map.get(key), now);
505
+ if (last !== undefined && now - last < intervalMs) return false;
506
+ map.set(key, now);
507
+ return true;
508
+ }
509
+ }
510
+
511
+ /* ------------------------------------------------------------- dedup */
512
+
513
+ /**
514
+ * Dedup window: two events with the same idempotency key inside `DEDUP_WINDOW_MS` (five minutes)
515
+ * are one event. With a `file`, the window is shared by every pi process on the machine, so a push
516
+ * that several processes receive is handled exactly once.
517
+ */
518
+ export interface DedupHit {
519
+ traceId: string;
520
+ /** The FIRST arrival's policy; the audit reports what the winning entry declared. */
521
+ replacementPolicy?: ReplacementPolicy;
522
+ }
523
+
524
+ export class DedupWindow {
525
+ private readonly seen = new Map<string, { at: number; traceId: string; policy?: ReplacementPolicy }>();
526
+ private readonly windowMs: number;
527
+ private readonly file?: string;
528
+ constructor(windowMs: number = DEDUP_WINDOW_MS, file?: string) {
529
+ this.windowMs = windowMs;
530
+ this.file = file;
531
+ }
532
+
533
+ /** Returns the previous arrival when `key` was seen inside the window, else records this one. */
534
+ async check(key: string, traceId: string, now = Date.now(), policy?: ReplacementPolicy): Promise<DedupHit | undefined> {
535
+ if (!this.file) return this.checkMap(this.seen, key, traceId, now, policy);
536
+ const file = this.file;
537
+ return withFileLock(`${file}.lock`, () => {
538
+ let map = new Map<string, { at: number; traceId: string; policy?: ReplacementPolicy }>();
539
+ try {
540
+ map = new Map(Object.entries(JSON.parse(fs.readFileSync(file, "utf8"))));
541
+ } catch {
542
+ /* fresh */
543
+ }
544
+ const prev = this.checkMap(map, key, traceId, now, policy);
545
+ writeFileAtomic(file, JSON.stringify(Object.fromEntries(map)));
546
+ return prev;
547
+ });
548
+ }
549
+
550
+ private checkMap(map: Map<string, { at: number; traceId: string; policy?: ReplacementPolicy }>, key: string, traceId: string, now: number, policy?: ReplacementPolicy): DedupHit | undefined {
551
+ // A future stamp never ages out of the window on its own, so it would dedup its key forever.
552
+ for (const [k, v] of map) if (clampFuture(v.at, now) === undefined || now - v.at > this.windowMs) map.delete(k);
553
+ const prev = map.get(key);
554
+ if (prev) return { traceId: prev.traceId, replacementPolicy: prev.policy };
555
+ map.set(key, { at: now, traceId, policy });
556
+ return undefined;
557
+ }
558
+ }
package/src/trust.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Whether a directory may load project-local resources for an unattended run.
3
+ *
4
+ * pi's `ProjectTrustStore.get()` walks up the ancestor chain, which is right for a person opening
5
+ * a subdirectory of a project they trusted. It is wrong here: a job's cwd can be chosen by a model
6
+ * (`cron_create` takes one), and inherited trust would make `<trusted repo>/node_modules/anything`
7
+ * trusted too — enough to have that directory's `.pi/mcp.toml` spawn its `command` with nobody
8
+ * watching. An unattended run therefore requires the exact directory to have been trusted.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { ProjectTrustStore } from "@earendil-works/pi-coding-agent";
13
+ import { withinProject } from "./presence.ts";
14
+
15
+ /** pi's own normalisation: resolved, then through symlinks where the path exists. */
16
+ export function canonicalDir(dir: string): string {
17
+ const resolved = path.resolve(dir);
18
+ try {
19
+ return fs.realpathSync(resolved);
20
+ } catch {
21
+ return resolved;
22
+ }
23
+ }
24
+
25
+ /** True only when the user trusted this very directory, not an ancestor of it. */
26
+ export function isExactlyTrusted(agentDir: string, cwd: string): boolean {
27
+ if (!cwd) return false;
28
+ const entry = new ProjectTrustStore(agentDir).getEntry(cwd);
29
+ if (!entry || entry.decision !== true) return false;
30
+ return canonicalDir(entry.path) === canonicalDir(cwd);
31
+ }
32
+
33
+ /**
34
+ * The other route: the project the *user* opened this session in is trusted, and that reaches the
35
+ * directories inside it — a worktree, `--cwd ./sub`, a path through a symlink.
36
+ *
37
+ * It does not reach out of it. The trust site asked `sameProject`, which is symmetric and blocks
38
+ * only `/` and `$HOME`, so an *ancestor* counted as the same project: `cron_create` takes a
39
+ * model-chosen `cwd` with no confirmation, and `cwd: ".."` would have had the parent directory's
40
+ * `.pi/extensions` and `.pi/mcp.toml` loaded in an unattended run — the thing this module exists to
41
+ * prevent. Scoping a *list* is a different question and keeps the symmetric answer (`sameProject` in
42
+ * `tools.ts`): showing a job is not running one.
43
+ */
44
+ export function sessionTrustCovers(sessionCwd: string, cwd: string): boolean {
45
+ if (!sessionCwd || !cwd) return false;
46
+ return withinProject(sessionCwd, cwd);
47
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Loading this package's TypeScript from outside pi.
3
+ *
4
+ * `pi-loops` (src/cli-entry.mjs) and the headless host (src/host-entry.mjs) are plain Node
5
+ * processes — no pi around them, and by this project's rules no build step — so each has to import a
6
+ * `.ts` file directly. Node strips the types itself, except under `node_modules`, where it refuses
7
+ * outright (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). That is exactly where
8
+ * `pi install npm:@alphacoder-v0/pi-loops` puts this package, so the npm route needs a second way in
9
+ * while the checkout and `git:` routes keep the first one.
10
+ *
11
+ * The second way is jiti, which ships inside pi's own install and is what pi already uses to load
12
+ * extensions. pi is a peer dependency, so borrowing its copy installs nothing: "no runtime
13
+ * dependencies" stays true.
14
+ *
15
+ * Which way is chosen is decided *before* the import, from the path, rather than by catching the
16
+ * stripping error and retrying. Two reasons. A half-executed module graph is not a thing to retry —
17
+ * side effects in whatever did load stay done. And when jiti is missing too, deciding up front is
18
+ * what makes the failure honest: the person is told which pi was looked for and where, instead of a
19
+ * stack trace about type stripping that names the wrong problem.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import { createRequire } from "node:module";
23
+ import * as path from "node:path";
24
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
+ import { piAliases, piPackage } from "./pi-resolver.mjs";
26
+
27
+ /**
28
+ * Can Node strip the types in this file itself? It refuses for anything with a `node_modules`
29
+ * directory anywhere above it, which is the test this mirrors.
30
+ */
31
+ export function needsJiti(entryPath) {
32
+ return path.resolve(entryPath).split(path.sep).includes("node_modules");
33
+ }
34
+
35
+ /** jiti's entry point inside pi's install — or an error that says where pi was looked for. */
36
+ function jitiEntry() {
37
+ let dir;
38
+ try {
39
+ dir = piPackage();
40
+ } catch (err) {
41
+ // `npm root -g` is the last resort and it can fail on its own (no npm on PATH).
42
+ throw new Error(`could not work out where pi is installed (${err?.message ?? err}); set PI_LOOPS_PI_PACKAGE to its package directory`);
43
+ }
44
+ try {
45
+ return createRequire(path.join(dir, "package.json")).resolve("jiti");
46
+ } catch {
47
+ // Node's own resolution error names jiti and not the thing a person can fix, so say it here.
48
+ const missing = fs.existsSync(path.join(dir, "package.json")) ? "" : " — and no pi package is there at all";
49
+ throw new Error(
50
+ `this copy is installed under node_modules, where Node will not strip TypeScript, so it needs the jiti that ships inside pi${missing}.\n` +
51
+ ` pi package: ${dir}\n` +
52
+ ` looked for: ${path.join(dir, "node_modules", "jiti")}\n` +
53
+ ` set PI_LOOPS_PI_PACKAGE to the directory of your @earendil-works/pi-coding-agent install`,
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Import a `.ts` file next to an `.mjs` entry point, whichever way this install allows. `alias`
60
+ * comes from the resolver hook's map so jiti and the hook agree on which pi they mean.
61
+ */
62
+ export async function importTs(parentUrl, specifier) {
63
+ const entry = fileURLToPath(new URL(specifier, parentUrl));
64
+ if (!needsJiti(entry)) return await import(pathToFileURL(entry).href);
65
+ const { createJiti } = await import(pathToFileURL(jitiEntry()).href);
66
+ return await createJiti(parentUrl, { moduleCache: false, alias: piAliases() }).import(entry);
67
+ }