@alphacoder-v0/pi-loops 0.18.0 → 0.19.3

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/web.mjs CHANGED
@@ -452,8 +452,7 @@ function nextRuns() {
452
452
  // This machine's file. Leadership is per host, and so is the clock a cron expression is matched
453
453
  // against, so on a shared `$HOME` each machine keeps its own answers rather than overwriting the
454
454
  // other's with times computed in a different timezone.
455
- const safe = HOST.replace(/[^A-Za-z0-9._-]/g, "_");
456
- const doc = readJson(path.join(LOOPS_DIR, `next-runs.${safe}.json`), { next: {} });
455
+ const doc = readJson(path.join(LOOPS_DIR, "next-runs.json"), { next: {} });
457
456
  return doc?.next && typeof doc.next === "object" ? doc.next : {};
458
457
  }
459
458
 
@@ -467,16 +466,13 @@ function automation(cwd) {
467
466
  // The directory is the evidence, not any one file in it: a project with rules and no cron jobs
468
467
  // has no jobs.json at all, and reporting "pi-loops not found" there would be a lie.
469
468
  if (!fs.existsSync(LOOPS_DIR)) return { installed: false, dir: LOOPS_DIR };
469
+ // A plain job runs only in the session that created it; the id in this session's header says
470
+ // whether that is this one, and the panel says "resume it" rather than promising a time.
471
+ const currentSession = sessionFile ? readHead(sessionFile)?.id : undefined;
470
472
  const jobsFile = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] });
471
473
  /**
472
- * This project's, whatever machine they were made on.
473
- *
474
- * The host used to be part of this test, so a job stamped with another hostname vanished from
475
- * the panel — and a hostname changes on its own: a rebuilt container, a machine renamed by
476
- * DHCP, a restored backup. What you got was a job sitting enabled in `jobs.json`, never running,
477
- * invisible in the one place you would look. The terminal always listed it and said whose it
478
- * was; so does this, and the count of everything in other projects goes with it, because a list
479
- * that silently drops things is worse than a longer list.
474
+ * This project's jobs, and the count of everything in other projects goes with it, because a
475
+ * list that silently drops things is worse than a longer list.
480
476
  */
481
477
  const inThisProject = (j) => sameProject(j.cwd, cwd);
482
478
  const allJobs = jobsFile.jobs ?? [];
@@ -492,7 +488,6 @@ function automation(cwd) {
492
488
  lastError: j.lastError,
493
489
  running: !!j.running,
494
490
  // Set only when it is not this machine's: the panel says so, and nothing else has to guess.
495
- otherHost: j.host && j.host !== HOST ? j.host : undefined,
496
491
  // What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
497
492
  // is the position in a numbered list — which this panel does not have, so telling someone to
498
493
  // use one was telling them to go and find a terminal. A name or a full id resolves anywhere.
@@ -502,7 +497,9 @@ function automation(cwd) {
502
497
  // follows. A time that has already passed is a stale answer rather than a next run (the job
503
498
  // fired and no tick has rewritten the file yet), and saying nothing beats saying something
504
499
  // visibly wrong.
505
- next: j.enabled && !(j.host && j.host !== HOST) ? futureOnly(nexts[j.id]) : undefined,
500
+ next: j.enabled && (j.stateful || (currentSession && j.sessionId === currentSession)) ? futureOnly(nexts[j.id]) : undefined,
501
+ // Set for a plain job of a session that is not this one: it is asleep, and this is what wakes it.
502
+ asleep: !j.stateful && !(currentSession && j.sessionId === currentSession) ? "session " + String(j.sessionId || "?").slice(0, 8) + " — resume it to run" : undefined,
506
503
  }));
507
504
  const allRules = readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? [];
508
505
  const rules = allRules
@@ -547,6 +544,8 @@ const PAGE_COMMANDS = [
547
544
  { name: "clear", description: "start a new session — this one stays on disk, /resume brings it back" },
548
545
  { name: "new", description: "start a new session — this one stays on disk, /resume brings it back" },
549
546
  { name: "resume", description: "go back to an earlier session in this project" },
547
+ { name: "sessions", description: "list this project's sessions: id, when, what was first said" },
548
+ { name: "session", description: "export [path] | import <path> — the .pisession archive commands" },
550
549
  { name: "compact", description: "compact the context; anything after it steers the summary" },
551
550
  ];
552
551
 
