@computesdk/pi-loops 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/extensions/loop.ts +69 -35
  2. package/package.json +1 -1
@@ -121,6 +121,10 @@ export type ToolDef<P = any> = {
121
121
  description: string;
122
122
  parameters: any;
123
123
  outputSchema?: any;
124
+ /** One line for the default system prompt's "Available tools" section: without it Pi omits the tool there. */
125
+ promptSnippet?: string;
126
+ /** Rules for the Guidelines section, shown only while this tool is active. Workflow belongs here, not in the snippet. */
127
+ promptGuidelines?: string[];
124
128
  /** `model-only` for the tools that ask the user or orchestrate: never callable from scripts. */
125
129
  exposure?: "direct" | "model-only" | "codemode" | "deferred" | "hidden";
126
130
  /** "sequential" for tools that share in-memory state, so one message's calls cannot interleave. */
@@ -131,6 +135,8 @@ export type ToolDef<P = any> = {
131
135
  renderCall?: (args: any, theme: any) => any;
132
136
  renderResult?: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) => any;
133
137
  };
138
+ /** Resolve a model spec against the registry captured while the ctx was alive. */
139
+ export type ModelPicker = (spec?: string | null) => Model;
134
140
  /** A candidate for the judge: a checked-out directory at a commit. */
135
141
  export type Candidate = { label: string; dir: string; commit: string; fresh?: boolean };
136
142
  /** One round of a run. */
@@ -364,16 +370,16 @@ export async function stopAll() { shuttingDown = true; const all = [...active];
364
370
  * *extension* adds (rather than one built into Pi) is unknown inside it and its credential cannot be resolved. Say that
365
371
  * instead of pi's "No API key found", which points at /login and sends people the wrong way.
366
372
  */
367
- function explainAuth(e: unknown, role: string, spec: string | null, ctx: Ctx): unknown {
373
+ function explainAuth(e: unknown, role: string, spec: string | null, registry?: ModelRegistryLike): unknown {
368
374
  const provider = errText(e).match(/No API key found for ([\w.-]+)/)?.[1]?.replace(/\.+$/, ""); // pi ends the sentence with a period
369
375
  if (!provider) return e;
370
376
  // the host can see a model on that provider (so this session is missing something the host has), not a bare missing login
371
- const hostKnows = (ctx.modelRegistry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
377
+ const hostKnows = (registry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
372
378
  if (!hostKnows) return e;
373
379
  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 });
374
380
  }
375
381
 
376
- 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 }) {
382
+ 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 }) {
377
383
  const agentDir = getAgentDir();
378
384
  const spec = model ?? settings.models[role] ?? null;
379
385
  const agent = loadAgent(role, { cwd, agentDir });
@@ -386,15 +392,15 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
386
392
  // "no extensions" would also drop the *providers* extensions add (anything not built into Pi), and an unregistered
387
393
  // provider has no credentials. Copy the host's registrations over: config only, no extension code runs, still no tools.
388
394
  const known = new Set(services.modelRuntime.getRegisteredProviderIds());
389
- for (const id of ctx.modelRegistry?.getRegisteredProviderIds?.() ?? []) {
395
+ for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
390
396
  if (known.has(id)) continue;
391
- const config = ctx.modelRegistry?.getRegisteredProviderConfig?.(id);
397
+ const config = registry?.getRegisteredProviderConfig?.(id);
392
398
  if (config) { services.modelRuntime.registerProvider(id, config as any); continue; }
393
- const native = ctx.modelRegistry?.getRegisteredNativeProvider?.(id); // a provider built with createProvider, not config
399
+ const native = registry?.getRegisteredNativeProvider?.(id); // a provider built with createProvider, not config
394
400
  if (native) services.modelRuntime.registerNativeProvider(native as any);
395
401
  }
396
402
  // creating the session resolves the credential too, so it belongs inside this try: that is where an unusable provider shows up
397
- const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pickModel(ctx, spec ?? agent.model), noTools: "all" });
403
+ const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pick(spec ?? agent.model), noTools: "all" });
398
404
  session = track(created);
399
405
  const { text, images } = flattenParts(content);
