@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
@@ -8,7 +8,9 @@ import { importTs } from "./ts-entry.mjs";
8
8
  try {
9
9
  await importTs(import.meta.url, "./host.ts");
10
10
  } catch (err) {
11
- // stdout and stderr are the host log; a line there is what `pi-loops host start` points a person at.
12
- process.stderr.write(`pi-loops host: ${err?.message ?? err}\n`);
13
- process.exit(1);
11
+ // stdout and stderr are the host log; a line there is what `pi-loops start` points a person at.
12
+ process.stderr.write(`pi-loops: ${err?.message ?? err}\n`);
13
+ // 78 (EX_CONFIG): it cannot run here — the package or pi is not where it was. A systemd unit with
14
+ // RestartPreventExitStatus=78 does not restart it, where a crash (1) would be restarted.
15
+ process.exit(78);
14
16
  }
@@ -1,22 +1,32 @@
1
1
  /**
2
- * What the headless host runs (see host.ts for the process shell): the same LoopScheduler and
3
- * TriggerRuntime as the extension, wired for a process that has no chat — promotions go to the
4
- * inbox, control-plane operations by sub-agents are audited into `triggers-audit.jsonl`, and the
5
- * process leaves as soon as an interactive pi owns the clock. Injected so it can be unit-tested.
2
+ * What the always-on host runs (see host.ts for the process shell): the same LoopScheduler and
3
+ * TriggerRuntime as the extension, wired for a process that has no chat. It runs every loop and
4
+ * checks every rule; a promotion is a request addressed to the conversation that created the rule
5
+ * (src/requests.ts): its holder delivers it, and this process does when it is closed. Control-plane
6
+ * operations by sub-agents are audited into `triggers-audit.jsonl`. Injected so it can be unit-tested.
6
7
  */
