@alphacoder-v0/pi-loops 0.20.0 → 0.22.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/CONTRACT.md +139 -0
  3. package/README.md +11 -7
  4. package/README.zh-CN.md +6 -4
  5. package/docs/cli.md +19 -1
  6. package/docs/downstream.md +60 -0
  7. package/docs/goal.md +4 -2
  8. package/docs/loops.md +55 -9
  9. package/docs/recipes.md +94 -18
  10. package/docs/session-archive.md +8 -3
  11. package/docs/web-ui-parity.md +11 -1
  12. package/package.json +5 -2
  13. package/recipes/_tracker/issue-tracker-local.md +3 -1
  14. package/recipes/autoresearch/experiment.md +30 -6
  15. package/recipes/autoresearch/recipe.toml +2 -0
  16. package/recipes/changelog-draft/recipe.toml +4 -0
  17. package/recipes/changelog-draft/release.md +8 -3
  18. package/recipes/ci-sweeper/recipe.toml +4 -0
  19. package/recipes/ci-sweeper/sweep.md +2 -1
  20. package/recipes/daily-digest/digest.md +5 -4
  21. package/recipes/daily-digest/recipe.toml +2 -0
  22. package/recipes/deps-sweeper/README.md +11 -0
  23. package/recipes/deps-sweeper/audit.md +99 -0
  24. package/recipes/deps-sweeper/recipe.toml +17 -0
  25. package/recipes/ecosystem/recipe.toml +3 -0
  26. package/recipes/ecosystem/scan.md +1 -1
  27. package/recipes/issue-loop/implement.md +17 -4
  28. package/recipes/issue-loop/labels.sh +18 -0
  29. package/recipes/issue-loop/recipe.toml +2 -0
  30. package/recipes/issue-loop/triage.md +16 -2
  31. package/recipes/pr-watch/recipe.toml +3 -0
  32. package/skills/pi-loops/SKILL.md +3 -2
  33. package/src/archive.ts +97 -2
  34. package/src/cli.ts +103 -16
  35. package/src/goal.ts +11 -0
  36. package/src/hooks.ts +25 -1
  37. package/src/host-runtime.ts +4 -8
  38. package/src/inbox.ts +118 -55
  39. package/src/job-signal.ts +78 -0
  40. package/src/pi-loops.ts +177 -109
  41. package/src/presence.ts +5 -0
  42. package/src/protocol.ts +45 -2
  43. package/src/recipe.ts +158 -2
  44. package/src/scheduler.ts +6 -2
  45. package/src/store.ts +8 -3
  46. package/src/tools.ts +17 -11
  47. package/src/triggers.ts +8 -3
  48. package/src/web.mjs +73 -7
package/src/recipe.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * to `parseAddArgs` — the same parser, the same refusals — so a recipe can never create a job a
10
10
  * person could not have typed.
11
11
  */
12
- import { execFileSync } from "node:child_process";
12
+ import { execFile, execFileSync } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
14
  import * as os from "node:os";
15
15
  import * as path from "node:path";