400
406
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -407,7 +413,7 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
407
413
  if (last?.stopReason === "error") throw new Error(last.errorMessage || `${role} errored`);
408
414
  }
409
415
  return parseJson(session.getLastAssistantText());
410
- } catch (e) { throw explainAuth(e, role, spec, ctx); }
416
+ } catch (e) { throw explainAuth(e, role, spec, registry); }
411
417
  finally {
412
418
  signal?.removeEventListener("abort", onAbort);
413
419
  if (session) {
@@ -419,14 +425,14 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
419
425
  }
420
426
 
421
427
  /* ---------- an attempt: forked session + worktree + style agent ---------- */
422
- 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> {
428
+ 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> {
423
429
  const agentDir = getAgentDir();
424
430
  const agent = loadAgent(style, { cwd, agentDir });
425
431
  const fork = state.best.session ?? src;
426
432
  const sessionManager = fork && fs.existsSync(fork) ? SessionManager.forkFrom(fork, dir, sessionDir) : SessionManager.create(dir, sessionDir);
427
433
  const loader = new DefaultResourceLoader({ cwd: dir, agentDir, noExtensions: true, noPromptTemplates: true, noThemes: true, appendSystemPrompt: [agent.body] });
428
434
  await loader.reload();
429
- const picked = pickModel(ctx, model ?? settings.models[style] ?? agent.model);
435
+ const picked = pick(model ?? settings.models[style] ?? agent.model);
430
436
  const { session: created } = await createAgentSession({ cwd: dir, agentDir, model: picked, tools: agent.tools?.length ? agent.tools : undefined, resourceLoader: loader, sessionManager });
431
437
  const session = track(created);
432
438
  live?.attempts.set(`R${n}-${style}`, session);
@@ -525,6 +531,13 @@ export const LoopResult = Type.Object({
525
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()]) }),
526
532
  rounds: Type.Array(RoundOut),
527
533
  });
