@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/src/schedule.ts CHANGED
@@ -127,6 +127,26 @@ export function formatSchedule(schedule: Schedule): string {
127
127
  }
128
128
  }
129
129
 
130
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
131
+
132
+ /**
133
+ * A schedule as the page's Loops pane says it: the common cron shapes in words (`every hour`,
134
+ * `daily 09:00`, `weekdays 09:00`, `weekly Mon 09:00`), anything else as `formatSchedule` says it.
135
+ * A person who typed "daily" reads that back, not `0 9 * * *`.
136
+ */
137
+ export function scheduleWords(schedule: Schedule): string {
138
+ if (schedule.kind !== "cron") return formatSchedule(schedule);
139
+ const [m, h, dom, mon, dow, ...rest] = schedule.expr.trim().split(/\s+/);
140
+ if (rest.length || dom !== "*" || mon !== "*") return schedule.expr;
141
+ if (m === "0" && h === "*" && dow === "*") return "every hour";
142
+ if (!/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(h)) return schedule.expr;
143
+ const at = `${h.padStart(2, "0")}:${m.padStart(2, "0")}`;
144
+ if (dow === "*") return `daily ${at}`;
145
+ if (dow === "1-5") return `weekdays ${at}`;
146
+ if (/^[0-7]$/.test(dow)) return `weekly ${DAY_NAMES[Number(dow)]} ${at}`;
147
+ return schedule.expr;
148
+ }
149
+
130
150
  /**
131
151
  * The machine's offset from UTC at that instant, as `+08:00`.
132
152
  *
package/src/seen.ts CHANGED
@@ -5,6 +5,7 @@ import { Inbox, type InboxEntry } from "./inbox.ts";
5
5
  import { stamp } from "./schedule.ts";
6
6
  import { JobStore, type RunRecord } from "./store.ts";
7
7
  import { realpathish, sameProject } from "./paths.ts";
8
+ import { plural } from "./plural.ts";
8
9
 
9
10
  /**
10
11
  * When `/inbox` was last looked at, per project: the terminal's `/inbox`, and the page's Mark all
@@ -232,7 +233,6 @@ export function sinceWords(sinceMs: number, nowMs: number): string {
232
233
  return a.getFullYear() === b.getFullYear() ? `${date} ${time}` : `${date} ${a.getFullYear()} ${time}`;
233
234
  }
234
235
 
235
- const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
236
236
 
237
237
  /** How many findings came from this conversation, worth saying only when that is not all of them. */
238
238
  const fromHere = (s: AwaySummary): number => (s.fromThisConversation !== s.findings ? s.fromThisConversation : 0);
package/src/share.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * taken back, so the summary comes first and the confirmation after it.
10
10
  */
11
11
  import { redact } from "./redact.ts";
12
- import { plural } from "./job-lines.ts";
12
+ import { plural } from "./plural.ts";
13
13
  import { stamp } from "./schedule.ts";
14
14
 
