@alphacoder-v0/pi-loops 0.24.0 → 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/src/engine.ts CHANGED
@@ -1079,8 +1079,9 @@ export function deadLetterText(why: string, prompt: string): string {
1079
1079
  return `could not be delivered (${why}): ${prompt}`;
1080
1080
  }
1081
1081
 
1082
- /** The parts of a finding `deadLetterText` wrote, or undefined for any other finding. */
1083
- export function deadLetterParts(text: string): { why: string; prompt: string } | undefined {
1082
+ /** The parts of a dead letter's text, or undefined for any other finding: the engine's mark decides, never the text. */
1083
+ export function deadLetterParts(entry: { text: string; undelivered?: true }, text = entry.text): { why: string; prompt: string } | undefined {
1084
+ if (!entry.undelivered) return undefined;
1084
1085
  const m = /^could not be delivered \((.*?)\): ([\s\S]*)$/.exec(text);
1085
1086
  return m ? { why: m[1], prompt: m[2] } : undefined;
1086
1087
  }
@@ -1098,7 +1099,7 @@ async function deadLetter(store: RequestStore, sinks: EngineSinks, sessionId: st
1098
1099
  if (rule !== undefined) {
1099
1100
  if (!checkDecidedFinding(sinks.audit, row.id)) await sinks.inbox.appendOnce({ source: `trigger:${rule}`, run_id: row.id, job_id: rule, cwd, text: text || `rule result ${row.id}`, session_id: sessionId }, { run_id: row.id, source: `trigger:${rule}` });
1100
1101
  }
1101
- else await sinks.inbox.append({ source: row.source, run_id: row.id, cwd, text: deadLetterText(why, text.slice(0, DEAD_LETTER_CHARS) || `${row.kind} request ${row.id}`), session_id: sessionId });
1102
+ else await sinks.inbox.append({ source: row.source, run_id: row.id, cwd, text: deadLetterText(why, text.slice(0, DEAD_LETTER_CHARS) || `${row.kind} request ${row.id}`), session_id: sessionId, undelivered: true });
1102
1103
  sinks.audit.appendAudit({ cwd, type: "request", traceId: row.id, state: "dead", sourceLabel: row.source, eventLabel: row.session_id, summary: text, details: { session_id: row.session_id, kind: row.kind, attempts: row.attempts, last_error: row.last_error, reason: why } });
1103
1104
  store.remove(sessionId, [row.id]);
1104
1105
  }
@@ -16,6 +16,7 @@ import * as os from "node:os";
16
16
  import * as path from "node:path";
17
17
  import { DEAD_AFTER_ATTEMPTS, formatWait, heldTurnFor, stopRow } from "./engine.ts";
18
18
  import { snapshotLoopLabel } from "./job-lines.ts";
19
+ import { plural } from "./plural.ts";
19
20
  import { type LeaseStore, type ProcessProbe, holderAlive, sessionFileName } from "./lease.ts";
20
21
  import { conversationLabel, shortSessionId } from "./session-head.ts";
21
22
  import { previewRedacted, redact } from "./redact.ts";
@@ -205,7 +206,7 @@ export function requestLines(queues: readonly RequestQueue[], now: number, opts:
205
206
  const dead = queues.reduce((n, q) => n + q.dead, 0);
206
207
  const undelivered = (n: number) => `${n} could not be delivered`;
207
208
  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
+ const head = ` ${plural(total, "message")} waiting for ${plural(queues.length, "conversation")}${dead ? `, ${undelivered(dead)}` : ""}`;
209
210
  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
  }
211
212
 
@@ -604,6 +604,12 @@ export interface EnsureHostOptions extends SpawnHostOptions {
604
604
  page?: true;
605
605
  /** How long an upgrade waits for the old process to leave; `LEAVE_WAIT_MS` unless a test says. */
606
606
  leaveWaitMs?: number;
607
+ /**
608
+ * Called once when an upgrade is still waiting for the old process after `leaveNoticeMs` (2 s):
609
+ * it is letting a turn in the page finish, and a person waiting on this call is told why.
610
+ */
611
+ onLeaveWait?: () => void;
612
+ leaveNoticeMs?: number;
607
613
  /**
608
614
  * Whether a start waits for the process to record itself (up to 10 s). Every caller waits but a
609
615
  * window that is quitting: it has nothing to do with the answer, and waiting made quitting slow.
@@ -645,13 +651,22 @@ export async function ensureHost(opts: EnsureHostOptions): Promise<{ action: Ens
645
651
  if (action.kind === "upgrade") {
646
652
  recordUpgrader(opts.dir, { pid: process.pid, version: opts.myVersion, procStart: procStart(process.pid) });
647
653
  await askHost(opts.dir, { op: "upgrade" });
654
+ // A quitting window asks and goes. The claim above holds older windows back; the next window,
655
+ // or the next `pi-loops`, starts this version once the old process has left.
656
+ if (opts.wait === false) return { action };
648
657
  // Wait for it to go and start this version at once: the gap until the next tick is where an
649
658
  // older window used to start the old code again. It may first let a turn in the page finish.
650
659
  const old = rec!.pid;
651
- const deadline = Date.now() + (opts.leaveWaitMs ?? LEAVE_WAIT_MS);
660
+ const began = Date.now();
661
+ const deadline = began + (opts.leaveWaitMs ?? LEAVE_WAIT_MS);
662
+ let told = false;
652
663
  while (Date.now() < deadline) {
653
664
  const now = readHostService(opts.dir);
654
665
  if (!now || now.pid !== old || !hostServiceAlive(now)) break;
666
+ if (!told && Date.now() - began >= (opts.leaveNoticeMs ?? 2_000)) {
667
+ told = true;
668
+ opts.onLeaveWait?.();
669
+ }
655
670
  await new Promise((r) => setTimeout(r, 100));
656
671
  }
657
672
  const still = readHostService(opts.dir);
@@ -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.slice(0, 8)}`);
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.slice(0, 8)}: ${err?.message ?? err}`);
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.slice(0, 8)} after its turn: ${err?.message ?? err}`);
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.slice(0, 8)}`);
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.slice(0, 8)}: ${err?.message ?? err}`);
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.slice(0, 8)}, held for run ${busy.runId} that is no longer running`);
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.slice(0, 8)}: ${err?.message ?? err}`);
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.slice(0, 8)} went to the inbox: its conversation is gone`);
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.slice(0, 8)}: ${err?.message ?? err}`);
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.slice(0, 8)}: rang ${RINGS_PER_TICK} times since the last tick; left to the tick`);
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.slice(0, 8)}: ${err?.message ?? err}`))
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.text) ? "undelivered" : "loop";
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} finding${s.findings === 1 ? "" : "s"}`];
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
@@ -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 = readHead(entry.file);
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?.slice(0, 8) ?? "a conversation"} has no file yet (${how.sessionFile}); pi starts a new conversation in ${how.cwd}`);
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
@@ -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
- const rotated = fs.existsSync(marker);
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 count = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
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
- /** Whether `args` carry `-e <path>` or `--extension <path>` naming this package's root, a relative path read from `base`. */
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
- for (let i = 0; i + 1 < args.length; i++) {
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 (realpathish(path.resolve(base, args[i + 1])) === realpathish(PACKAGE_ROOT)) return true;
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 ? readHead(child.sessionFile)?.id : undefined);
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
- // The terminal's words for it (src/schedule.ts): `every 1d` on both. A hand-written job may carry no schedule.
1225
- schedule: j.schedule && typeof j.schedule === "object" ? (formatSchedule(j.schedule) ?? "?") : "?",
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.slice(0, 8)} read again from disk; it is current` : `page: ${sessionId.slice(0, 8)} could not be read again: ${answer?.error ?? "no answer"}`))
1475
- .catch((err: any) => opts.log(`page: ${sessionId.slice(0, 8)} could not be read again: ${err?.message ?? err}`));
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
- const scoped = (entries: InboxEntry[]) => (cwd ? inProject(entries, cwd, sameProject) : entries);
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 ${formatSchedule(after.schedule)}`, schedule: formatSchedule(after.schedule), ...next } };
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
- if (pathId === null) return void json(res, NO_SUCH, 404);
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 === 503) return void plainPage(res, 503, `<p>${escapeHtml(opened.body.error)}.</p><p><a href="/">Back</a></p>`, { secret });
2385
- return void json(res, opened.body, opened.status);
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
@@ -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
- /** A root so broad that containment would mean "everything": never treated as one project. */
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
+ }