@@ -42,6 +42,18 @@ export interface RecipeManifest {
42
42
  summary: string;
43
43
  needsTracker: boolean;
44
44
  levels: AutonomyLevel[];
45
+ /**
46
+ * Where the recipe sits in `/recipe list`: a `starter` reads and files findings and is the
47
+ * one to install first; an `advanced` one writes somewhere — a worktree, a pull request, a
48
+ * tracker — and wants the playbooks read before the level question is answered.
49
+ */
50
+ tier: RecipeTier;
51
+ /** One sentence each, for `/recipe show`: the situations this recipe is for. */
52
+ usefulWhen: string[];
53
+ /** What the project must have for a run to work, checked before the install (`PREFLIGHT_CHECKS`). */
54
+ needs: PreflightCheck[];
55
+ /** The same, for `propose` and `act` — the levels whose runs push, open pull requests, or write. */
56
+ needsPropose: PreflightCheck[];
45
57
  /** A script run once per project, with confirmation, before the jobs exist. */
46
58
  setup?: string;
47
59
  /** Files copied beside the playbooks (templates a playbook tells the run to create from). */
@@ -69,6 +81,18 @@ export interface InstallRecord {
69
81
  source: string;
70
82
  }
71
83
 
84
+ /**
85
+ * The checks a manifest may ask for before the install (`needs`, `needs_propose`). Each is one
86
+ * deterministic look at the project — a command on the PATH, a file, a remote — and none blocks:
87
+ * the point is that "the first sign of credentials that do not work should not be an empty inbox
88
+ * tomorrow morning" (README), so the confirmation says it tonight instead.
89
+ */
90
+ export const PREFLIGHT_CHECKS = ["gh", "git-remote", "ci-workflows", "lockfile", "tracker-github"] as const;
91
+ export type PreflightCheck = (typeof PREFLIGHT_CHECKS)[number];
92
+
93
+ export const RECIPE_TIERS = ["starter", "advanced"] as const;
94
+ export type RecipeTier = (typeof RECIPE_TIERS)[number];
95
+
72
96
  export const MANIFEST_FILE = "recipe.toml";
73
97
  export const RECORD_FILE = ".recipe.json";
74
98
  /** Where a recipe's files land in a project, relative to the project root. */
@@ -157,6 +181,19 @@ export function parseManifest(text: string, opts: { now?: number } = {}): Recipe
157
181
  for (const f of files) if (!safeRelative(f)) throw new Error(`${where}: files entry "${f}" must be inside the recipe directory`);
158
182
  const budget = doc.budget_hint_usd;
159
183
  if (budget !== undefined && (typeof budget !== "number" || budget < 0)) throw new Error(`${where}: budget_hint_usd must be a non-negative number`);
184
+ const tierRaw = str(doc, "tier", where) ?? "advanced";
185
+ if (!(RECIPE_TIERS as readonly string[]).includes(tierRaw)) throw new Error(`${where}: tier must be ${RECIPE_TIERS.join(" or ")} (got "${tierRaw}")`);
186
+ const usefulWhen = strList(doc, "useful_when", where) ?? [];
187
+ const checks = (key: string): PreflightCheck[] => {
188
+ const out: PreflightCheck[] = [];
189
+ for (const c of strList(doc, key, where) ?? []) {
190
+ if (!(PREFLIGHT_CHECKS as readonly string[]).includes(c)) throw new Error(`${where}: ${key} entry "${c}" is not a check (checks are ${PREFLIGHT_CHECKS.join(", ")})`);
191
+ if (!out.includes(c as PreflightCheck)) out.push(c as PreflightCheck);
192
+ }
193
+ return out;
194
+ };
195
+ const needs = checks("needs");
196
+ const needsPropose = checks("needs_propose");
160
197
  const jobsRaw = doc.job;
161
198
  if (!Array.isArray(jobsRaw) || jobsRaw.length === 0) throw new Error(`${where}: at least one [[job]] is required`);
162
199
  const jobs: RecipeJob[] = [];
@@ -190,7 +227,7 @@ export function parseManifest(text: string, opts: { now?: number } = {}): Recipe
190
227
  }
191
228
  jobs.push(job);
192
229
  }
193
- return { name, summary, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, setup, files, budgetHintUsd: budget as number | undefined, jobs };
230
+ return { name, summary, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, tier: tierRaw as RecipeTier, usefulWhen, needs, needsPropose, setup, files, budgetHintUsd: budget as number | undefined, jobs };
194
231
  }
195
232
 
196
233
  /** Relative, inside the directory, and a single file: no `..`, no absolute path, no trailing slash. */
@@ -306,6 +343,125 @@ export function readLevelLine(text: string): AutonomyLevel | undefined {
306
343
  return l && (AUTONOMY_LEVELS as readonly string[]).includes(l) ? (l as AutonomyLevel) : undefined;
307
344
  }
308
345
 