15
15
  export interface ShareMessage {
package/src/store.ts CHANGED
@@ -196,8 +196,17 @@ export function homeLoopsDir(agentDir: string): string {
196
196
 
197
197
  export function defaultLoopsDir(agentDir?: string): string {
198
198
  if (process.env.PI_LOOPS_DIR) return process.env.PI_LOOPS_DIR;
199
- const base = agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
200
- return path.join(base, "loops");
199
+ return path.join(agentDir ?? piAgentDir(), "loops");
200
+ }
201
+
202
+ /**
203
+ * pi's agent directory, by pi's rule (its `getAgentDir`): `PI_CODING_AGENT_DIR` with a leading `~`
204
+ * expanded, else `~/.pi/agent`. Here so that `pi-loops` does not load pi's SDK, about a second, for
205
+ * a path; test/store.test.ts holds it to the SDK's answer.
206
+ */
207
+ export function piAgentDir(): string {
208
+ const env = process.env.PI_CODING_AGENT_DIR;
209
+ return env ? env.replace(/^~(?=$|\/)/, os.homedir()) : path.join(os.homedir(), ".pi", "agent");
201
210
  }
202
211
 
203
212
  /**
package/src/tools.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * SetCronJobState and NewTrigger / ListTriggers / RemoveTrigger / SetTriggerState — as pi tool
4
4
  * definitions. One factory serves the interactive session (hop 0, registered with pi), every
5
5
  * in-process sub-session (hop 1, passed as customTools) and the headless host (hop 1, no UI).
6
+ * Hop 1 does not get `new_trigger`, `remove_trigger` or `cron_remove`: nobody can approve any of them there.
6
7
  */
7
8
  import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal } from "./job-signal.ts";
8
9
  import { formatWait, queuedPrompt, type QueuedPrompt } from "./engine.ts";
@@ -16,7 +17,7 @@ import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
16
17
  import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
17
18
  import { asleepNote, ownerSession, runsSomewhere, whereRuns, type OwnerView } from "./job-owner.ts";
18
19
  import { inProject } from "./inbox.ts";
19
- import { plural } from "./job-lines.ts";
20
+ import { plural } from "./plural.ts";
20
21
  import type { TriggerRuntime } from "./trigger-runtime.ts";
21
22
  import { sameProject, withinProject } from "./paths.ts";
22
23
  import { type TriggerStore, cleanCondition, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
@@ -323,11 +324,16 @@ export interface ToolScope {
323
324
  parentSessionId?: string;
324
325
  parentCwd?: string;
325
326
  }
327
+ // Every call of these is a prompt-class operation, denied at hop > 0 (controlPlanePreflight): a
328
+ // tool that can only fail is one an agent tries first.
329
+ const HOP0_ONLY = new Set(["new_trigger", "remove_trigger", "cron_remove"]);
330
+
326
331
  /**
327
332
  * The cron/trigger tools are registered in sub-agents too (Prompt-class ones are denied there)
328
333
  * and bounds cycles with a hop count. The same definitions serve the interactive session
329
334
  * (hop 0, registered with pi) and every in-process sub-session (hop 1, passed as customTools).
330
335
  */
336
+
331
337
  export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinition<any, any>[] {
332
338
  const defs: ToolDefinition<any, any>[] = [];
333
339
  // Only where a person's message can arrive: a sub-agent is told nothing about it.
@@ -341,9 +347,9 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
341
347
  "Create an event/condition-based dynamic trigger rule. Use this for future events such as a browser tab, file, MCP notification, webhook, or other condition becoming true. Do not use this for fixed time, recurring, scheduled, hourly, daily, weekly, cron, crontab, 定时任务, 每小时, or similar time-based jobs; use cron_create instead.",
342
348
  parameters: Type.Object(
343
349
  {
344
- condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events." }),
350
+ condition: Type.String({ description: "The natural-language condition that should be evaluated against future trigger events. For 'when <path> exists', keep the person's words: '<path> exists', with nothing added (no aside in parentheses) — a condition like that is checked with a local stat, no model call, while the path is absent." }),
345
351
  action: Type.String({ description: "The action to perform when the condition matches. This may be a shell command or a natural-language instruction." }),
346
- spec: Type.Optional(Type.String({ description: "Fallback complete trigger rule text when condition and action cannot be supplied separately." })),
352
+ spec: Type.Optional(Type.String({ description: "The whole rule as one sentence, only when condition and action cannot be given apart." })),
347
353
  fire_once: Type.Optional(Type.Boolean({ description: "Whether to disable the rule after the first successful match. Defaults to true unless the user explicitly asks for a repeating trigger." })),
348
354
  promote_to_chat: Type.Optional(Type.Boolean({ description: "Whether a match's result goes into this conversation (true) or the inbox (false). With a person present the question starts at this conversation whatever this says; with nobody to ask, the default is false." })),
349
355
  },
@@ -456,14 +462,14 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
456
462
  "Create a scheduled job. Use when the user asks for a fixed time, recurring, scheduled, hourly, daily, weekly, crontab, 定时任务, 每小时, 每天, or similar time-based job. Jobs persist across pi restarts. A plain job runs its prompt in this chat when due. Set stateful=true for loop mode: a fresh sub-agent runs it, keeps persistent notes across runs (injected each time), and routes findings to the user's /inbox instead of the chat — use that for recurring watch/triage jobs like \"check for new issues and report only what changed\"." + watchSentence,
457
463
  parameters: Type.Object({
458
464
  schedule: Type.String({
459
- description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "@daily", "every 30m", "in 10m", or "at 2026-09-08T18:00".',
465
+ description: 'Local-time schedule: 5-field cron ("0 9 * * *", "*/30 * * * 1-5"), "hourly", "daily" (09:00), "weekly" (Monday 09:00), "@daily" (midnight), "every 30m", "in 10m", or "at 2026-09-08T18:00".',
460
466
  }),
461
467
  action: Type.String({ description: "Natural-language instruction to run when the schedule is due. For stateful jobs, write it for a fresh agent whose only memory is its own notes." }),
462
468
  stateful: Type.Optional(Type.Boolean({ description: "Loop mode: sub-agent + notes between runs + findings to the inbox. With a person present the question starts at the inbox unless this is false; with nobody to ask, the default is false." })),
463
469
  verify: Type.Optional(Type.Boolean({ description: "Maker/checker: a second adversarial sub-agent verifies each finding before it enters the inbox (stateful jobs only, default false). Use when the user asks for verified, double-checked, or high-precision findings." })),
464
470
  name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
465
471
  cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
466
- catch_up: Type.Optional(Type.Boolean({ description: "Run once for a tick that was missed while nothing could run the job: a loop when pi-loops next starts, a plain job when its conversation is next opened or pi-loops next starts (default: true for stateful jobs, false for plain jobs)." })),
472
+ catch_up: Type.Optional(Type.Boolean({ description: "Run once for a missed tick (default: true for stateful jobs, false for plain jobs)." })),
467
473
  }),
468
474
  async execute(_id, params, signal, _onUpdate, ctx) {
469
475
  let schedule: LoopJob["schedule"];
@@ -521,29 +527,22 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
521
527
  name: "cron_remove",
522
528
  label: "Remove cron job",
523
529
  description:
524
- "Preview or confirm removal of a scheduled job by id or name. Use confirm=false first when the user asks to delete, remove, or clear a scheduled job, cron job, crontab entry, or 定时任务. Call confirm=true only after the user explicitly confirms removal. Removal deletes the job itself; a loop's saved notes and run transcripts are kept on disk, and `/cron gc --purge` is what clears them.",
530
+ "Remove a scheduled job by id or name. Use when the user asks to delete, remove, or clear a scheduled job, cron job, crontab entry, or 定时任务; the user is asked to confirm in the same call. Removal deletes the job itself; a loop's saved notes and run transcripts are kept on disk, and `/cron gc --purge` is what clears them.",
525
531
  parameters: Type.Object({
526
532
  ref: Type.String({ description: "Job id (for example cron-abc123), unique id prefix, or name." }),
527
- confirm: Type.Optional(Type.Boolean({ description: "false to preview the removal; true only after explicit user confirmation." })),
528
533
  }),
529
534
  async execute(_id, params, _signal, _onUpdate, ctx) {
530
535
  const job = resolveJobRefScoped(host.scheduler.store.load(), params.ref, host.session().cwd);
531
- if (!job) return { content: [{ type: "text", text: `no cron job matching '${params.ref}' in this project; cron_list shows the ids and names` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
536
+ if (!job) return { content: [{ type: "text", text: `no cron job matching '${params.ref}' in this project; cron_list shows the ids and names` }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, audit_entry_id: undefined as string | undefined } };
532
537
  const label = `${job.id}${job.name ? ` "${job.name}"` : ""}`;
533
- if (!params.confirm) {
534
- return {
535
- content: [{ type: "text", text: `remove cron job ${label} requires confirmation\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\ncall cron_remove again with confirm=true only after the user confirms` }],
536
- details: { id: job.id as string | undefined, removed_count: 0, confirmation_required: true, audit_entry_id: undefined as string | undefined },
537
- };
538
- }
539
538
  // The only control-plane tool that used to skip this gate, so a sub-agent (hop > 0) could
540
539
  // delete any project's job unapproved — and what that costs is the automation itself: the
541
540
  // loop stops running and its id is gone from jobs.json. Its notes and transcripts stay
542
541
  // behind (under an id nothing schedules any more; `/cron gc --purge` clears those), which
543
- // is why the gate is about what stops, not about data destroyed. The preview above stays
544
- // free: it is what the user is shown before deciding.
542
+ // is why the gate is about what stops, not about data destroyed. The dialog shows the
543
+ // schedule and the action: one question, where a preview call used to make it two.
545
544
  const denied = await host.confirmTool(ctx, { label: `remove cron job ${label}`, tool: "cron_remove", reason: "remove a scheduled job", preview: `${formatSchedule(job.schedule)} · ${previewRedacted(job.prompt, 120)}`, args: params }, scope.hop);
546
- if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, confirmation_required: false, audit_entry_id: undefined as string | undefined } };
545
+ if (denied) return { content: [{ type: "text", text: denied }], isError: true, details: { id: undefined as string | undefined, removed_count: 0, audit_entry_id: undefined as string | undefined } };
547
546
  const { aborted } = await host.scheduler.removeJob(job.id);
548
547
  const auditEntryId = host.cronControlAudit("remove", scope.actor, job, undefined);
549
548
  // The slash command says where a loop's notes went; a tool-driven removal has to say it too,
@@ -553,7 +552,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
553
552
  const kept = job.stateful ? "\nits loop state and run transcripts are kept; /cron gc --purge clears them" : "";
554
553
  const abortedNote = aborted > 0 ? "\naborted its running run" : "";
555
554
  const statePath = job.stateful ? host.scheduler.store.statePath(job.id) : undefined;
556
- return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1, confirmation_required: false, audit_entry_id: auditEntryId, state_path: statePath } };
555
+ return { content: [{ type: "text", text: `removed cron job ${label}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}${kept}${abortedNote}` }], details: { id: job.id as string | undefined, removed_count: 1, audit_entry_id: auditEntryId, state_path: statePath } };
557
556
  },
558
557
  });
559
558
 
@@ -587,5 +586,5 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
587
586
  };
588
587
  },
