@computesdk/pi-loops 1.0.4 → 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
  };
@@ -123,6 +155,8 @@ export type ToolDef<P = any> = {
123
155
  outputSchema?: any;
124
156
  /** One line for the default system prompt's "Available tools" section: without it Pi omits the tool there. */
125
157
  promptSnippet?: string;
158
+ /** Rules for the Guidelines section, shown only while this tool is active. Workflow belongs here, not in the snippet. */
159
+ promptGuidelines?: string[];
126
160
  /** `model-only` for the tools that ask the user or orchestrate: never callable from scripts. */
127
161
  exposure?: "direct" | "model-only" | "codemode" | "deferred" | "hidden";
128
162
  /** "sequential" for tools that share in-memory state, so one message's calls cannot interleave. */
@@ -133,6 +167,8 @@ export type ToolDef<P = any> = {
133
167
  renderCall?: (args: any, theme: any) => any;
134
168
  renderResult?: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) => any;
135
169
  };
170
+ /** Resolve a model spec against the registry captured while the ctx was alive. */
171
+ export type ModelPicker = (spec?: string | null) => Model;
136
172
  /** A candidate for the judge: a checked-out directory at a commit. */
137
173
  export type Candidate = { label: string; dir: string; commit: string; fresh?: boolean };
138
174
  /** One round of a run. */
