@alphacoder-v0/pi-loops 0.22.8 → 0.24.1

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 (92) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +114 -78
  4. package/README.zh-CN.md +62 -41
  5. package/docs/cli.md +352 -91
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +404 -61
  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 +41 -28
  17. package/docs/web-ui-parity.md +702 -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/browser-open.ts +169 -0
  32. package/src/cli-entry.mjs +12 -3
  33. package/src/cli.ts +960 -143
  34. package/src/config.ts +53 -5
  35. package/src/conversation-rows.ts +469 -0
  36. package/src/danger.ts +227 -6
  37. package/src/engine.ts +1240 -0
  38. package/src/goal.ts +71 -0
  39. package/src/held-read.ts +27 -0
  40. package/src/hooks.ts +49 -18
  41. package/src/host-control-channel.ts +455 -40
  42. package/src/host-control.ts +602 -111
  43. package/src/host-entry.mjs +5 -3
  44. package/src/host-runtime.ts +455 -42
  45. package/src/host.ts +659 -123
  46. package/src/inbox-card.ts +278 -0
  47. package/src/inbox.ts +139 -62
  48. package/src/job-edit.ts +7 -1
  49. package/src/job-health.ts +14 -2
  50. package/src/job-lines.ts +201 -13
  51. package/src/job-owner.ts +74 -13
  52. package/src/job-tune.ts +110 -0
  53. package/src/lease.ts +537 -0
  54. package/src/lock.ts +7 -2
  55. package/src/mcp-pool.ts +66 -14
  56. package/src/mcp.ts +1 -1
  57. package/src/open-file.ts +54 -0
  58. package/src/page-children.ts +1507 -0
  59. package/src/page-devices.ts +192 -0
  60. package/src/page-server.ts +3150 -0
  61. package/src/path-precheck.ts +215 -0
  62. package/src/paths.ts +24 -0
  63. package/src/pi-floor.ts +10 -0
  64. package/src/pi-loops.ts +1370 -473
  65. package/src/pi-resolver.d.mts +1 -0
  66. package/src/pi-resolver.mjs +110 -10
  67. package/src/presence.ts +12 -63
  68. package/src/protocol.ts +10 -1
  69. package/src/recipe.ts +43 -1
  70. package/src/requests.ts +354 -0
  71. package/src/rule-lines.ts +68 -0
  72. package/src/run-card.ts +105 -0
  73. package/src/runner.ts +18 -1
  74. package/src/schedule.ts +1 -2
  75. package/src/scheduler.ts +595 -245
  76. package/src/sdk-runner.ts +321 -27
  77. package/src/seen.ts +278 -0
  78. package/src/session-head.ts +21 -0
  79. package/src/share.ts +12 -2
  80. package/src/snapshot.ts +3 -2
  81. package/src/store.ts +81 -8
  82. package/src/tools.ts +196 -65
  83. package/src/trigger-runtime.ts +164 -127
  84. package/src/triggers.ts +39 -7
  85. package/src/trust.ts +46 -2
  86. package/src/ui-prefs.ts +22 -7
  87. package/src/watch-plan.ts +174 -0
  88. package/src/web.d.mts +3 -0
  89. package/src/web.mjs +2261 -2340
  90. package/src/window-executor.ts +459 -0
  91. package/src/window-guard.ts +158 -0
  92. package/docs/agents/issue-tracker.md +0 -34
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A window into the headless host, and a way to interrupt it.
2
+ * A window into the always-on host, and a way to interrupt it.
3
3
  *
4
4
  * The answer to "I am away and want to look in" would normally be that its process is still open: `--web` serves
5
5
  * a loopback UI and `/web-connect` relays it. pi-loops' host has no chat at all, so it publishes
@@ -14,12 +14,21 @@ import * as fs from "node:fs";
14
14
  import * as net from "node:net";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
+ import { DEAD_AFTER_ATTEMPTS, formatWait, heldTurnFor, stopRow } from "./engine.ts";
18
+ import { snapshotLoopLabel } from "./job-lines.ts";
19
+ import { type LeaseStore, type ProcessProbe, holderAlive, sessionFileName } from "./lease.ts";
20
+ import { conversationLabel, shortSessionId } from "./session-head.ts";
21
+ import { previewRedacted, redact } from "./redact.ts";
22
+ import type { RequestStore, SessionRequest } from "./requests.ts";
23
+ import { formatLocal, stamp } from "./schedule.ts";
24
+ import type { RunRecord, RunRequester } from "./store.ts";
17
25
 
18
- export const HOST_SOCKET = "host.sock";
26
+ /** A new name on purpose: a 0.22 pi-loops talks to `host.sock`, and only to its own kind of host. */
27
+ export const HOST_SOCKET = "host-service.sock";
19
28
  const MAX_UNIX_SOCKET_PATH = 100; // 104 on macOS, minus headroom so a rename or a longer socket name cannot cross it
20
29
 