@@ -1731,7 +1730,7 @@ function liveHostPid() {
1731
1730
  * their automation is dead when it is not.
1732
1731
  */
1733
1732
  function automationToKeepRunning() {
1734
- const ours = (x) => x.enabled && (!x.host || x.host === HOST);
1733
+ const ours = (x) => x.enabled;
1735
1734
  const jobs = (readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs ?? []).filter((j) => ours(j) && j.stateful);
1736
1735
  const rules = (readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? []).filter(ours);
1737
1736
  return jobs.length + rules.length;
@@ -2443,6 +2442,10 @@ const api = (p, b) =>
2443
2442
  .catch((e) => ({ success: false, error: String(e) }));
2444
2443
  const $ = (id) => document.getElementById(id);
2445
2444
  const feed = $("feed"), statusEl = $("status");
2445
+ /** How long a tab stays subscribed after it goes into the background. */
2446
+ const STREAM_PARK_MS = 15000;
2447
+ /** How long a send may take before the page says that it is taking long. */
2448
+ const SEND_WATCHDOG_MS = 6000;
2446
2449
  let state = {}, cwd = "", busy = false, atBottom = true, takesImages = true;
2447
2450
  /** Installed once the stream is up; until then a poll has nothing to compare against. */
2448
2451
  let checkStream;
@@ -3429,9 +3432,9 @@ function renderSidebar(s) {
3429
3432
  '<button data-run="' + safeText(j.id) + '">run</button></div>' +
3430
3433
  '<div class="m">' + safeText(str(j.prompt).slice(0, 90)) + "</div>" +
3431
3434
  '<div class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</div>" +
3435
+ (j.asleep ? '<div class="m" style="color:#c93">' + safeText(j.asleep) + "</div>" : "") +
3432
3436
  // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
3433
3437
  // and it says why nothing is happening rather than leaving you to find out from the silence.
3434
- (j.otherHost ? '<div class="m" style="color:#c93">other host: ' + safeText(j.otherHost) + " — run <b>/cron set " + safeText(j.ref) + " --host here</b></div>" : "") +
3435
3438
  (j.lastError ? '<div class="m" style="color:#c66">' + safeText(str(j.lastError).slice(0, 120)) + "</div>" : "") + "</div>";
3436
3439
  }
3437
3440
  for (const r of a.rules) {
@@ -3788,7 +3791,7 @@ $("input").addEventListener("paste", (e) => {
3788
3791
  $("composer").onsubmit = async (e) => {
3789
3792
  e.preventDefault();
3790
3793
  const input = $("input");
3791
- const text = input.value.trim();
3794
+ let text = input.value.trim();
3792
3795
  if (!text && !images.length) return;
3793
3796
  input.value = ""; hidePop();
3794
3797
  if (text) { sentPrompts.push(text); if (sentPrompts.length > 200) sentPrompts.shift(); }
@@ -3797,6 +3800,9 @@ $("composer").onsubmit = async (e) => {
3797
3800
  // instead of being sent: anything attached stays in the tray, and a session about to be replaced
3798
3801
  // is not given a message it would carry nowhere.
3799
3802
  if (pageCommand(text)) return;
3803
+ // '/session export|import' are the archive commands here. pi's own '/session' is a terminal
3804
+ // command that does not exist over rpc, so the two-word spelling is free to mean the extension's.
3805
+ text = text.replace(/^\/session\s+(export|import)\b/i, (_m, sub) => "/session-" + sub.toLowerCase());
3800
3806
  const payload = { text, images, mode: busy ? "follow_up" : undefined };
3801
3807
  const shown = text + (images.length ? "\n[" + images.length + " image(s)]" : "");
3802
3808
  if (!busy) {
@@ -3806,7 +3812,17 @@ $("composer").onsubmit = async (e) => {
3806
3812
  if (drewLocally.length > 20) drewLocally.shift();
3807
3813
  }
3808
3814
  images = []; drawThumbs();
3809
- const r = await api("/prompt", payload);
3815
+ // A send that neither succeeds nor fails is a send the browser has queued behind its connection
3816
+ // pool — every other tab of this address holding a stream is the usual reason (#14). The fetch is
3817
+ // not given up on; the page says what is happening instead of looking alive.
3818
+ const slow = setTimeout(() => { statusEl.textContent = "still sending — if it never lands, close the other tabs of this address"; }, SEND_WATCHDOG_MS);
3819
+ let r;
3820
+ try {
3821
+ r = await api("/prompt", payload);
3822
+ } finally {
3823
+ clearTimeout(slow);
3824
+ if (statusEl.textContent.startsWith("still sending")) statusEl.textContent = busy ? "working…" : "ready";
3825
+ }
3810
3826
  if (!r.success) row("err", "error", r.error || JSON.stringify(r));
3811
3827
  };
3812
3828
  $("stop").onclick = () => api("/abort", {});
@@ -3918,14 +3934,31 @@ function pageCommand(text) {
3918
3934
  if (!m) return false;
3919
3935
  const name = m[1].toLowerCase();
3920
3936
  const rest = m[2].trim();
3921
- if (name !== "clear" && name !== "new" && name !== "resume" && name !== "compact") return false;
3937
+ if (name === "session" && /^(export|import)\b/i.test(rest)) return false; // an extension command, sent as one
3938
+ if (name !== "clear" && name !== "new" && name !== "resume" && name !== "compact" && name !== "sessions" && name !== "session") return false;
3922
3939
  row("user", "you", text.trim());
3923
3940
  if (name === "compact") compactNow(rest);
3924
3941
  else if (name === "resume") openSessions();
3942
+ else if (name === "sessions") listSessionsHere();
3943
+ 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.");
3925
3944
  else swap("/session/new", {}, "new session — the one you left is under resume");
3926
3945
  return true;
3927
3946
  }
3928
3947
 
3948
+ /* /sessions: one line per session, newest first — short id, when it started, what was first said. No backticks in here: this is inside the page template. */
3949
+ async function listSessionsHere() {
3950
+ const r = await api("/sessions");
3951
+ const list = r.sessions || [];
3952
+ if (!list.length) return row("system", "sessions", "(no sessions for this project yet)");
3953
+ const lines = list.map((s) => {
3954
+ const when = String(s.startedAt || "").slice(0, 16);
3955
+ const said = plain(s.name || s.first || "").replace(/\s+/g, " ");
3956
+ const chars = [...said];
3957
+ return String(s.id).slice(0, 16) + " " + when + (s.current ? " [this one]" : "") + " " + (chars.length > 80 ? chars.slice(0, 80).join("") + "…" : said);
3958
+ });
3959
+ row("system", "sessions", lines.join("\n"));
3960
+ }
3961
+
3929
3962
  $("compact").onclick = () => compactNow("");
3930
3963
  $("clear").onclick = () => swap("/session/new", {}, "new session — the one you left is under resume");
3931
3964
 
@@ -4479,15 +4512,46 @@ function applyEvent(ev) {
4479
4512
  }
4480
4513
  };
4481
4514
 
4482
- const es = new EventSource("/events?token=" + TOKEN);
4483
- es.onmessage = (e) => {
4484
- const ev = JSON.parse(e.data);
4485
- if (resyncing) return void waiting.push(ev); // applied once the transcript is back
4486
- applyEvent(ev);
4515
+ /*
4516
+ * One stream per tab, and only while the tab is looked at.
4517
+ *
4518
+ * A browser gives one address six connections, and every open tab used to hold one of them for
4519
+ * its event stream — so the sixth tab, or a few forgotten behind the first, left nothing for the
4520
+ * request that sends what you typed: the fetch queued, neither succeeding nor failing, the bubble
4521
+ * drawn and the message never sent (#14). A tab in the background does not need to hear events as
4522
+ * they happen: it closes its stream after a moment, and when it is looked at again it opens a new
4523
+ * one and polls, and the seq/epoch check above reloads whatever it missed. The pi process behind
4524
+ * this page is not involved either way; it runs, and its jobs run, whether anyone is listening.
4525
+ */
4526
+ let es;
4527
+ const openStream = () => {
4528
+ if (es) return;
4529
+ es = new EventSource("/events?token=" + TOKEN);
4530
+ es.onmessage = (e) => {
4531
+ const ev = JSON.parse(e.data);
4532
+ if (resyncing) return void waiting.push(ev); // applied once the transcript is back
4533
+ applyEvent(ev);
4534
+ };
4535
+ es.onerror = () => { statusEl.textContent = "reconnecting…"; };
4536
+ es.onopen = () => refresh();
4487
4537
  };
4488
-
4489
- es.onerror = () => { statusEl.textContent = "reconnecting…"; };
4490
- es.onopen = () => refresh();
4538
+ openStream();
4539
+ let parking;
4540
+ document.addEventListener("visibilitychange", () => {
4541
+ clearTimeout(parking);
4542
+ if (document.visibilityState === "hidden") {
4543
+ // Not at once: switching tabs and back should not cost a reconnect and a reload.
4544
+ parking = setTimeout(() => {
4545
+ if (!es || document.visibilityState !== "hidden") return;
4546
+ es.close();
4547
+ es = undefined;
4548
+ statusEl.textContent = "paused in background";
4549
+ }, STREAM_PARK_MS);
4550
+ } else if (!es) {
4551
+ openStream();
4552
+ refresh().catch(() => {});
4553
+ }
4554
+ });
4491
4555
  setInterval(refresh, 8000);
4492
4556
  })();
4493
4557
  </script>`;