@@ -366,20 +402,21 @@ export async function stopAll() { shuttingDown = true; const all = [...active];
366
402
  * *extension* adds (rather than one built into Pi) is unknown inside it and its credential cannot be resolved. Say that
367
403
  * instead of pi's "No API key found", which points at /login and sends people the wrong way.
368
404
  */
369
- function explainAuth(e: unknown, role: string, spec: string | null, ctx: Ctx): unknown {
405
+ function explainAuth(e: unknown, role: string, spec: string | null, registry?: ModelRegistryLike): unknown {
370
406
  const provider = errText(e).match(/No API key found for ([\w.-]+)/)?.[1]?.replace(/\.+$/, ""); // pi ends the sentence with a period
371
407
  if (!provider) return e;
372
408
  // the host can see a model on that provider (so this session is missing something the host has), not a bare missing login
373
- const hostKnows = (ctx.modelRegistry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
409
+ const hostKnows = (registry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
374
410
  if (!hostKnows) return e;
375
411
  return new Error(`the ${role} cannot run on ${spec ?? provider}: "${provider}" is a Pi extension's provider, and a one-shot ${role} session loads no extensions, so it has no credentials there — give the ${role} a provider built into Pi (attempts can still use ${provider}).`, { cause: e });
376
412
  }
377
413
 
378
- async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal }: { role: string; model?: string | null; content: any; cwd: string; ctx: Ctx; settings: LoopSettings; meter?: Meter; signal?: AbortSignal }) {
414
+ async function oneShot({ role, model, content, cwd, pick, registry, settings, meter, signal }: { role: string; model?: string | null; content: any; cwd: string; pick: ModelPicker; registry?: ModelRegistryLike; settings: LoopSettings; meter?: Meter; signal?: AbortSignal }) {
379
415
  const agentDir = getAgentDir();
380
416
  const spec = model ?? settings.models[role] ?? null;
381
417
  const agent = loadAgent(role, { cwd, agentDir });
382
418
  let session: AgentSession | null = null;
419
+ let pickedModel: Model | null = null; // named in the finally, so the spend can be attributed to it
383
420
  const onAbort = () => session?.abort().catch(() => {});
384
421
  try {
385
422
  // what a one-shot wants: no extensions, no skills, no tools — just the role's system prompt and one answer
@@ -388,15 +425,16 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
388
425
  // "no extensions" would also drop the *providers* extensions add (anything not built into Pi), and an unregistered
389
426
  // provider has no credentials. Copy the host's registrations over: config only, no extension code runs, still no tools.
390
427
  const known = new Set(services.modelRuntime.getRegisteredProviderIds());
391
- for (const id of ctx.modelRegistry?.getRegisteredProviderIds?.() ?? []) {
428
+ for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
392
429
  if (known.has(id)) continue;
393
- const config = ctx.modelRegistry?.getRegisteredProviderConfig?.(id);
430
+ const config = registry?.getRegisteredProviderConfig?.(id);
394
431
  if (config) { services.modelRuntime.registerProvider(id, config as any); continue; }
395
- const native = ctx.modelRegistry?.getRegisteredNativeProvider?.(id); // a provider built with createProvider, not config
432
+ const native = registry?.getRegisteredNativeProvider?.(id); // a provider built with createProvider, not config
396
433
  if (native) services.modelRuntime.registerNativeProvider(native as any);
397
434
  }
398
435
  // creating the session resolves the credential too, so it belongs inside this try: that is where an unusable provider shows up
399
- const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pickModel(ctx, 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" });
400
438
  session = track(created);
401
439
  const { text, images } = flattenParts(content);
402
440
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -409,26 +447,26 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
409
447
  if (last?.stopReason === "error") throw new Error(last.errorMessage || `${role} errored`);
410
448
  }
411
449
  return parseJson(session.getLastAssistantText());
412
- } catch (e) { throw explainAuth(e, role, spec, ctx); }
450
+ } catch (e) { throw explainAuth(e, role, spec, registry); }
413
451
  finally {
414
452
  signal?.removeEventListener("abort", onAbort);
415
453
  if (session) {
416
454
  // a one-shot is a whole session with real spend: hand it to the round's meter before the session goes away
417
- 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);
418
456
  release(session);
419
457
  }
420
458
  }
421
459
  }
422
460
 
423
461
  /* ---------- an attempt: forked session + worktree + style agent ---------- */
424
- async function runAttempt({ state, style, n, dir, cwd, ctx, settings, model, meter, sessionDir, src, signal, onProgress, live }: { state: State; style: string; n: number; dir: string; cwd: string; ctx: PickCtx; settings: LoopSettings; model?: string | null; meter?: Meter; sessionDir?: string; src?: string | null; signal?: AbortSignal; onProgress: (progress: any) => void; live?: any }): Promise<Attempt> {
462
+ async function runAttempt({ state, style, n, dir, cwd, pick, settings, model, meter, sessionDir, src, signal, onProgress, live }: { state: State; style: string; n: number; dir: string; cwd: string; pick: ModelPicker; settings: LoopSettings; model?: string | null; meter?: Meter; sessionDir?: string; src?: string | null; signal?: AbortSignal; onProgress: (progress: any) => void; live?: any }): Promise<Attempt> {
425
463
  const agentDir = getAgentDir();
426
464
  const agent = loadAgent(style, { cwd, agentDir });
427
465
  const fork = state.best.session ?? src;
428
466
  const sessionManager = fork && fs.existsSync(fork) ? SessionManager.forkFrom(fork, dir, sessionDir) : SessionManager.create(dir, sessionDir);
429
467
  const loader = new DefaultResourceLoader({ cwd: dir, agentDir, noExtensions: true, noPromptTemplates: true, noThemes: true, appendSystemPrompt: [agent.body] });
430
468
  await loader.reload();
431
- const picked = pickModel(ctx, model ?? settings.models[style] ?? agent.model);
469
+ const picked = pick(model ?? settings.models[style] ?? agent.model);
432
470
  const { session: created } = await createAgentSession({ cwd: dir, agentDir, model: picked, tools: agent.tools?.length ? agent.tools : undefined, resourceLoader: loader, sessionManager });
433
471
  const session = track(created);
434
472
  live?.attempts.set(`R${n}-${style}`, session);
@@ -440,7 +478,7 @@ async function runAttempt({ state, style, n, dir, cwd, ctx, settings, model, met
440
478
  else if (e.type === "message_end" && e.message?.role === "assistant") {
441
479
  p.tokens += e.message.usage?.totalTokens || 0;
442
480
  p.cost += typeof e.message.usage?.cost === "number" ? e.message.usage.cost : e.message.usage?.cost?.total || 0;
443
- addUsage(meter, e.message.usage);
481
+ addUsage(meter, e.message.usage, modelSpec(picked));
444
482
  const t = (e.message.content || []).filter((c) => c.type === "text").map((c) => c.text).join("").trim();
445
483
  if (t) p.text = t.slice(-200);
446
484
  onProgress(p);
@@ -518,14 +556,31 @@ async function agentJSON({ role, model, prompt, ok, cwd, ctx, settings, signal,
518
556
  }
519
557
 
520
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
+ };
521
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")])) });
522
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()]),
523
- 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()) })) });
524
576
  export const LoopResult = Type.Object({
525
577
  run: Type.String(), status: Type.String(), kind: Type.String(), goal: Type.String(), baseCommit: Type.String(),
526
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()]) }),
527
- 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())),
528
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()) }))),
529
584
  });
