@computesdk/pi-loops 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -74,6 +74,8 @@ Screenshots come from `lib/shot.ts`: headless Chrome driven over the Chrome DevT
74
74
 
75
75
  **The pick is yours.** After every judged round you get the round card: when the run has `shots` (either mode) a side-by-side **sheet** of CURRENT and the attempts with their numbers (`.pi/loops/<run>/r<n>.png`, also shown inline in terminals with image support), and a picker — keep the judge's pick, take another attempt instead, show an attempt's diff, or mark an attempt's session to open afterwards (`/loop-open R1-bold`, `/loop-open judge`). The result ends with the **climb**: one line per round, CURRENT, each attempt with its delta, what was kept.
76
76
 
77
+ **What a round spends.** Every nested model call — the attempts, each judge sample, the council — is counted: the result carries `usage`, and the card ends with `Spent 68.8k tok · $0.0000`. A round started from the TUI returns before it finishes, so there is no tool result to carry that; instead Loops writes one `type: "usage"` entry per model to the session (kind `loops`, note naming the run), which is the entry kind Pi sums into its token and cost totals and shows in the footer. So a background round lands in `/cost` and the footer like any other model call, and `/resume` of that session still adds up.
78
+
77
79
  **Steering while it runs.** In the TUI the tool returns as soon as the round has started and the card arrives as a message when the judge is done, so the conversation stays open: `loop_status` (progress), `loop_talk("R1-bold", "…")` (a steering message into that attempt's session), `loop_rule("…")` (told to every running attempt, enforced by the judge from this round on, kept for later rounds), `loop_pick("wild")` (keep that attempt, or `CURRENT`, whatever the judge scores), `loop_stop()` (abort; worktrees stay, `loop({ from })` resumes). After the card, `loop_apply()` takes the run's best into the checkout — the winner's changed tracked files from its commit, as one commit; files it added or deleted are listed, not applied. Headless (`pi -p`) or with `wait: true` the tool blocks until the round is done, as before.
78
80
 
79
81
  Before round 1 of a bench run a **council** (two proposals + a synthesis) writes the rubric the judge judges against; your `rules` become decisive criteria in it.
@@ -14,6 +14,7 @@ import { better as betterOf, git, restoreProtected, sh, diffText, errText, type
14
14
  import { addRule, fromRules, newRubric, parseJson, rubricText, writeRubric } from "../lib/rubric.ts";
15
15
  import { sheet } from "../lib/sheet.ts";
16
16
  import { findChrome } from "../lib/shot.ts";
17
+ import { appendAnswer, appendQuestion, findQuestion, newQuestion, pendingQuestions, questionsOf, questionsText, questionText, type Question, type QuestionOption } from "../lib/questions.ts";
17
18
 
18
19
  const HERE = path.dirname(fileURLToPath(import.meta.url));
19
20
  /** Package root: `agents/`, `prompts/` and `lib/` sit one level up from `extensions/`. */
@@ -21,20 +22,47 @@ const ROOT = path.resolve(HERE, "..");
21
22
  export const STYLES = ["safe", "bold", "wild"];
22
23
  export const DEFAULTS = { attempts: 3, rounds: 1, timeoutMin: 15, tokenBudget: null, repeat: 2, minGain: 0.05, judgeSamples: 3, judgePairs: 2, judgePanel: true, council: true, models: {} };
23
24
  const STATE_DIR = path.join(".pi", "loops");
24
- /** Usage of a whole round: every attempt session, judge sample and council member, so the tool can report it. */
25
- export type Meter = { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number } };
26
- export const newMeter = (): Meter => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
25
+ /** Usage of a whole round, and of each model in it: a round's attempts, judge and council can be different models. */
26
+ export type UsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number } };
27
+ export type Meter = UsageTotals & { byModel: Record<string, UsageTotals> };
28
+ export const emptyTotals = (): UsageTotals => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
29
+ export const newMeter = (): Meter => ({ ...emptyTotals(), byModel: {} });
27
30
  /** Add one message's `usage`. Providers report what they report: anything missing counts as zero, never as NaN. */