21
30
  /**
22
- * Where the host listens. Normally `<dir>/host.sock`, but a unix socket path is capped at 108
31
+ * Where the host listens. Normally `<dir>/host-service.sock`, but a unix socket path is capped at 108
23
32
  * bytes on Linux (104 on macOS) — the whole `sockaddr_un.sun_path` — and a loops directory nested
24
33
  * a few levels deep goes past that. `listen()` then fails with EINVAL, the host runs on with no
25
34
  * control channel, and `pi-loops host status|abort|stop` report a host that "is not answering"
@@ -36,7 +45,7 @@ export function hostSocketPath(dir: string): string {
36
45
  // A byte count, not a character count: the kernel measures bytes.
37
46
  if (Buffer.byteLength(preferred) <= MAX_UNIX_SOCKET_PATH) return preferred;
38
47
  const digest = createHash("sha256").update(path.resolve(dir)).digest("hex").slice(0, 16);
39
- return path.join(os.tmpdir(), `pi-loops-${process.getuid?.() ?? "u"}`, `host-${digest}.sock`);
48
+ return path.join(os.tmpdir(), `pi-loops-${process.getuid?.() ?? "u"}`, `host-service-${digest}.sock`);
40
49
  }
41
50
 
42
51
  /**
@@ -83,39 +92,366 @@ function ownedSocket(socketPath: string): boolean {
83
92
  }
84
93
  }
85
94
 
95
+ /** One of the last few runs. `stopped`: a person, a quit or a restart stopped it; `byPerson`: a person did. */
96
+ export interface RecentRun {
97
+ job: string;
98
+ at: string;
99
+ ok: boolean;
100
+ stopped?: true;
101
+ byPerson?: true;
102
+ error?: string;
103
+ cost?: number;
104
+ }
105
+
106
+ /** A run record as the snapshot's recent runs carry it: a stopped run is neither ok nor a failure. */
107
+ export function recentRun(r: RunRecord): RecentRun {
108
+ const stopped = r.stoppedBy === "person" || r.aborted === true;
109
+ return {
110
+ job: r.jobName ?? r.jobId,
111
+ at: r.finishedAt,
112
+ ok: r.ok,
113
+ ...(stopped ? { stopped: true as const, ...(r.stoppedBy === "person" ? { byPerson: true as const } : {}) } : { error: r.error ? previewRedacted(r.error, 100) : undefined }),
114
+ cost: r.usage?.cost,
115
+ };
116
+ }
117
+
86
118
  export interface HostSnapshot {
87
119
  pid: number;
88
120
  host: string;
89
121
  startedAt: string;
122
+ /** Who started it, whose environment (keys exported in its shell, PATH) it has. */
123
+ startedBy?: string;
90
124
  model?: string;
91
- leader: boolean;
92
- runs: Array<{ runId: string; label: string; jobId: string; startedAt: string; promptPreview: string }>;
125
+ /** PI_LOOPS_VERSION of the host. */
126
+ version?: string;
127
+ /**
128
+ * The conversations a window holds right now, from the leases: where their plain jobs and results
129
+ * go. `page` when the holder is a conversation open in the browser page: its tabs, and how long
130
+ * since one of them asked for anything.
131
+ */
132
+ /** `title`: the conversation's name or first message (src/session-head.ts sessionTitle), when its file says. */
133
+ sessions?: Array<{ sessionId: string; pid: number; mode?: string; page?: { tabs: number; idleMs: number }; title?: string }>;
134
+ runs: Array<{ runId: string; label: string; jobId: string; startedAt: string; promptPreview: string; sessionFile?: string }>;
93
135
  checks: Array<{ traceId: string; sourceLabel: string; eventLabel: string; startedAt: string; cwd: string }>;
94
136
  jobs: { enabled: number; total: number };
95
137
  rules: { enabled: number; total: number };
96
138
  inboxNew: number;
97
139
  mcp: Array<{ name: string; state: string; lastError?: string }>;
98
140
  /** The last few runs, newest first: a host that has been failing for hours must not look idle. */
99
- recent?: Array<{ job: string; at: string; ok: boolean; error?: string; cost?: number }>;
141
+ recent?: RecentRun[];
100
142
  /** Jobs currently carrying an error, and when anything is next due. */
101
143
  failing?: Array<{ job: string; error: string }>;
102
144
  nextDue?: string;
103
145
  /** Today's spend against the cap, when one is configured. */
104
146
  budget?: { spent: number; cap: number };
147
+ /** The browser page's address, without its token, once its port is bound. */
148
+ page?: string;
149
+ /** Conversations open in the page right now. */
150
+ pageOpen?: number;
151
+ /** Why the page is not served yet, as the status line says it after `page: `. */
152
+ pageWaiting?: string;
153
+ /** The host's last rule check: windows show it in /triggers status and the panel, since checks run here. */
154
+ /** The latest rule check of each project. */
155
+ lastPolls?: Array<{ at: string; cwd: string; outcome: string; traceId: string; sourceLabel: string; eventLabel: string; summary: string }>;
156
+ /** The latest warnings from the background process, numbered from 1 per process (see `noticesToShow`). */
157
+ notices?: Notice[];
158
+ /** Every conversation with rows in `requests/`, read from the files (`requestQueues`). */
159
+ requests?: RequestQueue[];
160
+ }
161
+
162
+ /** A conversation's request queue as `pi-loops status` and the status page show it: depth, age, dead rows, who would run them. */
163
+ export interface RequestQueue {
164
+ sessionId: string;
165
+ count: number;
166
+ /** The oldest row's `created_at`. */
167
+ oldest: string;
168
+ /** Rows at the dead-letter limit, in the file until a drain buries them. */
169
+ dead: number;
170
+ holder: { kind: "terminal"; pid: number } | { kind: "host" } | { kind: "nobody" };
171
+ }
172
+
173
+ /**
174
+ * Each session's queue, from the files alone, so the CLI can say it while pi-loops is down. A
175
+ * session with nothing queued is left out. Nothing of a row's text is read into it.
176
+ */
177
+ export function requestQueues(store: Pick<RequestStore, "sessions" | "list">, leases: Pick<LeaseStore, "read">, probe?: ProcessProbe): RequestQueue[] {
178
+ return store
179
+ .sessions()
180
+ .sort()
181
+ .flatMap((sessionId): RequestQueue[] => {
182
+ const rows = store.list(sessionId);
183
+ if (!rows.length) return [];
184
+ const holder = leases.read(sessionId)?.holder;
185
+ const live = holder && holderAlive(holder, probe) ? holder : undefined;
186
+ return [
187
+ {
188
+ sessionId,
189
+ count: rows.length,
190
+ oldest: rows.map((r) => r.created_at).sort((a, b) => Date.parse(a) - Date.parse(b))[0],
191
+ dead: rows.filter((r) => r.attempts >= DEAD_AFTER_ATTEMPTS).length,
192
+ holder: !live ? { kind: "nobody" } : live.kind === "host" ? { kind: "host" } : { kind: "terminal", pid: live.pid },
193
+ },
194
+ ];
195
+ });
196
+ }
197
+
198
+ /**
199
+ * What waits for each conversation, in plain words: a total, then one line per conversation. None
200
+ * when nothing waits. `where: false` leaves out where each is open, for a view that says it already.
201
+ */
202
+ export function requestLines(queues: readonly RequestQueue[], now: number, opts: { where?: boolean } = {}): string[] {
203
+ if (!queues.length) return [];
204
+ const total = queues.reduce((n, q) => n + q.count, 0);
205
+ const dead = queues.reduce((n, q) => n + q.dead, 0);
206
+ const undelivered = (n: number) => `${n} could not be delivered`;
207
+ const where = (q: RequestQueue) => (q.holder.kind === "terminal" ? `open in pid ${line(q.holder.pid)}` : q.holder.kind === "host" ? "in the background" : "closed");
208
+ const head = ` ${total} message${total === 1 ? "" : "s"} waiting for ${queues.length} conversation${queues.length === 1 ? "" : "s"}${dead ? `, ${undelivered(dead)}` : ""}`;
209
+ return [head, ...queues.map((q) => ` ${shortSessionId(line(q.sessionId))}: ${line(q.count)} waiting · oldest ${formatWait(now - Date.parse(q.oldest))}${q.dead ? ` · ${undelivered(q.dead)}` : ""}${opts.where === false ? "" : ` · ${where(q)}`}`)];
210
+ }
211
+
212
+ /** A warning from the background process: a job disabled or parked, a state write that failed, the budget reached. */
213
+ export interface Notice {
214
+ seq: number;
215
+ at: string;
216
+ text: string;
217
+ /** The project it is about; none for the machine's (the budget, the store). */
218
+ cwd?: string;
219
+ }
220
+
221
+ /** How many warnings the background process keeps, and how long a repeat of one stays quiet. */
222
+ export const NOTICES_KEPT = 50;
223
+ const NOTICE_REPEAT_MS = 60 * 60 * 1000;
224
+ /** On a window's first look, warnings this recent are still news (one parked at startup, say). */
225
+ const NOTICE_RECENT_MS = 10 * 60 * 1000;
226
+
227
+ /**
228
+ * Keep a warning for windows to fetch, redacted, and return the next sequence number. The same text
229
+ * within the hour is kept once: a store that cannot be read says so on every tick.
230
+ */
231
+ export function keepNotice(kept: Notice[], seq: number, text: string, cwd: string | undefined, now: number): number {
232
+ const clean = redact(text);
233
+ if (kept.some((n) => n.text === clean && n.cwd === cwd && now - Date.parse(n.at) < NOTICE_REPEAT_MS)) return seq;
234
+ kept.push({ seq: seq + 1, at: stamp(now), text: clean, ...(cwd ? { cwd } : {}) });
235
+ while (kept.length > NOTICES_KEPT) kept.shift();
236
+ return seq + 1;
237
+ }
238
+
239
+ /**
240
+ * Which of the background process's warnings a window shows: its own project's and the machine's,
241
+ * each once. On its first look, only the recent ones: a window opened after something was said
242
+ * still hears it, and one opened a day later is not handed a backlog.
243
+ */
244
+ export function noticesToShow(notices: Notice[], seen: { pid: number; seq: number } | undefined, pid: number, cwd: string, sameProject: (a: string, b: string) => boolean, now: number = Date.now()): { show: Notice[]; seen: { pid: number; seq: number } } {
245
+ const last = notices.reduce((max, n) => Math.max(max, n.seq), 0);
246
+ const mine = (n: Notice) => !n.cwd || sameProject(n.cwd, cwd);
247
+ if (!seen) return { show: notices.filter((n) => mine(n) && now - Date.parse(n.at) < NOTICE_RECENT_MS), seen: { pid, seq: last } };
248
+ const after = seen.pid === pid ? seen.seq : 0;
249
+ return { show: notices.filter((n) => n.seq > after && mine(n)), seen: { pid, seq: Math.max(after, last) } };
250
+ }
251
+
252
+ /**
253
+ * `stop` carries why: `restart` and `upgrade` wait for the page's turns to finish (src/host.ts), a
254
+ * plain `stop` does not; one without a reason is a plain stop, as every sender before 0.24 sends it.
255
+ * `upgrade` comes from a window running a newer pi-loops: the host leaves, and that window starts the
256
+ * new code. `ring` is the doorbell: a producer wrote a row for a conversation it does not hold
257
+ * (`request:<session>`), or gave a conversation back (`lease:<session>`), and the host delivers
258
+ * without waiting for its tick. It is answered before the delivery runs, and ignored when lost.
259
+ */
260
+ export type HostRequest =
261
+ | { op: "status" }
262
+ | { op: "abort"; runId?: string; traceId?: string }
263
+ | { op: "stop"; reason?: StopReason }
264
+ | { op: "upgrade" }
265
+ | { op: "run"; jobId: string; requestedBy?: RunRequester }
266
+ | { op: "ring"; note: string }
267
+ | PageOpenRequest
268
+ | { op: "page-key"; sessionId: string }
269
+ | { op: "pair" };
270
+ /**
271
+ * Open a conversation in the browser page: `sessionId` and `sessionFile` for one that exists, neither
272
+ * for a new one in `cwd`. `piArgs` go to that conversation's pi when it is started for this.
273
+ */
274
+ export type PageOpenRequest = { op: "page-open"; cwd: string; sessionId?: string; sessionFile?: string; piArgs: string[]; openFile?: boolean; copyKey?: boolean };
275
+ /** How long the one-use key `pi-loops` prints for another browser (`copyKey`) works: long enough to copy it there. */
276
+ export const COPY_KEY_MS = 10 * 60_000;
277
+ /**
278
+ * `ran` is `true`, or why the loop did not start (the same refusal `/cron run` prints). `page-open`
279
+ * answers `url` (the conversation's page, no token), `sessionId` and `openKey` (one load, a minute),
280
+ * and with `openFile` the path of a 0600 file that sends a browser to the keyed page; with `copyKey`
281
+ * a second one-use key, good for `COPY_KEY_MS`, for the address the launcher prints;
282
+ * `reused` when the page already had it open, and `ignoredArgs`, the pi arguments that open pi never
283
+ * saw. `page-key` answers `openKey`. `pair` answers `code` and `addresses`, the page's addresses on
284
+ * the machine's networks (none while it listens on loopback only).
285
+ */
286
+ /** Why the process is asked to leave. */
287
+ export type StopReason = "stop" | "restart" | "upgrade";
288
+
289
+ /**
290
+ * What a second stop or upgrade is told while the first one is still leaving: it is not started
291
+ * again. A plain stop that arrives while a restart or an upgrade waits ends that wait instead.
292
+ */
293
+ export const ALREADY_LEAVING = "already leaving";
294
+
295
+ export type HostResponse =
296
+ | { ok: true; note?: typeof ALREADY_LEAVING; snapshot?: HostSnapshot; aborted?: boolean; stopping?: "run" | "row"; ran?: true | string; url?: string; sessionId?: string; openKey?: string; openFile?: string; copyKey?: string; reused?: true; ignoredArgs?: string[]; code?: string; addresses?: string[] }
297
+ | { ok: false; error: string };
298
+ /** What the page answers to `page-open`: everything but `ok`, or why not. */
299
+ export type PageOpened = { url: string; sessionId: string; openKey: string; openFile?: string; copyKey?: string; reused?: true; ignoredArgs?: string[] } | { error: string };
300
+
301
+ /**
302
+ * What `page-open` says to `--api-key`. A page's pi arguments are kept in `pages/` so the
303
+ * conversation reopens the same way after an idle stop or a restart; a key there is a secret on disk.
304
+ */
305
+ export const API_KEY_REFUSAL = "--api-key would be kept on disk with the page's pi arguments; set the provider's environment variable (for example ANTHROPIC_API_KEY or OPENAI_API_KEY) before pi-loops starts, or log in with pi's /login in a terminal";
306
+
307
+ /** pi arguments that would make a page's pi something other than a window: never passed through. */
308
+ const NOT_A_WINDOW = ["--mode", "-p", "--print", "--session-dir"];
309
+
310
+ /**
311
+ * What `page-open` refuses before anything is started, or undefined. The request came over the
312
+ * socket: `cwd` has to be a directory that exists, named absolutely, and every pi argument a string.
313
+ */
314
+ export function pageOpenRefusal(req: Partial<PageOpenRequest>): string | undefined {
315
+ if (typeof req.cwd !== "string" || !path.isAbsolute(req.cwd) || !isDirectory(req.cwd)) return "page-open needs cwd, an absolute path to a directory";
316
+ if (!Array.isArray(req.piArgs) || !req.piArgs.every((a) => typeof a === "string")) return "page-open needs piArgs, a list of strings";
317
+ for (const arg of req.piArgs) {
318
+ if (arg === "--api-key" || arg.startsWith("--api-key=")) return API_KEY_REFUSAL;
319
+ const flag = NOT_A_WINDOW.find((f) => arg === f || arg.startsWith(`${f}=`));
320
+ if (flag) return `${flag} would make the child something other than a window`;
321
+ }
322
+ if (req.sessionId !== undefined && typeof req.sessionId !== "string") return "page-open: sessionId must be a string";
323
+ if (req.sessionFile !== undefined && (typeof req.sessionFile !== "string" || !path.isAbsolute(req.sessionFile))) return "page-open: sessionFile must be an absolute path";
324
+ if (req.openFile !== undefined && typeof req.openFile !== "boolean") return "page-open: openFile must be true or false";
325
+ if (req.copyKey !== undefined && typeof req.copyKey !== "boolean") return "page-open: copyKey must be true or false";
326
+ return undefined;
327
+ }
328
+
329
+ function isDirectory(p: string): boolean {
330
+ try {
331
+ return fs.statSync(p).isDirectory();
332
+ } catch {
333
+ return false; // not there, or not ours to look at: not a directory to start pi in
334
+ }
105
335
  }