530
585
  /** `loop_status`: what a script polling a run gets. */
531
586
  export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String() });
@@ -533,15 +588,37 @@ export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String
533
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())) });
534
589
  /** `loop_apply`: what landed in the checkout, and what was deliberately left out. */
535
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;
536
596
 
537
- 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[]) {
538
598
  const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
539
599
  return {
540
- 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 ?? "",
541
601
  metric: { key: state.metric?.key ?? null, unit: state.metric?.unit ?? null, better: state.metric?.better ?? null },
542
- best: { label: state.best?.label ?? null, commit: state.best?.commit ?? null, score: n(state.best?.score), branch: state.best?.branch ?? null },
543
- 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,
544
- 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(),
545
622
  };
546
623
  }
547
624
  export const LoopParams = Type.Object({
@@ -783,18 +860,19 @@ export function runsText(cwd: string): string {
783
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");
784
861
  }
785
862
 
786
- export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs"];
863
+ export const SUBCOMMANDS = ["init", "models", "rounds", "budget", "timeout", "open", "runs", "questions", "answer"];
787
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. */
788
865
  export function isSubcommand(args?: string | null): boolean {
789
866
  const w = (args ?? "").trim().split(/\s+/).filter(Boolean);
790
867
  if (!w.length) return true;
791
868
  const sub = w[0].toLowerCase();
792
869
  if (!SUBCOMMANDS.includes(sub)) return false;
793
- 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;
794
871
  if (sub === "open") return w.length <= 3;
872
+ if (sub === "answer") return w.length >= 2 && w.length <= 8;
795
873
  return w.length <= 2;
796
874
  }
797
- 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]";
798
876
  export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void> {
799
877
  const [first, ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
800
878
  const sub = first?.toLowerCase();
@@ -818,6 +896,34 @@ export async function loopsCommand(args: string, ctx: Ctx, pi: Pi): Promise<void
818
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; }
819
897
  case "init": await pi.sendUserMessage?.(initPrompt()); return;
820
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
+ }
821
927
  default: ctx.ui.notify(LOOPS_USAGE, "info");
822
928
  }
823
929
  }
@@ -832,12 +938,20 @@ export default function loopsExtension(pi: Pi): void {
832
938
  });
833
939
  // state is module-scoped, so a session that starts after one has shut down must not inherit its latch
834
940
  pi.on?.("session_start", async () => { shuttingDown = false; RUNS.clear(); });