7
- import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
8
+ import * as fs from "node:fs";
9
+ import { SessionManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
8
10
  import * as path from "node:path";
9
11
  import type { LoopsConfig } from "./config.ts";
10
12
  import { HookRunner, runEndEvent, runStartEvent } from "./hooks.ts";
11
13
  import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
12
14
  import { redact } from "./redact.ts";
13
- import type { SubagentRequest, SubagentRunner } from "./runner.ts";
14
- import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
15
+ import { keepNotice, parseRingNote, type Notice } from "./host-control-channel.ts";
16
+ import type { RunnerResult, SubagentRequest, SubagentRunner } from "./runner.ts";
17
+ import { DELIVERY_JOB_ID, type LeaseHolder, LeaseStore, closedConversation, liveHolderPid, procStart, sameHolder } from "./lease.ts";
18
+ import { CEILING, type EngineSinks, type Executor, type ExecutorView, type FileMark, type QuietMark, bury, changesSomething, conversationHas, conversationView, decideDrain, drain, promotionTarget, promotionView, quietMark, ruleResultRow, sessionFileEntries, stillQuiet } from "./engine.ts";
19
+ import { RequestStore } from "./requests.ts";
20
+ import { PI_LOOPS_VERSION } from "./version.ts";
21
+ import type { PresenceEntry } from "./presence.ts";
22
+ import { DEFAULT_RUN_TIMEOUT_MS, type HeldSession, LoopScheduler, type PlainJobLeases, STOPPED_BY_PERSON, type SessionSnapshot } from "./scheduler.ts";
23
+ import { stamp } from "./schedule.ts";
15
24
  import { SubagentSlots } from "./slots.ts";
16
25
  import { type LoopJob, newId } from "./store.ts";
17
26
  import { type ToolHost, automationTools, createLoopJob } from "./tools.ts";
18
- import { TriggerRuntime } from "./trigger-runtime.ts";
27
+ import { TriggerRuntime, promotionToInbox } from "./trigger-runtime.ts";
19
28
  import { auditCronFinish, auditCronStart, TriggerStore, controlPlanePreflight } from "./triggers.ts";
29
+ import { shortSessionId } from "./session-head.ts";
20
30
 
21
31
  export interface HostRuntimeDeps {
22
32
  dir: string;
@@ -27,12 +37,18 @@ export interface HostRuntimeDeps {
27
37
  /** MCP tool definitions of the host's own (user-level) servers, for every sub-session. */
28
38
  mcpTools: () => ToolDefinition<any, any>[];
29
39
  /** That project's own MCP servers, connected on demand: the host has no project of its own. */
30
- projectMcpTools?: (cwd: string, taken: Set<string>) => Promise<ToolDefinition<any, any>[]>;
40
+ projectMcpTools?: (cwd: string, taken: Set<string>, trusted?: boolean) => Promise<ToolDefinition<any, any>[]>;
31
41
  /** Whether pi has a saved trust decision for exactly this directory (src/trust.ts). Default: no. */
32
42
  isProjectTrusted?: (cwd: string) => boolean;
43
+ /** pi's own saved decision for a conversation's directory or a parent: what a held turn there may load. */
44
+ ownerTrusts?: (cwd: string) => boolean;
33
45
  log: (message: string) => void;
34
- /** Called when an interactive pi owns the clock: the host has nothing left to do. */
35
- exit: (code: number) => void | Promise<void>;
46
+ /** Does pi still have this session? Plain jobs of deleted sessions are parked by the host's sweep. */
47
+ sessionExists?: (sessionId: string) => boolean;
48
+ /** Every tick, before the rules are checked (the legacy fence lives here). */
49
+ onTick?: (now: number) => void;
50
+ /** Is this conversation open in the browser page? Then its pi writes the file, and this process never holds it. */
51
+ pageHolds?: (sessionId: string) => boolean;
36
52
  }
37
53
 
38
54
  export interface HostRuntime {
@@ -40,14 +56,176 @@ export interface HostRuntime {
40
56
  triggers: TriggerRuntime;
41
57
  /** The tools a sub-session started by this host gets (its cwd and model, not the host's). */
42
58
  customTools: (req: SubagentRequest) => Promise<ToolDefinition<any, any>[]>;
59
+ /** Say a warning to the windows of `cwd`'s project (or every window, without one); see `notices`. */
60
+ notice(text: string, cwd?: string): void;
61
+ /** The latest warnings, for the snapshot a window fetches on each tick. */
62
+ notices(): Notice[];
63
+ /** The doorbell: deliver what is queued for a closed conversation now, not on the next tick. Returns at once. */
64
+ ring(sessionId: string): void;
65
+ /** The turn this process is running in the conversation, if any: the browser page waits for it. */
66
+ heldTurn(sessionId: string): { runId: string; label: string } | undefined;
43
67
  start(): void;
44
68
  stop(): Promise<void>;
45
69
  }
46
70
 
71
+ /** Rings one session may answer between two ticks; a burst of rows is delivered by one ring, so this is far above any real one. */
72
+ const RINGS_PER_TICK = 60;
73
+
47
74
  export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
48
75
  const { dir, log } = deps;
76
+ const kept: Notice[] = [];
77
+ let noticeSeq = 0;
78
+ let budgetSaid: string | undefined;
79
+ const notice = (text: string, cwd?: string) => {
80
+ noticeSeq = keepNotice(kept, noticeSeq, text, cwd, Date.now());
81
+ };
49
82
  let triggers: TriggerRuntime;
50
- let exiting = false;
83
+ const leases = new LeaseStore(dir);
84
+ const requests = new RequestStore(dir);
85
+ /** The host as a lease holder: it holds a closed conversation for one piece of work, then lets go. */
86
+ const ownStart = procStart(process.pid);
87
+ const me = (busy?: LeaseHolder["busy"]): LeaseHolder => ({ kind: "host", pid: process.pid, instance: "host", procStart: ownStart, acquiredAt: stamp(), pushServers: [], ...(busy ? { busy } : {}) });
88
+ const fileMtime = (file: string | undefined): number | undefined => {
89
+ if (!file) return undefined;
90
+ try {
91
+ return fs.statSync(file).mtimeMs;
92
+ } catch {
93
+ return undefined; // gone: nothing for the quiet guard to remember
94
+ }
95
+ };
96
+ const fileMark = (file: string): FileMark => {
97
+ try {
98
+ const { size, mtimeMs } = fs.statSync(file);
99
+ return { size, mtimeMs };
100
+ } catch {
101
+ return undefined; // not there: a mark of its own, so its appearance is a change
102
+ }
103
+ };
104
+ /** Per closed conversation, the quiet mark of its last "nothing to do" (src/engine.ts quietMark). */
105
+ const quiet = new Map<string, QuietMark>();
106
+ /** Open in a pi-loops 0.22 window, which fires its plain jobs without a lease: never the host's to hold. */
107
+ let legacyOpen = (_sessionId: string): boolean => false;
108
+ const heldLeases: PlainJobLeases = {
109
+ heldEpoch: (sessionId) => (leases.holds(sessionId, me()) ? leases.read(sessionId)?.epoch : undefined),
110
+ holderOf: (sessionId) => liveHolderPid(leases, sessionId),
111
+ acquireForWork: (sessionId, runId, jobId) => {
112
+ if (legacyOpen(sessionId) || deps.pageHolds?.(sessionId)) return undefined;
113
+ const got = leases.acquireForHost(sessionId, me({ runId, jobId, since: stamp() }));
114
+ if (!("lease" in got) || !got.lease.sessionFile) return undefined;
115
+ return { sessionId, epoch: got.lease.epoch, sessionFile: got.lease.sessionFile, cwd: got.lease.cwd };
116
+ },
117
+ // With the epoch it took, and the mtime it left: see LeaseStore.release.
118
+ releaseWork: (sessionId, epoch) => void leases.release(sessionId, me(), { epoch, fileMtimeMs: fileMtime(leases.read(sessionId)?.sessionFile) }),
119
+ };
120
+ /** A plain job the host may fire: a conversation pi-loops knows the file of, that nobody holds and the page does not have open. */
121
+ const canHold = (sessionId: string | undefined): boolean => !(sessionId !== undefined && deps.pageHolds?.(sessionId)) && closedConversation(leases, sessionId, legacyOpen);
122
+
123
+ /** The engine's sinks here: the jobs the rows name, the inbox for dead letters, the audit. */
124
+ const sinks = (): EngineSinks => ({ jobs: { exists: (id) => scheduler.store.load().some((j) => j.id === id), enabled: (id) => scheduler.store.load().find((j) => j.id === id)?.enabled ?? false, goalActive: () => false }, inbox: scheduler.inbox, audit: triggers.store });
125
+
126
+ /**
127
+ * The held turns this process is in, by session: the run (the lease's `busy.runId`), when it
128
+ * started, and whether the runner has the file (`writing`). A stop may name the run from the turn's
129
+ * own first drain on; nothing is appended beside the runner once it writes.
130
+ */
131
+ const turnOpen = new Map<string, { requestId: string; startedAt: string; writing: boolean; label: string }>();
132
+ /**
133
+ * The engine's executor while this process holds a closed conversation: an `append` row is written
134
+ * into the file at once, each one once (the `[Trigger <id>]` already in the file is the check).
135
+ * No attended row runs here (`CEILING.held`); a turn row is not admitted yet either (held turns move
136
+ * onto rows in a later stage), so it waits for a window instead of failing here. While a turn of
137
+ * this process runs in the conversation its runner is writing the file, so nothing is appended
138
+ * beside it: only a stop or a cancel acts, and the inputs wait.
139
+ */
140
+ const heldView = (sessionId: string): ExecutorView => {
141
+ const open = turnOpen.get(sessionId);
142
+ const writing = open?.writing ?? false;
143
+ return { kind: "held", ceiling: CEILING.held, admits: (row) => !writing && row.wake !== "turn", idle: !writing, running: open && { requestId: open.requestId, startedAt: open.startedAt }, inFlight: new Set(), connected: () => true, producerAlive: () => true, mine: () => false };
144
+ };
145
+ function heldExecutor(held: HeldSession): Executor {
146
+ const sm = SessionManager.open(held.sessionFile);
147
+ return {
148
+ holds: (sessionId) => leases.holds(sessionId, me(), held.epoch),
149
+ entries: () => conversationView(() => sm.getEntries()),
150
+ view: () => heldView(held.sessionId),
151
+ append: (row) => {
152
+ sm.appendCustomMessageEntry("pi-loops:trigger", row.payload?.text ?? "", true);
153
+ // Written into a closed conversation, so no screen showed it: the audit row says so. The
154
+ // "since you last looked" line counts the rule's finding in the inbox (src/seen.ts).
155
+ triggers.store.appendAudit({ cwd: row.payload?.cwd ?? "", type: "trigger_promotion", traceId: row.id, state: "appended", details: { session_id: held.sessionId } });
156
+ // And said in the project's open windows: the person is likely in another conversation of
157
+ // it now, where a rule's result used to land (0.22), and would not know to look there.
158
+ // Once an hour per conversation (keepNotice keeps one of each text an hour).
159
+ notice(`a rule's result was added to conversation ${shortSessionId(held.sessionId)}, which is closed: pi --session ${held.sessionFile} shows it`, row.payload?.cwd);
160
+ },
161
+ turn: () => {
162
+ throw new Error("the background process does not run turn requests yet");
163
+ },
164
+ // As `/triggers abort` does: the run's AbortController, through the scheduler.
165
+ abort: () => {
166
+ const run = turnOpen.get(held.sessionId);
167
+ if (run) scheduler.abortRun(run.requestId);
168
+ },
169
+ // Never asked: nothing is in flight here (the view's set is empty), a row is in the file or not.
170
+ abortWhenStarted: () => undefined,
171
+ // Never asked either: a person row is attended, above this executor's ceiling.
172
+ refused: () => undefined,
173
+ };
174
+ }
175
+
176
+ /** Requests queued for a conversation this process holds: delivered now, while it holds it. */
177
+ async function drainInto(held: HeldSession): Promise<void> {
178
+ await drain(held.sessionId, requests, heldExecutor(held), Date.now(), sinks());
179
+ }
180
+
181
+ /** One plain job's turn in a closed conversation (src/scheduler.ts launchHeld). */
182
+ async function heldTurn(job: LoopJob, prompt: string, runId: string, held: HeldSession, signal: AbortSignal): Promise<{ preempted: boolean; result: RunnerResult; landed: boolean }> {
183
+ const stillHolds = () => leases.holds(held.sessionId, me(), held.epoch);
184
+ // The run exists from here: a stop that arrives during this first drain names it.
185
+ // What the page's waiting line calls it: a job's id is 32 characters no person typed.
186
+ const open = { requestId: runId, startedAt: scheduler.runningRuns().find((r) => r.runId === runId)?.startedAt ?? stamp(), label: job.name ?? runId.slice(0, 12) };
187
+ turnOpen.set(held.sessionId, { ...open, writing: false });
188
+ log(`cron ${job.name ?? job.id}: turn ${runId.slice(0, 12)} in session ${held.sessionId.slice(0, 8)}`);
189
+ let result: RunnerResult;
190
+ try {
191
+ try {
192
+ await drainInto(held);
193
+ } catch (err: any) {
194
+ log(`requests for session ${held.sessionId.slice(0, 8)}: ${err?.message ?? err}`);
195
+ }
196
+ if (signal.aborted) {
197
+ // Stopped before it began: nothing to run, and no model call to start for a turn already over.
198
+ log(`cron ${job.name ?? job.id}: turn ${runId.slice(0, 12)} stopped before it began`);
199
+ return { preempted: false, result: { ok: false, exitCode: 1, timedOut: false, text: "", errorMessage: "aborted", stopReason: "aborted", usage: { input: 0, output: 0, cost: 0, turns: 0 } }, landed: false };
200
+ }
201
+ // As the conversation's owner: pi's own trust for its directory, read now, and the job's declared tools.
202
+ // Without the conversation's own directory (an older lease), the job's cwd could be one a model
203
+ // chose: no owner trust then.
204
+ const cwd = held.cwd ?? job.cwd;
205
+ turnOpen.set(held.sessionId, { ...open, writing: true });
206
+ // No model or thinking level: the turn is the conversation's own and runs on what the
207
+ // conversation is on, as its window turn does (a 0.22 job's captured `model` was never used).
208
+ // The conversation is the turn's parent session, so the tools act for it: `cron_create
209
+ // stateful:false` binds to it, `all_projects` lists as a window's does.
210
+ result = await deps.runner({ cwd, prompt, tools: job.tools, builtinTools: job.builtinTools, trusted: held.cwd ? !!deps.ownerTrusts?.(held.cwd) : false, timeoutMs: job.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS, signal, hop: 1, kind: "held", jobId: job.id, runId, sessionFile: held.sessionFile, stillHolds, parentSessionId: held.sessionId, parentCwd: cwd });
211
+ } finally {
212
+ turnOpen.delete(held.sessionId);
213
+ }
214
+ // Taken back only if the turn did not complete: a window that arrives as the answer is saved has
215
+ // nothing to run again, and rolling the slot back would answer the same prompt twice.
216
+ const preempted = !result.ok && !stillHolds();
217
+ const byPerson = !result.ok && signal.aborted && signal.reason === STOPPED_BY_PERSON;
218
+ log(`cron ${job.name ?? job.id}: ${preempted ? "a window took the session back; its turn stopped" : result.ok ? "turn done" : byPerson ? `turn stopped by you (from ${scheduler.stopFrom(runId) === "page" ? "the page" : "a terminal"})` : `turn FAILED (${redact(result.errorMessage ?? "")})`}`);
219
+ // Read from the file, not from the result: a turn cut off after pi wrote the prompt has landed
220
+ // it, and the row it came from is done either way.
221
+ let landed = result.ok;
222
+ try {
223
+ landed = conversationHas(SessionManager.open(held.sessionFile).getEntries(), runId);
224
+ } catch (err: any) {
225
+ log(`cron ${job.name ?? job.id}: could not read session ${held.sessionId.slice(0, 8)} after its turn: ${err?.message ?? err}`);
226
+ }
227
+ return { preempted, result, landed };
228
+ }
51
229
 
52
230
  /* ------------------------------------------------------ lifecycle hooks */
53
231
 
@@ -106,10 +284,13 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
106
284
  getSession: deps.session,
107
285
  getSettings: () => ({ maxConcurrentRuns: deps.config().maxConcurrentRuns, catchUp: deps.config().cronCatchUp, dailyBudgetUsd: deps.config().dailyBudgetUsd }),
108
286
  runner: deps.runner,
109
- // No dead-session parking here: that is a decision the interactive leader can show the user.
287
+ // Every loop, and the plain jobs of closed conversations: a plain job's result is a message in
288
+ // its conversation, so the host fires one only where no window holds it.
289
+ owns: (job) => job.stateful || canHold(job.sessionId),
290
+ leases: heldLeases,
291
+ sessionExists: deps.sessionExists,
110
292
  hooks: {
111
- // Plain (inject) jobs belong to a chat; the host has none, so they stay dormant here.
112
- onInject: () => undefined,
293
+ onHeldTurn: heldTurn,
113
294
  onRunStart: (job, runId) => {
114
295
  // The same rows the interactive extension writes: without them `/triggers audit` is
115
296
  // blank for every hour the host had the clock.
@@ -124,7 +305,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
124
305
  onCatchUp: (job) => log(`loop ${job.name ?? job.id}: catching up a missed tick`),
125
306
  onRunFinished: ({ job, record, result }) => {
126
307
  auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
127
- log(`loop ${job.name ?? job.id}: ${record.ok ? "ok" : `FAILED (${redact(record.error ?? "")})`} · ${record.findings} finding(s)${record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : ""}`);
308
+ log(`loop ${job.name ?? job.id}: ${record.ok ? "ok" : record.stoppedBy === "person" ? `stopped by you (from ${record.stoppedFrom === "page" ? "the page" : "a terminal"})` : `FAILED (${redact(record.error ?? "")})`} · ${record.findings} finding(s)${record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : ""}`);
128
309
  const hooks = runHooks.get(record.runId);
129
310
  if (!hooks) return;
130
311
  // `$PI_RUN_OK` alone answers "did last night's loop fail" — the thing the host exists to
@@ -134,24 +315,242 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
134
315
  const forget = () => void runHooks.delete(record.runId);
135
316
  void hooks.fire(runEndEvent(job, record)).then(forget, forget);
136
317
  },
137
- onTick: async (now, leader) => {
138
- if (!leader) {
139
- if (exiting) return;
140
- exiting = true;
141
- log("an interactive pi owns the clock; exiting");
142
- await deps.exit(0);
143
- return;
144
- }
145
- await triggers.tick(now, leader);
318
+ onTick: async (now) => {
319
+ deps.onTick?.(now);
320
+ releaseStrayLeases(now);
321
+ await deliverToClosed();
322
+ await triggers.tick(now);
323
+ },
324
+ log: (msg, level, job) => {
325
+ log(redact(msg));
326
+ if (level !== "info") notice(msg, job?.cwd);
327
+ },
328
+ // A loop failing night after night: said in its project's windows, as it was when loops ran in one.
329
+ onSchedulerError: (msg, job) => {
330
+ log(redact(msg));
331
+ notice(msg, job?.cwd);
332
+ },
333
+ onBudgetExceeded: (spent, cap) => {
334
+ // Each due job reports it; a person needs it once a day.
335
+ const day = stamp().slice(0, 10);
336
+ if (budgetSaid === day) return;
337
+ budgetSaid = day;
338
+ const message = `today's automation has cost $${spent.toFixed(2)} of the $${cap.toFixed(2)} budget; dispatching is paused until tomorrow or a higher [limits] daily_budget_usd`;
339
+ log(message);
340
+ notice(message);
146
341
  },
147
- log: (msg) => log(redact(msg)),
148
342
  },
149
343
  });
150
344
 
151
- const toInbox = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }) => {
152
- await scheduler.inbox.append({ source: `trigger:${trigger.sourceLabel}`, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: trigger.cwd ?? "" });
345
+ legacyOpen = (sessionId) => scheduler.presenceList().some((e: PresenceEntry) => e.sessionId === sessionId && !e.leases && e.kind !== "host");
346
+
347
+ const toInbox = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }, forSession?: string) => {
348
+ // The check decided this result's finding already (TriggerRuntime's inbox); a push with no rule did not.
349
+ await promotionToInbox(scheduler.inbox, triggers.store, content, trigger, { cwd: trigger.cwd ?? "", sessionId: forSession });
153
350
  return "inbox" as const;
154
351
  };