106
336
 
107
- export type HostRequest = { op: "status" } | { op: "abort"; runId?: string; traceId?: string } | { op: "stop" };
108
- export type HostResponse = { ok: true; snapshot?: HostSnapshot; aborted?: boolean } | { ok: false; error: string };
337
+ /** What a stop asked of pi-loops in the background came to: the run is being stopped, its conversation's queue has the stop, or nothing was running. */
338
+ export type StopAnswer = { stopping: "run" | "row" } | { finished: true };
339
+
340
+ export interface StopDeps {
341
+ /** The runs in flight here (`scheduler.runningRuns()`). */
342
+ runs: () => ReadonlyArray<{ runId: string }>;
343
+ /** The closed conversation this process holds for `runId`'s turn, when the run is a held turn. */
344
+ heldSession: (runId: string) => string | undefined;
345
+ /** Abort a loop run as a person asked (`scheduler.abortRun`, STOPPED_BY_PERSON). */
346
+ abortRun: (runId: string) => boolean;
347
+ /** Append a stop row for `runId` to the conversation's queue and ring it. */
348
+ stopThroughRow: (sessionId: string, runId: string) => void;
349
+ }
350
+
351
+ /**
352
+ * The one stop decision, for the control socket's `abort` and the page's Stop this run. A held turn
353
+ * in a closed conversation is stopped through a stop row in its queue, which is what leaves the
354
+ * `request` audit row saying so; a loop run is aborted at once. `prefix` lets a typed id be the
355
+ * shortened one the status lines show; the page sends whole ids only.
356
+ */
357
+ export function stopRun(ref: string, deps: StopDeps, opts: { prefix: boolean }): StopAnswer {
358
+ const runs = deps.runs();
359
+ const match = runs.find((r) => r.runId === ref) ?? (opts.prefix && ref ? runs.find((r) => r.runId.startsWith(ref)) : undefined);
360
+ if (!match) return { finished: true };
361
+ const sessionId = deps.heldSession(match.runId);
362
+ if (sessionId) {
363
+ deps.stopThroughRow(sessionId, match.runId);
364
+ return { stopping: "row" };
365
+ }
366
+ return deps.abortRun(match.runId) ? { stopping: "run" } : { finished: true };
367
+ }
368
+
369
+ /** What `stopRunFrom` needs of the background process: its scheduler, its held turns, its leases and its queue. */
370
+ export interface StopHost {
371
+ scheduler: { runningRuns(): ReadonlyArray<{ runId: string }>; abortRun(runId: string, from: "page" | "cli"): boolean; noteStopFrom(runId: string, from: "page" | "cli"): void };
372
+ heldTurn(sessionId: string): { runId: string } | undefined;
373
+ ring(sessionId: string): void;
374
+ leases: () => Parameters<typeof heldTurnFor>[0];
375
+ append: (row: SessionRequest) => void;
376
+ createdBy: () => SessionRequest["created_by"];
377
+ }
378
+
379
+ /**
380
+ * The one stop the background process offers, wired: the socket's `abort` (`source` "cli", a typed
381
+ * prefix allowed) and the page's Stop this run and the waiting page's Stop it (`source` "person",
382
+ * the whole id). A held turn gets a stop row in its conversation's queue, rung at once, and its
383
+ * holder's drain aborts the turn; the record says where the stop came from either way.
384
+ */
385
+ export function stopRunFrom(source: "cli" | "person", prefix: boolean, host: StopHost): (runId: string) => StopAnswer {
386
+ const from = source === "person" ? "page" : "cli";
387
+ return (runId) =>
388
+ stopRun(runId, {
389
+ runs: () => host.scheduler.runningRuns(),
390
+ heldSession: (id) => {
391
+ const turn = heldTurnFor(host.leases(), id);
392
+ return turn?.kind === "turn" && turn.runId === id && host.heldTurn(turn.sessionId)?.runId === id ? turn.sessionId : undefined;
393
+ },
394
+ abortRun: (id) => host.scheduler.abortRun(id, from),
395
+ stopThroughRow: (sessionId, id) => {
396
+ host.scheduler.noteStopFrom(id, from);
397
+ host.append(stopRow(sessionId, { id }, source, host.createdBy()));
398
+ host.ring(sessionId);
399
+ },
400
+ }, { prefix });
401
+ }
109
402
 