534
+ /** `loop_status`: what a script polling a run gets. */
535
+ export const StatusResult = Type.Object({ run: Type.String(), phase: Type.String() });
536
+ /** `loop_init`: what was written to .pi/loops.json. */
537
+ 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
+ /** `loop_apply`: what landed in the checkout, and what was deliberately left out. */
539
+ 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()) });
540
+
528
541
  export function structuredResult(state: Pick<State, "id" | "status" | "kind" | "goal" | "baseCommit" | "metric" | "best">, rounds: Round[]) {
529
542
  const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
530
543
  return {
@@ -823,12 +836,20 @@ export default function loopsExtension(pi: Pi): void {
823
836
  });
824
837
  // state is module-scoped, so a session that starts after one has shut down must not inherit its latch
825
838
  pi.on?.("session_start", async () => { shuttingDown = false; RUNS.clear(); });
826
- pi.on?.("session_shutdown", async () => { shuttingDown = true; for (const r of RUNS.values()) r.ctrl.abort(); await stopAll(); });
839
+ pi.on?.("session_shutdown", async () => {
840
+ shuttingDown = true;
841
+ const runs = [...RUNS.values()];
842
+ for (const r of runs) r.ctrl.abort();
843
+ // disposing the session invalidates the ctx, so the runs must be finished before this returns, not just aborted
844
+ await Promise.allSettled(runs.map((r) => r.promise));
845
+ await stopAll();
846
+ });
827
847
  async function run(params: LoopParamsIn, signal: AbortSignal | undefined, onUpdate: ((update: any) => void) | undefined, ctx: Ctx, live: any) {
828
848
  const cwd = ctx.cwd;
849
+ const hasUI = ctx.hasUI ?? false; // a getter on the ctx proxy: stale after replacement, so read it once
829
850
  let settings = loopSettings(pi, cwd);
830
851
  const chosen = Object.keys(settings.models).some((k) => k !== "pool");
831
- if (!params.from && !chosen && ctx.hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
852
+ if (!params.from && !chosen && hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
832
853
  const r = await pickModels(ctx, { reason: "First loop here" });
833
854
  if (r && "error" in r) throw new Error(r.error);
834
855
  if (r && "models" in r) settings = { ...settings, models: { ...settings.models, ...r.models } };
@@ -863,7 +884,7 @@ export default function loopsExtension(pi: Pi): void {
863
884
  if (SELF_SHOT.test(state.build ?? "")) findChrome();
864
885
  // a visual round is only as good as the eyes on it: refuse a judge (or panel member) that cannot see the screenshots
865
886
  if (judgedOnScreenshots(state)) {
866
- const asModel = (spec: string | null | undefined) => { try { return pickModel(ctx, spec); } catch { return null; } }; // an unavailable model reports itself later
887
+ const asModel = (spec: string | null | undefined) => { try { return pick(spec); } catch { return null; } }; // an unavailable model reports itself later
867
888
  const panelOn = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1;
868
889
  const problem = visionProblem([
869
890
  { role: "judge", key: "judge", spec: models.judge, model: asModel(models.judge) },
@@ -879,21 +900,27 @@ export default function loopsExtension(pi: Pi): void {
879
900
  const runDir = path.join(cwd, STATE_DIR, state.id);
880
901
  // every nested model call in this round — attempts, judge samples, council — adds to one meter, reported as the result's `usage`
881
902
  const meter = newMeter();
882
- // a TUI call returns as soon as the round starts, and session replacement invalidates the old ctx: capture what the
883
- // background run needs as plain data now (the model registry is the one shared object that stays valid)
903
+ // A TUI call returns as soon as the round starts, and `ctx` is a getter proxy that throws once the session is
904
+ // replaced (`runner.assertActive()`), so the background run holds only values read while it is still alive: the
905
+ // model registry object itself, the session paths, and the status sink. `bindUI` rebinds the new session's UI,
906
+ // so the old one is not touched after a switch.
884
907
  const sessionDir = ctx.sessionManager?.getSessionDir?.();
885
908
  const sessionFile = ctx.sessionManager?.getSessionFile?.() ?? null;
886
- const setStatus = (text?: string) => { try { ctx.ui.setStatus?.("loops", text); } catch {} };
909
+ const pickCtx: PickCtx = { cwd, model: ctx.model, modelRegistry: ctx.modelRegistry, ui: ctx.ui };
910
+ const pick: ModelPicker = (spec) => pickModel(pickCtx, spec);
911
+ const statusSink = ctx.ui;
912
+ let alive = true;
913
+ const setStatus = (text?: string) => { if (alive) { try { statusSink.setStatus?.("loops", text); } catch {} } };
887
914
  const details: Details = { run: state.id, kind: state.kind, round: null, judge: null, attempts: [], phase: "starting" };
888
915
  if (live) { live.id = state.id; live.state = state; live.details = details; live.cwd = cwd; live.started?.(); }
889
916
  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) }); };
890
- 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 });
891
- const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd, ctx, settings, meter, signal });
917
+ 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 });
918
+ const askAs = (role: string, model?: string | null) => (content: any) => oneShot({ role, model, content, cwd, pick, registry: pickCtx.modelRegistry, settings, meter, signal });
892
919
  // the judge panel: every council model (the attempts' models) scores every candidate, labels blind; the judge model keeps vetoes and the head-to-head
893
920
  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;
894
921
  state.models = { ...Object.fromEntries(STYLES.map((st) => [st, models[st]])), judge: models.judge, council: models.council, spread: models.spread };
895
922
  const needsYou = async (title: string, options: string[], fallback: string): Promise<string> => {
896
- if (ctx.hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
923
+ if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
897
924
  return fallback;
898
925
  };
899
926
 
@@ -925,7 +952,7 @@ export default function loopsExtension(pi: Pi): void {
925
952
  try { git(cwd, "worktree", "remove", "-f", dir); } catch {}
926
953
  git(cwd, "worktree", "add", "-q", "-f", "-B", branch, dir, round.from ?? state.baseCommit);
927
954
  const slot = details.attempts.find((a) => a.style === style)!;
928
- 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(); } });
955
+ 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(); } });
929
956
  Object.assign(slot, { status: r.status, tool: null, text: r.summary || r.error || "" }); push();
930
957
  return { ...r, branch, dir: path.relative(cwd, dir) };
931
958
  }));
