@computesdk/pi-loops 1.0.4 → 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 +48 -25
  2. package/package.json +1 -1
@@ -123,6 +123,8 @@ export type ToolDef<P = any> = {
123
123
  outputSchema?: any;
124
124
  /** One line for the default system prompt's "Available tools" section: without it Pi omits the tool there. */
125
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[];
126
128
  /** `model-only` for the tools that ask the user or orchestrate: never callable from scripts. */
127
129
  exposure?: "direct" | "model-only" | "codemode" | "deferred" | "hidden";
128
130
  /** "sequential" for tools that share in-memory state, so one message's calls cannot interleave. */
@@ -133,6 +135,8 @@ export type ToolDef<P = any> = {
133
135
  renderCall?: (args: any, theme: any) => any;
134
136
  renderResult?: (result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) => any;
135
137
  };
138
+ /** Resolve a model spec against the registry captured while the ctx was alive. */
139
+ export type ModelPicker = (spec?: string | null) => Model;
136
140
  /** A candidate for the judge: a checked-out directory at a commit. */
137
141
  export type Candidate = { label: string; dir: string; commit: string; fresh?: boolean };
138
142
  /** One round of a run. */
@@ -366,16 +370,16 @@ export async function stopAll() { shuttingDown = true; const all = [...active];
366
370
  * *extension* adds (rather than one built into Pi) is unknown inside it and its credential cannot be resolved. Say that
367
371
  * instead of pi's "No API key found", which points at /login and sends people the wrong way.
368
372
  */
369
- 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 {
370
374
  const provider = errText(e).match(/No API key found for ([\w.-]+)/)?.[1]?.replace(/\.+$/, ""); // pi ends the sentence with a period
371
375
  if (!provider) return e;
372
376
  // 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);
377
+ const hostKnows = (registry?.getAvailable?.() ?? []).some((m) => (m as { provider?: string }).provider === provider);
374
378
  if (!hostKnows) return e;
375
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 });
376
380
  }
377
381
 
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 }) {
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 }) {
379
383
  const agentDir = getAgentDir();
380
384
  const spec = model ?? settings.models[role] ?? null;
381
385
  const agent = loadAgent(role, { cwd, agentDir });
@@ -388,15 +392,15 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
388
392
  // "no extensions" would also drop the *providers* extensions add (anything not built into Pi), and an unregistered
389
393
  // provider has no credentials. Copy the host's registrations over: config only, no extension code runs, still no tools.
390
394
  const known = new Set(services.modelRuntime.getRegisteredProviderIds());
391
- for (const id of ctx.modelRegistry?.getRegisteredProviderIds?.() ?? []) {
395
+ for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
392
396
  if (known.has(id)) continue;
393
- const config = ctx.modelRegistry?.getRegisteredProviderConfig?.(id);
397
+ const config = registry?.getRegisteredProviderConfig?.(id);
394
398
  if (config) { services.modelRuntime.registerProvider(id, config as any); continue; }
395
- 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
396
400
  if (native) services.modelRuntime.registerNativeProvider(native as any);
397
401
  }
398
402
  // 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" });
403
+ const { session: created } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(cwd), model: pick(spec ?? agent.model), noTools: "all" });
400
404
  session = track(created);
401
405
  const { text, images } = flattenParts(content);
402
406
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -409,7 +413,7 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
409
413
  if (last?.stopReason === "error") throw new Error(last.errorMessage || `${role} errored`);
410
414
  }
411
415
  return parseJson(session.getLastAssistantText());
