@alphacoder-v0/pi-loops 0.18.0 → 0.19.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/pi-loops.ts CHANGED
@@ -37,6 +37,7 @@ import { execFile } from "node:child_process";
37
37
  import { createHash } from "node:crypto";
38
38
  import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
39
39
  import { applyJobEdit } from "./job-edit.ts";
40
+ import { asleepNote, runsIn } from "./job-owner.ts";
40
41
  import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, purgeInstall, readRecord, requireRecipeName, resolveRecipeRef, updateFiles } from "./recipe.ts";
41
42
  import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
42
43
  import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
@@ -663,7 +664,7 @@ export default function piLoops(pi: ExtensionAPI) {
663
664
  if (n > 0) parts.push(`Inbox: ${n} new`);
664
665
  // Machine-wide like the inbox count and the running list above it: the clock is one per host,
665
666
  // and a loop failing in another checkout is still this machine's automation going quiet.
666
- const failing = failingSummary(scheduler.store.load(), os.hostname());
667
+ const failing = failingSummary(scheduler.store.load());
667
668
  if (failing) parts.push(failing);
668
669
  const running = [...scheduler.runningLabels(), ...triggers.runningList().map((r) => (r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel))];
669
670
  if (running.length) parts.push(`running: ${running.join(", ")}`);
@@ -730,13 +731,9 @@ export default function piLoops(pi: ExtensionAPI) {
730
731
 
731
732
  function jobLines(jobs: LoopJob[]): string[] {
732
733
  const now = Date.now();
733
- const here = os.hostname();
734
734
  return jobs.map((job, i) => {
735
- // A job stamped with another machine's hostname is filtered out by the scheduler
736
- // (`job.host !== os.hostname()`), so promising a next run would be a lie. This also
737
- // catches a renamed machine or a rebuilt container, which needs no second machine at all.
738
- const elsewhere = job.host && job.host !== here ? `[other host: ${job.host} — /cron set ${i + 1} --host here]` : undefined;
739
- const next = job.enabled && !elsewhere
735
+ // A next run is shown where it will happen: a plain job of a session not open here has none.
736
+ const next = job.enabled && runsIn(job, session.sessionId)
740
737
  ? computeNext(
741
738
  {
742
739
  schedule: job.schedule,
@@ -746,9 +743,10 @@ export default function piLoops(pi: ExtensionAPI) {
746
743
  now,
747
744
  )
748
745
  : undefined;
749
- const dormant = !job.stateful && job.sessionId !== session.sessionId ? `[dormant: session ${(job.sessionId ?? "?").slice(0, 8)} not open here]` : undefined;
746
+ const asleepMark = asleepNote(job, session.sessionId);
747
+ const dormant = asleepMark ? `[${asleepMark}]` : undefined;
750
748
  const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
751
- const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, elsewhere, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
749
+ const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
752
750
  .filter(Boolean)
753
751
  .join(" ");
754
752
  const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
@@ -798,7 +796,7 @@ export default function piLoops(pi: ExtensionAPI) {
798
796
  " --verify: maker/checker — a second adversarial sub-agent reviews findings before they enter /inbox (--checker-model <provider/id> to use another model)",
799
797
  " more flags: --name <n> --cwd <dir> --model <provider/id> --thinking <lvl> --tools a,b --timeout 20m --catchup|--no-catchup (default: loops catch up a missed tick, plain jobs do not)",
800
798
  "/cron enable|disable|remove <n|id|name> /cron run <n|id|name> fire now",
801
- "/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] [--host here|-] change what a job runs with (- = use the session's current)",
799
+ "/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] change what a job runs with (- = use the session's current)",
802
800
  ' also [--prompt "<text>"] [--schedule "<expr>"]: reword a loop or move it to another hour in place — the job keeps its id, and a stateful loop keeps its notes',
803
801
  "/cron state <n|id|name> the loop's notes (state spine)",
804
802
  "/cron runs [n|id|name] recent runs /cron trace [n|id|name] [k] [checker] k-th latest run's transcript (maker, or its checker)",
@@ -1067,7 +1065,6 @@ export default function piLoops(pi: ExtensionAPI) {
1067
1065
  // it can be tested. Anything it refuses throws before the store is touched.
1068
1066
  const applied = applyJobEdit(job, change, {
1069
1067
  now: Date.now(),
1070
- hostName: os.hostname(),
1071
1068
  maxPromptBytes: MAX_PROMPT_BYTES,
1072
1069
  checkName: checkJobName,
1073
1070
  others: scheduler.store.load().filter((j) => j.id !== job.id),
@@ -1094,7 +1091,6 @@ export default function piLoops(pi: ExtensionAPI) {
1094
1091
  ` model: ${updated.model ?? "(the running session's current model)"}`,
1095
1092
  ` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
1096
1093
  ` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : "default"}`,
1097
- ` host: ${updated.host ?? "(any machine)"}`,
1098
1094
  ]);
1099
1095
  return;
1100
1096
  }
@@ -1512,8 +1508,8 @@ export default function piLoops(pi: ExtensionAPI) {
1512
1508
  /* ---------------------------------------------------------- /triggers */
1513
1509
 
1514
1510
  // The menu names every subcommand; the arguments live in TRIGGERS_HELP below, which `/triggers
1515
- // help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel`,
1516
- // `set --host`) was left out of the product.
1511
+ // help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel`)
1512
+ // was left out of the product.
1517
1513
  const TRIGGERS_USAGE = "[status|rules|sources|hooks|enable|disable|remove|set|run|running|audit|abort|panel|help] — /triggers help for the arguments";
1518
1514
 
1519
1515
  const TRIGGERS_HELP = [
@@ -1522,7 +1518,7 @@ export default function piLoops(pi: ExtensionAPI) {
1522
1518
  "/triggers sources MCP push sources, the local crontab, the dynamic checker — and what each has seen (/triggers hooks is the same view)",
1523
1519
  "/triggers enable <n|id> /triggers disable <n|id> also --all (this project) | --all-projects (the machine)",
1524
1520
  "/triggers remove <n|id> also remove --all | remove --all-projects; /new-trigger creates one",
1525
- "/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--host here|-] what the action runs with (- = the session's current; --host - = any machine)",
1521
+ "/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] what the action runs with (- = the session's current)",
1526
1522
  "/triggers run <n|id> check one rule now — dedup, audit, sub-agent and promotion as on a poll",
1527
1523
  "/triggers running actions in flight (dynamic checks and cron runs), and the sub-agent slots in use",
1528
1524
  "/triggers audit [N] [--all] recent decisions, with each run's transcript path (default 10)",
@@ -1540,9 +1536,7 @@ export default function piLoops(pi: ExtensionAPI) {
1540
1536
  const fired = r.firedAt ? `, fired_at=${r.firedAt}` : "";
1541
1537
  const head = numbered ? `${String(i + 1).padStart(2)}. ` : " - ";
1542
1538
  const other = r.createdBy?.sessionId && r.createdBy.sessionId !== session.sessionId ? ` (session ${r.createdBy.sessionId.slice(0, 8)})` : "";
1543
- // Same as jobLines: the runtime skips a rule stamped with another machine's hostname.
1544
- const elsewhere = r.host && r.host !== os.hostname() ? ` [other host: ${r.host}]` : "";
1545
- return `${head}${r.id} [${state}, ${fire}, ${out}${fired}]${elsewhere} when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
1539
+ return `${head}${r.id} [${state}, ${fire}, ${out}${fired}] when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
1546
1540
  });
1547
1541
  }
1548
1542
 
@@ -1671,7 +1665,7 @@ export default function piLoops(pi: ExtensionAPI) {
1671
1665
  case "set": {
1672
1666
  const change = parseSetArgs(rest);
1673
1667
  if (change.name !== undefined) {
1674
- ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking, --timeout and --host", "warning");
1668
+ ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking and --timeout", "warning");
1675
1669
  return;
1676
1670
  }
1677
1671
  const rule = pickRule(change.ref);
@@ -1680,14 +1674,12 @@ export default function piLoops(pi: ExtensionAPI) {
1680
1674
  if (change.model !== undefined) r.model = change.model ?? undefined;
1681
1675
  if (change.thinking !== undefined) r.thinking = change.thinking ?? undefined;
1682
1676
  if (change.timeoutMs !== undefined) r.timeoutMs = change.timeoutMs ?? undefined;
1683
- if (change.host !== undefined) r.host = change.host === "here" ? os.hostname() : undefined;
1684
1677
  });
1685
1678
  if (!updated) return;
1686
1679
  show(ctx, `updated trigger ${updated.id}`, [
1687
1680
  ` model: ${updated.model ?? "(the running session's current model)"}`,
1688
1681
  ` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
1689
1682
  ` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : `default (${Math.round(triggers.runTimeoutMs / 1000)}s)`}`,
1690
- ` host: ${updated.host ?? "(any machine)"}`,
1691
1683
  ]);
1692
1684
  return;
1693
1685
  }
@@ -2471,11 +2463,12 @@ export default function piLoops(pi: ExtensionAPI) {
2471
2463
  pruneLogs(dir);
2472
2464
  // What was loaded is printed on every start; without a line here a session
2473
2465
  // can begin with loops and rules the user has entirely forgotten about.
2474
- const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled);
2466
+ // "Active here" means it: a plain job of another session is asleep, not active.
2467
+ const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled && runsIn(j, session.sessionId));
2475
2468
  const myRules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && r.enabled);
2476
2469
  // What is scheduled says nothing about what is working: a loop that has failed every night
2477
2470
  // since Tuesday is still counted as active, and this line is the only one a user reads.
2478
- const failing = failingSummary(myJobs, os.hostname());
2471
+ const failing = failingSummary(myJobs);
2479
2472
  log.info(`session start: ${myJobs.length} enabled loop(s), ${myRules.length} enabled rule(s)${failing ? `, ${failing}` : ""} in ${session.cwd}`);
2480
2473
  if (ctx.hasUI && (myJobs.length || myRules.length)) {
2481
2474
  const next = myJobs
@@ -2542,14 +2535,14 @@ export default function piLoops(pi: ExtensionAPI) {
2542
2535
  /** Whether this quitting pi should hand the clock to a background host. May throw on a damaged store. */
2543
2536
  function handOffDecision(here: string, wasStarted: boolean): ReturnType<typeof shouldHandOff> | undefined {
2544
2537
  if (!wasStarted) return undefined;
2545
- const enabledRules = triggers.store.load().filter((r) => r.enabled && (!r.host || r.host === here)).length;
2538
+ const enabledRules = triggers.store.load().filter((r) => r.enabled).length;
2546
2539
  return shouldHandOff({
2547
2540
  auto: handsOffOnQuit(),
2548
2541
  presence: scheduler.presenceList(),
2549
2542
  selfPid: process.pid,
2550
2543
  selfInstance: scheduler.self.instance,
2551
2544
  hostName: here,
2552
- enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful && (!j.host || j.host === here)).length,
2545
+ enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful).length,
2553
2546
  enabledRules,
2554
2547
  pushServers: hostPushWork(loadMcpConfigFiles({ dir, projectTrusted: false }).servers, enabledRules),
2555
2548
  hostAlive: !!liveHost(dir),
package/src/protocol.ts CHANGED
@@ -35,9 +35,8 @@ export function composeLoopPrompt(action: string, previousState: string | undefi
35
35
  * The run time, and how to write one.
36
36
  *
37
37
  * The notes this prompt asks for hold watermarks — "everything up to here has been seen" — and
38
- * a watermark is a time the model writes in whatever shape it likes. That was survivable while
39
- * a loop belonged to one machine. It does not: a job with no `host` runs on any machine sharing
40
- * the `$HOME` (scheduler.ts, and `/cron set <ref> --host -` asks for exactly that), so run N can
38
+ * a watermark is a time the model writes in whatever shape it likes. A machine's clock is not a
39
+ * constant either: `TZ` gets set, a laptop travels, a container is rebuilt in UTC — so run N can
41
40
  * write "checked up to 20:00" in Shanghai and run N+1 read it in New York.
42
41
  *
43
42
  * The stamp carries its offset; this asks for the same of anything the model writes back. It
package/src/scheduler.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The heartbeat. One pi process on the machine owns the timer at a time
3
- * (leadership lives in scheduler.<host>.json with a heartbeat); every other process
3
+ * (leadership lives in scheduler.json with a heartbeat); every other process
4
4
  * with this extension stands by and takes over when the owner exits or dies.
5
5
  * Loop jobs are therefore host-agnostic: close the pi that created a loop and
6
6
  * any other open pi keeps it ticking; restart pi and missed ticks are caught up
@@ -20,7 +20,8 @@ import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
20
20
  import { previewRedacted, redact } from "./redact.ts";
21
21
  import { type SubagentSlot, SubagentSlots } from "./slots.ts";
22
22
  import { backoffWaitMs, computeDue, computeNext, formatLocal, formatLocalZoned, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
23
- import { type CheckerRecord, JobStore, type LoopJob, type RunRecord, hostFileTag, newId } from "./store.ts";
23
+ import { type CheckerRecord, JobStore, type LoopJob, type RunRecord, newId } from "./store.ts";
24
+ import { ownerSession, runsIn } from "./job-owner.ts";
24
25
 
25
26
  export const DEFAULT_TICK_MS = 30_000;
26
27
  export const LEADER_STALE_MS = 90_000;
@@ -172,12 +173,12 @@ export class LoopScheduler {
172
173
  this.store = new JobStore(opts.dir);
173
174
  this.inbox = new Inbox(opts.dir);
174
175
  // One leader per host: machines sharing a $HOME must not elect each other.
175
- this.leaderFile = path.join(opts.dir, `scheduler.${hostFileTag()}.json`);
176
+ this.leaderFile = path.join(opts.dir, "scheduler.json");
176
177
  // Per host, like the leader record beside it, and for the same reason. Leadership is per
177
178
  // host; two machines sharing a `$HOME` are both leaders, and a cron expression is matched
178
179
  // against local time — so one file would be two machines writing different answers over each
179
180
  // other, and a panel showing whichever wrote last.
180
- this.nextRunsFile = path.join(opts.dir, `next-runs.${hostFileTag()}.json`);
181
+ this.nextRunsFile = path.join(opts.dir, "next-runs.json");
181
182
  this.leaderLock = path.join(opts.dir, "scheduler.lock");
182
183
  this.getSession = opts.getSession;
183
184
  this.hooks = opts.hooks ?? {};
@@ -276,7 +277,9 @@ export class LoopScheduler {
276
277
  if (this.kind === "interactive" && rec.kind === "host") return true;
277
278
  const age = now - Date.parse(rec.heartbeatAt);
278
279
  if (Number.isNaN(age) || age > LEADER_STALE_MS) return true;
279
- if (rec.host === os.hostname() && !pidAlive(rec.pid)) return true;
280
+ // One machine: a pid that is not alive here is not alive. (A record from a renamed or rebuilt
281
+ // box used to be untouchable until its heartbeat aged out; now it is taken over like any other.)
282
+ if (!pidAlive(rec.pid)) return true;
280
283
  return false;
281
284
  }
282
285
 
@@ -415,11 +418,9 @@ export class LoopScheduler {
415
418
  this.log(`dead-session check failed: ${err?.message ?? err}`);
416
419
  }
417
420
  }
418
- const host = os.hostname();
419
421
  for (const job of jobs) {
420
422
  if (!job.enabled) continue;
421
- if (job.host && job.host !== host) continue; // another machine's job (shared $HOME)
422
- const owned = job.stateful ? leader : !!session.sessionId && job.sessionId === session.sessionId;
423
+ const owned = job.stateful ? leader : runsIn(job, session.sessionId);
423
424
  if (!owned) continue;
424
425
  // One unusable job must never stop the clock for the others: a schedule that cannot be
425
426
  // evaluated disables that job and says why, instead of throwing out of the tick (pi
@@ -461,7 +462,7 @@ export class LoopScheduler {
461
462
  if (!exists) return;
462
463
  // Open somewhere (--no-session, --session-dir, another sessions root)? Then it is alive whatever the disk says.
463
464
  const live = new Set(this.presence.list(this.now()).map((e) => e.sessionId).filter(Boolean));
464
- const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId && (!j.host || j.host === os.hostname()) && !live.has(j.sessionId) && !exists(j.sessionId));
465
+ const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId && !live.has(j.sessionId) && !exists(j.sessionId));
465
466
  for (const job of dead) {
466
467
  await this.store.update(job.id, (j) => {
467
468
  j.enabled = false;
@@ -481,12 +482,8 @@ export class LoopScheduler {
481
482
  */
482
483
  private writeNextRuns(jobs: LoopJob[], now: number): void {
483
484
  const next: Record<string, string> = {};
484
- const host = os.hostname();
485
485
  for (const job of jobs) {
486
486
  if (!job.enabled || !isValidSchedule(job.schedule)) continue;
487
- // The same filter `dispatch` applies: this file is per host, and a next run for a job this
488
- // machine never dispatches (shared $HOME) is a promise nothing here keeps.
489
- if (job.host && job.host !== host) continue;
490
487
  const at = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
491
488
  if (at !== undefined) next[job.id] = stamp(at);
492
489
  }
@@ -717,6 +714,9 @@ export class LoopScheduler {
717
714
  // job the dead-session sweep parked is the marker `gc()` matches on, so running it once left
718
715
  // a job nothing could fire and nothing could collect.
719
716
  if (!job.enabled) return "is disabled (/cron enable it first)";
717
+ // The same reasoning for a job that is enabled but belongs to another session: its prompt is
718
+ // written for that conversation, and running it here would land it in this one.
719
+ if (!runsIn(job, this.getSession().sessionId)) return `belongs to session ${ownerSession(job)}, which is not open here — resume it, or /cron add the job again in this chat`;
720
720
  // No `dueIso`: this run was asked for, so whatever slot the schedule owes is still owed.
721
721
  if (!(await this.injectPlainJob(job, undefined, now))) return "has no chat to run in (the background host has no session)";
722
722
  return true;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What a session listing needs from a session file without reading the whole file: the header,
3
+ * the name someone gave it, the first thing the person said, and how many messages there are.
4
+ *
5
+ * `src/web.mjs` carries its own copy of this (it is one file with no imports, on purpose); a
6
+ * change here is a change there.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import type { DynamicTriggerRule } from "./triggers.ts";
10
+ import type { LoopJob } from "./store.ts";
11
+
12
+ export interface SessionHead {
13
+ id: string;
14
+ cwd: string;
15
+ /** The header's timestamp, as written. */
16
+ startedAt?: string;
17
+ /** `/name`, when someone set one. */
18
+ name?: string;
19
+ /** The first user message, as typed, bounded. */
20
+ first?: string;
21
+ messages: number;
22
+ /** The file was longer than the window read; `messages` is a floor. */
23
+ truncated: boolean;
24
+ }
25
+
26
+ const HEAD_BYTES = 64 * 1024;
27
+
28
+ export function readSessionHead(file: string, bytes: number = HEAD_BYTES): SessionHead | undefined {
29
+ let text: string;
30
+ try {
31
+ const fd = fs.openSync(file, "r");
32
+ try {
33
+ const buf = Buffer.alloc(bytes);
34
+ text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, bytes, 0));
35
+ } finally {
36
+ fs.closeSync(fd);
37
+ }
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ const lines = text.split("\n");
42
+ let header: { type?: unknown; id?: unknown; cwd?: unknown; timestamp?: unknown };
43
+ try {
44
+ header = JSON.parse(lines[0] ?? "");
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ if (header?.type !== "session" || typeof header.id !== "string") return undefined;
49
+ const truncated = text.length >= bytes;
50
+ let name: string | undefined;
51
+ let first: string | undefined;
52
+ let messages = 0;
53
+ // The last line of a partial read is half a line; a file that fitted has no such line.
54
+ for (const line of lines.slice(1, truncated ? -1 : undefined)) {
55
+ if (!line.trim()) continue;
56
+ let entry: any;
57
+ try {
58
+ entry = JSON.parse(line);
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name.slice(0, 200);
63
+ if (entry?.type !== "message") continue;
64
+ messages++;
65
+ if (first || entry.message?.role !== "user") continue;
66
+ const content = entry.message.content;
67
+ const said = typeof content === "string" ? content : (content ?? []).map((c: any) => (typeof c?.text === "string" ? c.text : "")).join(" ");
68
+ if (said.trim()) first = said.trim().slice(0, 200);
69
+ }
70
+ return { id: header.id, cwd: typeof header.cwd === "string" ? header.cwd : "", startedAt: typeof header.timestamp === "string" ? header.timestamp : undefined, name, first, messages, truncated };
71
+ }
72
+
73
+ /**
74
+ * One line of preview: newlines become spaces, and it is cut at `max` *characters* — not bytes,
75
+ * so a CJK character or an emoji at the edge is kept whole — with an ellipsis when something was cut.
76
+ */
77
+ export function previewText(text: string, max = 80): string {
78
+ const flat = text.replace(/\s*\n\s*/g, " ").trim();
79
+ const chars = [...flat];
80
+ return chars.length > max ? `${chars.slice(0, max).join("")}…` : flat;
81
+ }
82
+
83
+ /**
84
+ * The automation badge of a session listing: the plain jobs bound to this session (`sessionId`)
85
+ * and the jobs and rules it created. `2 cron, 1 trigger`; `automation off` when everything
86
+ * it has is disabled; nothing when it has nothing.
87
+ */
88
+ export function automationBadge(sessionId: string, jobs: LoopJob[], rules: DynamicTriggerRule[]): string | undefined {
89
+ const mine = jobs.filter((j) => j.sessionId === sessionId || j.createdBy?.sessionId === sessionId);
90
+ const rulesMine = rules.filter((r) => r.createdBy?.sessionId === sessionId);
91
+ if (!mine.length && !rulesMine.length) return undefined;
92
+ const parts: string[] = [];
93
+ const cron = mine.filter((j) => j.enabled).length;
94
+ const trig = rulesMine.filter((r) => r.enabled).length;
95
+ if (cron) parts.push(`${cron} cron`);
96
+ if (trig) parts.push(`${trig} trigger`);
97
+ return parts.length ? parts.join(", ") : "automation off";
98
+ }
package/src/store.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * state/<job-id>.md loop state — the "state spine", human-readable
8
8
  * inbox.jsonl the triage inbox (see inbox.ts)
9
9
  * runs.jsonl run log, bounded
10
- * scheduler.<host>.json which process on this machine currently owns the timer
10
+ * scheduler.json which process on this machine currently owns the timer
11
11
  * *.lock mkdir locks
12
12
  */
13
13
  import * as fs from "node:fs";
@@ -53,8 +53,6 @@ export interface LoopJob {
53
53
  recipe?: string;
54
54
  createdAt: string;
55
55
  createdBy?: { sessionId?: string; cwd: string };
56
- /** Host the job belongs to (shared $HOME across machines): other hosts ignore it. Missing = any host (pre-0.1.3). */
57
- host?: string;
58
56
  /** non-stateful jobs only: the session that receives the message. */
59
57
  sessionId?: string;
60
58
  lastDueAt?: string;
@@ -219,22 +217,11 @@ export function newId(prefix: string): string {
219
217
  return `${prefix}-${randomBytes(16).toString("hex")}`;
220
218
  }
221
219
 
222
- /**
223
- * A hostname as one filename component: `scheduler.<tag>.json`, `next-runs.<tag>.json`, one
224
- * presence file per pi. Two machines sharing a `$HOME` must not write over each other, and a
225
- * hostname may contain characters a path segment may not.
226
- *
227
- * `src/web.mjs` spells the same expression out again because it has no imports, and `src/presence.ts`
228
- * has its own copy; `test/store.test.ts` pins those spellings to this one.
229
- */
230
- export function hostFileTag(host: string = os.hostname()): string {
231
- return host.replace(/[^A-Za-z0-9._-]/g, "_");
232
- }
233
-
234
220
  /**
235
221
  * On-disk shape of `jobs.json`, bumped whenever a field becomes load-bearing, so an older pi-loops sharing this directory refuses
236
- * the file instead of silently rewriting it without that field. Version 2 adds `host` (which gates
237
- * dispatch), `verify`/`checkerModel`, `timeoutMs` and `consecutiveFailures`.
222
+ * the file instead of silently rewriting it without that field. Version 2 added `verify`/`checkerModel`,
223
+ * `timeoutMs` and `consecutiveFailures` (and a `host` that gated dispatch until 0.19.0: pi-loops runs
224
+ * on one machine, so the field is ignored when read and dropped when written).
238
225
  */
239
226
  export const JOBS_FILE_VERSION = 2;
240
227
 
@@ -317,6 +304,9 @@ export class JobStore {
317
304
  throw new Error(`${this.jobsFile}: version ${parsed.version} was written by a newer pi-loops (this build understands ${JOBS_FILE_VERSION}); upgrade pi-loops`);
318
305
  }
319
306
  if (!Array.isArray(parsed?.jobs)) throw new Error(`${this.jobsFile}: missing "jobs" array`);
307
+ // Until 0.19.0 a job carried the hostname that created it and other machines ignored it.
308
+ // pi-loops runs on one machine: the stamp is dropped here and gone on the next write.
309
+ for (const j of parsed.jobs) delete (j as { host?: unknown }).host;
320
310
  return parsed.jobs;
321
311
  }
322
312
 
package/src/tools.ts CHANGED
@@ -12,6 +12,7 @@ import { previewRedacted } from "./redact.ts";
12
12
  import { computeNext, formatLocal, formatSchedule, parseSchedule, stamp } from "./schedule.ts";
13
13
  import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
14
14
  import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
15
+ import { asleepNote, ownerSession, runsIn } from "./job-owner.ts";
15
16
  import type { TriggerRuntime } from "./trigger-runtime.ts";
16
17
  import { withinProject } from "./presence.ts";
17
18
  import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
@@ -153,7 +154,6 @@ export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session"
153
154
  createdAt: stamp(),
154
155
  // A sub-agent schedules on behalf of the session that runs it.
155
156
  createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd },
156
- host: os.hostname(),
157
157
  sessionId: owningSessionId(input.stateful, host.session().sessionId, scope?.parentSessionId),
158
158
  runCount: 0,
159
159
  skippedOverlap: 0,
@@ -172,18 +172,12 @@ function renderTriggerRulesForTool(rules: ReturnType<TriggerStore["load"]>, host
172
172
  }
173
173
 
174
174
  /**
175
- * Whether this machine will ever dispatch the job. The scheduler filters on `job.host`, so a job
176
- * stamped with another machine's hostname (a shared $HOME, a renamed box, a rebuilt container) is
177
- * that host's to run — and a next run promised for it is a time nothing here will honour.
178
- * docs/loops.md says as much; `/cron` obeyed it and the model-facing list did not.
175
+ * When the job next runs *here*, or undefined: disabled, or a plain job of a session this process
176
+ * does not hold. The list is filtered the way dispatch filters — a next run for a job nothing here
177
+ * will dispatch is a time nothing intends to keep, and a model reads it as a promise.
179
178
  */
180
- function runsHere(job: LoopJob): boolean {
181
- return !job.host || job.host === os.hostname();
182
- }
183
-
184
- /** When the job next runs, or undefined when nothing here will run it. */
185
- function nextRunForTool(job: LoopJob, now: number): number | undefined {
186
- if (!job.enabled || !runsHere(job)) return undefined;
179
+ function nextRunForTool(job: LoopJob, now: number, sessionId: string | undefined): number | undefined {
180
+ if (!job.enabled || !runsIn(job, sessionId)) return undefined;
187
181
  return computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
188
182
  }
189
183
 
@@ -194,8 +188,9 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">)
194
188
  const lines = [`cron jobs: ${jobs.length}`];
195
189
  for (const job of jobs) {
196
190
  lines.push(`- ${job.id}${job.name ? ` "${job.name}"` : ""} [${job.enabled ? "enabled" : "disabled"}${job.stateful ? ", stateful" : ""}${job.verify ? ", verify" : ""}] schedule: ${formatSchedule(job.schedule)} action: ${previewRedacted(job.prompt, 120)}${job.cwd !== host.session().cwd ? ` cwd: ${job.cwd}` : ""}`);
197
- if (!runsHere(job)) lines.push(` other_host: ${job.host} (this machine does not run it)`);
198
- const next = nextRunForTool(job, now);
191
+ const asleep = asleepNote(job, host.session().sessionId);
192
+ if (asleep) lines.push(` ${asleep} (no next run here)`);
193
+ const next = nextRunForTool(job, now, host.session().sessionId);
199
194
  if (next) lines.push(` next_run: ${stamp(next)}`);
200
195
  if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
201
196
  if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
@@ -250,7 +245,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
250
245
  const reason = fromSpec ? "create dynamic trigger from `spec` field" : "create dynamic trigger from `condition` + `action` fields";
251
246
  const denied = await host.confirmTool(ctx, { label: "create dynamic trigger", tool: "new_trigger", reason, preview: `when ${previewRedacted(condition, 80)} -> ${previewRedacted(action, 80)}`, args: params }, scope.hop);
252
247
  if (denied) return deny(denied);
253
- const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking, host: os.hostname() });
248
+ const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking });
254
249
  host.refreshBadge();
255
250
  return {
256
251
  content: [{ type: "text", text: `created dynamic trigger ${rule.id}\ncondition: ${rule.condition}\naction: ${rule.action}\nfire_once: ${rule.fireOnce}\npromote_to_chat: ${rule.promoteToChat}\n(checked every ${host.triggers.pollIntervalSecs}s by a background sub-agent)` }],
@@ -342,7 +337,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
342
337
  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." })),
343
338
  name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
344
339
  cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
345
- catch_up: Type.Optional(Type.Boolean({ description: "Run once at startup if a tick was missed while no pi was open (default: true for stateful jobs, false for plain jobs)." })),
340
+ catch_up: Type.Optional(Type.Boolean({ description: "Run once for a tick that was missed: a loop when a pi next takes the clock, a plain job when the session that created it is next opened (default: true for stateful jobs, false for plain jobs)." })),
346
341
  }),
347
342
  async execute(_id, params) {
348
343
  const schedule = parseSchedule(params.schedule);
@@ -365,7 +360,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
365
360
  // Three lines — what was created, when it runs, what it will do — then where its output goes.
366
361
  return {
367
362
  content: [{ type: "text", text: `created cron job ${job.id}${job.name ? ` "${job.name}"` : ""}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\n${job.stateful ? "[stateful] " : ""}next run ${next ? stamp(next) : "—"}. ${where}` }],
368
- details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: "machine", next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
363
+ details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: job.stateful ? "machine" : "session", owner_session: ownerSession(job), next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
369
364
  };
370
365
  },
371
366
  });
@@ -383,8 +378,8 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
383
378
  const jobs = everywhere ? host.scheduler.store.load() : host.scheduler.store.load().filter((j) => sameProject(j.cwd, listCwd));
384
379
  const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
385
380
  const nowMs = Date.now();
386
- const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs);
387
- return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), created_at: j.createdAt })) } };
381
+ const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs, host.session().sessionId);
382
+ return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), owner_session: ownerSession(j), asleep: !runsIn(j, host.session().sessionId) || undefined, created_at: j.createdAt })) } };
388
383
  },
389
384
  });
390
385
 
@@ -316,8 +316,7 @@ export class TriggerRuntime {
316
316
  * `ownIfUnowned` is passed straight to `ownsRule`: it only adds the rules nobody owns.
317
317
  */
318
318
  private rulesFor(cwd: string | undefined, ownIfUnowned: boolean): { applicable: DynamicTriggerRule[]; owned: DynamicTriggerRule[] } {
319
- const host = this.self?.host ?? os.hostname();
320
- const all = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host));
319
+ const all = this.store.load().filter((r) => r.enabled);
321
320
  const applicable = cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
322
321
  return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
323
322
  }
@@ -333,7 +332,7 @@ export class TriggerRuntime {
333
332
  // backlog, not the leader's, so leadership does not gate them.
334
333
  this.retryPending();
335
334
  const host = this.self?.host ?? os.hostname();
336
- const rules = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host) && this.ownsRule(r, leader) && !this.inBackoff(r, now));
335
+ const rules = this.store.load().filter((r) => r.enabled && this.ownsRule(r, leader) && !this.inBackoff(r, now));
337
336
  if (!rules.length) return;
338
337
  const byCwd = new Map<string, DynamicTriggerRule[]>();
339
338
  for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
@@ -386,8 +385,7 @@ export class TriggerRuntime {
386
385
  if (trigger.source.kind === "mcp" && delivery === "sub_agent" && !trigger.cwd && !this.getSession().cwd) {
387
386
  // No project of our own (the headless host): evaluate the push once per project that has
388
387
  // rules, each in that project, as tick() does — never all rules at once in $HOME.
389
- const host = this.self?.host ?? os.hostname();
390
- const cwds = [...new Set(this.store.load().filter((r) => r.enabled && (!r.host || r.host === host)).map((r) => r.cwd))];
388
+ const cwds = [...new Set(this.store.load().filter((r) => r.enabled).map((r) => r.cwd))];
391
389
  if (!cwds.length) return await this.admit(trigger, delivery); // audited as no_rules
392
390
  const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
393
391
  return outcomes.find(Boolean);
package/src/triggers.ts CHANGED
@@ -35,8 +35,6 @@ export interface DynamicTriggerRule {
35
35
  thinking?: string;
36
36
  /** Per-rule cap on the check/action sub-agent; default `[triggers] run_timeout_secs`. */
37
37
  timeoutMs?: number;
38
- /** Host the rule belongs to (shared $HOME): other hosts ignore it. Missing = any host (pre-0.1.3). */
39
- host?: string;
40
38
  createdBy?: { sessionId?: string };
41
39
  /** Checks that failed in a row (a check covers every rule it evaluated). Cleared by one that completes; drives the poll backoff in the trigger runtime, as `JobStore`'s field of the same name drives the scheduler's. */
42
40
  consecutiveFailures?: number;
@@ -262,6 +260,8 @@ export class TriggerStore {
262
260
  if (!text.trim()) return [];
263
261
  const parsed = JSON.parse(text) as RulesFile;
264
262
  if (!Array.isArray(parsed?.rules)) throw new Error(`${this.rulesFile}: missing "rules" array`);
263
+ // The pre-0.19.0 host stamp, dropped the way jobs.json drops it.
264
+ for (const r of parsed.rules) delete (r as { host?: unknown }).host;
265
265
  return parsed.rules;
266
266
  }
267
267
 
@@ -274,7 +274,7 @@ export class TriggerStore {
274
274
  });
275
275
  }
276
276
 
277
- async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string; host?: string }): Promise<DynamicTriggerRule> {
277
+ async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string }): Promise<DynamicTriggerRule> {
278
278
  const condition = input.condition.trim();
279
279
  const action = input.action.trim();
280
280
  if (!condition || !action) throw new Error("trigger rule needs both a condition and an action");
@@ -289,7 +289,6 @@ export class TriggerStore {
289
289
  cwd: input.cwd,
290
290
  model: input.model,
291
291
  thinking: input.thinking,
292
- host: input.host,
293
292
  createdBy: { sessionId: input.sessionId },
294
293
  };
295
294
  await this.mutate((rules) => rules.push(rule));