110
403
  export interface HostChannelHandlers {
111
404
  status: () => HostSnapshot;
112
405
  abortRun: (runId: string) => boolean;
406
+ /** When given, `abort` with a run id goes through here instead of `abortRun` (src/host.ts). */
407
+ stopRun?: (runId: string) => StopAnswer;
113
408
  abortCheck: (traceId: string) => boolean;
114
- stop: () => void;
409
+ /**
410
+ * Called once per leave, and once more with `stop` when a plain stop cuts short the wait of a
411
+ * restart or an upgrade. Every stop or upgrade after the first is answered `ALREADY_LEAVING`.
412
+ */
413
+ stop: (reason: StopReason) => void;
414
+ /** `requestedBy` is the conversation a person asked from; absent from an older window, and then the run is as before. */
415
+ run: (jobId: string, requestedBy?: RunRequester) => Promise<true | string>;
416
+ /** Deliver what is queued for the session, now; returns at once. */
417
+ ring: (sessionId: string) => void;
418
+ /** Open a conversation in the browser page; the request has passed `pageOpenRefusal`. */
419
+ pageOpen: (req: Omit<PageOpenRequest, "op">) => Promise<PageOpened>;
420
+ /** A fresh one-shot key for a conversation's page, or why there is none. */
421
+ pageKey: (sessionId: string) => { openKey: string } | { error: string };
422
+ /** A pairing code for another device and the addresses it can open, or why there is none. */
423
+ pair: () => { code: string; addresses: string[] } | { error: string };
115
424
  }