346
+ /**
347
+ * What a playbook forbids, for `/recipe show` and the install confirmation: the non-empty lines
348
+ * under every `## Never…` heading (`## Never`, `## Never bump`), up to the next `## ` heading.
349
+ * The section is the packaged recipes' convention for the safety envelope of a run, and the one
350
+ * place a person can read it before the playbook is installed and followed unattended.
351
+ */
352
+ export function neverSection(text: string): string[] {
353
+ const out: string[] = [];
354
+ let inside = false;
355
+ for (const line of text.split("\n")) {
356
+ if (/^## /.test(line)) {
357
+ inside = /^## Never\b/.test(line);
358
+ continue;
359
+ }
360
+ if (inside && line.trim()) out.push(line.trimEnd());
361
+ }
362
+ return out;
363
+ }
364
+
365
+ /* ------------------------------------------------------------------------------- preflight */
366
+
367
+ /**
368
+ * Which checks apply to an install of `manifest` at `level`: the declared ones, `needs_propose`
369
+ * above `report`, and — for a recipe that reads the tracker — `gh` when the tracker description
370
+ * uses it. That last rule is what keeps a local Markdown tracker out of it: its description has
371
+ * no `gh` in it, so nothing is asked for that the project was never going to use. Returned in
372
+ * `PREFLIGHT_CHECKS` order, each once.
373
+ */
374
+ export function preflightChecks(manifest: RecipeManifest, level: AutonomyLevel, trackerText: string | undefined): PreflightCheck[] {
375
+ const wanted = new Set<PreflightCheck>(manifest.needs);
376
+ if (level !== "report") for (const c of manifest.needsPropose) wanted.add(c);
377
+ if (manifest.needsTracker && trackerText && /\bgh\b/.test(trackerText)) wanted.add("gh");
378
+ return PREFLIGHT_CHECKS.filter((c) => wanted.has(c));
379
+ }
380
+
381
+ export interface PreflightLine {
382
+ check: PreflightCheck;
383
+ /** true = there; false = not there; undefined = could not tell (a timeout, an unreadable file). */
384
+ ok: boolean | undefined;
385
+ /** What was found, and for a miss what it means for the runs. */
386
+ detail: string;
387
+ }
388
+
389
+ const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "Cargo.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "go.sum", "Gemfile.lock", "composer.lock"];
390
+
391
+ /**
392
+ * Runs the checks against `project`. Never throws: a check that cannot run says so in its line.
393
+ * The commands run off the turn (`gh auth status` reaches the network), so a slow one holds the
394
+ * confirmation, not the whole window.
395
+ */
396
+ export async function runPreflight(checks: PreflightCheck[], project: string, opts: { env?: NodeJS.ProcessEnv } = {}): Promise<PreflightLine[]> {
397
+ const env = opts.env ?? process.env;
398
+ const run = (file: string, args: string[]): Promise<{ code: number | "missing" | "timeout"; out: string }> =>
399
+ new Promise((resolve) => {
400
+ execFile(file, args, { cwd: project, env, encoding: "utf8", timeout: 10_000 }, (err: any, stdout, stderr) => {
401
+ if (!err) return resolve({ code: 0, out: String(stdout) });
402
+ if (err.code === "ENOENT") return resolve({ code: "missing", out: "" });
403
+ if (err.killed || err.code === "ETIMEDOUT") return resolve({ code: "timeout", out: "" });
404
+ resolve({ code: typeof err.code === "number" ? err.code : 1, out: `${stdout ?? ""}${stderr ?? ""}` });
405
+ });
406
+ });
407
+ const lines: PreflightLine[] = [];
408
+ for (const check of checks) {
409
+ switch (check) {
410
+ case "gh": {
411
+ const r = await run("gh", ["auth", "status"]);
412
+ if (r.code === 0) lines.push({ check, ok: true, detail: "gh: logged in" });
413
+ else if (r.code === "missing") lines.push({ check, ok: false, detail: "gh: not on the PATH — install the GitHub CLI and run `gh auth login`; runs that use it fail until then" });
414
+ else if (r.code === "timeout") lines.push({ check, ok: undefined, detail: "gh: could not check (`gh auth status` took more than 10s)" });
415
+ else lines.push({ check, ok: false, detail: "gh: not logged in — run `gh auth login`; runs that use it fail until then" });
416
+ break;
417
+ }
418
+ case "git-remote": {
419
+ const r = await run("git", ["remote", "get-url", "origin"]);
420
+ if (r.code === 0) lines.push({ check, ok: true, detail: `git remote: origin is ${r.out.trim().replace(/(:\/\/)[^/@\s]+@/, "$1")}` });
421
+ else if (r.code === "missing") lines.push({ check, ok: undefined, detail: "git remote: could not check (git is not on the PATH)" });
422
+ else if (r.code === "timeout") lines.push({ check, ok: undefined, detail: "git remote: could not check (git took more than 10s)" });
423
+ else lines.push({ check, ok: false, detail: "git remote: no origin — runs that fetch from it, push to it or open pull requests fail" });
424
+ break;
425
+ }
426
+ case "ci-workflows": {
427
+ let names: string[];
428
+ try {
429
+ names = fs.readdirSync(path.join(project, ".github", "workflows")).filter((n) => /\.ya?ml$/.test(n));
430
+ } catch {
431
+ names = [];
432
+ }
433
+ lines.push(names.length ? { check, ok: true, detail: `CI workflows: ${names.length} under .github/workflows` } : { check, ok: false, detail: "CI workflows: none under .github/workflows — runs that read the default branch's checks find no runs" });
434
+ break;
435
+ }
436
+ case "lockfile": {
437
+ const found = LOCKFILES.find((f) => fs.existsSync(path.join(project, f)));
438
+ lines.push(found ? { check, ok: true, detail: `lockfile: ${found}` } : { check, ok: false, detail: "lockfile: none found — the audit and outdated commands need one to name the package manager" });
439
+ break;
440
+ }
441
+ case "tracker-github": {
442
+ let text: string | undefined;
443
+ try {
444
+ text = fs.readFileSync(path.join(project, TRACKER_FILE), "utf8");
445
+ } catch {
446
+ text = undefined;
447
+ }
448
+ if (text === undefined) lines.push({ check, ok: false, detail: `tracker: ${TRACKER_FILE} is missing` });
449
+ else if (/\bgh\b/.test(text)) lines.push({ check, ok: true, detail: "tracker: GitHub" });
450
+ else lines.push({ check, ok: false, detail: "tracker: local Markdown, which has no pull requests — this recipe has nothing to watch" });
451
+ break;
452
+ }
453
+ }
454
+ }
455
+ return lines;
456
+ }
457
+
458
+ /** The lines the confirmation and `show` print: one per check, or one line when every check passed. */
459
+ export function renderPreflight(lines: PreflightLine[]): string[] {
460
+ if (!lines.length) return [];
461
+ if (lines.every((l) => l.ok)) return [" all checks pass"];
462
+ return lines.map((l) => ` ${l.ok ? "✓" : l.ok === false ? "✗" : "?"} ${l.detail}`);
463
+ }
464
+
309
465
  /* ------------------------------------------------------------------------------ installing */
