@alphacoder-v0/pi-loops 0.22.8 → 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.
- package/CHANGELOG.md +664 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A window into the
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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?:
|
|
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
|
-
|
|
108
|
-
export type
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
196
|
-
timer
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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 ${
|
|
233
|
-
` ${s.
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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
|
}
|