116
425
 
426
+ /** What a doorbell note may say: which store changed, and for which session. */
427
+ export type RingNote = { kind: "request" | "lease"; sessionId: string };
428
+ const MAX_RING_NOTE = 200;
429
+
430
+ /**
431
+ * A note arrives over the socket, or is a file name a watcher saw: the session id in it becomes a
432
+ * path under the loops directory, so anything `sessionFileName` refuses is refused here, and the
433
+ * note never reaches the stores.
434
+ */
435
+ export function parseRingNote(note: unknown): RingNote | undefined {
436
+ if (typeof note !== "string" || note.length > MAX_RING_NOTE) return undefined;
437
+ const m = /^(request|lease):(.+)$/s.exec(note);
438
+ if (!m) return undefined;
439
+ try {
440
+ return { kind: m[1] as RingNote["kind"], sessionId: sessionFileName(m[2]) };
441
+ } catch {
442
+ return undefined; // not a session id: a path, an empty name
443
+ }
444
+ }
445
+
446
+ /**
447
+ * How long a connection may stay open, by op. `page-open` may wait 15 s for an orphaned pi to stop
448
+ * and as long again for the new one to name its conversation; nothing else waits on pi.
449
+ */
450
+ export const CONTROL_TIMEOUT_MS = { "page-open": 30_000, other: 10_000 } as const;
451
+ export type ControlTimeouts = { [op in keyof typeof CONTROL_TIMEOUT_MS]: number };
452
+
117
453
  /** One newline-delimited JSON request per connection, answered and closed. */
