@alphacoder-v0/pi-loops 0.24.1 → 0.24.2
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 +83 -0
- package/README.md +2 -3
- package/docs/cli.md +9 -5
- package/docs/configuration.md +1 -1
- package/docs/design.md +3 -2
- package/docs/triggers.md +11 -7
- package/docs/web-ui-parity.md +7 -6
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -0
- package/src/activity.ts +4 -3
- package/src/cli.ts +28 -19
- package/src/engine.ts +4 -3
- package/src/host-control-channel.ts +2 -1
- package/src/host-control.ts +16 -1
- package/src/host-runtime.ts +11 -11
- package/src/inbox-card.ts +2 -2
- package/src/inbox.ts +7 -0
- package/src/job-lines.ts +1 -5
- package/src/job-signal.ts +2 -1
- package/src/mcp.ts +2 -0
- package/src/page-children.ts +3 -58
- package/src/page-server.ts +71 -24
- package/src/path-precheck.ts +2 -0
- package/src/paths.ts +15 -2
- package/src/pi-loops.ts +8 -7
- package/src/plural.ts +4 -0
- package/src/run-card.ts +3 -2
- package/src/schedule.ts +20 -0
- package/src/seen.ts +1 -1
- package/src/share.ts +1 -1
- package/src/store.ts +11 -2
- package/src/tools.ts +18 -19
- package/src/triggers.ts +2 -3
- package/src/trust.ts +1 -11
- package/src/web.mjs +68 -37
package/src/host-runtime.ts
CHANGED
|
@@ -185,13 +185,13 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
185
185
|
// What the page's waiting line calls it: a job's id is 32 characters no person typed.
|
|
186
186
|
const open = { requestId: runId, startedAt: scheduler.runningRuns().find((r) => r.runId === runId)?.startedAt ?? stamp(), label: job.name ?? runId.slice(0, 12) };
|
|
187
187
|
turnOpen.set(held.sessionId, { ...open, writing: false });
|
|
188
|
-
log(`cron ${job.name ?? job.id}: turn ${runId.slice(0, 12)} in session ${held.sessionId
|
|
188
|
+
log(`cron ${job.name ?? job.id}: turn ${runId.slice(0, 12)} in session ${shortSessionId(held.sessionId)}`);
|
|
189
189
|
let result: RunnerResult;
|
|
190
190
|
try {
|
|
191
191
|
try {
|
|
192
192
|
await drainInto(held);
|
|
193
193
|
} catch (err: any) {
|
|
194
|
-
log(`requests for session ${held.sessionId
|
|
194
|
+
log(`requests for session ${shortSessionId(held.sessionId)}: ${err?.message ?? err}`);
|
|
195
195
|
}
|
|
196
196
|
if (signal.aborted) {
|
|
197
197
|
// Stopped before it began: nothing to run, and no model call to start for a turn already over.
|
|
@@ -222,7 +222,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
222
222
|
try {
|
|
223
223
|
landed = conversationHas(SessionManager.open(held.sessionFile).getEntries(), runId);
|
|
224
224
|
} catch (err: any) {
|
|
225
|
-
log(`cron ${job.name ?? job.id}: could not read session ${held.sessionId
|
|
225
|
+
log(`cron ${job.name ?? job.id}: could not read session ${shortSessionId(held.sessionId)} after its turn: ${err?.message ?? err}`);
|
|
226
226
|
}
|
|
227
227
|
return { preempted, result, landed };
|
|
228
228
|
}
|
|
@@ -377,10 +377,10 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
377
377
|
await drainInto(held);
|
|
378
378
|
if (traceId === undefined) return true;
|
|
379
379
|
const delivered = conversationHas(SessionManager.open(held.sessionFile).getEntries(), traceId);
|
|
380
|
-
if (delivered) log(`trigger ${traceId.slice(0, 8)}: appended to session ${sessionId
|
|
380
|
+
if (delivered) log(`trigger ${traceId.slice(0, 8)}: appended to session ${shortSessionId(sessionId)}`);
|
|
381
381
|
return delivered;
|
|
382
382
|
} catch (err: any) {
|
|
383
|
-
log(`requests for session ${sessionId
|
|
383
|
+
log(`requests for session ${shortSessionId(sessionId)}: ${err?.message ?? err}`);
|
|
384
384
|
// Giving the lease back below rings this process again; with the same failure waiting
|
|
385
385
|
// (an inbox it cannot write), that ring would fail the same way, for ever.
|
|
386
386
|
quietUntilTick.add(sessionId);
|
|
@@ -402,9 +402,9 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
402
402
|
if (!lease.holder || !busy || !sameHolder(lease.holder, me()) || running.has(busy.runId) || now - Date.parse(busy.since) < 60_000) continue;
|
|
403
403
|
try {
|
|
404
404
|
leases.release(lease.sessionId, me(), { epoch: lease.epoch, fileMtimeMs: fileMtime(lease.sessionFile) });
|
|
405
|
-
log(`gave back session ${lease.sessionId
|
|
405
|
+
log(`gave back session ${shortSessionId(lease.sessionId)}, held for run ${busy.runId} that is no longer running`);
|
|
406
406
|
} catch (err: any) {
|
|
407
|
-
log(`lease ${lease.sessionId
|
|
407
|
+
log(`lease ${shortSessionId(lease.sessionId)}: ${err?.message ?? err}`);
|
|
408
408
|
}
|
|
409
409
|
}
|
|
410
410
|
}
|
|
@@ -437,7 +437,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
437
437
|
const file = leases.read(sessionId)?.sessionFile;
|
|
438
438
|
if (file && fs.existsSync(file)) return; // there, only busy or recently written: a later tick
|
|
439
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
|
|
440
|
+
if (buried.length) log(`${buried.length} queued request(s) for session ${shortSessionId(sessionId)} went to the inbox: its conversation is gone`);
|
|
441
441
|
return;
|
|
442
442
|
}
|
|
443
443
|
// The same decision the drain makes, read without the lease (a read needs none). Marked before
|
|
@@ -475,7 +475,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
475
475
|
try {
|
|
476
476
|
await deliverSession(sessionId);
|
|
477
477
|
} catch (err: any) {
|
|
478
|
-
log(`requests for session ${sessionId
|
|
478
|
+
log(`requests for session ${shortSessionId(sessionId)}: ${err?.message ?? err}`);
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
}
|
|
@@ -503,7 +503,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
503
503
|
rungSinceTick.set(sessionId, rung);
|
|
504
504
|
if (rung > RINGS_PER_TICK) {
|
|
505
505
|
quietUntilTick.add(sessionId);
|
|
506
|
-
log(`doorbell for session ${sessionId
|
|
506
|
+
log(`doorbell for session ${shortSessionId(sessionId)}: rang ${RINGS_PER_TICK} times since the last tick; left to the tick`);
|
|
507
507
|
return;
|
|
508
508
|
}
|
|
509
509
|
if (delivering.has(sessionId)) {
|
|
@@ -512,7 +512,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
512
512
|
}
|
|
513
513
|
delivering.add(sessionId);
|
|
514
514
|
deliverSession(sessionId)
|
|
515
|
-
.catch((err: any) => log(`requests for session ${sessionId
|
|
515
|
+
.catch((err: any) => log(`requests for session ${shortSessionId(sessionId)}: ${err?.message ?? err}`))
|
|
516
516
|
.then(() => {
|
|
517
517
|
delivering.delete(sessionId);
|
|
518
518
|
if (ringAgain.delete(sessionId)) ring(sessionId);
|
package/src/inbox-card.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function ruleResultText(text: string): string {
|
|
|
137
137
|
export function inboxCard(entry: InboxEntry, ctx: CardContext): InboxCard | undefined {
|
|
138
138
|
if (!entry.id || !entry.text) return undefined;
|
|
139
139
|
const text = capChars(redact(screenText(entry.text)).replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS);
|
|
140
|
-
const origin = entry.source.startsWith("trigger:") ? "rule" : deadLetterParts(entry
|
|
140
|
+
const origin = entry.source.startsWith("trigger:") ? "rule" : deadLetterParts(entry) ? "undelivered" : "loop";
|
|
141
141
|
const loopId = origin === "loop" ? entry.job_id : undefined;
|
|
142
142
|
const loopName = loopId ? ctx.loopName(loopId) : undefined;
|
|
143
143
|
// A rule check files its result with the first matched rule's id (src/trigger-runtime.ts); a push
|
|
@@ -248,7 +248,7 @@ export function claimPrompt(e: InboxEntry): string {
|
|
|
248
248
|
// person's yes, so the turn carries the decision out rather than re-investigating it.
|
|
249
249
|
const checkpoint = e.kind === "checkpoint" ? "\nThis finding is a checkpoint: claiming it is the person's approval of the decision it recommends — carry that decision out, and stop where the finding says a person acts." : "";
|
|
250
250
|
// A prompt that never reached the chat: claiming it is asking for it now, not investigating why it was late.
|
|
251
|
-
const late = deadLetterParts(text);
|
|
251
|
+
const late = deadLetterParts(e, text);
|
|
252
252
|
if (late) return `This prompt (${source}, for ${cwd}) could not be delivered earlier (${late.why}). Do what it asks now:\n${late.prompt}`;
|
|
253
253
|
return `A recurring loop (${source}, running in ${cwd}) reported this finding — investigate and address it:\n${text}${verified}${checkpoint}`;
|
|
254
254
|
}
|
package/src/inbox.ts
CHANGED
|
@@ -61,6 +61,11 @@ export interface InboxEntry {
|
|
|
61
61
|
dismissed_at?: string;
|
|
62
62
|
/** The transcript of the run that filed this finding (RunRecord.sessionFile). */
|
|
63
63
|
transcript?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Set by the engine's dead letter alone: a prompt that never reached its conversation. The text
|
|
66
|
+
* says so too, but the text is anyone's to write, and a claim of this finding carries it out.
|
|
67
|
+
*/
|
|
68
|
+
undelivered?: true;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/** The fields a finding is, in their order — CONTRACT.md §2.2 and docs/downstream.md §3. */
|
|
@@ -136,6 +141,7 @@ export class Inbox {
|
|
|
136
141
|
dismissed_at: entry.dismissed_at,
|
|
137
142
|
...(entry.session_id === undefined ? {} : { session_id: entry.session_id }),
|
|
138
143
|
...(entry.transcript === undefined ? {} : { transcript: entry.transcript }),
|
|
144
|
+
...(entry.undelivered ? { undelivered: true as const } : {}),
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
|
|
@@ -336,6 +342,7 @@ function readFinding(raw: any): InboxEntry | undefined {
|
|
|
336
342
|
verified_reason: str("verified_reason", "verifiedReason"),
|
|
337
343
|
dismissed_at: str("dismissed_at"),
|
|
338
344
|
transcript: str("transcript"),
|
|
345
|
+
...(raw.undelivered === true ? { undelivered: true as const } : {}),
|
|
339
346
|
};
|
|
340
347
|
}
|
|
341
348
|
|
package/src/job-lines.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { shortSessionId } from "./session-head.ts";
|
|
|
12
12
|
import type { InboxEntry } from "./inbox.ts";
|
|
13
13
|
import { asleepNote, runsSomewhere, type OwnerView } from "./job-owner.ts";
|
|
14
14
|
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
|
|
15
|
+
import { plural } from "./plural.ts";
|
|
15
16
|
import { previewRedacted } from "./redact.ts";
|
|
16
17
|
import type { SessionRequest } from "./requests.ts";
|
|
17
18
|
import { computeNext, formatLocal, formatSchedule } from "./schedule.ts";
|
|
@@ -104,11 +105,6 @@ export function pickQueued(rows: readonly SessionRequest[], ref: string): Sessio
|
|
|
104
105
|
return prefixed.length === 1 ? prefixed[0] : undefined;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
/** `1 loop`, `2 loops`: a count a person reads, never `loop(s)`. */
|
|
108
|
-
export function plural(n: number, one: string, many = `${one}s`): string {
|
|
109
|
-
return `${n} ${n === 1 ? one : many}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
108
|
/**
|
|
113
109
|
* The one-time line a session start says when plain jobs carry `--tools`: 0.22.8 stored the list
|
|
114
110
|
* and never used it, and since 0.23 a turn in a window asks before a tool outside it. Undefined when
|
package/src/job-signal.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { InboxEntry } from "./inbox.ts";
|
|
11
11
|
import type { RunRecord } from "./store.ts";
|
|
12
|
+
import { plural } from "./plural.ts";
|
|
12
13
|
|
|
13
14
|
export interface LoopSignal {
|
|
14
15
|
/** Runs that finished in the window (failed ones included: they cost, and they are what `quiet` is measured against). */
|
|
@@ -71,7 +72,7 @@ export function loopSignal(jobId: string, runs: RunRecord[], inbox: InboxEntry[]
|
|
|
71
72
|
*/
|
|
72
73
|
export function signalSummary(s: LoopSignal): string | undefined {
|
|
73
74
|
if (!s.findings) return undefined;
|
|
74
|
-
const parts = [`${s.findings
|
|
75
|
+
const parts = [`${plural(s.findings, "finding")}`];
|
|
75
76
|
if (s.claimed) parts.push(`${s.claimed} claimed`);
|
|
76
77
|
if (s.dismissed) parts.push(`${s.dismissed} dismissed${s.dismissedWithReason ? ` (${s.dismissedWithReason} with a reason)` : ""}`);
|
|
77
78
|
return parts.join(" · ");
|
package/src/mcp.ts
CHANGED
|
@@ -769,6 +769,8 @@ export class McpSource {
|
|
|
769
769
|
if (f.method === "ping") send({ jsonrpc: "2.0", id: f.id, result: {} });
|
|
770
770
|
else send({ jsonrpc: "2.0", id: f.id, error: { code: -32601, message: `pi-loops is a notification-only client; ${f.method} is not supported` } });
|
|
771
771
|
} else if (typeof f.method === "string") {
|
|
772
|
+
// A line read before the stop and parsed after it: whoever owned this source has let it go.
|
|
773
|
+
if (this.stopped) continue;
|
|
772
774
|
this.status.lastEventAt = stamp();
|
|
773
775
|
this.status.queuedCount++;
|
|
774
776
|
this.status.lastError = undefined; // A successful push clears the last error
|
package/src/page-children.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { DELIVERY_JOB_ID, holderAlive, type ProcessProbe, procStart, type Sessio
|
|
|
18
18
|
import { withFileLockSync, writeFileAtomic } from "./lock.ts";
|
|
19
19
|
import { realpathish } from "./paths.ts";
|
|
20
20
|
import { stamp } from "./schedule.ts";
|
|
21
|
+
import { readSessionHead, shortSessionId } from "./session-head.ts";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Run one of the page's callbacks so that nothing it throws, or rejects with, leaves it.
|
|
@@ -200,62 +201,6 @@ export function claimPageChild(env: NodeJS.ProcessEnv = process.env, proc: objec
|
|
|
200
201
|
*/
|
|
201
202
|
export const COMMANDS_TTL_MS = 30_000;
|
|
202
203
|
|
|
203
|
-
const SESSION_HEAD = 64 * 1024;
|
|
204
|
-
|
|
205
|
-
/** The header, a name if one was set early, and the first thing a person said. */
|
|
206
|
-
export function readHead(file: string) {
|
|
207
|
-
let text;
|
|
208
|
-
let readBytes = 0;
|
|
209
|
-
try {
|
|
210
|
-
const fd = fs.openSync(file, "r");
|
|
211
|
-
try {
|
|
212
|
-
const buf = Buffer.alloc(SESSION_HEAD);
|
|
213
|
-
// `SESSION_HEAD` is a byte window, so the decision to cut is the byte count read, not the
|
|
214
|
-
// decoded string's length: a multi-byte character makes those differ.
|
|
215
|
-
readBytes = fs.readSync(fd, buf, 0, SESSION_HEAD, 0);
|
|
216
|
-
text = buf.toString("utf8", 0, readBytes);
|
|
217
|
-
} finally {
|
|
218
|
-
fs.closeSync(fd);
|
|
219
|
-
}
|
|
220
|
-
} catch {
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
223
|
-
const lines = text.split("\n");
|
|
224
|
-
let header;
|
|
225
|
-
try {
|
|
226
|
-
header = JSON.parse(lines[0] ?? "");
|
|
227
|
-
} catch {
|
|
228
|
-
return undefined;
|
|
229
|
-
}
|
|
230
|
-
if (header?.type !== "session" || typeof header.id !== "string") return undefined;
|
|
231
|
-
const cut = readBytes >= SESSION_HEAD;
|
|
232
|
-
let name;
|
|
233
|
-
let first;
|
|
234
|
-
let messages = 0;
|
|
235
|
-
// The last line of a partial read is half a line. A file that fitted has no such line, and
|
|
236
|
-
// dropping one there loses a message from a count that could have been exact.
|
|
237
|
-
for (const line of lines.slice(1, cut ? -1 : undefined)) {
|
|
238
|
-
if (!line.trim()) continue;
|
|
239
|
-
let entry;
|
|
240
|
-
try {
|
|
241
|
-
entry = JSON.parse(line);
|
|
242
|
-
} catch {
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
// Written by whatever set it — a person, a model, an extension — so it is bounded here the
|
|
246
|
-
// way the first message is, rather than carrying 64KB into the picker.
|
|
247
|
-
if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name.slice(0, 200);
|
|
248
|
-
if (entry?.type !== "message") continue;
|
|
249
|
-
messages++;
|
|
250
|
-
if (first || entry.message?.role !== "user") continue;
|
|
251
|
-
const content = entry.message.content;
|
|
252
|
-
const said = typeof content === "string" ? content : (content ?? []).map((c: any) => (typeof c?.text === "string" ? c.text : "")).join(" ");
|
|
253
|
-
// A mention is expanded before it is sent, so the first user message can be a whole file.
|
|
254
|
-
if (said.trim()) first = said.trim().slice(0, 200);
|
|
255
|
-
}
|
|
256
|
-
return { id: header.id, startedAt: header.timestamp, name, first, messages, truncated: cut };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
204
|
/**
|
|
260
205
|
* The model a session was last using, as pi would read it back.
|
|
261
206
|
*
|
|
@@ -765,7 +710,7 @@ export class PageChild {
|
|
|
765
710
|
// the open session is otherwise a way to be offered the one destination that is refused.
|
|
766
711
|
const open = this.sessionFile ? realpathish(this.sessionFile) : undefined;
|
|
767
712
|
for (const entry of out.slice(0, limit)) {
|
|
768
|
-
const head =
|
|
713
|
+
const head = readSessionHead(entry.file);
|
|
769
714
|
if (!head) continue; // not a session file, or not a readable one
|
|
770
715
|
sessions.push({ ...entry, ...head, current: realpathish(entry.file) === open });
|
|
771
716
|
}
|
|
@@ -1207,7 +1152,7 @@ export class ChildPool {
|
|
|
1207
1152
|
// there. `--session` on that path starts another conversation in it; without it, pi starts a
|
|
1208
1153
|
// new one in the project, in a file of its own, and the pool follows it there (`moved`).
|
|
1209
1154
|
const resumable = how.sessionFile !== undefined && fs.existsSync(how.sessionFile);
|
|
1210
|
-
if (how.sessionFile && !resumable) this.opts.log(`pi-loops page: ${how.sessionId
|
|
1155
|
+
if (how.sessionFile && !resumable) this.opts.log(`pi-loops page: ${how.sessionId ? shortSessionId(how.sessionId) : "a conversation"} has no file yet (${how.sessionFile}); pi starts a new conversation in ${how.cwd}`);
|
|
1211
1156
|
const argv = [...args, "--mode", "rpc", ...(resumable ? ["--session", how.sessionFile as string] : []), ...how.piArgs];
|
|
1212
1157
|
// A process group of its own (POSIX): a terminal's Ctrl-C goes to the whole foreground group,
|
|
1213
1158
|
// and pi in rpc mode has no SIGINT handler, so it died before anyone could ask it to quit. pi
|
package/src/page-server.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { COPY_KEY_MS, type HostSnapshot, type PageOpened, type PageOpenRequest,
|
|
|
20
20
|
import { buildSessionProjection, migrateSessionEntries, parseSessionEntries, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { DELIVERY_JOB_ID, holderAlive, holderDelivers, type LeaseHolder, LeaseStore, procStart, type SessionLease, sessionFileName, shouldReadAgain } from "./lease.ts";
|
|
22
22
|
import { ACTIVITY_WINDOW_MS, activityFrom, attentionFrom, machineWide } from "./activity.ts";
|
|
23
|
-
import { formatSchedule, stamp } from "./schedule.ts";
|
|
23
|
+
import { formatSchedule, scheduleWords, stamp } from "./schedule.ts";
|
|
24
24
|
import { type Look, lookLine, watchOf } from "./path-precheck.ts";
|
|
25
25
|
import { loopSignal, type LoopSignal, SIGNAL_WINDOW_MS } from "./job-signal.ts";
|
|
26
26
|
import { applyTune, rowSignal, scheduleAudit, suggestTune } from "./job-tune.ts";
|
|
@@ -29,13 +29,14 @@ import { Inbox, type InboxEntry, inProject } from "./inbox.ts";
|
|
|
29
29
|
import { type ClaimDelivery, type InboxCard, DISMISS_CHIPS, cardsOf, claimLine, claimPrompt, dismissLine, dismissOutcome, screenText } from "./inbox-card.ts";
|
|
30
30
|
import { INBOX_TEXT_MAX_CHARS } from "./protocol.ts";
|
|
31
31
|
import { redact } from "./redact.ts";
|
|
32
|
-
import { readSessionHead } from "./session-head.ts";
|
|
32
|
+
import { readSessionHead, shortSessionId } from "./session-head.ts";
|
|
33
33
|
import { JobStore, type LoopJob, MAX_PROMPT_BYTES } from "./store.ts";
|
|
34
34
|
import { summarizeSessionFile } from "./transcript.ts";
|
|
35
35
|
import { conversationLabel } from "./session-head.ts";
|
|
36
|
-
import { pidAlive } from "./lock.ts";
|
|
36
|
+
import { pidAlive, withFileLockSync } from "./lock.ts";
|
|
37
37
|
import { asleepNote, type OwnerView, runsSomewhere, whereRuns } from "./job-owner.ts";
|
|
38
38
|
import { loopLabel, snapshotLoopLabel } from "./job-lines.ts";
|
|
39
|
+
import { plural } from "./plural.ts";
|
|
39
40
|
import {
|
|
40
41
|
type ChildPool,
|
|
41
42
|
type ChildView,
|
|
@@ -50,7 +51,6 @@ import {
|
|
|
50
51
|
PAGE_STREAMS_PER_CHILD,
|
|
51
52
|
PAGE_STREAMS_TOTAL,
|
|
52
53
|
type PageChild,
|
|
53
|
-
readHead,
|
|
54
54
|
} from "./page-children.ts";
|
|
55
55
|
import { removeOpenFile, sweepOpenFiles, writeOpenFile } from "./open-file.ts";
|
|
56
56
|
import { DEVICE_COOKIE, DEVICE_STORAGE_KEY, DeviceStore, KEY_KINDS, type KeyKind, UrlKeys } from "./page-devices.ts";
|
|
@@ -124,12 +124,40 @@ export interface PageServer {
|
|
|
124
124
|
* to do). You will not have to type it: a browser is remembered after its first visit.
|
|
125
125
|
*/
|
|
126
126
|
export function webToken(dir: string, fail: (message: string) => never): string {
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
// Two windows opening together may both start pi-loops, and both get here before either claims
|
|
129
|
+
// the service: under the lock, the second reads the token the first wrote instead of replacing it.
|
|
130
|
+
// `fail` is called after the lock is released: the host's `fail` exits, and a lock left behind
|
|
131
|
+
// would hold the next start for its whole stale window.
|
|
132
|
+
try {
|
|
133
|
+
return withFileLockSync(path.join(dir, "web-token.lock"), () =>
|
|
134
|
+
readOrMakeToken(dir, (message) => {
|
|
135
|
+
throw new TokenRefused(message);
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
} catch (err: any) {
|
|
139
|
+
return fail(err instanceof TokenRefused ? err.message : `pi-loops web: ${err?.message ?? err}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class TokenRefused extends Error {}
|
|
144
|
+
|
|
145
|
+
function readOrMakeToken(dir: string, fail: (message: string) => never): string {
|
|
127
146
|
const file = path.join(dir, "web-token");
|
|
128
147
|
// 0.22.8 kept its token in a cookie that every port on 127.0.0.1 receives for a year, so the
|
|
129
148
|
// first 0.24 start replaces a token it finds without this marker, once. A token written after
|
|
130
149
|
// the marker is the person's own and is kept.
|
|
131
150
|
const marker = path.join(dir, "web-token.v024");
|
|
132
|
-
|
|
151
|
+
// lstat, as for the token file: a dangling symlink here is not "not rotated yet", and reading
|
|
152
|
+
// it as that rotated the token on every start and then failed on the marker.
|
|
153
|
+
let markerStat: fs.Stats | undefined;
|
|
154
|
+
try {
|
|
155
|
+
markerStat = fs.lstatSync(marker);
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
if (err?.code !== "ENOENT") fail(`pi-loops web: ${err?.message ?? err}`);
|
|
158
|
+
}
|
|
159
|
+
if (markerStat && !markerStat.isFile()) fail(`pi-loops web: ${marker} is not a regular file`);
|
|
160
|
+
const rotated = !!markerStat;
|
|
133
161
|
try {
|
|
134
162
|
// lstat, not stat: a symlink here is not a token file, it is someone else choosing where
|
|
135
163
|
// this process reads and writes. LOOPS_DIR can be pointed anywhere with --loops-dir.
|
|
@@ -149,7 +177,6 @@ export function webToken(dir: string, fail: (message: string) => never): string
|
|
|
149
177
|
}
|
|
150
178
|
}
|
|
151
179
|
const made = randomBytes(16).toString("hex");
|
|
152
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
153
180
|
fs.writeFileSync(file, `${made}\n`, { mode: 0o600, flag: "w" });
|
|
154
181
|
fs.chmodSync(file, 0o600);
|
|
155
182
|
if (!rotated) {
|
|
@@ -981,8 +1008,7 @@ export function renderHostPage(s: HostStatus): string {
|
|
|
981
1008
|
const snap = s.snapshot;
|
|
982
1009
|
/** A stamp as the page's clock writes it (CLOCK_SCRIPT); the stamp itself until then. */
|
|
983
1010
|
const time = (iso: unknown) => `<time datetime="${e(iso)}">${e(iso)}</time>`;
|
|
984
|
-
const
|
|
985
|
-
const since = ({ sinceYouLooked: y }: SessionRow) => (y.runs === 0 && y.findings === 0 ? "—" : `${count(y.runs, "run")}${y.failed ? `, ${y.failed} failed` : ""}, ${count(y.findings, "finding")}${y.mine && y.mine !== y.findings ? `, ${y.mine} from this conversation` : ""}`);
|
|
1011
|
+
const since = ({ sinceYouLooked: y }: SessionRow) => (y.runs === 0 && y.findings === 0 ? "—" : `${plural(y.runs, "run")}${y.failed ? `, ${y.failed} failed` : ""}, ${plural(y.findings, "finding")}${y.mine && y.mine !== y.findings ? `, ${y.mine} from this conversation` : ""}`);
|
|
986
1012
|
// A title and a path are what someone typed or named: drawn as text, as the page's list draws them.
|
|
987
1013
|
const rows = s.sessions.map((row) => `<tr><td>${e(plainText(conversationLabel(row.sessionId, row.title)))}</td><td>${e(plainText(row.cwd ?? "—"))}</td><td>${e(openIn(row.holder))}</td><td>${e(row.plainJobs)}</td><td>${e(row.rules)}</td><td>${e(row.mailbox)}</td><td>${e(since(row))}</td></tr>`).join("");
|
|
988
1014
|
const table = `<div class="scroll"><table><thead><tr><th>conversation</th><th>project</th><th>where</th><th>loops</th><th>rules</th><th>waiting</th><th>since you looked</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
@@ -1046,11 +1072,21 @@ export async function openForLaunch(
|
|
|
1046
1072
|
/** This package's root: what an `-e` that loads pi-loops names. */
|
|
1047
1073
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1048
1074
|
|
|
1049
|
-
/**
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether `args` carry `-e <path>`, `--extension <path>` or `--extension=<path>` naming this
|
|
1077
|
+
* package: its root, or its entry file `src/extension-entry.ts` (package.json `pi.extensions`). A
|
|
1078
|
+
* relative path is read from `base`.
|
|
1079
|
+
*/
|
|
1050
1080
|
function loadsPiLoops(args: readonly string[], base: string): boolean {
|
|
1051
|
-
|
|
1081
|
+
const ours = [PACKAGE_ROOT, path.join(PACKAGE_ROOT, "src", "extension-entry.ts")].map(realpathish);
|
|
1082
|
+
const names = (p: string) => ours.includes(realpathish(path.resolve(base, p)));
|
|
1083
|
+
for (let i = 0; i < args.length; i++) {
|
|
1084
|
+
if (args[i].startsWith("--extension=")) {
|
|
1085
|
+
if (names(args[i].slice("--extension=".length))) return true;
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1052
1088
|
if (args[i] !== "-e" && args[i] !== "--extension") continue;
|
|
1053
|
-
if (
|
|
1089
|
+
if (i + 1 < args.length && names(args[i + 1])) return true;
|
|
1054
1090
|
i++;
|
|
1055
1091
|
}
|
|
1056
1092
|
return false;
|
|
@@ -1199,7 +1235,7 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
1199
1235
|
// whether that is this one, and the panel says "resume it" rather than promising a time.
|
|
1200
1236
|
// pi's own word first: before the first message there is no session file to read a header from,
|
|
1201
1237
|
// and the window's own plain job was marked as another session's.
|
|
1202
|
-
const currentSession = sessionId || (child.sessionFile ?
|
|
1238
|
+
const currentSession = sessionId || (child.sessionFile ? readSessionHead(child.sessionFile)?.id : undefined);
|
|
1203
1239
|
const jobsFile = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] });
|
|
1204
1240
|
/**
|
|
1205
1241
|
* This project's jobs, and the count of everything in other projects goes with it, because a
|
|
@@ -1221,8 +1257,8 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
1221
1257
|
id: j.id,
|
|
1222
1258
|
name: j.name,
|
|
1223
1259
|
label: loopLabel({ id: String(j.id), name: typeof j.name === "string" ? j.name : undefined }),
|
|
1224
|
-
//
|
|
1225
|
-
schedule: j.schedule && typeof j.schedule === "object" ? (
|
|
1260
|
+
// In words for the common cron shapes (src/schedule.ts scheduleWords). A hand-written job may carry no schedule.
|
|
1261
|
+
schedule: j.schedule && typeof j.schedule === "object" ? (scheduleWords(j.schedule) ?? "?") : "?",
|
|
1226
1262
|
stateful: !!j.stateful,
|
|
1227
1263
|
enabled: !!j.enabled,
|
|
1228
1264
|
prompt: maskSecrets(j.prompt),
|
|
@@ -1471,8 +1507,8 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
1471
1507
|
caughtUpAt.set(child, Date.now());
|
|
1472
1508
|
child
|
|
1473
1509
|
.swapSession(undefined, { restoreModel: true, reopen: true })
|
|
1474
|
-
.then((answer: any) => opts.log(answer?.success ? `page: ${sessionId
|
|
1475
|
-
.catch((err: any) => opts.log(`page: ${sessionId
|
|
1510
|
+
.then((answer: any) => opts.log(answer?.success ? `page: ${shortSessionId(sessionId)} read again from disk; it is current` : `page: ${shortSessionId(sessionId)} could not be read again: ${answer?.error ?? "no answer"}`))
|
|
1511
|
+
.catch((err: any) => opts.log(`page: ${shortSessionId(sessionId)} could not be read again: ${err?.message ?? err}`));
|
|
1476
1512
|
}
|
|
1477
1513
|
/** When each page pi last tried to catch up, so one that stays aside is not reloaded on every poll. */
|
|
1478
1514
|
const caughtUpAt = new WeakMap<PageChild, number>();
|
|
@@ -1832,7 +1868,15 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
1832
1868
|
const all = inbox.list();
|
|
1833
1869
|
const fresh = all.filter((e) => e.status === "new");
|
|
1834
1870
|
const cwd = cwdAsked || (here ? (leaseOf(here)?.cwd ?? conversationThere(here)?.cwd) : undefined) || undefined;
|
|
1835
|
-
|
|
1871
|
+
// Resolved on disk once per distinct cwd in this request, not once per finding: 2000 findings took
|
|
1872
|
+
// about 24 ms a pass, for a handful of projects. `b` is always `cwd` here, so `a` is the key.
|
|
1873
|
+
const matched = new Map<string, boolean>();
|
|
1874
|
+
const sameAsked = (a: string, b: string) => {
|
|
1875
|
+
let yes = matched.get(a);
|
|
1876
|
+
if (yes === undefined) matched.set(a, (yes = sameProject(a, b)));
|
|
1877
|
+
return yes;
|
|
1878
|
+
};
|
|
1879
|
+
const scoped = (entries: InboxEntry[]) => (cwd ? inProject(entries, cwd, sameAsked) : entries);
|
|
1836
1880
|
const queued = queuedClaims();
|
|
1837
1881
|
const jobs = jobNames();
|
|
1838
1882
|
const rules = new Map(objectsIn(readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules).map((r) => [String(r.id), r.condition]));
|
|
@@ -2121,7 +2165,7 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
2121
2165
|
// The time goes as a stamp: the page writes it in the browser's local time, as it does the rest of the row.
|
|
2122
2166
|
if (applied.op === "disable") return { status: 200, body: { ok: true, op: applied.op, line: "paused" } };
|
|
2123
2167
|
const next = applied.nextRun.kind === "at" ? { nextAt: stamp(applied.nextRun.at) } : applied.nextRun.kind === "due" ? { due: true } : {};
|
|
2124
|
-
return { status: 200, body: { ok: true, op: applied.op, line: `now ${
|
|
2168
|
+
return { status: 200, body: { ok: true, op: applied.op, line: `now ${scheduleWords(after.schedule)}`, schedule: scheduleWords(after.schedule), ...next } };
|
|
2125
2169
|
}
|
|
2126
2170
|
|
|
2127
2171
|
/**
|
|
@@ -2351,7 +2395,9 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
2351
2395
|
secret = device.secret;
|
|
2352
2396
|
res.appendHeader("set-cookie", deviceCookie(device.id));
|
|
2353
2397
|
}
|
|
2354
|
-
|
|
2398
|
+
// A person opened this address: the answer is a page with a way back, never JSON.
|
|
2399
|
+
const noSuch = () => plainPage(res, 404, '<p>No such conversation.</p><p><a href="/">Back</a></p>', { secret });
|
|
2400
|
+
if (pathId === null) return void noSuch();
|
|
2355
2401
|
|
|
2356
2402
|
let sessionId = pathId ?? keyed;
|
|
2357
2403
|
if (sessionId === undefined) {
|
|
@@ -2381,8 +2427,8 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
2381
2427
|
return void plainPage(res, 503, `<p>${escapeHtml(crashLine)}.</p>${words ? `<pre>${escapeHtml(words)}</pre>` : ""}<p><a href="/c/${encodeURIComponent(sessionId)}/?again=1">Start again</a></p>`, { secret });
|
|
2382
2428
|
}
|
|
2383
2429
|
// pi could not be started: every one of the page's six is busy, say. A person reads this.
|
|
2384
|
-
if (opened.status ===
|
|
2385
|
-
return void
|
|
2430
|
+
if (opened.status === 404) return void noSuch();
|
|
2431
|
+
return void plainPage(res, opened.status, `<p>${escapeHtml(opened.body.error)}.</p><p><a href="/">Back</a></p>`, { secret });
|
|
2386
2432
|
}
|
|
2387
2433
|
pool.touch(sessionId);
|
|
2388
2434
|
opts.rememberPage?.(sessionId);
|
|
@@ -2868,9 +2914,10 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
2868
2914
|
if (route === "/session/new" && req.method === "POST") {
|
|
2869
2915
|
await snapshot(child);
|
|
2870
2916
|
const answer = await child.swapSession(child.newSessionPath());
|
|
2871
|
-
// The pool re-keys the child to the new id and its pages are told, before this answers.
|
|
2917
|
+
// The pool re-keys the child to the new id and its pages are told, before this answers. The
|
|
2918
|
+
// answer carries the id too: the page moves on it before it reloads (src/web.mjs swap).
|
|
2872
2919
|
await snapshot(child);
|
|
2873
|
-
return void json(res, answer);
|
|
2920
|
+
return void json(res, { ...answer, sessionId: child.sessionId });
|
|
2874
2921
|
}
|
|
2875
2922
|
if (route === "/session/switch" && req.method === "POST") {
|
|
2876
2923
|
const { file } = await body(req);
|
|
@@ -2888,7 +2935,7 @@ export function startPageServer(opts: PageServerOptions): Promise<PageServer> {
|
|
|
2888
2935
|
// so a relative path would be checked here and opened somewhere else.
|
|
2889
2936
|
const answer = await child.swapSession(path.resolve(match.file), { restoreModel: true });
|
|
2890
2937
|
await snapshot(child);
|
|
2891
|
-
return void json(res, answer);
|
|
2938
|
+
return void json(res, { ...answer, sessionId: child.sessionId });
|
|
2892
2939
|
}
|
|
2893
2940
|
if (route === "/queue/clear" && req.method === "POST") {
|
|
2894
2941
|
// Everything "queued N ✕" counts: every input row, the person's and pi-loops', as each one's
|
package/src/path-precheck.ts
CHANGED
|
@@ -62,6 +62,8 @@ export function pathOnlyCondition(condition: string, at: { cwd: string; home: st
|
|
|
62
62
|
const token = pathToken(s);
|
|
63
63
|
if ("why" in token) return token;
|
|
64
64
|
const rest = token.rest.trim();
|
|
65
|
+
// No aside in parentheses is dropped: "exists (or ~/b appears)" is true while this path is absent,
|
|
66
|
+
// and no word list can tell such an aside from "(a build finished)". The model reads those.
|
|
65
67
|
const verbWhy = verbOnly(rest);
|
|
66
68
|
if (verbWhy) return { why: verbWhy };
|
|
67
69
|
const bad = badPath(token.path);
|
package/src/paths.ts
CHANGED
|
@@ -33,9 +33,12 @@ export function realpathish(p: string): string {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* A root so broad that containment would mean "everything": never treated as one project. `dir` is
|
|
38
|
+
* resolved, so `$HOME` is too: a HOME reached through a symlink is still HOME.
|
|
39
|
+
*/
|
|
37
40
|
function tooBroad(dir: string): boolean {
|
|
38
|
-
return dir === "/" || dir === os.homedir() || path.dirname(dir) === dir;
|
|
41
|
+
return dir === "/" || dir === realpathish(os.homedir()) || path.dirname(dir) === dir;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** True when `p` is the project rooted at `root` or a directory inside it, symlinks resolved. */
|
|
@@ -55,3 +58,13 @@ export function withinProject(root: string, p: string): boolean {
|
|
|
55
58
|
export function sameProject(a: string, b: string): boolean {
|
|
56
59
|
return withinProject(b, a) || withinProject(a, b);
|
|
57
60
|
}
|
|
61
|
+
|
|
62
|
+
/** pi's own normalisation: resolved, then through symlinks where the path exists. */
|
|
63
|
+
export function canonicalDir(dir: string): string {
|
|
64
|
+
const resolved = path.resolve(dir);
|
|
65
|
+
try {
|
|
66
|
+
return fs.realpathSync(resolved);
|
|
67
|
+
} catch {
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/pi-loops.ts
CHANGED
|
@@ -45,10 +45,11 @@ import { createHash } from "node:crypto";
|
|
|
45
45
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
46
46
|
import { applyJobEdit } from "./job-edit.ts";
|
|
47
47
|
import { jobLines as cronJobLines } from "./job-lines.ts";
|
|
48
|
+
import { plural } from "./plural.ts";
|
|
48
49
|
import { openWhere, runsHere } from "./job-owner.ts";
|
|
49
50
|
import { type AcquireOutcome, LeaseBinding, LeaseStore, TURN_EVENTS, type TurnMarks, closedConversation, markTurnEvent, openWindowPids, procStart, turnSinceOf } from "./lease.ts";
|
|
50
51
|
import { type DrainOptions, type EngineSinks, type HandOverDeps, type HandOverOutcome, type Producer, type RowAddress, armRetry, cancelRow, coalesced, drain, dropContinuations, goalRow, handOver as engineHandOver, handOverTarget, heldTurnFor, inboxClaimRow, newTriggerRow, promotionTarget, promotionView, pushRunRow, recipeRow, releaseLocalRows, ringProduced, ruleResultRow, stopRow, waitingLine } from "./engine.ts";
|
|
51
|
-
import { PANEL_RUN_LIMIT, heldJobsNotice, leftConversationLine, panelLines, pickQueued,
|
|
52
|
+
import { PANEL_RUN_LIMIT, heldJobsNotice, leftConversationLine, panelLines, pickQueued, queueLines, toolsCapNotice } from "./job-lines.ts";
|
|
52
53
|
import { RequestStore, type SessionRequest } from "./requests.ts";
|
|
53
54
|
import { WindowExecutor, unattendedTurn } from "./window-executor.ts";
|
|
54
55
|
import { type GuardAnswer, WINDOW_GUARD_CONFIRM_MS, askRefusingFirst, windowGuard } from "./window-guard.ts";
|
|
@@ -1120,7 +1121,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1120
1121
|
const parts: string[] = [];
|
|
1121
1122
|
const n = scheduler.inbox.newCount();
|
|
1122
1123
|
const decisions = scheduler.inbox.decisionCount();
|
|
1123
|
-
if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${decisions
|
|
1124
|
+
if (n > 0) parts.push(`Inbox: ${n} new${decisions ? ` (${plural(decisions, "decision")})` : ""}`);
|
|
1124
1125
|
// Machine-wide like the inbox count and the running list above it: one process owns the
|
|
1125
1126
|
// machine's clock, and a loop failing in another checkout is still this machine's automation
|
|
1126
1127
|
// going quiet.
|
|
@@ -1134,7 +1135,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1134
1135
|
const queued = here ? requests.list(here).filter((r) => r.kind === "input" && !executor.handedOver.has(r.id)).length : 0;
|
|
1135
1136
|
if (queued) parts.push(`queued ${queued}`);
|
|
1136
1137
|
const attention = mcpSources.filter((s) => s.status.state === "disconnected" || s.status.state === "auth_failed").length;
|
|
1137
|
-
if (attention) parts.push(`mcp: ${attention
|
|
1138
|
+
if (attention) parts.push(`mcp: ${plural(attention, "source")} down`);
|
|
1138
1139
|
const paused = hostPauseReason();
|
|
1139
1140
|
if (paused && hostHasWork()) parts.push(`loops paused: ${paused}`);
|
|
1140
1141
|
else if (hostState?.kind === "channel-broken") parts.push(HOST_STATE_BADGE["channel-broken"]);
|
|
@@ -1322,7 +1323,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1322
1323
|
for (const row of targets) requests.append(cancelRow(sessionId, { id: row.id }, "window", createdBy()));
|
|
1323
1324
|
const result = await drainRequests();
|
|
1324
1325
|
const gone = targets.filter((row) => result?.removed.includes(row.id)).length;
|
|
1325
|
-
ctx.ui.notify(gone === targets.length ? `cancelled ${gone
|
|
1326
|
+
ctx.ui.notify(gone === targets.length ? `cancelled ${plural(gone, "queued item")}` : `cancelled ${gone} of ${targets.length}; the rest are handed to pi already (Esc stops the turn) or are for the window that has this conversation to cancel`, "info");
|
|
1326
1327
|
refreshBadge();
|
|
1327
1328
|
return;
|
|
1328
1329
|
}
|
|
@@ -1618,7 +1619,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1618
1619
|
const who = job ? "" : `${r.jobName ?? r.jobId} `;
|
|
1619
1620
|
const cost = r.usage?.cost ? ` $${r.usage.cost.toFixed(3)}` : "";
|
|
1620
1621
|
const chk = r.checker ? (r.checker.ok ? `, checker kept ${r.checker.kept}/${r.checker.kept + r.checker.dropped.length + r.checker.unreviewed}` : ", checker FAILED") : "";
|
|
1621
|
-
const tail = r.ok ? `${r.findings
|
|
1622
|
+
const tail = r.ok ? `${plural(r.findings, "finding")}${chk}${r.stateUpdated ? ", state updated" : ""}` : r.stoppedBy === "person" ? `stopped by you ${r.stoppedFrom === "page" ? "from the page" : "from a terminal"}` : r.aborted ? "stopped" : `FAILED: ${previewRedacted(r.error ?? "?", 80)}`;
|
|
1622
1623
|
return `${String(i + 1).padStart(2)}. ${formatLocal(Date.parse(r.startedAt))} ${who}${secs}s${cost} · ${tail}${r.sessionFile ? "" : " (no transcript)"}`;
|
|
1623
1624
|
}),
|
|
1624
1625
|
job ? `transcript: /cron trace ${job.name ?? job.id} <k>` : "transcript: /cron trace <job> <k>",
|
|
@@ -2039,7 +2040,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2039
2040
|
}
|
|
2040
2041
|
const lines = inboxLines(entries, true);
|
|
2041
2042
|
const elsewhere = all.length - entries.length;
|
|
2042
|
-
if (elsewhere) lines.push(`+ ${elsewhere
|
|
2043
|
+
if (elsewhere) lines.push(`+ ${plural(elsewhere, "finding")} in other projects — /inbox --all`);
|
|
2043
2044
|
const decisions = entries.filter((e) => e.kind === "checkpoint").length;
|
|
2044
2045
|
show(ctx, `Inbox (${where}, ${entries.length} new${decisions ? `, ${decisions} need${decisions === 1 ? "s" : ""} a decision` : ""}, times ${localOffset(Date.now())}):`, lines);
|
|
2045
2046
|
markSeenIfShown(everywhere);
|
|
@@ -2210,7 +2211,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2210
2211
|
const here = all.filter((r) => sameProject(r.cwd, session.cwd));
|
|
2211
2212
|
const elsewhere = all.length - here.length;
|
|
2212
2213
|
const lines = here.length ? ruleLines(here, true) : ["none"];
|
|
2213
|
-
if (elsewhere) lines.push(` + ${elsewhere
|
|
2214
|
+
if (elsewhere) lines.push(` + ${plural(elsewhere, "rule")} in other projects — /triggers rules --all`);
|
|
2214
2215
|
if (rest.trim() === "--all") {
|
|
2215
2216
|
show(ctx, `Dynamic trigger rules (this machine, ${all.length}):`, all.length ? ruleLines(all, false) : ["none"]);
|
|
2216
2217
|
return;
|
package/src/plural.ts
ADDED
package/src/run-card.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { screenText } from "./inbox-card.ts";
|
|
8
8
|
import { loopLabel } from "./job-lines.ts";
|
|
9
|
+
import { plural } from "./plural.ts";
|
|
9
10
|
import { previewRedacted } from "./redact.ts";
|
|
10
11
|
import type { RunRecord } from "./store.ts";
|
|
11
12
|
|
|
@@ -48,12 +49,12 @@ export function runLine(r: RunRecord, name?: string): string {
|
|
|
48
49
|
if (r.aborted) return `${label} stopped · ${secs}s`;
|
|
49
50
|
if (!r.ok) return `${label} FAILED · ${secs}s · ${previewRedacted(r.error ?? "unknown error", ERROR_CHARS)}`;
|
|
50
51
|
const cost = r.usage?.cost ? ` · $${r.usage.cost.toFixed(3)}` : "";
|
|
51
|
-
return `${label} · ${secs}s${cost} · ${r.findings
|
|
52
|
+
return `${label} · ${secs}s${cost} · ${plural(r.findings, "finding")}`;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** The one line a burst's carded rest is folded into. */
|
|
55
56
|
export function burstLine(placed: { more: number; failed: number }): string {
|
|
56
|
-
return `+${placed.more
|
|
57
|
+
return `+${plural(placed.more, "more run")} finished${placed.failed ? ` (${placed.failed} failed)` : ""} — /cron runs`;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The carded runs of `fresh`, oldest first, at most `max`; `more` counts the carded rest and `failed` the failures among them. */
|