835
- pi.on?.("session_shutdown", async () => { shuttingDown = true; for (const r of RUNS.values()) r.ctrl.abort(); await stopAll(); });
941
+ pi.on?.("session_shutdown", async () => {
942
+ shuttingDown = true;
943
+ const runs = [...RUNS.values()];
944
+ for (const r of runs) r.ctrl.abort();
945
+ // disposing the session invalidates the ctx, so the runs must be finished before this returns, not just aborted
946
+ await Promise.allSettled(runs.map((r) => r.promise));
947
+ await stopAll();
948
+ });
836
949
  async function run(params: LoopParamsIn, signal: AbortSignal | undefined, onUpdate: ((update: any) => void) | undefined, ctx: Ctx, live: any) {
837
950
  const cwd = ctx.cwd;
951
+ const hasUI = ctx.hasUI ?? false; // a getter on the ctx proxy: stale after replacement, so read it once
838
952
  let settings = loopSettings(pi, cwd);
839
953
  const chosen = Object.keys(settings.models).some((k) => k !== "pool");
840
- if (!params.from && !chosen && ctx.hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
954
+ if (!params.from && !chosen && hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
841
955
  const r = await pickModels(ctx, { reason: "First loop here" });
842
956
  if (r && "error" in r) throw new Error(r.error);
843
957
  if (r && "models" in r) settings = { ...settings, models: { ...settings.models, ...r.models } };
@@ -872,7 +986,7 @@ export default function loopsExtension(pi: Pi): void {
872
986
  if (SELF_SHOT.test(state.build ?? "")) findChrome();
873
987
  // a visual round is only as good as the eyes on it: refuse a judge (or panel member) that cannot see the screenshots
874
988
  if (judgedOnScreenshots(state)) {
875
- const asModel = (spec: string | null | undefined) => { try { return pickModel(ctx, spec); } catch { return null; } }; // an unavailable model reports itself later
989
+ const asModel = (spec: string | null | undefined) => { try { return pick(spec); } catch { return null; } }; // an unavailable model reports itself later
876
990
  const panelOn = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1;
877
991
  const problem = visionProblem([
878
992
  { role: "judge", key: "judge", spec: models.judge, model: asModel(models.judge) },
@@ -888,23 +1002,41 @@ export default function loopsExtension(pi: Pi): void {
888
1002
  const runDir = path.join(cwd, STATE_DIR, state.id);
889
1003
  // every nested model call in this round — attempts, judge samples, council — adds to one meter, reported as the result's `usage`
890
1004
  const meter = newMeter();
891
- // a TUI call returns as soon as the round starts, and session replacement invalidates the old ctx: capture what the
892
- // background run needs as plain data now (the model registry is the one shared object that stays valid)
893
- const sessionDir = ctx.sessionManager?.getSessionDir?.();
894
- const sessionFile = ctx.sessionManager?.getSessionFile?.() ?? null;
895
- const setStatus = (text?: string) => { try { ctx.ui.setStatus?.("loops", text); } catch {} };
1005
+ // A TUI call returns as soon as the round starts, and `ctx` is a getter proxy that throws once the session is
1006
+ // replaced (`runner.assertActive()`), so the background run holds only values read while it is still alive: the
1007
+ // model registry object itself, the session paths, and the status sink. `bindUI` rebinds the new session's UI,
1008
+ // so the old one is not touched after a switch.
1009
+ const sessionManager = ctx.sessionManager;
1010
+ const sessionDir = sessionManager?.getSessionDir?.();
1011
+ const sessionFile = sessionManager?.getSessionFile?.() ?? null;
1012
+ const pickCtx: PickCtx = { cwd, model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui };
1013
+ const pick: ModelPicker = (spec) => pickModel(pickCtx, spec);
1014
+ const statusSink = ctx.ui;
1015
+ let alive = true;
1016
+ const setStatus = (text?: string) => { if (alive) { try { statusSink.setStatus?.("loops", text); } catch {} } };
896
1017
  const details: Details = { run: state.id, kind: state.kind, round: null, judge: null, attempts: [], phase: "starting" };
897
1018
  if (live) { live.id = state.id; live.state = state; live.details = details; live.cwd = cwd; live.started?.(); }
898
1019
  const push = (phase?: string) => { details.phase = phase ?? details.phase; if (live) setStatus(`${state.id} r${details.round ?? "?"} · ${details.phase}`); onUpdate?.({ content: [{ type: "text", text: `${state.id} r${details.round ?? "?"} · ${details.phase}\n${details.attempts.map((a) => ` ${a.style.padEnd(5)} ${a.status.padEnd(8)} ${a.tool ? `[${a.tool}] ` : ""}${a.tools} tools, ${a.tokens} tok${a.text ? ` — ${a.text.split("\n").pop()?.slice(0, 80) ?? ""}` : ""}`).join("\n")}` }], details: structuredClone(details) }); };
899
- const askModel = (role: string, i: number = 0) => (content: any) => oneShot({ role, model: role === "council" ? (models.council ?? [])[i % (models.council?.length || 1)] : models[role], content, cwd, ctx, settings, meter, signal });
900
- const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd, ctx, settings, meter, signal });
1020
+ const askModel = (role: string, i: number = 0) => (content: any) => oneShot({ role, model: role === "council" ? (models.council ?? [])[i % (models.council?.length || 1)] : models[role], content, cwd, pick, registry: pickCtx.modelRegistry, settings, meter, signal });
1021
+ const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd, pick, registry: pickCtx.modelRegistry, settings, meter, signal });
901
1022
  // the judge panel: every council model (the attempts' models) scores every candidate, labels blind; the judge model keeps vetoes and the head-to-head
902
1023
  const panel = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1 ? (models.council ?? []).map((m) => ({ name: shortModel(m), ask: askAs("judge", m) })) : undefined;
903
1024
  state.models = { ...Object.fromEntries(STYLES.map((st) => [st, models[st]])), judge: models.judge, council: models.council, spread: models.spread };
904
1025
  const needsYou = async (title: string, options: string[], fallback: string): Promise<string> => {
905
- if (ctx.hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
1026
+ if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
906
1027
  return fallback;
907
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
+ };
908
1040
 
909
1041
  if (!state.rubric && settings.council !== false && state.kind === "bench") {
910
1042
  push("council writes the rubric");
@@ -934,7 +1066,7 @@ export default function loopsExtension(pi: Pi): void {
934
1066
  try { git(cwd, "worktree", "remove", "-f", dir); } catch {}
935
1067
  git(cwd, "worktree", "add", "-q", "-f", "-B", branch, dir, round.from ?? state.baseCommit);
936
1068
  const slot = details.attempts.find((a) => a.style === style)!;
937
- const r = await runAttempt({ state, style, n, dir, cwd, ctx, settings, model: models[style], meter, sessionDir, src: sessionFile, signal, live, onProgress: (p) => { Object.assign(slot, p); push(); } });
1069
+ const r = await runAttempt({ state, style, n, dir, cwd, pick, settings, model: models[style], meter, sessionDir, src: sessionFile, signal, live, onProgress: (p) => { Object.assign(slot, p); push(); } });
938
1070
  Object.assign(slot, { status: r.status, tool: null, text: r.summary || r.error || "" }); push();
939
1071
  return { ...r, branch, dir: path.relative(cwd, dir) };
940
1072
  }));
@@ -1017,7 +1149,7 @@ export default function loopsExtension(pi: Pi): void {
1017
1149
  else judge.why = `${judge.why} (your pick ${live.pick} was not a passing attempt)`;
1018
1150
  live.pick = null;
1019
1151
  }
1020
- if (ctx.hasUI && !live && takeable.length) {
1152
+ if (hasUI && !live && takeable.length) {
1021
1153
  for (;;) {
1022
1154
  const others = takeable.filter((a) => a.label !== judge.pick);
1023
1155
  const opts = [`Keep ${judge.pick}${judge.pick === "CURRENT" ? " (nothing beat it)" : " (judge's pick)"}`, ...others.map((a) => `Take ${a.label} instead`), ...valid.map((a) => `Show ${a.label}'s diff`), ...valid.filter((a) => a.session).map((a) => `Open ${a.label}'s session after the run`)];
@@ -1046,6 +1178,14 @@ export default function loopsExtension(pi: Pi): void {
1046
1178
  }
1047
1179
  }
1048
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
+ }
1049
1189
  done.push(round);
1050
1190
  push(`round ${n}: kept ${judge.pick}`);
1051
1191
  }
@@ -1053,17 +1193,28 @@ export default function loopsExtension(pi: Pi): void {
1053
1193
  state.status = aborted ? "aborted" : "done";
1054
1194
  saveState(cwd, state);
1055
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
+ }
1056
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);
1057
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}`);
1058
1205
  const spend = meterText(meter);
1059
1206
  const content: any[] = [{ type: "text", text: spend ? `${text}\nSpent ${spend} on this run's models.` : text }];
1060
1207
  if (lastSheet) { try { content.push({ type: "image", data: fs.readFileSync(path.join(cwd, lastSheet)).toString("base64"), mimeType: "image/png" }); } catch {} }
1061
- 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 })) } };
1062
1209
  }