118
- export function serveHostChannel(dir: string, handlers: HostChannelHandlers, log?: (message: string) => void): net.Server {
454
+ export function serveHostChannel(dir: string, handlers: HostChannelHandlers, log?: (message: string) => void, timeouts: ControlTimeouts = CONTROL_TIMEOUT_MS): net.Server {
119
455
  const socketPath = hostSocketPath(dir);
120
456
  if (!prepareSocketDir(socketPath, log)) return net.createServer();
121
457
  // The directory holds prompts, findings, transcripts and now a control socket: it is the
@@ -136,6 +472,10 @@ export function serveHostChannel(dir: string, handlers: HostChannelHandlers, log
136
472
  log?.(`control channel: ${err?.message ?? err}`);
137
473
  return net.createServer(); // an unbound server: the host runs, `host status` falls back to the pid
138
474
  }
475
+ // One leave per process: a window that finds an older version keeps asking it to make way every
476
+ // tick, and none of those may start the wait for the page's turns again. A person's plain stop
477
+ // may still end that wait, once: it must not sit out two minutes and then meet the CLI's SIGKILL.
478
+ let leaving: StopReason | undefined;
139
479
  const server = net.createServer((socket) => {
140
480
  socket.setEncoding("utf8");
141
481
  let buf = "";
@@ -146,24 +486,66 @@ export function serveHostChannel(dir: string, handlers: HostChannelHandlers, log
146
486
  if (buf.length > 4096) socket.destroy();
147
487
  return;
148
488
  }
149
- let response: HostResponse;
150
- try {
151
- const req = JSON.parse(buf.slice(0, nl)) as HostRequest;
152
- if (req.op === "status") response = { ok: true, snapshot: handlers.status() };
153
- else if (req.op === "abort") response = { ok: true, aborted: req.runId ? handlers.abortRun(req.runId) : req.traceId ? handlers.abortCheck(req.traceId) : false };
154
- else if (req.op === "stop") {
155
- response = { ok: true };
156
- setTimeout(() => handlers.stop(), 50).unref?.();
157
- } else response = { ok: false, error: `unknown op ${JSON.stringify((req as any).op)}` };
158
- } catch (err: any) {
159
- response = { ok: false, error: err?.message ?? String(err) };
160
- }
161
- socket.end(`${JSON.stringify(response)}\n`, () => socket.destroy());
489
+ const raw = buf.slice(0, nl);
162
490
  buf = "";
163
491
  socket.removeAllListeners("data"); // one request per connection, never replayed
492
+ const answer = async (): Promise<HostResponse> => {
493
+ const req = JSON.parse(raw) as HostRequest;
494
+ if (req.op === "page-open") socket.setTimeout(timeouts["page-open"]);
495
+ if (req.op === "status") return { ok: true, snapshot: handlers.status() };
496
+ if (req.op === "abort") {
497
+ if (req.runId && handlers.stopRun) {
498
+ const answer = handlers.stopRun(req.runId);
499
+ return "stopping" in answer ? { ok: true, aborted: true, stopping: answer.stopping } : { ok: true, aborted: false };
500
+ }
501
+ return { ok: true, aborted: req.runId ? handlers.abortRun(req.runId) : req.traceId ? handlers.abortCheck(req.traceId) : false };
502
+ }
503
+ if (req.op === "run") {
504
+ if (typeof req.jobId !== "string") return { ok: false, error: "run needs a jobId" };
505
+ const by: unknown = req.requestedBy;
506
+ if (by === undefined) return { ok: true, ran: await handlers.run(req.jobId) };
507
+ const sessionId = by && typeof by === "object" ? (by as { sessionId?: unknown }).sessionId : undefined;
508
+ if (typeof sessionId !== "string" || !sessionId) return { ok: false, error: "run's requestedBy needs a sessionId" };
509
+ return { ok: true, ran: await handlers.run(req.jobId, { sessionId }) };
510
+ }
511
+ if (req.op === "ring") {
512
+ const note = parseRingNote(req.note);
513
+ if (!note) return { ok: false, error: "ring needs a note of the form request:<session> or lease:<session>" };
514
+ handlers.ring(note.sessionId);
515
+ return { ok: true };
516
+ }
517
+ if (req.op === "page-open") {
518
+ const refused = pageOpenRefusal(req);
519
+ if (refused) return { ok: false, error: refused };
520
+ const opened = await handlers.pageOpen({ cwd: req.cwd, sessionId: req.sessionId, sessionFile: req.sessionFile, piArgs: req.piArgs, ...(req.openFile ? { openFile: true } : {}), ...(req.copyKey ? { copyKey: true } : {}) });
521
+ return "error" in opened ? { ok: false, error: opened.error } : { ok: true, ...opened };
522
+ }
523
+ if (req.op === "page-key") {
524
+ if (typeof req.sessionId !== "string") return { ok: false, error: "page-key needs a sessionId" };
525
+ const key = handlers.pageKey(req.sessionId);
526
+ return "error" in key ? { ok: false, error: key.error } : { ok: true, openKey: key.openKey };
527
+ }
528
+ if (req.op === "pair") {
529
+ const paired = handlers.pair();
530
+ return "error" in paired ? { ok: false, error: paired.error } : { ok: true, code: paired.code, addresses: paired.addresses };
531
+ }
532
+ if (req.op === "stop" || req.op === "upgrade") {
533
+ const reason: StopReason = req.op === "upgrade" ? "upgrade" : req.reason === "restart" || req.reason === "upgrade" ? req.reason : "stop";
534
+ const first = !leaving;
535
+ if (!first && !(reason === "stop" && leaving !== "stop")) return { ok: true, note: ALREADY_LEAVING };
536
+ leaving = reason;
537
+ setTimeout(() => handlers.stop(reason), 50).unref?.();
538
+ return first ? { ok: true } : { ok: true, note: ALREADY_LEAVING };
539
+ }
540
+ return { ok: false, error: `unknown op ${JSON.stringify((req as any).op)}` };
541
+ };
542
+ answer()
543
+ .catch((err: any): HostResponse => ({ ok: false, error: err?.message ?? String(err) }))
544
+ .then((response) => socket.end(`${JSON.stringify(response)}\n`, () => socket.destroy()))
545
+ .catch(() => socket.destroy());
164
546
  });
165
547
  socket.on("error", () => socket.destroy());
166
- socket.setTimeout(10_000, () => socket.destroy());
548
+ socket.setTimeout(timeouts.other, () => socket.destroy());
167
549
  });