@@ -1008,7 +1035,7 @@ export default function loopsExtension(pi: Pi): void {
1008
1035
  else judge.why = `${judge.why} (your pick ${live.pick} was not a passing attempt)`;
1009
1036
  live.pick = null;
1010
1037
  }
1011
- if (ctx.hasUI && !live && takeable.length) {
1038
+ if (hasUI && !live && takeable.length) {
1012
1039
  for (;;) {
1013
1040
  const others = takeable.filter((a) => a.label !== judge.pick);
1014
1041
  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`)];
@@ -1053,7 +1080,12 @@ export default function loopsExtension(pi: Pi): void {
1053
1080
  }
1054
1081
 
1055
1082
  pi.registerTool?.({
1056
- name: "loop", exposure: "model-only", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1083
+ name: "loop", exposure: "model-only", executionMode: "sequential",
1084
+ promptSnippet: "run hill-climbing rounds: three forked attempts in git worktrees, a judge keeps the best",
1085
+ promptGuidelines: [
1086
+ "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`.",
1087
+ "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.",
1088
+ ], annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1057
1089
  label: "loop",
1058
1090
  outputSchema: LoopResult,
1059
1091
  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.",
@@ -1073,12 +1105,13 @@ export default function loopsExtension(pi: Pi): void {
1073
1105
  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.");
1074
1106
  }
1075
1107
  if (!ctx.hasUI || params.wait) return run(params, signal, onUpdate, ctx, null);
1108
+ const ui = ctx.ui; // the object, held by value: the ctx getter throws once the session is replaced
1076
1109
  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() };
1077
1110
  const started = new Promise((resolve) => { live.started = resolve; });
1078
1111
  live.promise = run(params, live.ctrl.signal, (u) => { live.last = u; }, ctx, live).then(
1079
1112
  (r) => { try { pi.sendUserMessage?.(r.content, { deliverAs: "followUp" }); } catch {} },
1080
1113
  (e) => { live.error = e; if (live.id) { try { pi.sendUserMessage?.(`Loop ${live.id} failed: ${e.message}`, { deliverAs: "followUp" }); } catch {} } }
1081
- ).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ctx.ui.setStatus?.("loops", undefined); } catch {} });
1114
+ ).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ui.setStatus?.("loops", undefined); } catch {} });
1082
1115
  await started;
1083
1116
  if (!live.id) throw live.error ?? new Error("the loop did not start");
1084
1117
  RUNS.set(live.id, live);
@@ -1098,28 +1131,28 @@ export default function loopsExtension(pi: Pi): void {
1098
1131
  return new Text([head, ...lines].join("\n") + body, 0, 0);
1099
1132
  },
1100
1133
  });
1101
- const said = (t: string) => ({ content: [{ type: "text", text: t }] });
1134
+ const said = (t: string, details: unknown = undefined) => ({ content: [{ type: "text", text: t }], details });
1102
1135
  const idParam = Type.Optional(Type.String({ description: "Run id; default: the round started most recently" }));