412
- } catch (e) { throw explainAuth(e, role, spec, ctx); }
416
+ } catch (e) { throw explainAuth(e, role, spec, registry); }
413
417
  finally {
414
418
  signal?.removeEventListener("abort", onAbort);
415
419
  if (session) {
@@ -421,14 +425,14 @@ async function oneShot({ role, model, content, cwd, ctx, settings, meter, signal
421
425
  }
422
426
 
423
427
  /* ---------- 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> {
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> {
425
429
  const agentDir = getAgentDir();
426
430
  const agent = loadAgent(style, { cwd, agentDir });
427
431
  const fork = state.best.session ?? src;
428
432
  const sessionManager = fork && fs.existsSync(fork) ? SessionManager.forkFrom(fork, dir, sessionDir) : SessionManager.create(dir, sessionDir);
429
433
  const loader = new DefaultResourceLoader({ cwd: dir, agentDir, noExtensions: true, noPromptTemplates: true, noThemes: true, appendSystemPrompt: [agent.body] });
430
434
  await loader.reload();
431
- const picked = pickModel(ctx, model ?? settings.models[style] ?? agent.model);
435
+ const picked = pick(model ?? settings.models[style] ?? agent.model);
432
436
  const { session: created } = await createAgentSession({ cwd: dir, agentDir, model: picked, tools: agent.tools?.length ? agent.tools : undefined, resourceLoader: loader, sessionManager });
433
437
  const session = track(created);
434
438
  live?.attempts.set(`R${n}-${style}`, session);
@@ -832,12 +836,20 @@ export default function loopsExtension(pi: Pi): void {
832
836
  });
833
837
  // state is module-scoped, so a session that starts after one has shut down must not inherit its latch
834
838
  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(); });
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
+ });
836
847
  async function run(params: LoopParamsIn, signal: AbortSignal | undefined, onUpdate: ((update: any) => void) | undefined, ctx: Ctx, live: any) {
837
848
  const cwd = ctx.cwd;
849
+ const hasUI = ctx.hasUI ?? false; // a getter on the ctx proxy: stale after replacement, so read it once
838
850
  let settings = loopSettings(pi, cwd);
839
851
  const chosen = Object.keys(settings.models).some((k) => k !== "pool");
840
- if (!params.from && !chosen && ctx.hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
852
+ if (!params.from && !chosen && hasUI && (ctx.modelRegistry?.getAvailable?.() ?? []).length > 1) {
841
853
  const r = await pickModels(ctx, { reason: "First loop here" });
842
854
  if (r && "error" in r) throw new Error(r.error);
843
855
  if (r && "models" in r) settings = { ...settings, models: { ...settings.models, ...r.models } };
@@ -872,7 +884,7 @@ export default function loopsExtension(pi: Pi): void {
872
884
  if (SELF_SHOT.test(state.build ?? "")) findChrome();
873
885
  // a visual round is only as good as the eyes on it: refuse a judge (or panel member) that cannot see the screenshots
874
886
  if (judgedOnScreenshots(state)) {
875
- 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
876
888
  const panelOn = settings.council !== false && settings.judgePanel !== false && (models.council?.length ?? 0) > 1;
877
889
  const problem = visionProblem([
878
890
  { role: "judge", key: "judge", spec: models.judge, model: asModel(models.judge) },
@@ -888,21 +900,27 @@ export default function loopsExtension(pi: Pi): void {
888
900
  const runDir = path.join(cwd, STATE_DIR, state.id);
889
901
  // every nested model call in this round — attempts, judge samples, council — adds to one meter, reported as the result's `usage`
890
902
  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)
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.
893
907
  const sessionDir = ctx.sessionManager?.getSessionDir?.();
894
908
  const sessionFile = ctx.sessionManager?.getSessionFile?.() ?? null;
895
- 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 {} } };
896
914
  const details: Details = { run: state.id, kind: state.kind, round: null, judge: null, attempts: [], phase: "starting" };
897
915
  if (live) { live.id = state.id; live.state = state; live.details = details; live.cwd = cwd; live.started?.(); }
898
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) }); };
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 });
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 });
901
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
902
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;
903
921
  state.models = { ...Object.fromEntries(STYLES.map((st) => [st, models[st]])), judge: models.judge, council: models.council, spread: models.spread };
904
922
  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; }
923
+ if (hasUI && !live) { const r = await ctx.ui.select(title, options); return r ?? fallback; }
906
924
  return fallback;
907
925
  };
908
926
 
@@ -934,7 +952,7 @@ export default function loopsExtension(pi: Pi): void {
934
952
  try { git(cwd, "worktree", "remove", "-f", dir); } catch {}
935
953
  git(cwd, "worktree", "add", "-q", "-f", "-B", branch, dir, round.from ?? state.baseCommit);
936
954
  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(); } });
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(); } });
938
956
  Object.assign(slot, { status: r.status, tool: null, text: r.summary || r.error || "" }); push();
939
957
  return { ...r, branch, dir: path.relative(cwd, dir) };
940
958
  }));
@@ -1017,7 +1035,7 @@ export default function loopsExtension(pi: Pi): void {
1017
1035
  else judge.why = `${judge.why} (your pick ${live.pick} was not a passing attempt)`;
1018
1036
  live.pick = null;
1019
1037
  }
1020
- if (ctx.hasUI && !live && takeable.length) {
1038
+ if (hasUI && !live && takeable.length) {
1021
1039
  for (;;) {
1022
1040
  const others = takeable.filter((a) => a.label !== judge.pick);
1023
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`)];
@@ -1063,7 +1081,11 @@ export default function loopsExtension(pi: Pi): void {
1063
1081
 
1064
1082
  pi.registerTool?.({
1065
1083
  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 },
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 },
1067
1089
  label: "loop",
1068
1090
  outputSchema: LoopResult,
1069
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.",
@@ -1083,12 +1105,13 @@ export default function loopsExtension(pi: Pi): void {
1083
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.");
1084
1106
  }
1085
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
1086
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() };
1087
1110
  const started = new Promise((resolve) => { live.started = resolve; });
1088
1111
  live.promise = run(params, live.ctrl.signal, (u) => { live.last = u; }, ctx, live).then(
1089
1112
  (r) => { try { pi.sendUserMessage?.(r.content, { deliverAs: "followUp" }); } catch {} },
1090
1113
  (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 {} });
1114
+ ).finally(() => { if (live.id) RUNS.delete(live.id); live.started?.(); try { ui.setStatus?.("loops", undefined); } catch {} });
1092
1115
  await started;
1093
1116
  if (!live.id) throw live.error ?? new Error("the loop did not start");
1094
1117
  RUNS.set(live.id, live);
@@ -1123,13 +1146,13 @@ export default function loopsExtension(pi: Pi): void {
1123
1146
  } });
1124
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 }),
1125
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") } }; } });
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 }),
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 }),
1127
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}`); } });
1128
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 }),
1129
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"})`); } });
1130
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 }),
1131
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`); } });
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 }),
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 }),
1133
1156
  async execute(_id, p, _signal, _onUpdate, ctx) {
1134
1157
  const mine = !p.id || p.id === "last" ? sessionRuns(ctx).find((id) => fs.existsSync(stateFile(ctx.cwd, id))) : null;
1135
1158
  const state = loadState(ctx.cwd, mine ?? p.id);
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.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",