168
550
  server.on("error", (err: any) => log?.(`control channel: ${err?.message ?? err}`));
169
551
  server.listen(socketPath, () => {
@@ -188,32 +570,46 @@ export function askHost(dir: string, request: HostRequest, timeoutMs = 2000): Pr
188
570
  if (!ownedSocket(socketPath)) return resolve(undefined);
189
571
  const socket = net.createConnection(socketPath);
190
572
  let buf = "";
573
+ let timer: NodeJS.Timeout | undefined;
191
574
  const done = (value: HostResponse | undefined) => {
575
+ clearTimeout(timer);
192
576
  socket.destroy();
193
577
  resolve(value);
194
578
  };
195
- const timer = setTimeout(() => done(undefined), timeoutMs);
196
- timer.unref?.();
579
+ timer = setTimeout(() => done(undefined), timeoutMs);
580
+ // `pi-loops` has nothing else to wait on during a page-open: with the timer unref'd, Node would
581
+ // exit mid-wait with "unsettled top-level await". A window's asks must not hold pi open.
582
+ if (request.op !== "page-open") timer.unref?.();
197
583
  socket.setEncoding("utf8");
198
584
  socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
199
585
  socket.on("data", (chunk) => {
200
586
  buf += chunk;
201
587
  const nl = buf.indexOf("\n");
202
588
  if (nl < 0) return;
203
- clearTimeout(timer);
204
589
  try {
205
590
  done(JSON.parse(buf.slice(0, nl)) as HostResponse);
206
591
  } catch {
207
592
  done(undefined);
208
593
  }
209
594
  });
210
- socket.on("error", () => {
211
- clearTimeout(timer);
212
- done(undefined);
213
- });
595
+ socket.on("error", () => done(undefined));
596
+ // Closed with no answer (it gave up on the request): no answer, now, not at our own timeout.
597
+ socket.on("close", () => done(undefined));
214
598
  });
215
599
  }
216
600
 
601
+ /**
602
+ * Ring the host's doorbell, if one is listening: the row is in the file already, so the answer and
603
+ * every failure are ignored, and the caller never waits longer than `askHost`'s timeout. The tick
604
+ * is the floor for a ring that is lost.
605
+ */
606
+ export function ringHost(dir: string, note: string): Promise<void> {
607
+ return askHost(dir, { op: "ring", note }).then(
608
+ () => undefined,
609
+ () => undefined,
610
+ );
611
+ }
612
+
217
613
  /**
218
614
  * Everything here arrived over a socket and is printed to a terminal. Even with the socket
219
615
  * ownership-checked, a snapshot is data from another process: escape sequences would repaint the
@@ -222,22 +618,41 @@ export function askHost(dir: string, request: HostRequest, timeoutMs = 2000): Pr
222
618
  function line(text: unknown): string {
223
619
  return String(text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
224
620
  }
621
+ /** A stamp in the one local format every terminal line uses; the text itself when it is not a time. */
622
+ function localTime(text: unknown): string {
623
+ const ms = Date.parse(String(text ?? ""));
624
+ return Number.isNaN(ms) ? line(text) : formatLocal(ms);
625
+ }
225
626
  function money(n: unknown, digits: number): string {
226
627
  return Number.isFinite(Number(n)) ? Number(n).toFixed(digits) : "?";
227
628
  }