1063
1210
 
1064
1211
  pi.registerTool?.({
1065
1212
  name: "loop", exposure: "model-only", executionMode: "sequential",
1066
- promptSnippet: "run hill-climbing rounds: three forked attempts in git worktrees, a judge keeps the best", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1213
+ promptSnippet: "run hill-climbing rounds: three forked attempts in git worktrees, a judge keeps the best",
1214
+ promptGuidelines: [
1215
+ "When the ask is comparative — make this faster, improve this, which of these is better — prefer `loop` over editing by hand: it forks attempts into their own worktrees and a judge keeps the best, and your checkout is untouched until `loop_apply`.",
1216
+ "Give a `loop` round a `bench` when a number can be measured, and `routes` when the goal is what a page looks like; without either it is judged on the diffs.",
1217
+ ], annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1067
1218
  label: "loop",
1068
1219
  outputSchema: LoopResult,
1069
1220
  description: "Run hill-climbing round(s) on the current git repo: three forked attempt sessions (safe/bold/wild agents) in their own worktrees, a judge keeps the best. Bench mode when `bench` is set (each attempt sets up and measures itself; a judge agent re-measures every candidate, reads the diffs and vetoes rule breaks); visual mode otherwise (`build` writes `shots`, a judge panel scores them). `from` continues a run from its last judged round.",
@@ -1083,12 +1234,13 @@ export default function loopsExtension(pi: Pi): void {
1083
1234
  if (!choice?.startsWith("Approve")) return notRun("discuss", "Not run. Discuss the spec with me in chat (what is unclear, what should be different), then call loop again when we agree.");
1084
1235
  }
1085
1236
  if (!ctx.hasUI || params.wait) return run(params, signal, onUpdate, ctx, null);
1237
+ const ui = ctx.ui; // the object, held by value: the ctx getter throws once the session is replaced
1086
1238
  const live: any = { id: null, state: null, details: null, cwd: ctx.cwd, attempts: new Map(), pick: null, last: null, error: null, ctrl: new AbortController(), startedAt: Date.now() };
1087
1239
  const started = new Promise((resolve) => { live.started = resolve; });
1088
1240
  live.promise = run(params, live.ctrl.signal, (u) => { live.last = u; }, ctx, live).then(
1089
1241
  (r) => { try { pi.sendUserMessage?.(r.content, { deliverAs: "followUp" }); } catch {} },
1090
1242
  (e) => { live.error = e; if (live.id) { try { pi.sendUserMessage?.(`Loop ${live.id} failed: ${e.message}`, { deliverAs: "followUp" }); } catch {} } }
1091
- ).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ctx.ui.setStatus?.("loops", undefined); } catch {} });
1243
+ ).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ui.setStatus?.("loops", undefined); } catch {} });
1092
1244
  await started;