352
+ /**
353
+ * A rule's result is a request addressed to a conversation (src/engine.ts promotionTarget): the one
354
+ * that created the rule while a window holds it, else one a window holds in the rule's project,
355
+ * else the closed creating conversation, which this process appends to while it holds it for that
356
+ * one write. A closed conversation this process cannot take this instant (written moments ago,
357
+ * busy) keeps the row for a later tick. The inbox is for a result no conversation can take.
358
+ */
359
+ const promote = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }, forSession?: string): Promise<"queued" | "chat" | "inbox"> => {
360
+ const route = promotionTarget(forSession, promotionView(leases, trigger.cwd, legacyOpen));
361
+ if (route.to === "inbox") return toInbox(content, trigger, route.sessionId);
362
+ requests.append(ruleResultRow(route.sessionId, content, trigger, { pid: process.pid, instance: "host", procStart: ownStart, version: PI_LOOPS_VERSION }));
363
+ if (!canHold(route.sessionId)) return "queued";
364
+ return (await deliverQueued(route.sessionId, trigger.traceId)) ? "chat" : "queued";
365
+ };
366
+
367
+ /**
368
+ * Take a closed conversation for one write and deliver what is queued for it. True when `traceId`
369
+ * (if given) is now in the conversation. The tick calls it for every closed conversation with a
370
+ * queue, so a result queued while the file was busy, or for a window that quit before reading it,
371
+ * still arrives.
372
+ */
373
+ async function deliverQueued(sessionId: string, traceId?: string): Promise<boolean> {
374
+ const held = heldLeases.acquireForWork?.(sessionId, traceId ?? "requests", DELIVERY_JOB_ID);
375
+ if (!held) return false;
376
+ try {
377
+ await drainInto(held);
378
+ if (traceId === undefined) return true;
379
+ const delivered = conversationHas(SessionManager.open(held.sessionFile).getEntries(), traceId);
380
+ if (delivered) log(`trigger ${traceId.slice(0, 8)}: appended to session ${sessionId.slice(0, 8)}`);
381
+ return delivered;
382
+ } catch (err: any) {
383
+ log(`requests for session ${sessionId.slice(0, 8)}: ${err?.message ?? err}`);
384
+ // Giving the lease back below rings this process again; with the same failure waiting
385
+ // (an inbox it cannot write), that ring would fail the same way, for ever.
386
+ quietUntilTick.add(sessionId);
387
+ return false;
388
+ } finally {
389
+ heldLeases.releaseWork?.(sessionId, held.epoch);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * A conversation this process still holds for a turn that is no longer running: the release at
395
+ * the turn's end failed (a lock timeout, a full disk), and nothing ran or was delivered there again
396
+ * until a restart. Given back here, a minute after the turn began, which no turn's setup takes.
397
+ */
398
+ function releaseStrayLeases(now: number): void {
399
+ const running = new Set(scheduler.runningRuns().map((r) => r.runId));
400
+ for (const lease of leases.list()) {
401
+ const busy = lease.holder?.busy;
402
+ if (!lease.holder || !busy || !sameHolder(lease.holder, me()) || running.has(busy.runId) || now - Date.parse(busy.since) < 60_000) continue;
403
+ try {
404
+ leases.release(lease.sessionId, me(), { epoch: lease.epoch, fileMtimeMs: fileMtime(lease.sessionFile) });
405
+ log(`gave back session ${lease.sessionId.slice(0, 8)}, held for run ${busy.runId} that is no longer running`);
406
+ } catch (err: any) {
407
+ log(`lease ${lease.sessionId.slice(0, 8)}: ${err?.message ?? err}`);
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * What is queued for one conversation nobody has open: delivered by this process, not left waiting
414
+ * for a resume. A conversation whose file is gone can never take it, so its rows become dead
415
+ * letters in the inbox, marked for it, instead of waiting there for ever.
416
+ *
417
+ * Writes nothing when the drain would change nothing. Taking the lease is itself a write, and this
418
+ * process hears its own writes as rings (`watch` below): a delivery that took the lease only to
419
+ * find every row waiting would ring itself into the next one, for ever.
420
+ */
421
+ async function deliverSession(sessionId: string): Promise<void> {
422
+ const rows = requests.list(sessionId);
423
+ if (!rows.length) {
424
+ quiet.delete(sessionId);
425
+ return;
426
+ }
427
+ // A turn of this process's own is running there: the conversation is held already, for that
428
+ // turn, so a stop or cancel is applied through that lease, with the epoch it took, and nothing
429
+ // is taken a second time. The executor admits no input while the runner has the file.
430
+ const lease = leases.read(sessionId);
431
+ if (turnOpen.has(sessionId) && lease?.holder && sameHolder(lease.holder, me()) && lease.sessionFile) {
432
+ await drainInto({ sessionId, epoch: lease.epoch, sessionFile: lease.sessionFile, cwd: lease.cwd });
433
+ return;
434
+ }
435
+ if (!canHold(sessionId)) {
436
+ if (liveHolderPid(leases, sessionId) !== undefined || legacyOpen(sessionId) || deps.pageHolds?.(sessionId)) return; // its holder drains it
437
+ const file = leases.read(sessionId)?.sessionFile;
438
+ if (file && fs.existsSync(file)) return; // there, only busy or recently written: a later tick
439
+ const buried = await bury(sessionId, requests, sinks(), "its conversation is gone");
440
+ if (buried.length) log(`${buried.length} queued request(s) for session ${sessionId.slice(0, 8)} went to the inbox: its conversation is gone`);
441
+ return;
442
+ }
443
+ // The same decision the drain makes, read without the lease (a read needs none). Marked before
444
+ // the read, so a write that lands during it shows as a change on the next tick, not a miss.
445
+ const file = leases.read(sessionId)?.sessionFile;
446
+ if (!file) return;
447
+ const files = [fileMark(file), fileMark(path.join(dir, "jobs.json"))];
448
+ const now = Date.now();
449
+ if (stillQuiet(quiet.get(sessionId), files, rows, now)) return;
450
+ quiet.delete(sessionId);
451
+ const entries = sessionFileEntries(file);
452
+ if (!changesSomething(decideDrain(rows, conversationView(() => entries), sinks().jobs, heldView(sessionId), now))) {
453
+ quiet.set(sessionId, quietMark(files, rows, now));
454
+ return;
455
+ }
456
+ await deliverQueued(sessionId);
457
+ }
458
+
459
+ /**
460
+ * Every closed conversation with something queued, on the tick: the floor under the doorbell, so a
461
+ * ring that was lost costs at most one tick. One conversation's failure (a lock that timed out)
462
+ * does not keep the others, or the rule checks after this, from their tick.
463
+ */
464
+ async function deliverToClosed(): Promise<void> {
465
+ quietUntilTick.clear();
466
+ rungSinceTick.clear();
467
+ // Image files whose row is gone (a crash between the two writes): one process sweeps them, here.
468
+ try {
469
+ const swept = requests.sweepImages();
470
+ if (swept.length) log(`removed ${swept.length} image file(s) no request names any more`);
471
+ } catch (err: any) {
472
+ log(`requests: ${err?.message ?? err}`);
473
+ }
474
+ for (const sessionId of requests.sessions()) {
475
+ try {
476
+ await deliverSession(sessionId);
477
+ } catch (err: any) {
478
+ log(`requests for session ${sessionId.slice(0, 8)}: ${err?.message ?? err}`);
479
+ }
480
+ }
481
+ }
482
+
483
+ /* ------------------------------------------------------------ the doorbell */
484
+
485
+ /**
486
+ * Rings arrive in bursts (every append, every lease write is one), so a session has at most one
487
+ * delivery running and one more to run after it: a ring during a delivery is kept as that one
488
+ * rerun, which sees whatever the running one read too early to see. Nothing waits on a timer.
489
+ */
490
+ const delivering = new Set<string>();
491
+ const ringAgain = new Set<string>();
492
+ /**
493
+ * The bell is never the only way a row arrives, so it may stop ringing: after a delivery that
494
+ * failed, and past a number of rings no real burst reaches between two ticks. Either way the
495
+ * session is the tick's until then. This bounds a loop of self-rings that a check above missed;
496
+ * the timers it would starve include the tick's own.
497
+ */
498
+ const quietUntilTick = new Set<string>();
499
+ const rungSinceTick = new Map<string, number>();
500
+ function ring(sessionId: string): void {
501
+ if (quietUntilTick.has(sessionId)) return;
502
+ const rung = (rungSinceTick.get(sessionId) ?? 0) + 1;
503
+ rungSinceTick.set(sessionId, rung);
504
+ if (rung > RINGS_PER_TICK) {
505
+ quietUntilTick.add(sessionId);
506
+ log(`doorbell for session ${sessionId.slice(0, 8)}: rang ${RINGS_PER_TICK} times since the last tick; left to the tick`);
507
+ return;
508
+ }
509
+ if (delivering.has(sessionId)) {
510
+ ringAgain.add(sessionId);
511
+ return;
512
+ }
513
+ delivering.add(sessionId);
514
+ deliverSession(sessionId)
515
+ .catch((err: any) => log(`requests for session ${sessionId.slice(0, 8)}: ${err?.message ?? err}`))
516
+ .then(() => {
517
+ delivering.delete(sessionId);
518
+ if (ringAgain.delete(sessionId)) ring(sessionId);
519
+ })
520
+ .catch((err: any) => log(`doorbell: ${err?.message ?? err}`));
521
+ }
522
+
523
+ /**
524
+ * The file name is the whole message: `<session>.jsonl` in `requests/` is a row written for it,
525
+ * `<session>.json` in `leases/` a conversation given back, or taken. Either rings the session.
526
+ * fs.watch can miss events and is best-effort by design; the tick still delivers, so a watcher
527
+ * that fails to start, or fails later, is logged and not restarted.
528
+ */
529
+ const watchers: fs.FSWatcher[] = [];
530
+ function watch(): void {
531
+ if (watchers.length) return;
532
+ const stores: Array<{ sub: "requests" | "leases"; ext: string; kind: "request" | "lease" }> = [
533
+ { sub: "requests", ext: ".jsonl", kind: "request" },
534
+ { sub: "leases", ext: ".json", kind: "lease" },
535
+ ];
536
+ for (const { sub, ext, kind } of stores) {
537
+ const where = path.join(dir, sub);
538
+ try {
539
+ fs.mkdirSync(where, { recursive: true });
540
+ const watcher = fs.watch(where, (_event, name) => {
541
+ const file = String(name ?? "");
542
+ if (!file.endsWith(ext)) return;
543
+ const note = parseRingNote(`${kind}:${file.slice(0, -ext.length)}`);
544
+ if (note) ring(note.sessionId);
545
+ });
546
+ watcher.on("error", (err) => log(`${sub} watch: ${err?.message ?? err}`));
547
+ watchers.push(watcher);
548
+ } catch (err: any) {
549
+ log(`${sub} watch: ${err?.message ?? err}`);
550
+ }
551
+ }
552
+ }
553
+
155
554
  triggers = new TriggerRuntime({
156
555
  store: new TriggerStore(dir),
157
556
  jobStore: scheduler.store,
@@ -162,24 +561,25 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
162
561
  dedupFile: path.join(dir, "dedup.json"),
163
562
  slots,
164
563
  budget: () => scheduler.budgetState(),
165
- self: scheduler.self,
166
- presence: () => scheduler.presenceList(),
167
- isLeader: () => scheduler.isLeader,
564
+ inbox: scheduler.inbox,
168
565
  hooks: {
169
- // No chat here: everything a rule would have promoted lands in the inbox.
170
- onPromote: toInbox,
171
- onInjectAndRun: toInbox,
566
+ onPromote: promote,
567
+ // A push that injects and runs reaches the host only when no window has its server (host.ts): the inbox.
568
+ onInjectAndRun: (prompt, trigger) => toInbox(prompt, trigger),
172
569
  onFinished: (o) => log(`trigger ${o.trigger.traceId.slice(0, 8)} ${o.delivery} ${o.ok ? "ok" : `FAILED (${redact(o.error ?? "")})`}${o.matchedRules.length ? ` matched ${o.matchedRules.length}` : ""}`),
173
570
  log: (msg) => log(redact(msg)),
174
571
  },
175
572
  });
176
573
 
177
574
  /** The tools' host for one sub-session: that run's cwd and model, not the process defaults. */
178
- const toolHostFor = (req: Pick<SubagentRequest, "cwd" | "model" | "thinking">): ToolHost => {
575
+ const toolHostFor = (req: Pick<SubagentRequest, "cwd" | "model" | "thinking" | "builtinTools" | "tools" | "kind" | "parentSessionId">): ToolHost => {
576
+ const held = req.kind === "held";
179
577
  const host: ToolHost = {
180
578
  scheduler,
181
579
  triggers,
182
- session: () => ({ ...deps.session(), cwd: req.cwd, model: req.model ?? deps.session().model, thinking: req.thinking ?? deps.session().thinking }),
580
+ // A job a run makes gets no more built-in tools than that run (createLoopJob trims to them), and no trust of its own.
581
+ // A held turn's session is the conversation it runs in; a loop's run has none.
582
+ session: () => ({ ...deps.session(), sessionId: req.parentSessionId, cwd: req.cwd, model: req.model ?? deps.session().model, thinking: req.thinking ?? deps.session().thinking, builtinTools: req.builtinTools ?? req.tools?.filter((t) => PI_BUILTIN_TOOL_NAMES.includes(t)) ?? deps.session().builtinTools, trusted: false }),
183
583
  createJob: (input, scope) => createLoopJob(host, input, scope),
184
584
  // The cron_control_plane audit has no session to live in here; /triggers audit shows it instead.
185
585
  cronControlAudit: (op, actor, before, after) => {
@@ -189,8 +589,9 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
189
589
  log(`cron control plane: ${op} by ${actor} ${job?.id ?? ""}`);
190
590
  return id;
191
591
  },
192
- // Nobody can approve here: Prompt-class operations are denied fail-closed.
193
- confirmTool: async (_ctx, request, atHop) => controlPlanePreflight({ hop: Math.max(1, atHop), hasUI: false }, request.label),
592
+ // Nobody can approve here: Prompt-class operations are denied fail-closed. A held turn is the
593
+ // conversation's own, so its refusal says where the person was not, not "sub-agent".
594
+ confirmTool: async (_ctx, request, atHop) => controlPlanePreflight({ hop: held ? 0 : Math.max(1, atHop), hasUI: false, held }, request.label),
194
595
  refreshBadge: () => undefined,
195
596
  };
196
597
  return host;
@@ -199,15 +600,28 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
199
600
  return {
200
601
  scheduler,
201
602
  triggers,
603
+ notice,
604
+ notices: () => [...kept],
605
+ ring,
606
+ heldTurn: (sessionId) => {
607
+ const open = turnOpen.get(sessionId);
608
+ return open && { runId: open.requestId, label: open.label };
609
+ },
202
610
  customTools: async (req) => {
203
611
  const shared = deps.mcpTools();
204
- const automation = automationTools({ hop: req.hop, actor: "sub-agent", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd }, toolHostFor(req));
612
+ // A held turn is the conversation's own turn, as in a window (hop 0, actor "tool"): the
613
+ // tools list and bind as they would there. A loop's run is a sub-agent's.
614
+ const automation = automationTools(req.kind === "held" ? { hop: 0, actor: "tool", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd } : { hop: req.hop, actor: "sub-agent", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd }, toolHostFor(req));
205
615
  const taken = new Set([...PI_BUILTIN_TOOL_NAMES, ...shared.map((t) => t.name), ...automation.map((t) => t.name)]);
206
- const project = req.cwd ? ((await deps.projectMcpTools?.(req.cwd, taken)) ?? []) : [];
616
+ const project = req.cwd ? ((await deps.projectMcpTools?.(req.cwd, taken, req.trusted)) ?? []) : [];
207
617
  return [...shared, ...project, ...automation];
208
618
  },
209
- start: () => scheduler.start(),
619
+ start: () => {
620
+ scheduler.start();
621
+ watch();
622
+ },
210
623
  stop: async () => {
624
+ for (const w of watchers.splice(0)) w.close();
211
625
  await triggers.stop();
212
626
  await scheduler.stop();
213
627
  // The runs are over; their hooks may still be in flight. Same bounded wait as the