228
629
 
229
- /** The lines `/cron host` shows for a live host. */
230
- export function renderHostSnapshot(s: HostSnapshot): string[] {
630
+ export function openConversations(n: number): string {
631
+ return n === 0 ? "no conversation open" : n === 1 ? "1 open conversation" : `${n} open conversations`;
632
+ }
633
+
634
+ /** The lines `/cron host` shows for a live host. `now` is for the ages of the queued requests. */
635
+ export function renderHostSnapshot(s: HostSnapshot, now = Date.now()): string[] {
231
636
  const lines = [
232
- ` pid ${line(s.pid)} on ${line(s.host)}, started ${line(s.startedAt)}${s.model ? `, model ${line(s.model)}` : ""}`,
233
- ` ${s.leader ? "owns the clock" : "standby"} · ${line(s.jobs?.enabled)}/${line(s.jobs?.total)} loop(s), ${line(s.rules?.enabled)}/${line(s.rules?.total)} rule(s) enabled · inbox: ${line(s.inboxNew)} new`,
637
+ ` pid ${line(s.pid)} on ${line(s.host)}, started ${localTime(s.startedAt)}${s.model ? `, model ${line(s.model)}` : ""}`,
638
+ ` ${s.version ? `pi-loops ${line(s.version)} · ` : ""}${line(s.jobs?.enabled)}/${line(s.jobs?.total)} loops, ${line(s.rules?.enabled)}/${line(s.rules?.total)} rules enabled · inbox: ${line(s.inboxNew)} new`,
639
+ ...(s.startedBy ? [` started by ${line(s.startedBy)}`] : []),
234
640
  ];
235
- for (const r of s.runs ?? []) lines.push(` running ${line(r.label)} (${line(r.runId).slice(0, 12)}) since ${line(r.startedAt)}: ${line(r.promptPreview)}`);
641
+ for (const h of s.sessions ?? []) {
642
+ const which = line(conversationLabel(String(h.sessionId ?? ""), typeof h.title === "string" ? h.title : undefined));
643
+ if (h.page) lines.push(` conversation ${which} open in the page (pid ${line(h.pid)}, ${line(h.page.tabs)} ${Number(h.page.tabs) === 1 ? "tab" : "tabs"}, idle ${formatWait(Number(h.page.idleMs))})`);
644
+ else lines.push(` conversation ${which} open in pid ${line(h.pid)}${h.mode ? ` (${line(h.mode)})` : ""}`);
645
+ }
646
+ for (const r of s.runs ?? []) lines.push(` running ${line(r.label)} (${line(r.runId).slice(0, 12)}) since ${localTime(r.startedAt)}: ${line(r.promptPreview)}`);
236
647
  for (const c of s.checks ?? []) lines.push(` checking ${line(c.sourceLabel)}/${line(c.eventLabel)} (${line(c.traceId).slice(0, 8)}) in ${line(c.cwd)}`);
237
648
  for (const m of s.mcp ?? []) lines.push(` mcp ${line(m.name)}: ${line(m.state)}${m.lastError ? ` — ${line(m.lastError)}` : ""}`);
238
- if (!s.runs?.length && !s.checks?.length) lines.push(` nothing running right now${s.nextDue ? ` · next due ${line(s.nextDue)}` : ""}`);
649
+ if (!s.runs?.length && !s.checks?.length) lines.push(` nothing running right now${s.nextDue ? ` · next due ${localTime(s.nextDue)}` : ""}`);
239
650
  if (s.budget && Number(s.budget.cap) > 0) lines.push(` spent today: $${money(s.budget.spent, 2)} of $${money(s.budget.cap, 2)}${Number(s.budget.spent) >= Number(s.budget.cap) ? " — dispatching is paused" : ""}`);
240
- for (const f of s.failing ?? []) lines.push(` ! ${line(f.job)}: ${line(f.error)}`);
241
- for (const r of s.recent ?? []) lines.push(` ${r.ok ? "ok " : "FAIL"} ${line(r.at)} ${line(r.job)}${r.cost ? ` · $${money(r.cost, 3)}` : ""}${r.error ? ` — ${line(r.error)}` : ""}`);
651
+ for (const f of s.failing ?? []) lines.push(` ! ${line(snapshotLoopLabel(String(f.job ?? "")))}: ${line(f.error)}`);
652
+ if (s.page) lines.push(` page: ${line(s.page)}${s.pageOpen === undefined ? "" : ` · ${openConversations(Number(s.pageOpen) || 0)}`}`);
653
+ else if (s.pageWaiting) lines.push(` page: ${line(s.pageWaiting)}`);
654
+ // A stopped run is neither ok nor a failure.
655
+ for (const r of s.recent ?? []) lines.push(` ${r.ok ? "ok " : r.stopped ? "stop" : "FAIL"} ${localTime(r.at)} ${line(snapshotLoopLabel(String(r.job ?? "")))}${r.cost ? ` · $${money(r.cost, 3)}` : ""}${r.byPerson ? " — stopped by you" : r.error ? ` — ${line(r.error)}` : ""}`);
656
+ lines.push(...requestLines(s.requests ?? [], now));
242
657
  return lines;
243
658
  }