1103
- pi.registerTool?.({ name: "loop_init", exposure: "model-only", executionMode: "sequential", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_init", description: "Project setup for loops, written once to .pi/loops.json: serve (start the app on $PORT), check, bench + metric, protect. In the TUI it shows them and asks Save / Change / Discuss.", parameters: InitParams,
1136
+ pi.registerTool?.({ name: "loop_init", exposure: "model-only", executionMode: "sequential", promptSnippet: "set this repo up for loops (serve, check, bench, protect)", outputSchema: InitResult, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, label: "loop_init", description: "Project setup for loops, written once to .pi/loops.json: serve (start the app on $PORT), check, bench + metric, protect. In the TUI it shows them and asks Save / Change / Discuss.", parameters: InitParams,
1104
1137
  async execute(_id, p, _signal, _onUpdate, ctx) {
1105
1138
  const fields = Object.fromEntries(SETUP_KEYS.filter((k) => p[k] != null && (!Array.isArray(p[k]) || p[k].length)).map((k) => [k, p[k]]));
1106
1139
  if (ctx.hasUI) {
1107
1140
  const choice = await ctx.ui.select(`Project setup for loops (.pi/loops.json)\n\n${setupText(fields)}\n`, ["Save", "Change…", "Discuss"]);
1108
- if (choice?.startsWith("Change")) { const note = (await ctx.ui.input?.("What should change?", "e.g. serve is npm start -- -p $PORT; no check"))?.trim(); return { content: [{ type: "text", text: note ? `Not saved. Change requested: ${note}\nRevise and call loop_init again.` : "Not saved. Revise with me in chat, then call loop_init again." }], details: { phase: "change", note } }; }
1141
+ if (choice?.startsWith("Change")) { const note = (await ctx.ui.input?.("What should change?", "e.g. serve is npm start -- -p $PORT; no check"))?.trim(); return { content: [{ type: "text", text: note ? `Not saved. Change requested: ${note}\nRevise and call loop_init again.` : "Not saved. Revise with me in chat, then call loop_init again." }], details: { phase: "change", note }, structuredContent: { phase: "change", ...(note ? { note } : {}) } }; }
1109
1142
  if (choice !== "Save") return { content: [{ type: "text", text: "Not saved. Discuss the setup with me in chat, then call loop_init again." }], details: { phase: "discuss" } };
1110
1143
  }
1111
1144
  await saveConfigQueued(ctx.cwd, { ...Object.fromEntries(SETUP_KEYS.map((k) => [k, null])), ...fields });
1112
- return { content: [{ type: "text", text: `Saved ${path.relative(ctx.cwd, configFiles(ctx.cwd).project)}:\n${setupText(fields)}` }], details: { phase: "saved", setup: fields } };
1145
+ return { content: [{ type: "text", text: `Saved ${path.relative(ctx.cwd, configFiles(ctx.cwd).project)}:\n${setupText(fields)}` }], details: { phase: "saved", setup: fields }, structuredContent: { phase: "saved", ...fields } };
1113
1146
  } });
1114
- pi.registerTool?.({ name: "loop_status", 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 }),
1115
- async execute(_id, p) { const r = liveRun(p.id); return { content: r.last?.content ?? [{ type: "text", text: `${r.id} starting` }], details: r.details }; } });
1116
- pi.registerTool?.({ name: "loop_talk", 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 }),
1147
+ 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 }),
1148
+ 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") } }; } });
1149
+ 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 }),
1117
1150
  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}`); } });
1118
- pi.registerTool?.({ name: "loop_rule", 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 }),
1151
+ 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 }),
1119
1152
  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"})`); } });
1120
- pi.registerTool?.({ name: "loop_pick", 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 }),
1153
+ 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 }),
1121
1154
  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`); } });
1122
- pi.registerTool?.({ name: "loop_apply", 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 }),
1155
+ 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 }),
1123
1156
  async execute(_id, p, _signal, _onUpdate, ctx) {
1124
1157
  const mine = !p.id || p.id === "last" ? sessionRuns(ctx).find((id) => fs.existsSync(stateFile(ctx.cwd, id))) : null;
1125
1158
  const state = loadState(ctx.cwd, mine ?? p.id);
@@ -1129,8 +1162,9 @@ export default function loopsExtension(pi: Pi): void {
1129
1162
  const lines = [r.commit ? `Applied ${state.best.label} (${state.best.commit.slice(0, 7)}) as ${r.commit.slice(0, 7)}: ${r.applied.join(", ")}` : r.already ? `${state.best.label} is already in the checkout.` : "Nothing applied — the winner changed no tracked file."];
1130
1163
  if (r.files.added.length) lines.push(`Not applied — files it added: ${r.files.added.join(", ")} (git checkout ${state.best.commit.slice(0, 7)} -- <file> to take one)`);
1131
1164
  if (r.files.removed.length) lines.push(`Not applied — files it deleted: ${r.files.removed.join(", ")}`);
1132
- return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files } };
1165
+ return { ...said(lines.join("\n")), details: { run: state.id, applied: r.applied, commit: r.commit, files: r.files },
1166
+ structuredContent: { run: state.id, applied: r.applied, commit: r.commit ?? null, added: r.files.added, removed: r.files.removed } };
1133
1167
  } });
1134
- pi.registerTool?.({ name: "loop_stop", 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 }),
1168
+ 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 }),
1135
1169
  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}" }).`); } });
1136
1170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@computesdk/pi-loops",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",