28
- export function addUsage(m: Meter | undefined, u: any) {
31
+ function addTo(t: UsageTotals, u: any) {
32
+ t.input += u.input || 0; t.output += u.output || 0; t.cacheRead += u.cacheRead || 0; t.cacheWrite += u.cacheWrite || 0;
33
+ t.totalTokens += u.totalTokens || 0;
34
+ t.cost.input += u.cost?.input || 0; t.cost.output += u.cost?.output || 0;
35
+ t.cost.cacheRead += u.cost?.cacheRead || 0; t.cost.cacheWrite += u.cost?.cacheWrite || 0;
36
+ t.cost.total += typeof u.cost === "number" ? u.cost : u.cost?.total || 0;
37
+ }
38
+ export function addUsage(m: Meter | undefined, u: any, model?: string | null) {
29
39
  if (!m || !u) return;
30
- m.input += u.input || 0; m.output += u.output || 0; m.cacheRead += u.cacheRead || 0; m.cacheWrite += u.cacheWrite || 0;
31
- m.totalTokens += u.totalTokens || 0;
32
- m.cost.input += u.cost?.input || 0; m.cost.output += u.cost?.output || 0;
33
- m.cost.cacheRead += u.cost?.cacheRead || 0; m.cost.cacheWrite += u.cost?.cacheWrite || 0;
34
- m.cost.total += typeof u.cost === "number" ? u.cost : u.cost?.total || 0;
40
+ addTo(m, u);
41
+ if (model) { m.byModel[model] ??= emptyTotals(); addTo(m.byModel[model], u); }
42
+ }
43
+ /** The `usage` a tool result carries: the totals, without the per-model split. */
44
+ export const meterUsage = (m: Meter): UsageTotals => { const { byModel: _byModel, ...totals } = m; return totals; };
45
+
46
+ /**
47
+ * A background round cannot report its spend on a tool result — the card arrives as a message — so it records
48
+ * usage entries on the session instead: Pi's totals and the footer scan entries of `type: "usage"`, and such an
49
+ * entry is model-attributed and never enters the LLM context. One entry per model, since each is billed separately.
50
+ */
51
+ export function recordUsage(sm: SessionManagerLike | undefined, meter: Meter, note: string): string[] {
52
+ const recorded: string[] = [];
53
+ for (const [spec, u] of Object.entries(meter.byModel)) {
54
+ if (!u.totalTokens) continue;
55
+ const cut = spec.indexOf("/");
56
+ const provider = cut > 0 ? spec.slice(0, cut) : spec;
57
+ const model = cut > 0 ? spec.slice(cut + 1) : "unknown";
58
+ if (typeof sm?.appendUsage !== "function") break; // a session manager from an older Pi: nothing to record against
59
+ try { sm.appendUsage("loops", provider, model, u, note); recorded.push(spec); } catch {}
60
+ }
61
+ return recorded;
35
62
  }
63
+
36
64
  /** `3.2k tok · $0.0410`, for the card and the result. */
37
- export const meterText = (m: Meter | undefined) => m && m.totalTokens ? `${Math.round(m.totalTokens / 100) / 10}k tok · $${m.cost.total.toFixed(4)}` : "";
65
+ export const meterText = (m: UsageTotals | undefined) => m && m.totalTokens ? `${Math.round(m.totalTokens / 100) / 10}k tok · $${m.cost.total.toFixed(4)}` : "";
38
66
 
39
67
  /** A `build` that is our own screenshot CLI (written by `routes`): it needs a browser, so check for one up front. */
40
68
  const SELF_SHOT = /[\\/]shot\.ts/;
@@ -87,7 +115,9 @@ export type ModelRegistryLike = {
87
115
  getRegisteredNativeProvider?: (provider: string) => unknown;
88
116
  };
89
117
  /** The session-manager methods Loops calls. */
90
- export type SessionManagerLike = { getBranch?: () => any[]; getSessionDir?: () => string; getSessionFile?: () => string | undefined };
118
+ export type SessionManagerLike = { getBranch?: () => any[]; getSessionDir?: () => string; getSessionFile?: () => string | undefined;
119
+ /** Record spend that came from a session other than this one (`type: "usage"`), which Pi adds to its totals. */
120
+ appendUsage?: (kind: string, provider: string, model: string, usage: any, note?: string) => unknown };
91
121
  /** The subset of Pi's UI the extension calls. */
92
122
  export type Ui = {
93
123
  notify: (message: string, type?: string) => void;
@@ -101,6 +131,8 @@ export type PickCtx = { cwd: string; model?: ModelRef | null; modelRegistry?: Mo
101
131
  /** The Pi command/tool context Loops uses (only these fields are touched). */
102
132
  export type Ctx = PickCtx & {
103
133
  hasUI?: boolean;
134
+ /** `tui` in the terminal, `rpc`/`json` in a programmatic session: only the TUI wants a notify nudge. */
135
+ mode?: string;
104
136
  sessionManager?: SessionManagerLike;
105
137
  switchSession?: (file: string) => Promise<void>;
106
138
  };
@@ -384,6 +416,7 @@ async function oneShot({ role, model, content, cwd, pick, registry, settings, me
384
416
  const spec = model ?? settings.models[role] ?? null;
385
417
  const agent = loadAgent(role, { cwd, agentDir });
386
418
  let session: AgentSession | null = null;
419
+ let pickedModel: Model | null = null; // named in the finally, so the spend can be attributed to it
387
420
  const onAbort = () => session?.abort().catch(() => {});
388
421
  try {
389
422
  // what a one-shot wants: no extensions, no skills, no tools — just the role's system prompt and one answer
@@ -400,7 +433,8 @@ async function oneShot({ role, model, content, cwd, pick, registry, settings, me
400
433
  if (native) services.modelRuntime.registerNativeProvider(native as any);
401
434
  }
402
435
  // creating the session resolves the credential too, so it belongs inside this try: that is where an unusable provider shows up
403
- const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pick(spec ?? agent.model), noTools: "all" });
436
+ pickedModel = pick(spec ?? agent.model);
437
+ const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pickedModel, noTools: "all" });
404
438
  session = track(created);
405
439
  const { text, images } = flattenParts(content);
406
440
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -418,7 +452,7 @@ async function oneShot({ role, model, content, cwd, pick, registry, settings, me
418
452
  signal?.removeEventListener("abort", onAbort);
419
453
  if (session) {
420
454
  // a one-shot is a whole session with real spend: hand it to the round's meter before the session goes away
421
- for (const m of session.messages) if (m.role === "assistant") addUsage(meter, (m as { usage?: unknown }).usage);
455
+ for (const m of session.messages) if (m.role === "assistant") addUsage(meter, (m as { usage?: unknown }).usage, pickedModel ? modelSpec(pickedModel) : null);
422
456
  release(session);
423
457
  }
424
458
  }
@@ -444,7 +478,7 @@ async function runAttempt({ state, style, n, dir, cwd, pick, settings, model, me
444
478
  else if (e.type === "message_end" && e.message?.role === "assistant") {
445
479
  p.tokens += e.message.usage?.totalTokens || 0;
446
480
  p.cost += typeof e.message.usage?.cost === "number" ? e.message.usage.cost : e.message.usage?.cost?.total || 0;
447
- addUsage(meter, e.message.usage);
481
+ addUsage(meter, e.message.usage, modelSpec(picked));
448
482
  const t = (e.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join("").trim();
449
483
  if (t) p.text = t.slice(-200);
450
484
  onProgress(p);
@@ -522,14 +556,31 @@ async function agentJSON({ role, model, prompt, ok, cwd, ctx, settings, signal,
522
556
  }
523
557
 
524
558
  /* ---------- the tool ---------- */
559
+ /** The `loop` result's artifact pointers: a frontend renders a run without guessing where its worktrees keep things. */
560
+ const Artifacts = {
561
+ /** Paths (relative to the repo) that each attempt's build writes — the same paths in every attempt worktree. */
562
+ shots: Type.Optional(Type.Array(Type.String())),
563
+ sheet: Type.Optional(Type.Union([Type.String(), Type.Null()])),
564
+ dir: Type.Optional(Type.Union([Type.String(), Type.Null()])),
565
+ files: Type.Optional(Type.Any()),
566
+ tokens: Type.Optional(Type.Number()),
567
+ cost: Type.Optional(Type.Number()),
568
+ judgeTokens: Type.Optional(Type.Number()),
569
+ judgeCost: Type.Optional(Type.Number()),
570
+ from: Type.Optional(Type.Union([Type.String(), Type.Null()])),
571
+ };
525
572
  const Metric = Type.Object({ key: Type.Optional(Type.String({ description: "JSON key in the bench output; default ms" })), unit: Type.Optional(Type.String()), better: Type.Optional(Type.Union([Type.Literal("lower"), Type.Literal("higher")])) });
526
573
  const RoundOut = Type.Object({ n: Type.Number(), pick: Type.Union([Type.String(), Type.Null()]), why: Type.Union([Type.String(), Type.Null()]), scores: Type.Record(Type.String(), Type.Union([Type.Number(), Type.Null()])), reasons: Type.Record(Type.String(), Type.String()), sheet: Type.Union([Type.String(), Type.Null()]),
527
- attempts: Type.Array(Type.Object({ label: Type.String(), style: Type.String(), status: Type.String(), model: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), session: Type.Union([Type.String(), Type.Null()]), summary: Type.String(), cost: Type.Number() })) });
574
+ from: Artifacts.from, judgeTokens: Artifacts.judgeTokens, judgeCost: Artifacts.judgeCost,
575
+ attempts: Type.Array(Type.Object({ label: Type.String(), style: Type.String(), status: Type.String(), model: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), session: Type.Union([Type.String(), Type.Null()]), summary: Type.String(), cost: Type.Number(), dir: Artifacts.dir, tokens: Artifacts.tokens, bench: Type.Optional(Type.Any()), review: Type.Optional(Type.Any()), vetoed: Type.Optional(Type.Boolean()), restored: Type.Optional(Type.Boolean()) })) });
528
576
  export const LoopResult = Type.Object({
529
577
  run: Type.String(), status: Type.String(), kind: Type.String(), goal: Type.String(), baseCommit: Type.String(),
530
578
  metric: Type.Object({ key: Type.Union([Type.String(), Type.Null()]), unit: Type.Union([Type.String(), Type.Null()]), better: Type.Union([Type.String(), Type.Null()]) }),
531
- best: Type.Object({ label: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), score: Type.Union([Type.Number(), Type.Null()]), branch: Type.Union([Type.String(), Type.Null()]) }),
579
+ best: Type.Object({ label: Type.Union([Type.String(), Type.Null()]), commit: Type.Union([Type.String(), Type.Null()]), score: Type.Union([Type.Number(), Type.Null()]), branch: Type.Union([Type.String(), Type.Null()]), round: Type.Optional(Type.Union([Type.Number(), Type.Null()])), dir: Artifacts.dir, files: Artifacts.files }),
580
+ shots: Artifacts.shots,
581
+ rules: Type.Optional(Type.Array(Type.String())),
532
582
  rounds: Type.Array(RoundOut),
583
+ questions: Type.Optional(Type.Array(Type.Object({ id: Type.String(), kind: Type.String(), title: Type.String(), options: Type.Array(Type.String()) }))),
533
584
  });
534
585
  /** `loop_status`: what a script polling a run gets. */
535
586
  export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String() });
@@ -537,15 +588,37 @@ export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String
537
588
  export const InitResult = Type.Object({ phase: Type.String(), note: Type.Optional(Type.String()), serve: Type.Optional(Type.String()), check: Type.Optional(Type.String()), bench: Type.Optional(Type.String()), protect: Type.Optional(Type.Array(Type.String())) });
538
589
  /** `loop_apply`: what landed in the checkout, and what was deliberately left out. */
539
590
  export const ApplyResult = Type.Object({ run: Type.String(), applied: Type.Array(Type.String()), commit: Type.Union([Type.String(), Type.Null()]), added: Type.Array(Type.String()), removed: Type.Array(Type.String()) });
591
+ /** `loop_ask`: the queued question, and where a frontend reads it from. */
592
+ export const AskResult = Type.Object({ id: Type.String(), question: Type.String(), options: Type.Array(Type.String()), pending: Type.Number() });
593
+
594
+ /** The `loop` result's artifact pointers: a frontend renders a run without guessing where its worktrees keep things. */
595
+ const ArtifactsOld = null;
540
596
 
541
- export function structuredResult(state: Pick<State, "id" | "status" | "kind" | "goal" | "baseCommit" | "metric" | "best">, rounds: Round[]) {
597
+ export function structuredResult(state: Pick<State, "id" | "status" | "kind" | "goal" | "baseCommit" | "metric" | "best"> & Partial<State>, rounds: Round[]) {
542
598
  const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
543
599
  return {
544
- run: state.id, status: state.status, kind: state.kind, goal: state.goal, baseCommit: state.baseCommit,
600
+ run: state.id, status: state.status, kind: state.kind, goal: state.goal, baseCommit: state.baseCommit ?? "",
545
601
  metric: { key: state.metric?.key ?? null, unit: state.metric?.unit ?? null, better: state.metric?.better ?? null },
546
- best: { label: state.best?.label ?? null, commit: state.best?.commit ?? null, score: n(state.best?.score), branch: state.best?.branch ?? null },
547
- rounds: rounds.map((r) => ({ n: r.n, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null, scores: Object.fromEntries(Object.entries(r.judge?.scores ?? {}).map(([k, v]) => [k, n(v)])), reasons: Object.fromEntries(Object.entries(r.judge?.reasons ?? {}).map(([k, v]) => [k, String(v)])), sheet: r.sheet ?? null,
548
- attempts: r.attempts.map((a) => ({ label: a.label, style: a.style, status: a.status, model: a.model ?? null, commit: a.commit ?? null, session: a.session ?? null, summary: a.summary ?? "", cost: n(a.cost) ?? 0 })) })),
602
+ shots: state.shots ?? [],
603
+ rules: state.rules ?? [],
604
+ best: { label: state.best?.label ?? null, commit: state.best?.commit ?? null, score: n(state.best?.score), branch: state.best?.branch ?? null, round: n(state.best?.round), dir: state.best?.dir ?? null, files: state.winner?.files ?? null },
605
+ rounds: rounds.map((r) => ({ n: r.n, from: r.from ?? null, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null, scores: Object.fromEntries(Object.entries(r.judge?.scores ?? {}).map(([k, v]) => [k, n(v)])), reasons: Object.fromEntries(Object.entries(r.judge?.reasons ?? {}).map(([k, v]) => [k, String(v)])), sheet: r.sheet ?? null, judgeTokens: n(r.judgeTokens), judgeCost: n(r.judgeCost),
606
+ attempts: r.attempts.map((a) => ({ label: a.label, style: a.style, status: a.status, model: a.model ?? null, commit: a.commit ?? null, session: a.session ?? null, summary: a.summary ?? "", cost: n(a.cost) ?? 0, tokens: n(a.tokens), dir: a.dir ?? null, ...(a.bench ? { bench: a.bench } : {}), ...(a.review ? { review: a.review } : {}), ...(a.vetoed ? { vetoed: true } : {}), ...(a.restored ? { restored: true } : {}) })) })),
607
+ };
608
+ }
609
+
610
+ /** One judged round as a session entry: history that survives deleting `.pi/loops/<run>.json` and needs no query. */
611
+ export function roundEntry(state: State, r: Round) {
612
+ return {
613
+ run: state.id, n: r.n, status: r.status, kind: state.kind, goal: state.goal, metric: state.metric ?? null,
614
+ from: r.from ?? null, baseCommit: state.baseCommit ?? null, shots: state.shots ?? [], rules: state.rules ?? [],
615
+ pick: r.judge?.pick ?? null, why: r.judge?.why ?? null, human: !!r.judge?.human,
616
+ scores: r.judge?.scores ?? {}, reasons: r.judge?.reasons ?? {}, byModel: r.judge?.byModel ?? null,
617
+ sheet: r.sheet ?? null, judgeSession: r.judgeSession ?? null, judgeTokens: r.judgeTokens ?? null, judgeCost: r.judgeCost ?? null,
618
+ startedAt: r.startedAt ?? null, endedAt: r.endedAt ?? null,
619
+ best: { label: state.best?.label ?? null, commit: state.best?.commit ?? null, score: state.best?.score ?? null, round: state.best?.round ?? null, dir: state.best?.dir ?? null },
620
+ attempts: r.attempts.map((a) => ({ label: a.label, style: a.style, model: a.model ?? null, status: a.status, commit: a.commit ?? null, session: a.session ?? null, dir: a.dir ?? null, summary: a.summary ?? "", tokens: a.tokens ?? null, cost: a.cost ?? null, bench: a.bench ?? null, review: a.review ?? null })),
621
+ at: Date.now(),
549
622
  };
550
623
  }
551
624
  export const LoopParams = Type.Object({
@@ -787,18 +860,19 @@ export function runsText(cwd: string): string {
787
860
  return files.map((f) => { try { const st = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); return `${st.id} — ${st.status} — ${st.goal.slice(0, 60)}\n${climbText(st).split("\n").map((l) => " " + l).join("\n")}`; } catch { return f; } }).join("\n");
788
861
  }
789
862
 
790
- export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs"];
863
+ export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs", "questions", "answer"];
791
864
  /** Bare, or a subcommand word with at most its argument(s) — anything longer is a goal ("rounds of golf should load faster"); bad values error in loopsCommand instead of starting a round. */
792
865
  export function isSubcommand(args?: string | null): boolean {
793
866
  const w = (args ?? "").trim().split(/\s+/).filter(Boolean);
794
867
  if (!w.length) return true;
795
868
  const sub = w[0].toLowerCase();
796
869
  if (!SUBCOMMANDS.includes(sub)) return false;
797
- if (sub === "models" || sub === "runs" || sub === "init") return w.length === 1;
870
+ if (sub === "models" || sub === "runs" || sub === "init" || sub === "questions") return w.length === 1;
798
871
  if (sub === "open") return w.length <= 3;
872
+ if (sub === "answer") return w.length >= 2 && w.length <= 8;
799
873
  return w.length <= 2;
800
874
  }
801
- const LOOPS_USAGE = "/loop — card and picker · /loop <goal> — run a round · /loop init — project setup (serve, check, bench, protect) · /loop models · /loop rounds <n> · /loop budget <tokens, e.g. 800k|1.5m> · /loop timeout <min> · /loop open <R1-safe|judge> [run] · /loop runs";
875
+ const LOOPS_USAGE = "/loop — card and picker · /loop <goal> — run a round · /loop init — project setup (serve, check, bench, protect) · /loop models · /loop rounds <n> · /loop budget <tokens, e.g. 800k|1.5m> · /loop timeout <min> · /loop open <R1-safe|judge> [run] · /loop runs · /loop questions · /loop answer <id> [option]";
802
876
  export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void> {
803
877
  const [first, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
804
878
  const sub = first?.toLowerCase();
@@ -822,6 +896,34 @@ export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void
822
896
  case "open": { if (!rest[0]) { ctx.ui.notify("/loop open <R1-safe|judge> [run id]", "info"); return; } await openSession(ctx, rest[0], rest[1]); return; }
823
897
  case "init": await pi.sendUserMessage?.(initPrompt()); return;
824
898
  case "runs": ctx.ui.notify(runsText(ctx.cwd), "info"); return;
899
+ case "questions": {
900
+ const list = questionsOf(ctx);
901
+ ctx.ui.notify(questionsText(list.slice(0, 12)), "info");
902
+ const open = pendingQuestions(ctx);
903
+ if (open.length) {
904
+ const q = open[0];
905
+ const choice = await ctx.ui.select(questionText(q), q.options.map((o) => o.label));
906
+ if (choice) await loopsCommand(`answer ${q.id} ${choice}`, ctx, pi);
907
+ }
908
+ return;
909
+ }
910
+ case "answer": {
911
+ const open = questionsOf(ctx).filter((x) => !x.answer);
912
+ const q = findQuestion(open.map((x) => x.question), rest[0]);
913
+ if (!q) { ctx.ui.notify(`no open question ${rest[0] ?? ""} — /loop questions`, "error"); return; }
914
+ const want = rest.slice(1).join(" ").trim();
915
+ if (!want) { ctx.ui.notify(`which answer? ${q.options.map((o) => o.label).join(" · ")}`, "error"); return; }
916
+ const num = Number(want);
917
+ const choice = Number.isInteger(num) && num >= 1 && num <= q.options.length ? q.options[num - 1].label
918
+ : q.options.find((o) => o.label.toLowerCase() === want.toLowerCase())?.label
919
+ ?? q.options.find((o) => o.label.toLowerCase().includes(want.toLowerCase()))?.label;
920
+ if (!choice) { ctx.ui.notify(`"${want}" is not one of: ${q.options.map((o) => o.label).join(" · ")}`, "error"); return; }
921
+ appendAnswer(pi, { id: q.id, choice, by: "human", answeredAt: Date.now() });
922
+ ctx.ui.notify(`answered ${q.id}: ${choice}`, "info");
923
+ // the answer has to reach the agent: it arrives as a user message, the same way any other steering does
924
+ await pi.sendUserMessage?.(`Answer to ${q.id}${q.run ? ` (${q.run}${q.round ? ` r${q.round}` : ""})` : ""} — ${q.title}\n${choice}\n\nIf this changes a run, act on it now with loop_pick / loop_rule / loop_stop / loop_apply / loop({ from }).`);
925
+ return;
926
+ }
825
927
  default: ctx.ui.notify(LOOPS_USAGE, "info");
826
928
  }
827
929
  }
@@ -904,8 +1006,9 @@ export default function loopsExtension(pi: Pi): void {
904
1006
  // replaced (`runner.assertActive()`), so the background run holds only values read while it is still alive: the
905
1007
  // model registry object itself, the session paths, and the status sink. `bindUI` rebinds the new session's UI,
906
1008
  // so the old one is not touched after a switch.
907
- const sessionDir = ctx.sessionManager?.getSessionDir?.();
908
- const sessionFile = ctx.sessionManager?.getSessionFile?.() ?? null;
1009
+ const sessionManager = ctx.sessionManager;
1010
+ const sessionDir = sessionManager?.getSessionDir?.();
1011
+ const sessionFile = sessionManager?.getSessionFile?.() ?? null;
909
1012
  const pickCtx: PickCtx = { cwd, model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui };
910
1013
  const pick: ModelPicker = (spec) => pickModel(pickCtx, spec);
911
1014
  const statusSink = ctx.ui;
@@ -923,6 +1026,17 @@ export default function loopsExtension(pi: Pi): void {
923
1026
  if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
924
1027
  return fallback;
925
1028
  };
1029
+ // A queued question never blocks the round: it is an append-only session entry that the TUI, a script and a
1030
+ // frontend all read, and that stops being shown on its own if the round makes it moot.
1031
+ const askHuman = (fields: { kind: Question["kind"]; title: string; body?: string | null; options: QuestionOption[]; expiresInMin?: number }): Question | null => {
1032
+ try {
1033
+ const q = newQuestion({ run: state.id, round: details.round ?? null, kind: fields.kind, title: fields.title, body: fields.body ?? null, options: fields.options, expiresAt: Date.now() + (fields.expiresInMin ?? 360) * 60_000 });
1034
+ appendQuestion(pi, q);
1035
+ // the TUI has no queue to open: nudge once, and /loop questions shows the card
1036
+ try { if (ctx.mode === "tui") ctx.ui.notify(`Question for you (${q.kind})\n${questionText(q)}\n\n/loop questions · /loop answer ${q.id}`, "info"); } catch {}
1037
+ return q;
1038
+ } catch { return null; }
1039
+ };
926
1040
 
927
1041
  if (!state.rubric && settings.council !== false && state.kind === "bench") {
928
1042
  push("council writes the rubric");
@@ -1064,6 +1178,14 @@ export default function loopsExtension(pi: Pi): void {
1064
1178
  }
1065
1179
  }
1066
1180
  saveState(cwd, state);
1181
+ try { pi.appendEntry?.("loops-round", roundEntry(state, round)); } catch {}
1182
+ if (live) {
1183
+ const vetoed = results.filter((a) => a.review && a.review.ok === false);
1184
+ const bestTakeable = takeable.find((a) => a.label === judge.pick) ?? takeable[0];
1185
+ if (!takeable.length) askHuman({ kind: "stall", title: `Round ${n} of ${state.id} kept nothing`, body: String(judge.why ?? "").slice(0, 400), options: [{ label: "Keep climbing", suggested: true }, { label: "Stop the loop" }, { label: "Change the rules first" }] });
1186
+ else if (judge.pick === "CURRENT") askHuman({ kind: "close-call", title: `Round ${n}: nothing beat the current best — take ${bestTakeable.label} anyway?`, body: String(judge.why ?? "").slice(0, 400), options: [{ label: "Keep the current best", suggested: true }, { label: `Take ${bestTakeable.label} anyway` }] });
1187
+ else if (vetoed.length) askHuman({ kind: "veto", title: `Round ${n}: ${vetoed.map((a) => a.label).join(", ")} vetoed by the judge`, body: vetoed.map((a) => `${a.label}${a.summary ? ` — ${a.summary}` : ""}: ${a.review?.note ?? "rule break"}`).join("\n").slice(0, 400), options: [{ label: `Keep ${judge.pick}`, suggested: true }, ...vetoed.map((a) => ({ label: `Take ${a.label} anyway` }))] });
1188
+ }
1067
1189
  done.push(round);
1068
1190
  push(`round ${n}: kept ${judge.pick}`);
1069
1191
  }
@@ -1071,12 +1193,19 @@ export default function loopsExtension(pi: Pi): void {
1071
1193
  state.status = aborted ? "aborted" : "done";
1072
1194
  saveState(cwd, state);
1073
1195
  link();
1196
+ // The winner is a commit in a worktree, not in the checkout: the last question a run asks is whether to land it.
1197
+ if (live && state.status === "done" && state.winner?.files?.changed?.length) {
1198
+ const files: string[] = state.winner.files.changed;
1199
+ askHuman({ kind: "apply", expiresInMin: 24 * 60, title: `Apply ${state.best.label} to the checkout?`, body: `Replaces ${files.slice(0, 6).join(", ")}${files.length > 6 ? ` and ${files.length - 6} more` : ""} (tracked files only, one commit)${state.best.score != null ? ` — ${fmt(state.best.score, state.metric?.unit)} from the start` : ""}.`, options: [{ label: "Apply to the checkout", suggested: true }, { label: "Not now" }] });
1200
+ }
1074
1201
  const text = (aborted ? `Aborted during round ${details.round}; attempts' worktrees are kept under ${path.relative(cwd, runDir)}/. Resume with loop({ from: "${state.id}" }).\n\n` : "") + resultText(state, done, cwd);
1075
1202
  const lastSheet = [...done].reverse().find((r) => r.sheet)?.sheet;
1203
+ // a background round's spend has no tool result to ride on, so it goes into the session's own entries
1204
+ if (live) recordUsage(sessionManager, meter, `loop ${state.id}`);
1076
1205
  const spend = meterText(meter);
1077
1206
  const content: any[] = [{ type: "text", text: spend ? `${text}\nSpent ${spend} on this run's models.` : text }];
1078
1207
  if (lastSheet) { try { content.push({ type: "image", data: fs.readFileSync(path.join(cwd, lastSheet)).toString("base64"), mimeType: "image/png" }); } catch {} }
1079
- return { content, usage: meter.totalTokens ? meter : undefined, structuredContent: structuredResult(state, state.rounds), details: { ...structuredClone(details), phase: aborted ? "aborted" : "done", status: state.status, best: state.best, winner: state.winner, spent: spend || null, rounds: done.map((r) => ({ n: r.n, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null })) } };
1208
+ return { content, usage: meter.totalTokens ? meterUsage(meter) : undefined, structuredContent: structuredResult(state, state.rounds), details: { ...structuredClone(details), phase: aborted ? "aborted" : "done", status: state.status, best: state.best, winner: state.winner, spent: spend || null, rounds: done.map((r) => ({ n: r.n, pick: r.judge?.pick ?? null, why: r.judge?.why ?? null })) } };
1080
1209
  }
1081
1210
 
1082
1211
  pi.registerTool?.({
@@ -1165,6 +1294,19 @@ export default function loopsExtension(pi: Pi): void {
1165
1294
  return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files },
1166
1295
  structuredContent: { run: state.id, applied: r.applied, commit: r.commit ?? null, added: r.files.added, removed: r.files.removed } };
1167
1296
  } });
1297
+ pi.registerTool?.({ name: "loop_ask", exposure: "model-only", executionMode: "sequential",
1298
+ promptSnippet: "ask the person a decision as tap-able options (queued, never blocking)",
1299
+ promptGuidelines: ["Use `loop_ask` instead of asking in prose when the decision is the person's (which direction, whether to keep a version, whether to stop, whether to apply a winner): 2-4 options of a few words each. It does not block you; the answer arrives as a user message starting `Answer to <id>`."],
1300
+ label: "loop_ask", outputSchema: AskResult, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1301
+ description: "Ask the person who owns this repo a decision, as a queued card with tap-able options. Nothing blocks: the question is a session entry that the TUI shows via /loop questions and a frontend shows as a card, and the answer arrives later as a user message. Round outcomes (a veto, a close call, nothing passing, a winner to apply) raise these on their own.",
1302
+ parameters: Type.Object({ question: Type.String({ description: "One line, phrased as the decision" }), options: Type.Array(Type.String(), { description: "2-4 short answers" }), body: Type.Optional(Type.String({ description: "A sentence or two of context" })), suggested: Type.Optional(Type.Number({ description: "1-based index of the option you recommend" })), expiresInMin: Type.Optional(Type.Number({ description: "Hours-ish window before it stops being shown; default a day" })), id: idParam }),
1303
+ async execute(_id, p, _signal, _onUpdate, ctx) {
1304
+ const options: QuestionOption[] = (p.options as string[]).map((s, i) => ({ label: s, ...(p.suggested === i + 1 ? { suggested: true } : {}) }));
1305
+ const q = newQuestion({ run: p.id && p.id !== "last" ? p.id : null, kind: "ask", title: p.question, body: p.body ?? null, options, expiresAt: Date.now() + (p.expiresInMin ?? 24 * 60) * 60_000 });
1306
+ appendQuestion(pi, q);
1307
+ try { if (ctx.mode === "tui") ctx.ui.notify(`Question for you\n${questionText(q)}\n\n/loop answer ${q.id}`, "info"); } catch {}
1308
+ return { content: [{ type: "text", text: `asked ${q.id}: ${q.title} — ${options.map((o) => o.label).join(" / ")}. The answer arrives as a user message.` }], details: { id: q.id }, structuredContent: { id: q.id, question: q.title, options: options.map((o) => o.label), pending: pendingQuestions(ctx).length } };
1309
+ } });
1168
1310
  pi.registerTool?.({ name: "loop_stop", promptSnippet: "stop a running round; its worktrees stay", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, label: "loop_stop", description: "Stop a round running in the background: aborts its attempts and judge. What they committed stays in the worktrees; loop({ from }) resumes the run.", parameters: Type.Object({ id: idParam }),
1169
1311
  async execute(_id, p) { const r = liveRun(p.id); r.ctrl.abort(); await r.promise.catch(() => {}); return said(`${r.id} stopped. Resume with loop({ from: "${r.id}" }).`); } });
1170
1312
  }
@@ -0,0 +1,106 @@
1
+ // Queued questions: a loop asks, the person answers when they get to it.
2
+ //
3
+ // A question is two session entries, `loops-question` and `loops-answer`, so the TUI, a script and any frontend read
4
+ // the same append-only truth and nothing needs a side channel. Nothing here blocks: the loop keeps climbing while a
5
+ // question sits unanswered, and a question that the loop itself made moot (an attempt picked by hand, a new rule) is
6
+ // simply left to expire.
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ export type QuestionOption = { label: string; suggested?: boolean };
10
+
11
+ /** What a loop needs from the person running it. */
12
+ export type Question = {
13
+ id: string;
14
+ /** The run that asked, when it came from one. */
15
+ run?: string | null;
16
+ round?: number | null;
17
+ /** `ask` is raised on purpose; the rest are raised by a round's outcome. */
18
+ kind: "ask" | "close-call" | "veto" | "stall" | "apply";
19
+ title: string;
20
+ body?: string | null;
21
+ options: QuestionOption[];
22
+ createdAt: number;
23
+ /** When it stops being worth showing; the UI hides it, nothing deletes it. */
24
+ expiresAt?: number | null;
25
+ };
26
+
27
+ export type Answer = { id: string; choice: string; by?: string; answeredAt: number };
28
+ export type QuestionWithAnswer = { question: Question; answer: Answer | null };
29
+
30
+ export const QUESTION_ENTRY = "loops-question";
31
+ export const ANSWER_ENTRY = "loops-answer";
32
+
33
+ /** The bits of Pi's extension API this needs, so tests can pass a stub. */
34
+ export type QuestionPi = { appendEntry?: (type: string, data?: unknown) => void };
35
+ export type QuestionCtx = { sessionManager?: { getBranch?: () => unknown[] } };
36
+
37
+ const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : null);
38
+
39
+ export function newQuestion(fields: Omit<Question, "id" | "createdAt"> & { id?: string; createdAt?: number }): Question {
40
+ return {
41
+ id: fields.id ?? `q-${Date.now().toString(36)}${randomBytes(2).toString("hex")}`,
42
+ run: fields.run ?? null,
43
+ round: fields.round ?? null,
44
+ kind: fields.kind,
45
+ title: fields.title,
46
+ body: fields.body ?? null,
47
+ options: fields.options,
48
+ createdAt: fields.createdAt ?? Date.now(),
49
+ expiresAt: fields.expiresAt ?? null,
50
+ };
51
+ }
52
+
53
+ /** Every question the session's branch holds, newest first, with its answer when one was given. */
54
+ export function questionsOf(ctx: QuestionCtx): QuestionWithAnswer[] {
55
+ let entries: any[] = [];
56
+ try { entries = (ctx.sessionManager?.getBranch?.() ?? []) as any[]; } catch { return []; }
57
+ const answers = new Map<string, Answer>();
58
+ const asked: Question[] = [];
59
+ for (const e of entries) {
60
+ if (e?.type !== "custom") continue;
61
+ if (e.customType === ANSWER_ENTRY && str(e.data?.id)) {
62
+ answers.set(String(e.data.id), { id: String(e.data.id), choice: String(e.data?.choice ?? ""), by: str(e.data?.by) ?? undefined, answeredAt: Number(e.data?.answeredAt ?? 0) });
63
+ } else if (e.customType === QUESTION_ENTRY && str(e.data?.id)) {
64
+ const d = e.data as any;
65
+ asked.push({ id: String(d.id), run: str(d.run), round: typeof d.round === "number" ? d.round : null, kind: d.kind ?? "ask", title: String(d.title ?? ""), body: str(d.body), options: Array.isArray(d.options) ? d.options.map((o: any) => (typeof o === "string" ? { label: o } : { label: String(o?.label ?? ""), suggested: !!o?.suggested })).filter((o: QuestionOption) => o.label) : [], createdAt: Number(d.createdAt ?? 0), expiresAt: typeof d.expiresAt === "number" ? d.expiresAt : null });
66
+ }
67
+ }
68
+ // answers are appended after their question, so they are folded in once every entry has been read
69
+ return asked.reverse().map((question) => ({ question, answer: answers.get(question.id) ?? null }));
70
+ }
71
+
72
+ /** Unanswered questions that have not expired, newest first. */
73
+ export function pendingQuestions(ctx: QuestionCtx, now: number = Date.now(), run?: string | null): Question[] {
74
+ return questionsOf(ctx).filter((x) => !x.answer && !(x.question.expiresAt && x.question.expiresAt < now) && (!run || x.question.run === run)).map((x) => x.question);
75
+ }
76
+
77
+ export function appendQuestion(pi: QuestionPi, q: Question): Question {
78
+ try { pi.appendEntry?.(QUESTION_ENTRY, q); } catch {}
79
+ return q;
80
+ }
81
+
82
+ export function appendAnswer(pi: QuestionPi, answer: Answer): Answer {
83
+ try { pi.appendEntry?.(ANSWER_ENTRY, answer); } catch {}
84
+ return answer;
85
+ }
86
+
87
+ /** `q-abc123` as a whole, or the shortest unique tail a person would type (`abc123`, `q3`). */
88
+ export function findQuestion(list: Question[], wanted: unknown): Question | null {
89
+ const want = String(wanted ?? "").trim().toLowerCase();
90
+ if (!want) return null;
91
+ return list.find((q) => q.id.toLowerCase() === want) ?? list.find((q) => q.id.toLowerCase().endsWith(want.replace(/^q-?/, ""))) ?? null;
92
+ }
93
+
94
+ /** One question as the card the TUI and the UI both draw. `picked` marks the answer in the list. */
95
+ export function questionText(q: Question, picked?: string | null): string {
96
+ const opts = q.options.map((o, i) => ` ${picked && picked === o.label ? "✓" : String(i + 1) + "."} ${o.label}${o.suggested ? " (suggested)" : ""}`).join("\n");
97
+ return [q.kind === "ask" ? q.title : `${q.title}`, q.body ?? "", opts].filter(Boolean).join("\n");
98
+ }
99
+
100
+ export function questionsText(list: QuestionWithAnswer[]): string {
101
+ if (!list.length) return "no questions — a loop raises one when it needs a decision from you";
102
+ return list.map(({ question: q, answer }) => {
103
+ const head = `${q.id}${q.run ? ` · ${q.run}${q.round ? ` r${q.round}` : ""}` : ""} · ${q.kind}${answer ? ` · answered: ${answer.choice}` : ""}`;
104
+ return `${head}\n${questionText(q, answer?.choice ?? null)}`;
105
+ }).join("\n\n");
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@computesdk/pi-loops",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Hill-climbing loops as Pi units: one `loop` tool, style agents, forked attempt sessions in git worktrees, a judge keeps the best.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "scripts": {
51
51
  "check": "tsc --noEmit",
52
- "test": "tsc --noEmit && node test/rubric.ts && node test/judge.ts && node test/measure.ts && node test/shot.ts && node test/loop.ts"
52
+ "test": "tsc --noEmit && node test/rubric.ts && node test/judge.ts && node test/measure.ts && node test/shot.ts && node test/questions.ts && node test/loop.ts"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@earendil-works/pi-coding-agent": "*",