1093
1245
  if (!live.id) throw live.error ?? new Error("the loop did not start");
1094
1246
  RUNS.set(live.id, live);
@@ -1123,13 +1275,13 @@ export default function loopsExtension(pi: Pi): void {
1123
1275
  } });
1124
1276
  pi.registerTool?.({ name: "loop_status", promptSnippet: "progress of a loop round running in the background", outputSchema: StatusResult, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_status", description: "Progress of a loop round running in the background: phase, each attempt's status, tools and tokens.", parameters: Type.Object({ id: idParam }),
1125
1277
  async execute(_id, p) { const r = liveRun(p.id); return { content: r.last?.content ?? [{ type: "text", text: `${r.id} starting` }], details: r.details, structuredContent: { run: r.id, phase: String(r.details?.phase ?? "starting") } }; } });
1126
- pi.registerTool?.({ name: "loop_talk", promptSnippet: "tell a running attempt something", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, label: "loop_talk", description: "Tell a running attempt something (R1-bold, or just bold): delivered into its session as a steering message it acts on next.", parameters: Type.Object({ attempt: Type.String(), message: Type.String(), id: idParam }),
1278
+ pi.registerTool?.({ name: "loop_talk", promptSnippet: "tell a running attempt something", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, label: "loop_talk", description: "Tell a running attempt something (R1-bold, or just bold): delivered into its session as a steering message it acts on next.", parameters: Type.Object({ attempt: Type.String(), message: Type.String(), id: idParam }),
1127
1279
  async execute(_id, p) { const r = liveRun(p.id); const key = findAttempt(r, p.attempt); if (!key) throw new Error(`${p.attempt} is not running (running: ${[...r.attempts.keys()].join(", ") || "none"})`); await r.attempts.get(key).steer(`Message from the person running this loop: ${p.message}`); return said(`told ${key}: ${p.message}`); } });
1128
1280
  pi.registerTool?.({ name: "loop_rule", promptSnippet: "add a rule a running loop must follow", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_rule", description: "Add a rule to a running loop: every running attempt is told now, the judge enforces it from this round on, and it stays for later rounds.", parameters: Type.Object({ rule: Type.String(), id: idParam }),
1129
1281
  async execute(_id, p) { const r = liveRun(p.id); if (!r.state.rules.includes(p.rule)) { r.state.rules.push(p.rule); if (r.state.rubric) addRule(r.state.rubric, p.rule); saveState(r.cwd, r.state); } await Promise.allSettled([...r.attempts.values()].map((s) => s.steer(`New rule for this loop, from the person running it: ${p.rule}`))); return said(`rule added: ${p.rule} (told ${r.attempts.size} running attempt${r.attempts.size === 1 ? "" : "s"})`); } });
1130
1282
  pi.registerTool?.({ name: "loop_pick", promptSnippet: "keep a specific attempt as the winner yourself", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_pick", description: "Decide the running round yourself: when the judge is done, keep this attempt (R1-wild, or just wild; or CURRENT) as the best if its check passes, whatever the judge scored.", parameters: Type.Object({ attempt: Type.String(), id: idParam }),
1131
1283
  async execute(_id, p) { const r = liveRun(p.id); r.pick = p.attempt; return said(`when round ${r.details?.round ?? "?"} of ${r.id} is judged, ${p.attempt} is kept`); } });
1132
- pi.registerTool?.({ name: "loop_apply", promptSnippet: "take a run's best into the checkout (tracked files only)", outputSchema: ApplyResult, executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, label: "loop_apply", description: "Take a run's best into the checkout: checks out the winner's changed tracked files from its commit and commits them (files it added or deleted are listed, not applied). Default: this session's last run.", parameters: Type.Object({ id: idParam }),
1284
+ pi.registerTool?.({ name: "loop_apply", promptSnippet: "take a run's best into the checkout (tracked files only)", outputSchema: ApplyResult, executionMode: "sequential", promptGuidelines: ["`loop_apply` takes the winner's changed *tracked* files as one commit; files it added or deleted are listed, not applied — copy those yourself if the round needs them."], annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, label: "loop_apply", description: "Take a run's best into the checkout: checks out the winner's changed tracked files from its commit and commits them (files it added or deleted are listed, not applied). Default: this session's last run.", parameters: Type.Object({ id: idParam }),
1133
1285
  async execute(_id, p, _signal, _onUpdate, ctx) {
1134
1286
  const mine = !p.id || p.id === "last" ? sessionRuns(ctx).find((id) => fs.existsSync(stateFile(ctx.cwd, id))) : null;
1135
1287
  const state = loadState(ctx.cwd, mine ?? p.id);
@@ -1142,6 +1294,19 @@ export default function loopsExtension(pi: Pi): void {
1142
1294
  return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files },
1143
1295
  structuredContent: { run: state.id, applied: r.applied, commit: r.commit ?? null, added: r.files.added, removed: r.files.removed } };
1144
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
+ } });
1145
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 }),
1146
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}" }).`); } });
1147
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.4",
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": "*",