310
466
 
311
467
  export interface InstallPlan {
package/src/scheduler.ts CHANGED
@@ -15,7 +15,7 @@ import { PresenceRegistry, type PresenceEntry, type PresenceKind, type PresenceS
15
15
  import * as path from "node:path";
16
16
  import { Inbox } from "./inbox.ts";
17
17
  import { pidAlive, withFileLock, writeFileAtomic } from "./lock.ts";
18
- import { composeCheckerPrompt, composeLoopPrompt, parseCheckerOutput, parseRunOutput, stripProtocolTags } from "./protocol.ts";
18
+ import { composeCheckerPrompt, composeLoopPrompt, findingKind, parseCheckerOutput, parseRunOutput, stripProtocolTags } from "./protocol.ts";
19
19
  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";
@@ -767,8 +767,12 @@ export class LoopScheduler {
767
767
  /** The body of one run: everything between claiming the job and writing its record. */
768
768
  private async runOnce(job: LoopJob, claimed: LoopJob, runId: string, startedAt: string, ctrl: AbortController, session: SessionSnapshot, now: number, catchingUp: boolean, prior: { dueAt?: string; firedAt?: string }): Promise<void> {
769
769
  const previousState = this.store.readState(job.id);
770
+ // `job` is the pre-claim snapshot, so its `lastFiredAt` is when the previous run started —
771
+ // a reason given before that was already in front of that run.
772
+ const dismissed = this.inbox.feedbackFor(job.id, job.lastFiredAt).map((e) => ({ text: String(e.text), reason: String(e.dismiss_reason ?? "") }));
770
773
  const prompt = composeLoopPrompt(job.prompt, previousState, {
771
774
  name: job.name,
775
+ dismissed,
772
776
  // Zoned, unlike everything shown on a screen: this one goes into a sub-agent's prompt,
773
777
  // where a model is asked to reason about how long ago the last run was and has no
774
778
  // surrounding context to tell it which clock this came off. It used to be a bare local
@@ -834,7 +838,7 @@ export class LoopScheduler {
834
838
  const source = `cron:${job.name ?? job.id.slice(0, "cron-".length + 8)}`;
835
839
  for (const f of reviewed) {
836
840
  try {
837
- await this.inbox.append({ source, text: f.text, runId, jobId: job.id, cwd: job.cwd, verified: f.verified, verifiedReason: f.reason });
841
+ await this.inbox.append({ source, text: f.text, run_id: runId, job_id: job.id, cwd: job.cwd, kind: findingKind(f.text), verified: f.verified, verified_reason: f.reason });
838
842
  findings.push(f.text);
839
843
  } catch (err: any) {
840
844
  this.log(`loop ${job.id}: inbox append failed: ${err?.message ?? err}`);
package/src/store.ts CHANGED
@@ -579,11 +579,16 @@ export class JobStore {
579
579
  }
580
580
  }
581
581
 
582
- /** Resolve "<n>" (1-based position in `jobs`) or an id / unique id prefix / exact name. */
583
- export function resolveJobRef(jobs: LoopJob[], ref: string): LoopJob | undefined {
582
+ /**
583
+ * Resolve an id / unique id prefix / exact name — and, with `ordinals: true`, "<n>", the 1-based
584
+ * position in `jobs`. A number is a position in a list the caller printed, so only a caller that
585
+ * printed one may ask for it (`/cron`, which numbers what it shows); everywhere else, the
586
+ * model-facing tools included, an all-digit ref resolves to nothing.
587
+ */
588
+ export function resolveJobRef(jobs: LoopJob[], ref: string, opts?: { ordinals?: boolean }): LoopJob | undefined {
584
589
  const trimmed = ref.trim();
585
590
  if (!trimmed) return undefined;
586
- if (/^\d+$/.test(trimmed)) return jobs[Number(trimmed) - 1];
591
+ if (/^\d+$/.test(trimmed)) return opts?.ordinals ? jobs[Number(trimmed) - 1] : undefined;
587
592
  const exact = jobs.find((j) => j.id === trimmed || j.name === trimmed);
588
593
  if (exact) return exact;
589
594
  const byPrefix = jobs.filter((j) => j.id.startsWith(trimmed));
package/src/tools.ts CHANGED
@@ -4,6 +4,7 @@
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
6
  */
7
+ import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal } from "./job-signal.ts";
7
8
  import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
8
9
  import * as os from "node:os";
9
10
  import * as path from "node:path";
@@ -14,7 +15,7 @@ import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
14
15
  import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
15
16
  import { asleepNote, ownerSession, runsIn } from "./job-owner.ts";
16
17
  import type { TriggerRuntime } from "./trigger-runtime.ts";
17
- import { withinProject } from "./presence.ts";
18
+ import { sameProject, withinProject } from "./presence.ts";
18
19
  import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
19
20
 
20
21
  export interface ControlPlaneRequest {
@@ -77,16 +78,11 @@ function resolveJobCwd(sessionCwd: string, cwd: string | undefined): string {
77
78
  return cwd ? path.resolve(sessionCwd || process.cwd(), cwd) : path.resolve(sessionCwd);
78
79
  }
79
80
 
80
- /** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
81
- function sameProject(a: string, b: string): boolean {
82
- return withinProject(b, a) || withinProject(a, b);
83
- }
84
-
85
81
  /** A rule by ref, preferring this project's; another project's needs its exact id. */
86
82
  export function resolveRuleRefScoped(rules: DynamicTriggerRule[], ref: string, cwd: string): DynamicTriggerRule | undefined {
87
83
  // Ordinals are what the user sees in `/triggers rules`; a model's list may be a different one,
88
- // so the tools take an id, a unique prefix or a name — never a position.
89
- if (/^\d+$/.test(ref.trim())) return undefined;
84
+ // so the tools take an id, a unique prefix or a name — never a position. That is the resolver's
85
+ // default: no caller here printed a numbered list, so none of them asks for one.
90
86
  const mine = resolveRuleRef(
91
87
  rules.filter((r) => withinProject(cwd, r.cwd) || withinProject(r.cwd, cwd)),
92
88
  ref,
@@ -98,7 +94,6 @@ export function resolveRuleRefScoped(rules: DynamicTriggerRule[], ref: string, c
98
94
 
99
95
  /** A job by ref, preferring this project's; another project's needs its exact id. */
100
96
  export function resolveJobRefScoped(jobs: LoopJob[], ref: string, cwd: string): LoopJob | undefined {
101
- if (/^\d+$/.test(ref.trim())) return undefined;
102
97
  const mine = resolveJobRef(
103
98
  jobs.filter((j) => sameProject(j.cwd, cwd)),
104
99
  ref,
@@ -182,10 +177,13 @@ function nextRunForTool(job: LoopJob, now: number, sessionId: string | undefined
182
177
  }
183
178
 
184
179
  /** Cron jobs, rendered for a tool result. */
185
- function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">): string {
180
+ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session" | "scheduler">): string {
186
181
  if (!jobs.length) return "cron jobs: none";
187
182
  const now = Date.now();
188
183
  const lines = [`cron jobs: ${jobs.length}`];
184
+ // Read once for the listing, as `/cron` does; only loops have a signal to report.
185
+ const runs = jobs.some((j) => j.stateful) ? host.scheduler.store.allRuns() : [];
186
+ const inbox = jobs.some((j) => j.stateful) ? host.scheduler.inbox.list() : [];
189
187
  for (const job of jobs) {
190
188
  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}` : ""}`);
191
189
  const asleep = asleepNote(job, host.session().sessionId);
@@ -194,7 +192,15 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">)
194
192
  if (next) lines.push(` next_run: ${stamp(next)}`);
195
193
  if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
196
194
  if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
195
+ if (job.consecutiveFailures) lines.push(` consecutive_failures: ${job.consecutiveFailures}`);
197
196
  if (job.skippedOverlap) lines.push(` skipped_overlap_count: ${job.skippedOverlap}`);
197
+ if (job.stateful) {
198
+ // The same two facts `/cron` shows, so the model can say "this loop's findings are all
199
+ // dismissed" or "it has found nothing in 12 runs" when the user asks about their automation.
200
+ const s = loopSignal(job.id, runs, inbox, now - SIGNAL_WINDOW_MS);
201
+ if (s.findings) lines.push(` signal_30d: findings=${s.findings} claimed=${s.claimed} dismissed=${s.dismissed}`);
202
+ if (s.quiet >= QUIET_MARK_AFTER) lines.push(` quiet_streak: ${s.quiet} (consecutive runs with no finding)`);
203
+ }
198
204
  }
199
205
  return lines.join("\n");
200
206
  }
@@ -379,7 +385,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
379
385
  const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
380
386
  const nowMs = Date.now();
381
387
  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
+ 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, consecutive_failures: j.consecutiveFailures, 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 })) } };
383
389
  },
384
390
  });
385
391
 
package/src/triggers.ts CHANGED
@@ -443,11 +443,16 @@ export function auditCronFinish(store: TriggerStore, job: { id: string; cwd: str
443
443
  });
444
444
  }
445
445
 
446
- /** Resolve an id, a unique id prefix, or "<n>" in `rules`. */
447
- export function resolveRuleRef(rules: DynamicTriggerRule[], ref: string): DynamicTriggerRule | undefined {
446
+ /**
447
+ * Resolve an id or a unique id prefix — and, with `ordinals: true`, "<n>", the 1-based position in
448
+ * `rules`. A number is a position in a list the caller printed, so only a caller that printed one
449
+ * may ask for it (`/triggers`, which numbers what it shows); everywhere else, the model-facing
450
+ * tools included, an all-digit ref resolves to nothing.
451
+ */
452
+ export function resolveRuleRef(rules: DynamicTriggerRule[], ref: string, opts?: { ordinals?: boolean }): DynamicTriggerRule | undefined {
448
453
  const t = ref.trim();
449
454
  if (!t) return undefined;
450
- if (/^\d+$/.test(t)) return rules[Number(t) - 1];
455
+ if (/^\d+$/.test(t)) return opts?.ordinals ? rules[Number(t) - 1] : undefined;
451
456
  const exact = rules.find((r) => r.id === t);
452
457
  if (exact) return exact;
453
458
  const hits = rules.filter((r) => r.id.startsWith(t));
package/src/web.mjs CHANGED
@@ -512,11 +512,15 @@ function automation(cwd) {
512
512
  const elsewhere = { jobs: allJobs.length - jobs.length, rules: allRules.length - rules.length };
513
513
 
514
514
  let inboxNew = 0;
515
+ let inboxDecisions = 0;
515
516
  try {
516
517
  for (const line of fs.readFileSync(path.join(LOOPS_DIR, "inbox.jsonl"), "utf8").split("\n")) {
517
518
  if (!line.trim()) continue;
518
519
  try {
519
- if ((JSON.parse(line).status ?? "new") === "new") inboxNew++;
520
+ const e = JSON.parse(line);
521
+ if ((e.status ?? "new") !== "new") continue;
522
+ inboxNew++;
523
+ if (e.kind === "checkpoint") inboxDecisions++;
520
524
  } catch {
521
525
  /* skip a torn line */
522
526
  }
@@ -527,7 +531,7 @@ function automation(cwd) {
527
531
 
528
532
  // MCP servers and hooks are deliberately not counted from the config files here: what matters
529
533
  // is which ones actually connected and what they exposed, and that is in the runtime snapshot.
530
- return { installed: true, dir: LOOPS_DIR, jobs, rules, inboxNew, elsewhere };
534
+ return { installed: true, dir: LOOPS_DIR, jobs, rules, inboxNew, inboxDecisions, elsewhere };
531
535
  }
532
536
 
533
537
  /* ------------------------------------------------------------------ what a prompt needs first */
@@ -1359,14 +1363,21 @@ const server = http.createServer(async (req, res) => {
1359
1363
  if (!text && !(images ?? []).length) return void json(res, { success: false, error: "empty prompt" }, 400);
1360
1364
  const guard = commandRefusal(text ?? "");
1361
1365
  if (guard) return void json(res, guard, 400);
1362
- // Submitting while a turn runs queues instead of racing it, as the TUI does.
1363
- const type = mode === "steer" ? "steer" : mode === "follow_up" ? "follow_up" : "prompt";
1366
+ // Submitting while a turn runs queues instead of racing it, as the TUI does. `prompt` is the
1367
+ // one rpc command that carries both halves of that: pi queues it while streaming, and an
1368
+ // extension command — /inbox, /cron, /triggers, /goal — runs immediately, which is what the
1369
+ // terminal does. The `follow_up` and `steer` commands refuse anything naming one of them with
1370
+ // pi's "cannot be queued", which told the person to call prompt(). It is asked for
1371
+ // unconditionally because the page's busy flag is its own guess: a send that lands while pi
1372
+ // is streaming but before the page has noticed is queued rather than refused, and while pi
1373
+ // is idle the option is not read at all.
1374
+ const streamingBehavior = mode === "steer" ? "steer" : "followUp";
1364
1375
  const message = expandMentions(text ?? "", sessionCwd);
1365
1376
  // pi answers a prompt command when the command has *finished*, and a slash command may
1366
1377
  // sit in a dialog for as long as a person takes to read what it shows (a setup script,
1367
1378
  // say). Sixty seconds reported that as "timed out" while the command went on to succeed.
1368
1379
  const patience = message.trimStart().startsWith("/") ? 15 * 60_000 : undefined;
1369
- return void json(res, await rpc({ type, message, ...(images?.length ? { images } : {}) }, patience));
1380
+ return void json(res, await rpc({ type: "prompt", message, streamingBehavior, ...(images?.length ? { images } : {}) }, patience));
1370
1381
  }
1371
1382
  if (url.pathname === "/model" && req.method === "POST") {
1372
1383
  // Recorded below, once pi has accepted it.
@@ -3425,7 +3436,7 @@ function renderSidebar(s) {
3425
3436
  if (!a.installed) { box.innerHTML = '<div class="notice">pi-loops not found in ' + safeText(a.dir || "") + "</div>"; }
3426
3437
  else {
3427
3438
  let html = "";
3428
- html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
3439
+ html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new" + (a.inboxDecisions ? " (" + num(a.inboxDecisions) + " decision" + (a.inboxDecisions === 1 ? "" : "s") + ")" : "") + " · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
3429
3440
  for (const j of a.jobs) {
3430
3441
  html += '<div class="card' + (j.enabled ? "" : " off") + '"><div class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3431
3442
  '<span class="m">' + safeText(j.schedule) + (j.stateful ? " · loop" : "") + "</span>" +
@@ -3793,7 +3804,7 @@ $("composer").onsubmit = async (e) => {
3793
3804
  const input = $("input");
3794
3805
  let text = input.value.trim();
3795
3806
  if (!text && !images.length) return;
3796
- input.value = ""; hidePop();
3807
+ input.value = ""; hidePop(); saveDraft(); autoGrow();
3797
3808
  if (text) { sentPrompts.push(text); if (sentPrompts.length > 200) sentPrompts.shift(); }
3798
3809
  promptIdx = -1;
3799
3810
  // Typed rather than clicked, which is how these get used once they are a habit. They run here
@@ -3853,6 +3864,8 @@ $("undo").onclick = async () => {
3853
3864
  if (r.data?.cancelled) return row("notice", "", "undo was cancelled by an extension");
3854
3865
  // pi hands back the forked message; put it where it came from.
3855
3866
  $("input").value = r.data?.text ?? "";
3867
+ saveDraft();
3868
+ autoGrow();
3856
3869
  // The branch moved, so the conversation is taken again — through resync, which is the one path
3857
3870
  // that also resets what the page is counting against and drains what arrived meanwhile. This used
3858
3871
  // to be a second copy of it, and the copy was missing all three of those.
@@ -4152,6 +4165,51 @@ function remember(key, value) {
4152
4165
  }
4153
4166
  }
4154
4167
 
4168
+ /**
4169
+ * What you have typed and not sent is the only copy of it, and this page reloads for its own
4170
+ * reasons: the header button, the banner that says the page is stale, a phone that discards a
4171
+ * backgrounded tab. None of them should cost a prompt. sessionStorage rather than localStorage,
4172
+ * because two tabs of this address are open at once by design and one tab's typing must not land in
4173
+ * the other's box. As with the theme, the store may refuse — that costs the draft, never the page.
4174
+ */
4175
+ const DRAFT_KEY = "pi-web-draft";
4176
+ function savedDraft() {
4177
+ try {
4178
+ return sessionStorage.getItem(DRAFT_KEY) ?? "";
4179
+ } catch (_) {
4180
+ return "";
4181
+ }
4182
+ }
4183
+ /**
4184
+ * The text in the box, kept as it changes. Only the text: an attached image is one gesture to redo.
4185
+ * Recalling a sent prompt with the up-arrow deliberately does not come through here — it is a look at
4186
+ * something that already exists elsewhere, and storing it would drop the one copy of what does not.
4187
+ */
4188
+ function saveDraft() {
4189
+ try {
4190
+ const text = $("input").value;
4191
+ if (text) sessionStorage.setItem(DRAFT_KEY, text);
4192
+ else sessionStorage.removeItem(DRAFT_KEY);
4193
+ } catch (_) {
4194
+ // Nothing to do and nothing worth saying: the draft simply does not outlive a reload.
4195
+ }
4196
+ }
4197
+
4198
+ /**
4199
+ * The box grows with what is in it, up to the ceiling the stylesheet already puts on it. The order
4200
+ * matters: scrollHeight is measured against the height the box has now, so it is set back to auto
4201
+ * first — assigned straight back, the box can only ever get taller, and a shorter prompt would sit in
4202
+ * a slot as tall as the last one. The cap stays in CSS (max-height:40vh) rather than being said
4203
+ * twice, so past it the box scrolls exactly as it did before.
4204
+ */
4205
+ function autoGrow() {
4206
+ const el = $("input");
4207
+ el.style.height = "auto";
4208
+ // scrollHeight leaves out the border, and these boxes are border-box: without it added back, the
4209
+ // content overflows by exactly the border and a box that fits its text shows a scrollbar anyway.
4210
+ el.style.height = el.scrollHeight + (el.offsetHeight - el.clientHeight) + "px";
4211
+ }
4212
+
4155
4213
  const THEMES = ["system", "light", "dark"];
4156
4214
  function applyTheme(theme) {
4157
4215
  const root = document.documentElement;
@@ -4269,6 +4327,8 @@ function accept(i) {
4269
4327
  el.value = el.value.slice(0, from) + it.value + (it.value.endsWith("/") ? "" : " ") + el.value.slice(el.selectionStart);
4270
4328
  el.focus();
4271
4329
  hidePop();
4330
+ saveDraft();
4331
+ autoGrow();
4272
4332
  }
4273
4333
  /** What has been sent from this composer, for the up-arrow. Not window.history, which it shadowed. */
4274
4334
  const sentPrompts = [];
@@ -4303,12 +4363,14 @@ $("input").onkeydown = (e) => {
4303
4363
  if (promptIdx === -1) promptDraft = el.value;
4304
4364
  promptIdx = Math.min(promptIdx + 1, sentPrompts.length - 1);
4305
4365
  el.value = sentPrompts[sentPrompts.length - 1 - promptIdx];
4366
+ autoGrow();
4306
4367
  e.preventDefault();
4307
4368
  return;
4308
4369
  }
4309
4370
  if (e.key === "ArrowDown" && atEnd && promptIdx >= 0) {
4310
4371
  promptIdx -= 1;
4311
4372
  el.value = promptIdx === -1 ? promptDraft : sentPrompts[sentPrompts.length - 1 - promptIdx];
4373
+ autoGrow();
4312
4374
  e.preventDefault();
4313
4375
  return;
4314
4376
  }
@@ -4334,6 +4396,8 @@ $("input").onkeydown = (e) => {
4334
4396
  };
4335
4397
  $("input").oninput = () => {
4336
4398
  const el = $("input");
4399
+ saveDraft();
4400
+ autoGrow();
4337
4401
  const line = el.value.slice(0, el.selectionStart).split("\n").pop();
4338
4402
  if (/(^\/[\w-]*$)|(@[^\s]*$)/.test(line)) updatePop(); else hidePop();
4339
4403
  };
@@ -4472,6 +4536,8 @@ function applyEvent(ev) {
4472
4536
 
4473
4537
  /* ---------------- start ---------------- */
4474
4538
  (async () => {
4539
+ $("input").value = savedDraft();
4540
+ autoGrow();
4475
4541
  setAllWork(allOpen);
4476
4542
  await refresh();
4477
4543
  const hist = await api("/history");