589
588
  });
590
- return defs;
589
+ return scope.hop === 0 ? defs : defs.filter((d) => !HOP0_ONLY.has(d.name));
591
590
  }
package/src/triggers.ts CHANGED
@@ -463,9 +463,8 @@ export function auditArgs(rest: string): { limit: number; all: boolean } | { usa
463
463
  /**
464
464
  * The control-plane prompt gate, pre-flight part. Prompt-class tool calls (create or remove
465
465
  * a trigger, re-enable a trigger or a cron job) need a human. Sub-agents have no prompt channel
466
- * and are denied fail-closed ("control-plane prompt required but no
467
- * on_control_plane_prompt hook configured"); a UI-less interactive process is refused; an
468
- * interactive UI gets to ask. Returns the denial text, or undefined when the caller should ask.
466
+ * and are denied fail-closed; a UI-less interactive process is refused; an interactive UI gets
467
+ * to ask. Returns the denial text, or undefined when the caller should ask.
469
468
  */
470
469
  export function controlPlanePreflight(proc: { hop: number; hasUI: boolean; held?: boolean }, reason: string): string | undefined {
471
470
  // A plain job's turn in a closed conversation: the person's own turn, run where they were not.
package/src/trust.ts CHANGED
@@ -10,17 +10,7 @@
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { ProjectTrustStore, hasTrustRequiringProjectResources } from "@earendil-works/pi-coding-agent";
13
- import { withinProject } from "./paths.ts";
14
-
15
- /** pi's own normalisation: resolved, then through symlinks where the path exists. */
16
- export function canonicalDir(dir: string): string {
17
- const resolved = path.resolve(dir);
18
- try {
19
- return fs.realpathSync(resolved);
20
- } catch {
21
- return resolved;
22
- }
23
- }
13
+ import { canonicalDir, withinProject } from "./paths.ts";
24
14
 
25
15
  /**
26
16
  * pi's own saved decision for `cwd`: the nearest entry, this directory's or a parent's, so a
package/src/web.mjs CHANGED
@@ -508,6 +508,7 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
508
508
  header .grow *{flex-shrink:0}
509
509
  header .grow button{padding:5px 7px}
510
510
  #cost{flex-shrink:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
511
+ #cost:empty{display:none}
511
512
  }}
512
513
  /* The loops rail is not dropped when the window narrows, it is put behind the rail's Loops button: it
513
514
  slides over the conversation rather than taking a third of it, the way a drawer does elsewhere. */
@@ -597,22 +598,21 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
597
598
  <button id="expand">▸ steps</button>
598
599
  <span id="actions">
599
600
  <button id="reload">reload</button>
600
- <button id="find" aria-label="Search this session">find</button>
601
+ <button id="find" aria-label="Search this conversation">find</button>
601
602
  <button id="undo">undo</button>
602
- <button id="save" title="Export this session as HTML">save</button>
603
+ <button id="save" title="Export this conversation as HTML">save</button>
603
604
  <button id="share">share</button>
604
605
  <button id="compact">compact</button>
605
- <button id="clear">clear</button>
606
606
  <button id="resume">resume</button>
607
607
  <button id="adddev" aria-label="Add a device">add device</button>
608
- <button id="theme" aria-label="Change theme">theme: system</button>
608
+ <button id="theme">theme: system</button>
609
609
  <button id="notifyMe">notifications</button>
610
610
  </span>
611
611
  <span class="badge" id="status">connecting</span>
612
- <button id="more" title="Session actions" aria-label="Session actions">⋯</button>
612
+ <button id="more" title="Conversation actions" aria-label="Conversation actions">⋯</button>
613
613
  </span>
614
614
  </header>
615
- <div id="findbar" hidden><input id="findq" placeholder="search this session…" ><span id="findn" class="notice"></span></div>
615
+ <div id="findbar" hidden><input id="findq" placeholder="search this conversation…" ><span id="findn" class="notice"></span></div>
616
616
  <button id="stale" hidden></button>
617
617
  <div id="feed" role="log" aria-live="polite" aria-label="Conversation"></div>
618
618
  <div id="activitySlot"></div>
@@ -623,7 +623,7 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
623
623
  <div id="pop"></div>
624
624
  <div id="thumbs"></div>
625
625
  <div class="composer-row">
626
- <textarea id="input" placeholder="Message pi — / for commands, @ for files, Enter sends"></textarea>
626
+ <textarea id="input" placeholder="Message pi"></textarea>
627
627
  <button type="button" id="attach">attach</button>
628
628
  <button type="submit" class="primary">send</button>
629
629
  <button type="button" id="stop">stop</button>
@@ -643,8 +643,8 @@ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px
643
643
  <a href="#loops" id="tabLoops"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 2l3 3-3 3"/><path d="M4 11V9a4 4 0 0 1 4-4h12"/><path d="M7 22l-3-3 3-3"/><path d="M20 13v2a4 4 0 0 1-4 4H4"/></svg><span>Loops</span></a>
644
644
  </nav>
645
645
  <div id="scrim" hidden></div>
646
- <dialog id="menu" aria-label="Session actions" role="dialog">
647
- <h3>Session</h3><div id="menuBody"></div><div id="sid" class="sid"></div>
646
+ <dialog id="menu" aria-label="Conversation actions" role="dialog">
647
+ <h3>Conversation</h3><div id="menuBody"></div><div id="sid" class="sid"></div>
648
648
  <menu><button id="menuClose" value="close">close</button></menu>
649
649
  </dialog>
650
650
  <dialog id="pairdlg" aria-labelledby="pairTitle" role="dialog">
@@ -2003,7 +2003,8 @@ function drawLoopRow(slot, j) {
2003
2003
  num(g.findings30d) ? num(g.findings30d) + " finding" + (num(g.findings30d) === 1 ? "" : "s") + " in 30 days" : "",
2004
2004
  num(g.claimed30d) ? num(g.claimed30d) + " claimed" : "",
2005
2005
  num(g.dismissed30d) ? num(g.dismissed30d) + " dismissed" : "",
2006
- num(g.quietStreak) >= 3 ? "quiet ×" + num(g.quietStreak) : "",
2006
+ // A failing loop's empty runs are its errors, which the red line already says.
2007
+ num(g.quietStreak) >= 3 && !j.lastError ? "quiet ×" + num(g.quietStreak) : "",
2007
2008
  ].filter(Boolean).join(" · ") : "";
2008
2009
  if (said) slot.append(textEl("span", "signal", said));
2009
2010
  const told = tuneNote.get(j.id);
@@ -2029,15 +2030,16 @@ function renderSidebar(s) {
2029
2030
  else {
2030
2031
  let html = "";
2031
2032
  for (const j of a.jobs) {
2032
- // The prompt is shown twice — a two-line peek in the summary, the whole of it in the body —
2033
- // rather than once and cut, which is what the 90-character slice was: a preview that hid the
2034
- // rest with no way to see it. Both copies go through safeText; neither is sliced here.
2033
+ // An unnamed loop is told apart by its prompt: a two-line peek in the summary, the whole of it
2034
+ // in the body, rather than once and cut. A named loop's name says what it is, so no peek.
2035
2035
  const prompt = safeText(j.prompt);
2036
+ const named = typeof j.name === "string" && j.name !== "";
2037
+ const fired = [j.lastFiredAt ? "last fired " + safeText(clock(j.lastFiredAt)) : "", j.skippedOverlap ? "skipped overlaps " + num(j.skippedOverlap) : ""].filter(Boolean).join(" · ");
2036
2038
  html += '<details class="card job' + (j.enabled ? "" : " off") + '" data-key="job:' + safeText(j.id) + '">' +
2037
2039
  '<summary><span class="t"><span class="nm"><b>' + safeText(j.label || j.name || j.id) + "</b>" +
2038
2040
  '<span class="m">' + safeText(j.schedule) + (j.stateful ? "" : " · in its conversation") + (j.enabled ? "" : " · disabled") + "</span></span>" +
2039
2041
  '<button data-run="' + safeText(j.id) + '">run</button></span>' +
2040
- '<span class="m peek">' + prompt + "</span>" +
2042
+ (named ? "" : '<span class="m peek">' + prompt + "</span>") +
2041
2043
  // A run pi-loops holds has its own line, with Stop this run, under the card's name.
2042
2044
  runsLine(j) +
2043
2045
  // A one-line hint so a job that is failing says so before it is opened; the whole error is in
@@ -2051,20 +2053,22 @@ function renderSidebar(s) {
2051
2053
  '<div class="body">' +
2052
2054
  '<pre class="full">' + prompt + "</pre>" +
2053
2055
  '<button class="copy" data-copy="' + safeText(j.id) + '">copy</button>' +
2054
- '<div class="m">id ' + safeText(j.id) + " · cwd " + safeText(j.cwd) + (j.lastFiredAt ? " · last fired " + safeText(clock(j.lastFiredAt)) : "") + (j.skippedOverlap ? " · skipped overlaps " + num(j.skippedOverlap) : "") + "</div>" +
2056
+ (fired ? '<div class="m">' + fired + "</div>" : "") +
2055
2057
  // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
2056
2058
  // and it says why nothing is happening rather than leaving you to find out from the silence.
2057
2059
  (j.lastError ? '<div class="m bad">' + safeText(j.lastError) + "</div>" : "") +
2058
2060
  "</div></details>";
2059
2061
  }
2060
2062
  for (const r of a.rules) {
2063
+ // The condition is the rule's name: what it waits for. Cut in the title, whole in the body.
2064
+ const when = str(r.condition);
2065
+ const cut = clipChars(when, 80);
2061
2066
  html += '<details class="card rule' + (r.enabled ? "" : " off") + '" data-key="rule:' + safeText(r.id) + '">' +
2062
- '<summary><span class="t"><b>rule</b><span class="m">' + (r.enabled ? "" : "disabled · ") + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
2063
- '<span class="m peek">when ' + safeText(r.condition) + "</span>" +
2067
+ '<summary><span class="t"><b>' + safeText(cut) + '</b><span class="m">' + (r.enabled ? "" : "disabled · ") + (r.fireOnce ? "once" : "repeat") + "</span></span>" +
2064
2068
  (r.look ? '<span class="m looks" data-rulelook="' + safeText(r.id) + '"></span>' : "") +
2065
2069
  "</summary>" +
2066
2070
  '<div class="body">' +
2067
- '<pre class="full">when ' + safeText(r.condition) + "</pre>" +
2071
+ (cut !== when ? '<pre class="full">when ' + safeText(when) + "</pre>" : "") +
2068
2072
  '<pre class="full">→ ' + safeText(r.action) + "</pre>" +
2069
2073
  "</div></details>";
2070
2074
  }
@@ -2334,7 +2338,8 @@ async function refresh() {
2334
2338
  api("/stats").then((st) => {
2335
2339
  if (!st || st.error) return;
2336
2340
  const pct = st.contextUsage?.percent;
2337
- $("cost").textContent = "$" + (st.cost ?? 0).toFixed(3) + " · " + Math.round((st.tokens?.total ?? 0) / 1000) + "k" + (pct == null ? "" : " · ctx " + Math.round(pct) + "%");
2341
+ // Nothing spent yet: no badge, rather than "$0.000 · 0k · ctx 0%".
2342
+ $("cost").textContent = !st.tokens?.total ? "" : "$" + (st.cost ?? 0).toFixed(3) + " · " + Math.round(st.tokens.total / 1000) + "k" + (pct == null ? "" : " · ctx " + Math.round(pct) + "%");
2338
2343
  });
2339
2344
  for (const ask of state.pendingAsks || []) onAsk(ask);
2340
2345
  }
@@ -2592,6 +2597,13 @@ async function compactNow(instructions) {
2592
2597
 
2593
2598
  /** Why the next reload is happening, when it is happening because somebody asked for it. */
2594
2599
  let swapWhy = "";
2600
+ /**
2601
+ * A swap is waiting for its answer. The new session's events can arrive before the event that moves
2602
+ * this page to its id: reloaded on the first of them, the page read the old id's history, which the
2603
+ * server opens as that conversation in a pi of its own, and reloaded again on every event after,
2604
+ * each time without the reason. Events wait for the answer, which carries the new id.
2605
+ */
2606
+ let swapPending = false;
2595
2607
 
2596
2608
  /**
2597
2609
  * Start a new session, or go back to one.
@@ -2607,7 +2619,18 @@ async function swap(call, body, why) {
2607
2619
  // for a session you had just asked to start.
2608
2620
  swapWhy = why;
2609
2621
  const note = row("notice", "", "switching…");
2622
+ swapPending = true;
2610
2623
  const r = await api(call, body);
2624
+ swapPending = false;
2625
+ const held = waiting.splice(0, waiting.length);
2626
+ if (r.success && !r.data?.cancelled && typeof r.sessionId === "string" && r.sessionId && r.sessionId !== SESSION) {
2627
+ // Moved first, then one reload, for the reason given; what waited is applied after it.
2628
+ moveTo(r.sessionId);
2629
+ waiting.push(...held);
2630
+ await resync(reloadReason());
2631
+ return;
2632
+ }
2633
+ for (const ev of held) applyEvent(ev);
2611
2634
  // Mid-turn the server refuses rather than asking, and its refusal says what to do about it. A
2612
2635
  // dialog here would be a second way to answer the same question, on the one screen where the
2613
2636
  // turn it is about is already visible.
@@ -2639,7 +2662,7 @@ function pageCommand(text) {
2639
2662
  else if (name === "resume") openSessions();
2640
2663
  else if (name === "sessions") listSessionsHere();
2641
2664
  else if (name === "session") row("system", "session", "usage: /session export [path] [--exclude-triggers] | /session import <path>\n/sessions lists this project's sessions; the header shows this one's model, cost and context.");
2642
- else swap("/session/new", {}, "new session — the one you left is under resume");
2665
+ else newHere();
2643
2666
  return true;
2644
2667
  }
2645
2668
 
@@ -2658,7 +2681,8 @@ async function listSessionsHere() {
2658
2681
  }
2659
2682
 
2660
2683
  $("compact").onclick = () => compactNow("");
2661
- $("clear").onclick = () => swap("/session/new", {}, "new session — the one you left is under resume");
2684
+ /** A new conversation in this page's pi: + New in this project, and /clear or /new typed. */
2685
+ function newHere() { swap("/session/new", {}, "new conversation — the one you left is under resume"); }
2662
2686
 
2663
2687
  /** The sessions of this project, newest first — the conversation, not the filename. */
2664
2688
  async function openSessions() {
@@ -2738,7 +2762,6 @@ function qrSvg(q) {
2738
2762
  $("adddev").onclick = async () => {
2739
2763
  const r = await api("/pair", {});
2740
2764
  if (!r.success) { row("err", "error", r.error || "could not make a pairing code"); return; }
2741
- $("pairCode").textContent = r.code;
2742
2765
 
2743
2766
  // The address this browser reached, first: under tailscale serve that is the tailnet name, and
2744
2767
  // it is the only one anybody had to configure. Then whatever else the machine can see — which is
@@ -2746,6 +2769,10 @@ $("adddev").onclick = async () => {
2746
2769
  const thisWindow = location.origin + "/";
2747
2770
  const loopback = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/)/.test(thisWindow);
2748
2771
  const candidates = (loopback ? [] : [{ url: thisWindow, via: "this window" }]).concat(r.addresses || []);
2772
+ // No device but this one can reach the page: a code would be a grant nobody can use, so it is
2773
+ // retired at once and the dialog says only how to make the page reachable.
2774
+ if (candidates.length) $("pairCode").textContent = r.code;
2775
+ else { $("pairCode").textContent = ""; api("/pair", { cancel: true }); }
2749
2776
 
2750
2777
  const pick = $("pairPick");
2751
2778
  pick.innerHTML = "";
@@ -3163,10 +3190,19 @@ async function resync(why) {
3163
3190
  // Whatever happened while we were reading: anything the transcript already covers is dropped by
3164
3191
  // the same rule as always.
3165
3192
  const held = waiting.splice(0, waiting.length);
3166
- for (const ev of held) applyEvent(ev);
3193
+ // An event of an earlier epoch numbered at or before the transcript is the session that was left
3194
+ // (the server's numbering runs on across a swap): drawn, it would reload the page again.
3195
+ for (const ev of held) if (!(ev.seq && ev.epoch && ev.epoch !== epoch && ev.seq <= seen)) applyEvent(ev);
3167
3196
  refresh();
3168
3197
  }
3169
3198
 
3199
+ /** Point the page's routes and address at another conversation; the list marks it at the next poll. */
3200
+ function moveTo(id) {
3201
+ SESSION = id;
3202
+ history.replaceState(null, "", "/c/" + SESSION + "/");
3203
+ listAt = 0;
3204
+ }
3205
+
3170
3206
  /**
3171
3207
  * One event, against the numbering this page is counting on: already covered by the transcript, the
3172
3208
  * next one in sequence, or evidence that something in between never arrived.
@@ -3175,14 +3211,9 @@ function applyEvent(ev) {
3175
3211
  // While a reload runs, every event waits for it, a replayed one too: a reload started by the replay
3176
3212
  // clears the feed, and the replay must not go on drawing into what it cleared (the history it reads
3177
3213
  // has those events already). Applied once the transcript is back.
3178
- if (resyncing) return void waiting.push(ev);
3179
- if (ev.type === "page_moved" && ev.sessionId) {
3180
- // pi is in another conversation now (a new session, a resume): its routes and its address move.
3181
- SESSION = ev.sessionId;
3182
- history.replaceState(null, "", "/c/" + SESSION + "/");
3183
- // And the list marks it at the next poll, not fifteen seconds later.
3184
- listAt = 0;
3185
- }
3214
+ if (resyncing || swapPending) return void waiting.push(ev);
3215
+ // pi is in another conversation now (a new session, a resume).
3216
+ if (ev.type === "page_moved" && ev.sessionId) moveTo(ev.sessionId);
3186
3217
  if (!ev.seq) return handle(ev); // not numbered: nothing to reason about
3187
3218
  if (epoch && ev.epoch && ev.epoch !== epoch) {
3188
3219
  // A different run of the server: its numbers mean nothing next to the ones we were counting.
@@ -3220,7 +3251,7 @@ const convRows = [];
3220
3251
  let focusAt = 0;
3221
3252
  const HELD_LINE = "Your terminal has this conversation. What you send here is answered there.";
3222
3253
  const BESIDE_LINE = "A terminal has this conversation open, and this page's pi has no pi-loops: what you send here is answered here, beside the terminal.";
3223
- const PLACEHOLDER = $("input").placeholder || "Message pi — / for commands, @ for files, Enter sends";
3254
+ const PLACEHOLDER = $("input").placeholder || "Message pi";
3224
3255
  const phone = () => !!(window.matchMedia && window.matchMedia("(max-width:760px)").matches);
3225
3256
  const spanOf = (cls, text) => {
3226
3257
  const el = document.createElement("span");
@@ -3981,7 +4012,7 @@ async function startConversationIn(project) {
3981
4012
  /** + New: in the project the list shows. The open conversation's starts a new session in this page's pi, as it always did. */
3982
4013
  $("newConv").onclick = () => {
3983
4014
  const project = listedProject();
3984
- if (!otherProject(project)) return $("clear").onclick();
4015
+ if (!otherProject(project)) return newHere();
3985
4016
  const note = row("notice", "", "starting a new conversation in " + projectName(project) + "…");
3986
4017
  startConversationIn(project)
3987
4018
  .then((r) => (r.url ? location.assign(r.url) : void (note.textContent = "No new conversation in " + projectName(project) + ": " + r.error)))
@@ -4089,8 +4120,8 @@ function activityLine(i) {
4089
4120
  else bits.push(num(i.findings) + " finding" + (num(i.findings) === 1 ? "" : "s"));
4090
4121
  if (num(i.seconds)) bits.push(num(i.seconds) + "s");
4091
4122
  if (num(i.costUsd)) bits.push("$" + num(i.costUsd).toFixed(3));
4092
- // Only a card this conversation got is in the chat on the screen.
4093
- if (i.carded && i.fromThisConversation === true) bits.push("in chat");
4123
+ // Only a card this conversation got is in the chat on the screen (src/run-card.ts runCardsHere).
4124
+ if (i.inChat === true) bits.push("in chat");
4094
4125
  return { mark: i.stopped ? "■" : i.ok === false ? "✗" : "✓", text: name + " · " + bits.join(" · ") };
4095
4126
  }
4096
4127
 
@@ -4279,7 +4310,7 @@ const WATCH_EXAMPLES = [
4279
4310
  ];
4280
4311
  /** How long the modal waits for a plan or the end of the turn before it says pi did not answer. */
4281
4312
  const WATCH_ANSWER_MS = 90000;
4282
- const WATCH_TIME = /\d\s*(?:am|pm|m|min|minutes?|h|hours?|s|secs?|seconds?|d|days?|分钟|小时|秒|天|点)\b|\b(?:every|each|daily|nightly|hourly|weekly|monthly|morning|evening|night|noon|midnight|tomorrow|tonight|today|at\s+\d|@(?:hourly|daily|weekly|monthly)|in\s+(?:\d|an?\s+(?:hour|minute|day|week))|(?:once|twice)\s+a\s+(?:day|week|month)|mon|tue|wed|thu|fri|sat|sun|(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?|weekdays?|weeknights?|weekends?)|每|天|点|小时|分钟|周|明天|晚上|早上|(?:^|\s)(?:[\d*\/,-]+\s+){4}[\d*\/,-]+(?:\s|$)/i;
4313
+ const WATCH_TIME = /\d\s*(?:am|pm|m|min|minutes?|h|hours?|s|secs?|seconds?|d|days?|分钟|小时|秒|天|点)\b|\b(?:every|each|daily|nightly|hourly|weekly|biweekly|fortnightly|monthly|quarterly|yearly|annually|(?:morning|afternoon|evening|night)s?|noon|midnight|tomorrow|tonight|today|at\s+(?:\d|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|noon|midnight)|@(?:hourly|daily|weekly|monthly)|in\s+(?:\d|an?\s+(?:hour|minute|day|week)|a\s+(?:few|couple\s+of)\s+(?:minutes|hours|days|weeks))|(?:next|this)\s+(?:week|month|quarter|year|weekend|morning|afternoon|evening|monday|tuesday|wednesday|thursday|friday|saturday|sunday)|(?:once|twice)\s+a\s+(?:day|week|month)|(?:mon|tue|wed|thu|fri|sat|sun)s?|(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?|weekdays?|weeknights?|weekends?)\b|每|天|点|小时|分钟|周|明天|今晚|晚上|早上|下午|中午|凌晨|季度|(?:^|\s)(?:[\d*\/,-]+\s+){4}[\d*\/,-]+(?:\s|$)/i;
4283
4314
  const WATCH_WAIT = /\b(?:when|whenever|if|once|until|as soon as|after|appears?|exists?|changes?|fails?|gets?|arrives?|merge[sd]?|finish(?:es|ed)?|lands?)\b|当|如果|一旦|等|出现|存在|变|红|失败|完成|合并/i;
4284
4315
  /** No time word and no wait word: a thing to do now, not a watch. Generous on purpose: a doubt goes to the model. */
4285
4316
  function readsLikeNow(sentence) {
@@ -4644,7 +4675,7 @@ $("watchText").addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.
4644
4675
  */
4645
4676
  checkStream = (s) => {
4646
4677
  // A reload ends by refreshing, and a refresh polls: without this the two call each other.
4647
- if (resyncing || !s || !s.epoch) return;
4678
+ if (resyncing || swapPending || !s || !s.epoch) return;
4648
4679
  if (s.epoch !== epoch) {
4649
4680
  epoch = s.epoch;
4650
4681
  seen = s.seq ?? seen;