);
diff --git a/client/src/components/editor/ReasoningRow.tsx b/client/src/components/editor/ReasoningRow.tsx
index dd93217e..fea220a7 100644
--- a/client/src/components/editor/ReasoningRow.tsx
+++ b/client/src/components/editor/ReasoningRow.tsx
@@ -1,5 +1,5 @@
import { useEffect, useId, useRef, useState } from "react";
-import { Brain, ChevronRight } from "lucide-react";
+import { ChevronRight, Sparkles } from "lucide-react";
/** How long a finished block stays open before folding itself away. */
const AUTO_COLLAPSE_MS = 1000;
@@ -11,8 +11,14 @@ export interface ReasoningRowProps {
streaming: boolean;
/** Milliseconds the block took (or has taken so far). */
durationMs?: number;
- /** Start collapsed regardless of streaming state (used when replaying a saved transcript). */
+ /** Start collapsed regardless of streaming state so the reader can opt into the detail. */
defaultCollapsed?: boolean;
+ /**
+ * Show the block duration in the settled label. The conversation panel
+ * keeps elapsed time on the turn/activity row, so it can use the DeepSeek
+ * style `Think` disclosure without putting two clocks beside one stream.
+ */
+ showDuration?: boolean;
}
function formatDuration(ms: number): string {
@@ -23,11 +29,17 @@ function formatDuration(ms: number): string {
/**
* A turn's reasoning as a collapsible block — shimmering while it streams,
- * then labelled with how long it took, with the text under a left rule (the
+ * then labelled with its settled state, with the text under a left rule (the
* opencode idiom). Opens and closes itself around the stream, but a manual
* toggle wins from then on: the panel must never fight the reader.
*/
-export function ReasoningRow({ text, streaming, durationMs, defaultCollapsed = false }: ReasoningRowProps) {
+export function ReasoningRow({
+ text,
+ streaming,
+ durationMs,
+ defaultCollapsed = false,
+ showDuration = true,
+}: ReasoningRowProps) {
const [open, setOpen] = useState(!defaultCollapsed);
const toggled = useRef(false);
const bodyRef = useRef
(null);
@@ -56,9 +68,11 @@ export function ReasoningRow({ text, streaming, durationMs, defaultCollapsed = f
const label = streaming
? "Thinking…"
- : durationMs === undefined
- ? "Thought"
- : `Thought for ${formatDuration(durationMs)}`;
+ : !showDuration
+ ? "Think"
+ : durationMs === undefined
+ ? "Thought"
+ : `Thought for ${formatDuration(durationMs)}`;
return (
@@ -72,12 +86,21 @@ export function ReasoningRow({ text, streaming, durationMs, defaultCollapsed = f
aria-controls={bodyId}
className="flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors hover:bg-surface-2 active:bg-surface-3"
>
-
+
{label}
+ {streaming && durationMs !== undefined ? (
+
+ {formatDuration(durationMs)}
+
+ ) : null}
{
vi.useRealTimers();
});
-const goal = (overrides: Partial = {}): GoalState => ({
- goal: "make the jump test pass",
- startedAtMs: NOW - 12_000,
- evaluations: 3,
- ...overrides,
-});
-
describe("RunStatusPill", () => {
- it("renders nothing when no goal or loop is running", () => {
- const { container } = render( undefined} />);
+ it("renders nothing when no loop is running", () => {
+ const { container } = render( undefined} />);
expect(container.firstChild).toBeNull();
});
- it("names an active goal, its checks, and how long it has run", () => {
- vi.useFakeTimers();
- vi.setSystemTime(NOW);
- render( undefined} />);
-
- expect(screen.getByText("Goal")).toBeTruthy();
- expect(screen.getByText("make the jump test pass")).toBeTruthy();
- expect(screen.getByText("3 checks")).toBeTruthy();
- expect(screen.getByText("12s")).toBeTruthy();
- });
-
- it("counts a single evaluation in the singular", () => {
- vi.useFakeTimers();
- vi.setSystemTime(NOW);
- render( undefined} />);
- expect(screen.getByText("1 check")).toBeTruthy();
- });
-
it("names a running loop and offers to stop it", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const onStop = vi.fn();
render(
,
@@ -56,57 +29,48 @@ describe("RunStatusPill", () => {
expect(screen.getByText("Loop")).toBeTruthy();
expect(screen.getByText("ship the arena")).toBeTruthy();
expect(screen.getByText("1h 04m 07s")).toBeTruthy();
- // A loop reports no evaluator checks; showing "0 checks" would be a lie.
- expect(screen.queryByText(/check/)).toBeNull();
-
fireEvent.click(screen.getByRole("button", { name: "Stop loop" }));
- expect(onStop).toHaveBeenCalledWith("loop");
+ expect(onStop).toHaveBeenCalledOnce();
});
- it("clears the goal from the pill", () => {
+ it("labels a paced loop as a watch", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
- const onStop = vi.fn();
- render();
-
- fireEvent.click(screen.getByRole("button", { name: "Clear goal" }));
- expect(onStop).toHaveBeenCalledWith("goal");
+ render(
+ undefined}
+ />,
+ );
+ expect(screen.getByText("every 15m")).toBeTruthy();
});
- it("ticks the elapsed label while the run continues", () => {
+ it("ticks the elapsed label while the loop continues", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
- render( undefined} />);
+ render(
+ undefined}
+ />,
+ );
expect(screen.getByText("12s")).toBeTruthy();
act(() => {
- vi.advanceTimersByTime(1_000);
- });
- expect(screen.getByText("13s")).toBeTruthy();
- act(() => {
- vi.advanceTimersByTime(60_000);
+ vi.advanceTimersByTime(61_000);
});
expect(screen.getByText("1m 13s")).toBeTruthy();
});
- it("stops ticking once nothing is running", () => {
+ it("stops ticking once the loop is gone", () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
- const { rerender } = render( undefined} />);
+ const { rerender } = render(
+ undefined} />,
+ );
expect(vi.getTimerCount()).toBe(1);
- rerender( undefined} />);
+ rerender( undefined} />);
expect(vi.getTimerCount()).toBe(0);
});
-
- it("prefers the loop when a stale goal is still set — the loop owns Stop", () => {
- vi.useFakeTimers();
- vi.setSystemTime(NOW);
- render(
- undefined} />,
- );
- expect(screen.getByText("Loop")).toBeTruthy();
- expect(screen.queryByText("Goal")).toBeNull();
- expect(screen.getByRole("button", { name: "Stop loop" })).toBeTruthy();
- });
});
diff --git a/client/src/components/editor/RunStatusPill.tsx b/client/src/components/editor/RunStatusPill.tsx
index 965334a4..3befbf78 100644
--- a/client/src/components/editor/RunStatusPill.tsx
+++ b/client/src/components/editor/RunStatusPill.tsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
-import { Repeat, Square, Target } from "lucide-react";
+import { Repeat, Square } from "lucide-react";
import { formatDuration } from "../../lib/activity";
-import type { GoalState } from "../../lib/goal";
/** The `/loop` run in flight, as much of it as the composer needs to show. */
export interface ActiveLoopRun {
@@ -11,44 +10,7 @@ export interface ActiveLoopRun {
every?: string | null;
}
-export type RunMode = "goal" | "loop";
-
-interface ActiveRun {
- mode: RunMode;
- objective: string;
- startedAtMs: number;
- /** A paced loop's interval label; null for everything else. */
- every?: string | null;
- /** Evaluator passes so far; a loop has no equivalent, hence null. */
- checks: number | null;
-}
-
-/**
- * A `/goal` cannot be set while a loop runs, so at most one of these is real.
- * The loop still wins if both are somehow present: it is the run that owns the
- * Stop button, and offering to clear a goal would not halt it.
- */
-function activeRun(goal: GoalState | null, loop: ActiveLoopRun | null): ActiveRun | null {
- if (loop)
- return {
- mode: "loop",
- objective: loop.objective,
- startedAtMs: loop.startedAtMs,
- every: loop.every ?? null,
- checks: null,
- };
- if (goal) {
- return {
- mode: "goal",
- objective: goal.goal,
- startedAtMs: goal.startedAtMs,
- checks: Math.max(0, Math.floor(goal.evaluations)),
- };
- }
- return null;
-}
-
-function RunStatusPillBody({ run, onStop }: { run: ActiveRun; onStop: (mode: RunMode) => void }) {
+function RunStatusPillBody({ loop, onStop }: { loop: ActiveLoopRun; onStop: () => void }) {
const [nowMs, setNowMs] = useState(() => Date.now());
// The ticker lives here, not in the panel: a state update one level up would
// re-render the whole transcript once a second for a label this small.
@@ -56,30 +18,24 @@ function RunStatusPillBody({ run, onStop }: { run: ActiveRun; onStop: (mode: Run
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
- const elapsed = formatDuration(Math.max(0, nowMs - run.startedAtMs), "clock");
- const Icon = run.mode === "loop" ? Repeat : Target;
+ const elapsed = formatDuration(Math.max(0, nowMs - loop.startedAtMs), "clock");
return (
-
-
{run.mode === "loop" ? "Loop" : "Goal"}
+
+
Loop
{/* A watch is a different thing from a run-to-done loop; the pill has to
say which one is up there. */}
- {run.every ?
every {run.every} : null}
-
{run.objective}
- {run.checks != null ? (
-
- {run.checks} {run.checks === 1 ? "check" : "checks"}
-
- ) : null}
+ {loop.every ?
every {loop.every} : null}
+
{loop.objective}
{elapsed}
);
}
+
+interface WorkspaceTabPickerProps {
+ /** Open a view in the dock and make it active. */
+ onSelect: (tab: WorkspaceTab) => void;
+}
+
+/**
+ * First-use state for the tools dock. It is intentionally separate from the
+ * tab strip so an empty dock still has the same chrome and add menu as a dock
+ * with tabs, while the content area answers the obvious next question.
+ */
+export function WorkspaceTabPicker({ onSelect }: WorkspaceTabPickerProps) {
+ const choices = WORKSPACE_TABS.filter((tab) => tab !== "graph");
+
+ return (
+
+
+
What do you want to show here?
+
+ Choose a workspace tab to get started.
+
+
+ {choices.map((tab) => {
+ const meta = TAB_META[tab];
+ const Icon = meta.icon;
+ return (
+ onSelect(tab)}
+ className="flex items-center gap-2 rounded-lg border border-line bg-surface-1 px-3 py-2.5 text-left text-[12px] font-medium text-ink-subtle transition-colors hover:bg-surface-2 hover:text-ink-strong"
+ >
+
+ {meta.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/client/src/lib/checkpoints.test.ts b/client/src/lib/checkpoints.test.ts
index e51fab9d..bdb25289 100644
--- a/client/src/lib/checkpoints.test.ts
+++ b/client/src/lib/checkpoints.test.ts
@@ -1,75 +1,16 @@
-import { beforeEach, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
import {
- AUTO_CHECKPOINT_INTERVAL_MS,
- AUTO_CHECKPOINT_KEEP,
checkpointTakenAtMs,
- clearAutoCheckpoints,
- dueForCheckpoint,
formatCheckpointAge,
formatCheckpointList,
parseRestoreArgs,
- readAutoCheckpoints,
- recordAutoCheckpoint,
restoreWarning,
- type AutoCheckpoint,
} from "./checkpoints";
-function entry(index: number): AutoCheckpoint {
- return {
- id: `cp-${1_700_000_000_000 + index}`,
- takenAtMs: 1_700_000_000_000 + index,
- reason: "loop",
- objective: "polish the game",
- };
-}
-
-beforeEach(clearAutoCheckpoints);
-
-describe("the automatic checkpoint registry", () => {
- it("forgets the oldest ids once retention is reached", () => {
- for (let index = 1; index <= AUTO_CHECKPOINT_KEEP + 5; index += 1) {
- recordAutoCheckpoint("demo", entry(index));
- }
- const kept = readAutoCheckpoints("demo");
-
- expect(kept).toHaveLength(AUTO_CHECKPOINT_KEEP);
- expect(kept[0].id).toBe(entry(AUTO_CHECKPOINT_KEEP + 5).id);
- expect(kept.map((item) => item.id)).not.toContain(entry(1).id);
- });
-
- it("keeps games apart", () => {
- recordAutoCheckpoint("demo", entry(1));
- recordAutoCheckpoint("other", entry(2));
-
- expect(readAutoCheckpoints("demo").map((item) => item.id)).toEqual([entry(1).id]);
- expect(readAutoCheckpoints("other").map((item) => item.id)).toEqual([entry(2).id]);
- });
-
- it("survives a corrupt store rather than throwing into a run", () => {
- localStorage.setItem("calicode-auto-checkpoints", "{ not json");
- expect(readAutoCheckpoints("demo")).toEqual([]);
-
- localStorage.setItem("calicode-auto-checkpoints", JSON.stringify({ demo: [{ id: 7 }, entry(1)] }));
- expect(readAutoCheckpoints("demo").map((item) => item.id)).toEqual([entry(1).id]);
- });
-});
-
-describe("throttling", () => {
- it("always allows the first checkpoint of a run", () => {
- expect(dueForCheckpoint(null, 1_700_000_000_000)).toBe(true);
- });
-
- it("suppresses a second checkpoint inside the window and allows one after it", () => {
- const last = 1_700_000_000_000;
- expect(dueForCheckpoint(last, last + AUTO_CHECKPOINT_INTERVAL_MS - 1)).toBe(false);
- expect(dueForCheckpoint(last, last + AUTO_CHECKPOINT_INTERVAL_MS)).toBe(true);
- });
-});
-
describe("checkpoint ids", () => {
- it("reads the timestamp core minted into the id, including the collision suffix", () => {
+ it("reads timestamps from project and git ids, including collision suffixes", () => {
expect(checkpointTakenAtMs("cp-1700000000000")).toBe(1_700_000_000_000);
- expect(checkpointTakenAtMs("cp-1700000000000-3")).toBe(1_700_000_000_000);
+ expect(checkpointTakenAtMs("git-1700000000000-3")).toBe(1_700_000_000_000);
});
it("refuses to invent an age for an id it cannot read", () => {
@@ -100,32 +41,32 @@ describe("/restore arguments", () => {
});
describe("the /checkpoints listing", () => {
- it("names each id with its age and the run it guarded", () => {
+ it("names each core-owned restore point with its age and kind", () => {
const now = 1_700_000_000_000;
const text = formatCheckpointList(
[
- { id: "cp-a", takenAtMs: now - 5 * 60_000, reason: "loop", objective: "polish the game" },
- { id: "cp-b", takenAtMs: now - 3 * 3_600_000, reason: "goal", objective: "make the tests pass" },
+ { id: "git-a", createdAtMs: now - 5 * 60_000, kind: "git" },
+ { id: "cp-b", createdAtMs: now - 3 * 3_600_000, kind: "project" },
],
now,
);
- expect(text).toContain("cp-a — 5m ago, before a /loop turn (polish the game)");
- expect(text).toContain("cp-b — 3h ago, before a /goal turn (make the tests pass)");
+ expect(text).toContain("git-a — 5m ago, git snapshot");
+ expect(text).toContain("cp-b — 3h ago, project snapshot");
expect(text).toContain("/restore confirm");
});
+
+ it("explains when the core inventory is empty", () => {
+ expect(formatCheckpointList([], Date.now())).toContain("A /loop takes one before its first iteration");
+ });
});
describe("restoreWarning", () => {
it("tells a git restore point apart from a project copy", () => {
- // The two mechanisms cover opposite halves of the game, so naming the
- // wrong one is how someone approves a rollback that does not do what the
- // confirmation just described.
const git = restoreWarning("cp-git-1", "2m ago", true, "git");
expect(git).toContain("tracked files");
expect(git).toContain("HEAD and your branch do not move");
expect(git).toContain("does NOT restore untracked files");
- // A git restore point says nothing about the CaliCode project document.
expect(git).toContain("project.json");
const copy = restoreWarning("cp-1700000000000", "2m ago", true, "project");
@@ -135,7 +76,6 @@ describe("restoreWarning", () => {
});
it("defaults to the more conservative project wording", () => {
- // An unreachable core must not promise the workspace came back.
expect(restoreWarning("cp-1", null, true)).toContain("does NOT restore the attached workspace folder");
});
diff --git a/client/src/lib/checkpoints.ts b/client/src/lib/checkpoints.ts
index 179d8196..383fd318 100644
--- a/client/src/lib/checkpoints.ts
+++ b/client/src/lib/checkpoints.ts
@@ -1,115 +1,12 @@
-// Restore points for unattended runs.
-//
-// Core gives us exactly two primitives: `project_checkpoint` copies the project
-// directory and returns `{ id }`, and `project_revert` copies one back. There
-// is no RPC that lists checkpoints and none that deletes one, so the ids this
-// panel creates are remembered here — a restore point nobody can name is not a
-// restore point. The same gap means retention below can only bound the *list*;
-// bounding the bytes on disk needs a core method that does not exist yet.
+// Restore-point presentation and confirmation for unattended `/loop` runs.
+// Core owns creation, inventory, pruning, and restoration; keeping inventory
+// server-side means a loop remains recoverable after a reload or in another
+// window.
-export type AutoCheckpointReason = "loop" | "goal";
-
-export interface AutoCheckpoint {
- /** Core's checkpoint id, e.g. `cp-1723600000000`. */
+export interface ListedCheckpoint {
id: string;
- takenAtMs: number;
- reason: AutoCheckpointReason;
- /** The loop/goal objective this checkpoint was taken in front of. */
- objective: string;
-}
-
-/**
- * How long an automatic checkpoint stays fresh enough to skip taking another.
- *
- * Each one is a full copy of the project directory, and a three-day `/loop`
- * would otherwise take hundreds. Fifteen minutes bounds the work a single
- * destructive turn can cost to roughly one iteration while keeping a 72-hour
- * run near 288 copies — already more than anyone wants, which is why the
- * interval sits here rather than at one or five minutes.
- */
-export const AUTO_CHECKPOINT_INTERVAL_MS = 15 * 60_000;
-
-/** Automatic restore points remembered per game. Older ids are forgotten. */
-export const AUTO_CHECKPOINT_KEEP = 10;
-
-/** Objective text kept beside an id, so the list stays one line per entry. */
-const OBJECTIVE_CHARS = 60;
-
-const STORAGE_KEY = "calicode-auto-checkpoints";
-
-type Registry = Record;
-
-function isReason(value: unknown): value is AutoCheckpointReason {
- return value === "loop" || value === "goal";
-}
-
-function toEntry(value: unknown): AutoCheckpoint | null {
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
- const record = value as Record;
- if (typeof record.id !== "string" || record.id.length === 0) return null;
- if (typeof record.takenAtMs !== "number" || !Number.isFinite(record.takenAtMs)) return null;
- if (!isReason(record.reason)) return null;
- return {
- id: record.id,
- takenAtMs: record.takenAtMs,
- reason: record.reason,
- objective: typeof record.objective === "string" ? record.objective : "",
- };
-}
-
-function readRegistry(): Registry {
- try {
- const parsed: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
- const registry: Registry = {};
- for (const [slug, value] of Object.entries(parsed as Record)) {
- if (!Array.isArray(value)) continue;
- registry[slug] = value.flatMap((item) => {
- const entry = toEntry(item);
- return entry ? [entry] : [];
- });
- }
- return registry;
- } catch {
- return {};
- }
-}
-
-function writeRegistry(registry: Registry): void {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(registry));
- } catch {
- // A full or disabled localStorage must not break a run: the checkpoint
- // itself already exists on disk, only the name for it is lost.
- }
-}
-
-/** Newest first, capped at `AUTO_CHECKPOINT_KEEP`. */
-export function pruneAutoCheckpoints(entries: readonly AutoCheckpoint[]): AutoCheckpoint[] {
- return [...entries].sort((left, right) => right.takenAtMs - left.takenAtMs).slice(0, AUTO_CHECKPOINT_KEEP);
-}
-
-export function readAutoCheckpoints(slug: string): AutoCheckpoint[] {
- return pruneAutoCheckpoints(readRegistry()[slug] ?? []);
-}
-
-export function recordAutoCheckpoint(slug: string, entry: AutoCheckpoint): AutoCheckpoint[] {
- const registry = readRegistry();
- const kept = pruneAutoCheckpoints([entry, ...(registry[slug] ?? []).filter((item) => item.id !== entry.id)]);
- registry[slug] = kept;
- writeRegistry(registry);
- return kept;
-}
-
-/** Test seam: drops every remembered id for every game. */
-export function clearAutoCheckpoints(): void {
- writeRegistry({});
-}
-
-/** True when no checkpoint has been taken inside the throttle window. */
-export function dueForCheckpoint(lastAtMs: number | null, nowMs: number): boolean {
- if (lastAtMs === null) return true;
- return nowMs - lastAtMs >= AUTO_CHECKPOINT_INTERVAL_MS;
+ kind: "git" | "project";
+ createdAtMs: number;
}
/**
@@ -120,7 +17,7 @@ export function dueForCheckpoint(lastAtMs: number | null, nowMs: number): boolea
* still carries an age even though this panel never recorded it.
*/
export function checkpointTakenAtMs(id: string): number | null {
- const match = /^cp-(\d+)(?:-\d+)?$/.exec(id);
+ const match = /^(?:cp|git)-(\d+)(?:-\d+)?$/.exec(id);
if (!match) return null;
const millis = Number(match[1]);
return Number.isFinite(millis) && millis > 0 ? millis : null;
@@ -135,23 +32,17 @@ export function formatCheckpointAge(ageMs: number): string {
return `${Math.floor(hours / 24)}d ago`;
}
-function objectiveOf(entry: AutoCheckpoint): string {
- const trimmed = entry.objective.trim();
- if (!trimmed) return "";
- return trimmed.length > OBJECTIVE_CHARS ? `${trimmed.slice(0, OBJECTIVE_CHARS)}…` : trimmed;
-}
-
-export function formatCheckpointList(entries: readonly AutoCheckpoint[], nowMs: number): string {
+export function formatCheckpointList(entries: readonly ListedCheckpoint[], nowMs: number): string {
if (entries.length === 0) {
return [
- "No restore points recorded yet.",
- "One is taken automatically before the first /loop iteration and the first /goal round, then at most one every 15 minutes.",
+ "No restore points exist yet.",
+ "A /loop takes one before its first iteration, then at most one every 15 minutes.",
].join("\n");
}
const lines = entries.map((entry) => {
- const age = formatCheckpointAge(Math.max(0, nowMs - entry.takenAtMs));
- const objective = objectiveOf(entry);
- return `• ${entry.id} — ${age}, before a /${entry.reason} turn${objective ? ` (${objective})` : ""}`;
+ const age = formatCheckpointAge(Math.max(0, nowMs - entry.createdAtMs));
+ const kind = entry.kind === "git" ? "git snapshot" : "project snapshot";
+ return `• ${entry.id} — ${age}, ${kind}`;
});
return [
"Restore points, newest first:",
diff --git a/client/src/lib/goal.test.ts b/client/src/lib/goal.test.ts
deleted file mode 100644
index 419d8200..00000000
--- a/client/src/lib/goal.test.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { describe, expect, test } from "vitest";
-import {
- buildEvaluatorTranscript,
- buildTranscriptWindow,
- formatGoalStatus,
- goalContinuationPrompt,
- goalIsVerifiable,
- parseGoalCommand,
-} from "./goal";
-
-describe("parseGoalCommand", () => {
- test("empty arguments show the current goal", () => {
- expect(parseGoalCommand("")).toEqual({ action: "show" });
- expect(parseGoalCommand(" ")).toEqual({ action: "show" });
- });
-
- test("clear words retire the goal", () => {
- expect(parseGoalCommand("clear")).toEqual({ action: "clear" });
- expect(parseGoalCommand("off")).toEqual({ action: "clear" });
- expect(parseGoalCommand(" DONE ")).toEqual({ action: "clear" });
- });
-
- test("anything else sets the goal, trimmed", () => {
- expect(parseGoalCommand(" ship the double jump ")).toEqual({
- action: "set",
- goal: "ship the double jump",
- });
- });
-
- test("a clear word inside a longer goal still sets", () => {
- expect(parseGoalCommand("clear the level of debris")).toEqual({
- action: "set",
- goal: "clear the level of debris",
- });
- });
-});
-
-describe("buildTranscriptWindow", () => {
- const messages = [
- { role: "user", content: "add a double jump" },
- { role: "assistant", content: "on it" },
- { role: "tool", content: "3 tests passed", tool: "run_tests" },
- ];
-
- test("reports a whole transcript as untruncated", () => {
- expect(buildTranscriptWindow(messages)).toMatchObject({ kept: 3, total: 3, truncated: false });
- });
-
- test("counts what the budget dropped", () => {
- const window = buildTranscriptWindow(messages, 45);
- expect(window).toMatchObject({ kept: 1, total: 3, truncated: true });
- expect(window.text).toContain("3 tests passed");
- });
-
- test("reports a head-clipped single entry as truncated", () => {
- const window = buildTranscriptWindow([{ role: "user", content: "x".repeat(200) }], 40);
- expect(window).toMatchObject({ kept: 1, total: 1, truncated: true });
- });
-
- test("ignores entries with no content when counting", () => {
- const window = buildTranscriptWindow([...messages, { role: "assistant", content: " " }]);
- expect(window).toMatchObject({ kept: 3, total: 3, truncated: false });
- });
-});
-
-describe("buildEvaluatorTranscript", () => {
- const messages = [
- { role: "user", content: "add a double jump" },
- { role: "assistant", content: "on it" },
- { role: "tool", content: "3 tests passed", tool: "run_tests" },
- ];
-
- test("labels every entry by role and names the tool", () => {
- const excerpt = buildEvaluatorTranscript(messages);
- expect(excerpt).toBe("user: add a double jump\nassistant: on it\ntool(run_tests): 3 tests passed");
- });
-
- test("keeps the newest entries and drops the oldest when capped", () => {
- const excerpt = buildEvaluatorTranscript(messages, 45);
- expect(excerpt).toContain("tool(run_tests): 3 tests passed");
- expect(excerpt).not.toContain("add a double jump");
- expect(excerpt.length).toBeLessThanOrEqual(45);
- });
-
- test("truncates the front of a single over-long entry but keeps its label", () => {
- const excerpt = buildEvaluatorTranscript(
- [{ role: "tool", content: `${"x".repeat(200)}THE-END`, tool: "run_tests" }],
- 40,
- );
- expect(excerpt.startsWith("tool(run_tests): …")).toBe(true);
- expect(excerpt.endsWith("THE-END")).toBe(true);
- expect(excerpt.length).toBeLessThanOrEqual(40);
- });
-
- test("skips blank entries", () => {
- const excerpt = buildEvaluatorTranscript([
- { role: "assistant", content: " " },
- { role: "user", content: "keep going" },
- ]);
- expect(excerpt).toBe("user: keep going");
- });
-
- test("returns nothing for an empty transcript or a zero budget", () => {
- expect(buildEvaluatorTranscript([])).toBe("");
- expect(buildEvaluatorTranscript(messages, 0)).toBe("");
- });
-});
-
-describe("goalContinuationPrompt", () => {
- test("restates the goal, the reason, and the coming re-check", () => {
- const prompt = goalContinuationPrompt("ship the jump", "the jump height is still 0", 2);
- expect(prompt).toContain("Goal: ship the jump");
- expect(prompt).toContain("the jump height is still 0");
- expect(prompt).toContain("check 3");
- expect(prompt.split("\n")).toHaveLength(4);
- });
-
- test("reads as an instruction to continue", () => {
- expect(goalContinuationPrompt("x goal", "y reason", 0).startsWith("Keep going")).toBe(true);
- });
-});
-
-describe("formatGoalStatus", () => {
- test("reports no goal", () => {
- expect(formatGoalStatus(null)).toBe("goal: none");
- });
-
- test("a freshly set goal has not been checked", () => {
- expect(formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 0 })).toBe(
- "goal: ship the jump — set",
- );
- });
-
- test("counts checks and singularises one", () => {
- expect(formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 3 })).toBe(
- "goal: ship the jump — unmet (3 checks)",
- );
- expect(formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 1 })).toBe(
- "goal: ship the jump — unmet (1 check)",
- );
- });
-
- test("appends the latest evaluator reason", () => {
- expect(
- formatGoalStatus({ goal: "ship the jump", startedAtMs: 1, evaluations: 2, lastReason: "no jump input yet" }),
- ).toBe("goal: ship the jump — unmet (2 checks): no jump input yet");
- });
-});
-
-describe("goalIsVerifiable", () => {
- test("accepts goals with an observable end state", () => {
- expect(goalIsVerifiable("add a double jump and cover it with a test")).toEqual({ ok: true });
- expect(goalIsVerifiable("hold the frame time under 16ms while 200 crates spawn")).toEqual({ ok: true });
- });
-
- test("hints on goals too short to check", () => {
- const verdict = goalIsVerifiable("fix it");
- expect(verdict.ok).toBe(false);
- expect(verdict.hint).toContain("what should be true");
- });
-
- test("hints on purely subjective wording", () => {
- for (const goal of ["make the platforming feel better", "the lighting should look nicer overall"]) {
- const verdict = goalIsVerifiable(goal);
- expect(verdict.ok).toBe(false);
- expect(verdict.hint).toContain("observable end state");
- }
- });
-
- test("a subjective word paired with a named check passes", () => {
- expect(goalIsVerifiable("make the jump feel better until the jump test passes")).toEqual({ ok: true });
- expect(goalIsVerifiable("improve the frame time to 60 fps")).toEqual({ ok: true });
- });
-
- test("does not flag ordinary goals that merely contain no metrics", () => {
- expect(goalIsVerifiable("replace the placeholder crate mesh with the library one")).toEqual({ ok: true });
- });
-});
diff --git a/client/src/lib/goal.ts b/client/src/lib/goal.ts
deleted file mode 100644
index dfd396af..00000000
--- a/client/src/lib/goal.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-// Session-scoped objective for `/goal`. The goal outlives a turn: after each
-// one a tool-less evaluator judges it, and while it is unmet the evaluator's
-// reason is injected as the next turn's directive. Everything here is pure so
-// the panel owns the state and the RPC.
-
-export interface GoalState {
- goal: string;
- /** Epoch ms when it was set. */
- startedAtMs: number;
- /** Turns evaluated so far. */
- evaluations: number;
- /** Latest evaluator reason, shown in the status line. */
- lastReason?: string;
-}
-
-export type GoalCommand =
- | { action: "set"; goal: string }
- | { action: "show" }
- | { action: "clear" };
-
-/** Words that retire a goal without the evaluator having to agree. */
-const CLEAR_WORDS = new Set(["clear", "off", "done"]);
-
-const DEFAULT_TRANSCRIPT_CHARS = 8000;
-
-/** Parse `/goal` arguments: empty → show, "clear"/"off"/"done" → clear, else set. */
-export function parseGoalCommand(args: string): GoalCommand {
- const trimmed = args.trim();
- if (!trimmed) return { action: "show" };
- if (CLEAR_WORDS.has(trimmed.toLowerCase())) return { action: "clear" };
- return { action: "set", goal: trimmed };
-}
-
-function labelFor(entry: { role: string; tool?: string }): string {
- const role = entry.role.trim().toLowerCase() || "message";
- const tool = entry.tool?.trim();
- // The evaluator has to tell a claim ("I fixed it") from a result, so tool
- // entries carry the tool that produced them.
- return tool ? `${role}(${tool})` : role;
-}
-
-/**
- * Drop the head of an over-long entry but keep its label; a bare tail would
- * leave the evaluator unable to attribute the text.
- */
-function truncateFront(line: string, limit: number): string {
- if (line.length <= limit) return line;
- const separator = line.indexOf(": ");
- const label = separator > 0 ? line.slice(0, separator + 2) : "";
- const budget = limit - label.length - 1;
- if (budget <= 0) return line.slice(line.length - limit);
- return `${label}…${line.slice(line.length - budget)}`;
-}
-
-export interface TranscriptWindow {
- /** The excerpt itself. */
- text: string;
- /** Entries that made the excerpt. */
- kept: number;
- /** Entries with content that could have. */
- total: number;
- /** True when the budget dropped an entry, or clipped the head of one. */
- truncated: boolean;
-}
-
-/**
- * The excerpt plus what it left out.
- *
- * The counts exist so a caller can say so: a reader who cannot tell that the
- * head of a long run was dropped will read a confident answer about a step the
- * model never saw as if it were grounded.
- */
-export function buildTranscriptWindow(
- messages: Array<{ role: string; content: string; tool?: string }>,
- maxChars = DEFAULT_TRANSCRIPT_CHARS,
-): TranscriptWindow {
- const total = messages.filter((entry) => entry.content?.trim()).length;
- const limit = Math.floor(maxChars);
- if (!Number.isFinite(limit) || limit <= 0) {
- return { text: "", kept: 0, total, truncated: total > 0 };
- }
- const kept: string[] = [];
- let used = 0;
- let clipped = false;
- // Walk backwards: the recent turns decide whether the goal is met, so the
- // budget is spent from the newest entry outwards and the head is dropped.
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const entry = messages[index];
- const content = entry.content?.trim();
- if (!content) continue;
- const line = `${labelFor(entry)}: ${content}`;
- const cost = kept.length ? line.length + 1 : line.length;
- if (used + cost <= limit) {
- kept.unshift(line);
- used += cost;
- continue;
- }
- if (!kept.length) {
- kept.push(truncateFront(line, limit));
- clipped = true;
- }
- break;
- }
- return { text: kept.join("\n"), kept: kept.length, total, truncated: clipped || kept.length < total };
-}
-
-/** The excerpt handed to the evaluator: the tail of the transcript, capped. */
-export function buildEvaluatorTranscript(
- messages: Array<{ role: string; content: string; tool?: string }>,
- maxChars = DEFAULT_TRANSCRIPT_CHARS,
-): string {
- return buildTranscriptWindow(messages, maxChars).text;
-}
-
-/** The directive injected as the next turn's user message when a goal is unmet. */
-export function goalContinuationPrompt(goal: string, reason: string, evaluations: number): string {
- const check = Math.max(0, Math.floor(evaluations)) + 1;
- return [
- "Keep going — your goal is not met yet.",
- `Goal: ${goal.trim()}`,
- `Not met because: ${reason.trim()}`,
- `Fix exactly that, then continue. It is re-checked after this turn (check ${check}).`,
- ].join("\n");
-}
-
-/** One-line status for the transcript, e.g. `goal: ship the jump — unmet (3 checks)`. */
-export function formatGoalStatus(state: GoalState | null): string {
- if (!state) return "goal: none";
- const goal = state.goal.trim();
- const evaluations = Math.max(0, Math.floor(state.evaluations));
- // A met goal is cleared by the caller, so any surviving state is unmet.
- const head =
- evaluations === 0
- ? `goal: ${goal} — set`
- : `goal: ${goal} — unmet (${evaluations === 1 ? "1 check" : `${evaluations} checks`})`;
- const reason = state.lastReason?.trim();
- return reason ? `${head}: ${reason}` : head;
-}
-
-/**
- * Words that describe a feeling rather than an end state. A tool-less
- * evaluator cannot decide "nicer", so it would either never pass or pass
- * arbitrarily.
- */
-const SUBJECTIVE_WORDS = [
- "better",
- "nicer",
- "nice",
- "good",
- "great",
- "cool",
- "pretty",
- "prettier",
- "polished",
- "polish",
- "awesome",
- "fun",
- "clean",
- "improve",
- "improved",
-];
-
-/**
- * Cues that a check was named anyway ("looks better once the jump test
- * passes"). Their presence keeps a subjective word from tripping the hint.
- */
-const CHECK_CUES = ["test", "tests", "passes", "pass", "assert", "until", "verify", "check", "fps", "ms", "error", "errors"];
-
-const MIN_GOAL_CHARS = 12;
-
-/**
- * Advisory only — the caller still sets the goal. This is a hint printed
- * beside it, never a block, because the heuristic is deliberately crude and
- * would otherwise reject perfectly good goals it does not understand.
- */
-export function goalIsVerifiable(goal: string): { ok: boolean; hint?: string } {
- const trimmed = goal.trim();
- if (trimmed.length < MIN_GOAL_CHARS) {
- return {
- ok: false,
- hint: "That is short enough that I cannot tell when it is done. Say what should be true at the end.",
- };
- }
- const words = trimmed.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
- const vague = SUBJECTIVE_WORDS.find((word) => words.includes(word));
- if (!vague) return { ok: true };
- const hasCheck = words.some((word) => CHECK_CUES.includes(word)) || /\d/.test(trimmed);
- if (hasCheck) return { ok: true };
- return {
- ok: false,
- hint: `"${vague}" has no observable end state. Name the check that decides it, e.g. "…until the jump test passes".`,
- };
-}
diff --git a/client/src/lib/loopGuards.test.ts b/client/src/lib/loopGuards.test.ts
deleted file mode 100644
index 1f4c95ad..00000000
--- a/client/src/lib/loopGuards.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { budgetNotice, detectStall, exhaustionPrompt, type LoopAction } from "./loopGuards";
-
-const action = (tool: string, signature: string, failed?: boolean): LoopAction => ({ tool, signature, failed });
-
-const repeat = (count: number, value: LoopAction): LoopAction[] => Array.from({ length: count }, () => ({ ...value }));
-
-describe("detectStall", () => {
- test("no actions is ok", () => {
- expect(detectStall([])).toEqual({ level: "ok", reason: "" });
- });
-
- test("a varied sequence stays ok", () => {
- const verdict = detectStall([
- action("read_file", "a.ts"),
- action("edit_file", "a.ts"),
- action("run_tests", "unit"),
- action("read_file", "b.ts"),
- ]);
- expect(verdict.level).toBe("ok");
- expect(verdict.reason).toBe("");
- });
-
- test("the same tool with different arguments is progress, not a stall", () => {
- const verdict = detectStall([
- action("read_file", "a.ts"),
- action("read_file", "b.ts"),
- action("read_file", "c.ts"),
- action("read_file", "d.ts"),
- action("read_file", "e.ts"),
- ]);
- expect(verdict.level).toBe("ok");
- });
-
- test("warns at two identical actions in a row", () => {
- const verdict = detectStall(repeat(2, action("run_tests", "unit")));
- expect(verdict.level).toBe("warn");
- expect(verdict.reason).toContain("run_tests");
- expect(verdict.reason).toContain("2 times");
- });
-
- test("blocks at five identical actions in a row", () => {
- const verdict = detectStall(repeat(5, action("run_tests", "unit")));
- expect(verdict.level).toBe("block");
- expect(verdict.reason).toContain("5 times");
- });
-
- test("an intervening different action resets the run", () => {
- const verdict = detectStall([
- ...repeat(4, action("run_tests", "unit")),
- action("edit_file", "player.ts"),
- action("run_tests", "unit"),
- ]);
- expect(verdict.level).toBe("ok");
- });
-
- test("counts consecutive failures across different actions", () => {
- const warn = detectStall([
- action("read_file", "a.ts"),
- action("edit_file", "a.ts", true),
- action("run_tests", "unit", true),
- ]);
- expect(warn.level).toBe("warn");
- expect(warn.reason).toContain("2 steps");
- expect(warn.reason).toContain("run_tests");
-
- const block = detectStall([
- action("edit_file", "a.ts", true),
- action("edit_file", "b.ts", true),
- action("run_tests", "unit", true),
- action("edit_file", "c.ts", true),
- action("run_tests", "e2e", true),
- ]);
- expect(block.level).toBe("block");
- expect(block.reason).toContain("5 steps");
- });
-
- test("a successful step ends the failure run", () => {
- const verdict = detectStall([
- action("edit_file", "a.ts", true),
- action("edit_file", "b.ts", true),
- action("run_tests", "unit"),
- ]);
- expect(verdict.level).toBe("ok");
- });
-
- test("thresholds are configurable", () => {
- const actions = repeat(3, action("run_tests", "unit"));
- expect(detectStall(actions, { warnAfter: 4, blockAfter: 6 }).level).toBe("ok");
- expect(detectStall(actions, { warnAfter: 2, blockAfter: 3 }).level).toBe("block");
- });
-
- test("repeated failing calls report the repetition", () => {
- const verdict = detectStall(repeat(3, action("run_tests", "unit", true)));
- expect(verdict.level).toBe("warn");
- expect(verdict.reason).toContain("identical arguments");
- });
-});
-
-describe("budgetNotice", () => {
- test("states used and remaining iterations", () => {
- expect(budgetNotice(3, 12)).toContain("iteration 3 of 12");
- expect(budgetNotice(3, 12)).toContain("9 iterations remaining");
- });
-
- test("singularises the last iteration", () => {
- expect(budgetNotice(11, 12)).toContain("1 iteration remaining");
- });
-
- test("tells the model to wrap up when the budget is gone", () => {
- expect(budgetNotice(12, 12)).toContain("none remaining");
- expect(budgetNotice(99, 12)).toContain("iteration 12 of 12");
- });
-});
-
-describe("exhaustionPrompt", () => {
- test("asks for a handoff instead of more tool calls", () => {
- const prompt = exhaustionPrompt(" ship the double jump ", 25);
- expect(prompt).toContain("25 iterations");
- expect(prompt).toContain("Goal: ship the double jump");
- expect(prompt).toContain("Stop calling tools");
- expect(prompt).toContain("what still remains");
- expect(prompt).toContain("exact next step");
- });
-});
diff --git a/client/src/lib/loopGuards.ts b/client/src/lib/loopGuards.ts
deleted file mode 100644
index df710696..00000000
--- a/client/src/lib/loopGuards.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-// Runaway detection for `/loop`. An iteration counter alone cannot separate a
-// loop that is working from one retrying the same failing call, so the count is
-// paired with two cheap signals — identical repeated actions and repeated
-// failures — and the cap ends in a handoff rather than a hard stop.
-
-export interface LoopAction {
- /** Tool name, or a stable label for the step. */
- tool: string;
- /** Stable signature of the arguments — the caller stringifies. */
- signature: string;
- /** Did the step fail? */
- failed?: boolean;
-}
-
-export interface StallVerdict {
- /** "ok" | "warn" | "block" */
- level: "ok" | "warn" | "block";
- /** Human sentence naming what is repeating; empty when level is "ok". */
- reason: string;
-}
-
-const DEFAULT_WARN_AFTER = 2;
-const DEFAULT_BLOCK_AFTER = 5;
-
-const OK: StallVerdict = { level: "ok", reason: "" };
-
-function severity(level: StallVerdict["level"]): number {
- return level === "block" ? 2 : level === "warn" ? 1 : 0;
-}
-
-function levelForRun(run: number, warnAfter: number, blockAfter: number): StallVerdict["level"] {
- if (run >= blockAfter) return "block";
- if (run >= warnAfter) return "warn";
- return "ok";
-}
-
-function plural(count: number, unit: string): string {
- return `${count} ${unit}${count === 1 ? "" : "s"}`;
-}
-
-/**
- * Repeated identical actions and repeated failures across recent iterations.
- *
- * Only the run at the tail counts: a false positive kills a healthy loop, which
- * is far worse than letting one extra repeat through, so anything varied —
- * including a repeat that has since been broken by a different action — stays
- * "ok".
- */
-export function detectStall(
- actions: LoopAction[],
- options?: { warnAfter?: number; blockAfter?: number },
-): StallVerdict {
- // A run of one is just an action, so thresholds below 2 are meaningless and
- // would flag every loop on its first step.
- const warnAfter = Math.max(2, Math.floor(options?.warnAfter ?? DEFAULT_WARN_AFTER));
- const blockAfter = Math.max(warnAfter, Math.floor(options?.blockAfter ?? DEFAULT_BLOCK_AFTER));
- if (!actions.length) return OK;
-
- const last = actions[actions.length - 1];
- let repeats = 1;
- for (let index = actions.length - 2; index >= 0; index -= 1) {
- const action = actions[index];
- if (action.tool !== last.tool || action.signature !== last.signature) break;
- repeats += 1;
- }
-
- let failures = 0;
- for (let index = actions.length - 1; index >= 0; index -= 1) {
- if (!actions[index].failed) break;
- failures += 1;
- }
-
- const repeatLevel = levelForRun(repeats, warnAfter, blockAfter);
- const failureLevel = levelForRun(failures, warnAfter, blockAfter);
- if (severity(repeatLevel) >= severity(failureLevel)) {
- if (repeatLevel === "ok") return OK;
- return {
- level: repeatLevel,
- reason: `Ran the same \`${last.tool}\` call with identical arguments ${plural(repeats, "time")} in a row.`,
- };
- }
- return {
- level: failureLevel,
- reason: `The last ${plural(failures, "step")} all failed, most recently \`${last.tool}\`.`,
- };
-}
-
-/** Remaining-budget line so the model can pace itself and wrap up deliberately. */
-export function budgetNotice(iteration: number, cap: number): string {
- const total = Math.max(0, Math.floor(cap));
- const used = Math.min(total, Math.max(0, Math.floor(iteration)));
- const remaining = total - used;
- if (remaining <= 0) {
- return `Budget: iteration ${used} of ${total} — none remaining. Finish and summarise now.`;
- }
- return `Budget: iteration ${used} of ${total} — ${plural(remaining, "iteration")} remaining. Pace the work so it lands inside that budget.`;
-}
-
-/** The final, tools-stripped instruction when a loop runs out of budget. */
-export function exhaustionPrompt(goal: string, iterations: number): string {
- const used = Math.max(0, Math.floor(iterations));
- return [
- `The iteration budget is spent after ${plural(used, "iteration")} and the goal is not confirmed done.`,
- `Goal: ${goal.trim()}`,
- "Stop calling tools. Reply with a handoff: what you accomplished, what still remains, and the exact next step to take.",
- ].join("\n");
-}
diff --git a/client/src/lib/loopReports.ts b/client/src/lib/loopReports.ts
index f1ab2d68..3287f7da 100644
--- a/client/src/lib/loopReports.ts
+++ b/client/src/lib/loopReports.ts
@@ -159,7 +159,6 @@ export interface LoopRun {
profile: "standard" | "aaa";
status: "running" | "completed" | "stopped" | "failed";
iteration: number;
- maxIterations: number;
startedAtMs: number;
intervalMs?: number;
sessionId?: string;
diff --git a/client/src/lib/rpc.ts b/client/src/lib/rpc.ts
index 79097cb9..26f4e713 100644
--- a/client/src/lib/rpc.ts
+++ b/client/src/lib/rpc.ts
@@ -174,7 +174,6 @@ export type AgentEvent = {
loopId?: string;
/** `loop.iteration` only. */
iteration?: number;
- maxIterations?: number;
/** `loop.iteration` only: the prompt core is about to send. */
prompt?: string;
/** `loop.finished` only: the run's terminal view (see `LoopRun`). */
diff --git a/client/src/lib/slashCommands.test.ts b/client/src/lib/slashCommands.test.ts
index 8822cbc4..c0b536b1 100644
--- a/client/src/lib/slashCommands.test.ts
+++ b/client/src/lib/slashCommands.test.ts
@@ -52,6 +52,11 @@ describe("parseSlash", () => {
expect(parsed?.name).toBe("nope");
expect(parsed?.command).toBeNull();
});
+
+ test("does not expose the retired /goal command", () => {
+ expect(parseSlash("/goal ship it")?.command).toBeNull();
+ expect(SLASH_COMMANDS.some((command) => command.name === "goal")).toBe(false);
+ });
});
describe("/side", () => {
@@ -288,7 +293,7 @@ describe("runsBare", () => {
test("every other built-in completes into the composer instead", () => {
// Named explicitly because these are the ones that used to fire the moment
// the word was spelled, before any instructions could be added to them.
- for (const name of ["compact", "clear", "goal", "usage", "diff", "loop", "model", "restore"]) {
+ for (const name of ["compact", "clear", "usage", "diff", "loop", "model", "restore"]) {
expect(runsBare(SLASH_COMMANDS.find((command) => command.name === name)!)).toBe(false);
}
});
@@ -333,6 +338,12 @@ describe("/loop", () => {
expect(runLoop).not.toHaveBeenCalled();
expect(say).toHaveBeenCalled();
});
+
+ test("documents completion and Stop without promising an iteration cap", () => {
+ run("/loop");
+ expect(say.mock.calls[0][0]).toContain("until the goal is met or you stop the run");
+ expect(say.mock.calls[0][0]).not.toContain("iteration cap");
+ });
});
describe("/compact", () => {
diff --git a/client/src/lib/slashCommands.ts b/client/src/lib/slashCommands.ts
index f798dc6a..34607160 100644
--- a/client/src/lib/slashCommands.ts
+++ b/client/src/lib/slashCommands.ts
@@ -1,7 +1,6 @@
import { parseRestoreArgs } from "./checkpoints";
import { parseInterval, parseLoopArgs, type LoopProfile } from "./interval";
import type { FileCommandInfo, SkillInfo } from "./extensions";
-import { parseGoalCommand, type GoalCommand } from "./goal";
import type { CommandPanel } from "./types";
// Slash-command registry for the agent panel — the harness surface that codex,
@@ -58,8 +57,6 @@ export interface SlashContext {
runGraphGoal: (goal: string, template?: string) => Promise;
/** Cancel the active task graph, if any. */
stopGraph: () => void | Promise;
- /** Set, show or clear the session goal (see lib/goal). */
- runGoalCommand: (command: GoalCommand) => void | Promise;
/** Print the restore points automatic checkpointing recorded. */
listCheckpoints: () => void | Promise;
/**
@@ -113,17 +110,6 @@ export interface SlashCommand extends NamedCommand {
run: (args: string, ctx: SlashContext) => void | Promise;
}
-/**
- * Iterations `/loop` will run before it stops asking for tools and hands back
- * a summary. This is a runaway backstop, not a product limit: the loop's real
- * exit is its completion gate, and the real runaway guard is `detectStall`
- * in lib/loopGuards — a counter only catches a stuck loop by accident, since
- * twenty iterations of genuine progress and twenty repeats of one failing
- * action look identical to it. Comparable harnesses bear this out: Codex and
- * opencode ship no cap at all, and Hermes — the one that does — sits at 90.
- */
-export const MAX_LOOP_ITERATIONS = 100;
-
export const SLASH_COMMANDS: readonly SlashCommand[] = [
{
name: "help",
@@ -157,7 +143,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
// the loop would spend an hour chasing the goal "15m".
if (!goal || parseInterval(goal) !== null) {
ctx.say(
- "Usage: /loop — I'll keep working until the goal is met or I hit the iteration cap.\n" +
+ "Usage: /loop — I'll keep working until the goal is met or you stop the run.\n" +
"/loop — same work, paced: `/loop 15m run the tests and fix what fails` keeps re-checking every 15m until you stop it. Units are s, m, h.\n" +
"/loop --aaa — the quality pipeline: a specialist task graph, PIE evidence, and a judge that has to score the result before DONE is believed.",
);
@@ -231,12 +217,6 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
return ctx.runGraphGoal(match[2].trim(), match[1]);
},
},
- {
- name: "goal",
- summary: "Keep working toward a goal, re-checked after every turn",
- usage: "[objective | clear]",
- run: (args, ctx) => ctx.runGoalCommand(parseGoalCommand(args)),
- },
{
name: "graph-stop",
summary: "Cancel the running task graph",
diff --git a/client/src/lib/transcript.test.ts b/client/src/lib/transcript.test.ts
new file mode 100644
index 00000000..51b73102
--- /dev/null
+++ b/client/src/lib/transcript.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vitest";
+import { buildTranscriptWindow } from "./transcript";
+
+describe("buildTranscriptWindow", () => {
+ const messages = [
+ { role: "user", content: "add a double jump" },
+ { role: "assistant", content: "on it" },
+ { role: "tool", content: "3 tests passed", tool: "run_tests" },
+ ];
+
+ test("labels a whole transcript and reports it as untruncated", () => {
+ expect(buildTranscriptWindow(messages)).toEqual({
+ text: "user: add a double jump\nassistant: on it\ntool(run_tests): 3 tests passed",
+ kept: 3,
+ total: 3,
+ truncated: false,
+ });
+ });
+
+ test("keeps the newest entries and reports what the budget dropped", () => {
+ const window = buildTranscriptWindow(messages, 45);
+ expect(window).toMatchObject({ kept: 1, total: 3, truncated: true });
+ expect(window.text).toContain("tool(run_tests): 3 tests passed");
+ expect(window.text).not.toContain("add a double jump");
+ expect(window.text.length).toBeLessThanOrEqual(45);
+ });
+
+ test("clips the front of a single long entry but keeps its label", () => {
+ const window = buildTranscriptWindow(
+ [{ role: "tool", content: `${"x".repeat(200)}THE-END`, tool: "run_tests" }],
+ 40,
+ );
+ expect(window).toMatchObject({ kept: 1, total: 1, truncated: true });
+ expect(window.text.startsWith("tool(run_tests): …")).toBe(true);
+ expect(window.text.endsWith("THE-END")).toBe(true);
+ });
+
+ test("ignores blank entries and handles a zero budget", () => {
+ const withBlank = [...messages, { role: "assistant", content: " " }];
+ expect(buildTranscriptWindow(withBlank)).toMatchObject({ kept: 3, total: 3, truncated: false });
+ expect(buildTranscriptWindow(messages, 0)).toEqual({ text: "", kept: 0, total: 3, truncated: true });
+ });
+});
diff --git a/client/src/lib/transcript.ts b/client/src/lib/transcript.ts
new file mode 100644
index 00000000..673d40c9
--- /dev/null
+++ b/client/src/lib/transcript.ts
@@ -0,0 +1,65 @@
+const DEFAULT_TRANSCRIPT_CHARS = 8000;
+
+function labelFor(entry: { role: string; tool?: string }): string {
+ const role = entry.role.trim().toLowerCase() || "message";
+ const tool = entry.tool?.trim();
+ // Tool entries carry their source so a claim and a command result remain
+ // distinguishable after the transcript is flattened to text.
+ return tool ? `${role}(${tool})` : role;
+}
+
+/** Drop the head of an over-long entry while retaining its source label. */
+function truncateFront(line: string, limit: number): string {
+ if (line.length <= limit) return line;
+ const separator = line.indexOf(": ");
+ const label = separator > 0 ? line.slice(0, separator + 2) : "";
+ const budget = limit - label.length - 1;
+ if (budget <= 0) return line.slice(line.length - limit);
+ return `${label}…${line.slice(line.length - budget)}`;
+}
+
+export interface TranscriptWindow {
+ text: string;
+ /** Entries that made the excerpt. */
+ kept: number;
+ /** Non-empty entries that could have made it. */
+ total: number;
+ /** True when an entry was dropped or its head was clipped. */
+ truncated: boolean;
+}
+
+/**
+ * Build a labelled, newest-first-budgeted excerpt while preserving the
+ * original order of everything retained.
+ */
+export function buildTranscriptWindow(
+ messages: Array<{ role: string; content: string; tool?: string }>,
+ maxChars = DEFAULT_TRANSCRIPT_CHARS,
+): TranscriptWindow {
+ const total = messages.filter((entry) => entry.content?.trim()).length;
+ const limit = Math.floor(maxChars);
+ if (!Number.isFinite(limit) || limit <= 0) {
+ return { text: "", kept: 0, total, truncated: total > 0 };
+ }
+ const kept: string[] = [];
+ let used = 0;
+ let clipped = false;
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const entry = messages[index];
+ const content = entry.content?.trim();
+ if (!content) continue;
+ const line = `${labelFor(entry)}: ${content}`;
+ const cost = kept.length ? line.length + 1 : line.length;
+ if (used + cost <= limit) {
+ kept.unshift(line);
+ used += cost;
+ continue;
+ }
+ if (!kept.length) {
+ kept.push(truncateFront(line, limit));
+ clipped = true;
+ }
+ break;
+ }
+ return { text: kept.join("\n"), kept: kept.length, total, truncated: clipped || kept.length < total };
+}
diff --git a/client/src/lib/viewState.ts b/client/src/lib/viewState.ts
index a576266c..0dba9d58 100644
--- a/client/src/lib/viewState.ts
+++ b/client/src/lib/viewState.ts
@@ -27,8 +27,9 @@ export function withIntroducedTabs(
seen: readonly Tab[],
catalogue: readonly Tab[],
): Tab[] {
- // An empty strip means "no preference recorded", not "the user closed
- // everything" — a dock with no tabs has nothing to show and no way back.
+ // Callers that use this merge helper treat an empty strip as "no preference
+ // recorded". The app's first-use path handles its intentional blank state
+ // before calling here, so a chooser always remains available.
if (stored.length === 0) return [...catalogue];
const introduced = catalogue.filter((view) => !seen.includes(view));
return [...new Set([...stored, ...introduced])];
diff --git a/core/src/advisor.rs b/core/src/advisor.rs
index 906c90c2..00108250 100644
--- a/core/src/advisor.rs
+++ b/core/src/advisor.rs
@@ -59,6 +59,27 @@ const MAX_TOOL_ROUNDS: usize = 4;
const EMPTY_REPLY: &str =
"The advisor returned an empty reply. Ask again, or narrow the question to one part of the run.";
+/// Server-side ceiling on the observed transcript excerpt. The client
+/// truncates first; this keeps a buggy or non-browser client from crowding the
+/// operator's actual question out of the context window.
+const MAX_TRANSCRIPT_CHARS: usize = 12_000;
+
+/// Keep the tail of an over-long excerpt: the newest activity is what an
+/// explanation usually turns on. Character indexing keeps the returned slice
+/// on a UTF-8 boundary.
+fn clamp_transcript(transcript: &str) -> (&str, bool) {
+ let total = transcript.chars().count();
+ if total <= MAX_TRANSCRIPT_CHARS {
+ return (transcript, false);
+ }
+ let start = transcript
+ .char_indices()
+ .nth(total - MAX_TRANSCRIPT_CHARS)
+ .map(|(index, _)| index)
+ .unwrap_or(0);
+ (&transcript[start..], true)
+}
+
/// One question about a run: the thread so far plus everything that scopes it.
#[derive(Default)]
pub struct AdvisorRequest<'a> {
@@ -79,9 +100,9 @@ pub struct AdvisorRequest<'a> {
/// Load config the way the other model-backed RPCs do and ask once.
pub async fn advise(state: &AppState, request: AdvisorRequest<'_>) -> Result {
let mut config = { state.config.read().await.clone() };
- // Explaining a run is judging one, so it follows the same `judge` role
- // routing `goal_evaluate` uses: an operator who mapped a cheap model to
- // that role gets it here too. Unmapped, this is a no-op.
+ // Explaining a run is judging one, so it follows the graph judge's role
+ // routing. An operator who mapped a cheap model to that role gets it here
+ // too. Unmapped, this is a no-op.
crate::config::apply_role_model(&mut config, &["judge".to_string()]);
if let Some((provider, model)) = request.model {
apply_model_choice(&mut config, provider, model)?;
@@ -352,7 +373,7 @@ fn read_history(messages: &[Value]) -> Vec {
/// can never be mistaken for the operator speaking, and so the replayed
/// history keeps strict user/assistant alternation.
fn build_system(transcript: &str, project_slug: Option<&str>, anchor: Option<&str>) -> String {
- let (excerpt, truncated) = crate::goal::clamp_transcript(transcript);
+ let (excerpt, truncated) = clamp_transcript(transcript);
let mut prompt = String::from(SYSTEM_PROMPT);
prompt.push_str("\n\nOBSERVED SESSION");
if let Some(slug) = project_slug.map(str::trim).filter(|slug| !slug.is_empty()) {
@@ -394,7 +415,6 @@ fn clamp_anchor(anchor: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use crate::goal::MAX_TRANSCRIPT_CHARS;
use axum::response::sse::{Event, Sse};
use axum::routing::post;
use axum::Router;
@@ -939,6 +959,14 @@ mod tests {
assert!(longest_run(sent, 'y') < MAX_TRANSCRIPT_CHARS);
}
+ #[test]
+ fn transcript_clamping_keeps_utf8_intact() {
+ let transcript = "é".repeat(MAX_TRANSCRIPT_CHARS + 500);
+ let (kept, truncated) = clamp_transcript(&transcript);
+ assert!(truncated);
+ assert_eq!(kept.chars().count(), MAX_TRANSCRIPT_CHARS);
+ }
+
#[tokio::test]
async fn an_empty_history_is_rejected_before_any_provider_call() {
let (config, requests) = mock_config("sure.").await;
diff --git a/core/src/goal.rs b/core/src/goal.rs
deleted file mode 100644
index f8d77bdf..00000000
--- a/core/src/goal.rs
+++ /dev/null
@@ -1,471 +0,0 @@
-//! Tool-less goal verifier behind the client's `/goal` command.
-//!
-//! After each turn the client hands core the objective plus a transcript
-//! excerpt and gets back `{ met, reason }`. When `met` is false the reason is
-//! phrased as the next directive, so the client can feed it straight back to
-//! the worker.
-//!
-//! The evaluator is deliberately given no tools. It can only judge what the
-//! worker's own output already demonstrates, which forces goals to be stated
-//! as observable end states and stops the worker from grading its own homework
-//! with fresh tool calls.
-//!
-//! Parsing fails *closed*: anything we cannot read as a verdict becomes
-//! `met: false`. A verifier that fails open is worse than no verifier, because
-//! it silently declares victory.
-
-use crate::config::AppConfig;
-use crate::model;
-use crate::AppState;
-use anyhow::Result;
-use serde_json::{json, Value};
-
-/// Server-side ceiling on the transcript excerpt, in characters. The client
-/// truncates first; this exists so a client bug cannot blow the context of
-/// what is meant to be a cheap per-turn check.
-pub const MAX_TRANSCRIPT_CHARS: usize = 12_000;
-
-const SYSTEM_PROMPT: &str = "You are a strict, sceptical verifier. You have no tools: you cannot run commands, read files, or inspect anything yourself. You judge exactly one question — does the transcript excerpt contain evidence that the stated goal has actually been achieved?
-
-Rules:
-- Reply with a single JSON object and nothing else: {\"met\": true, \"reason\": \"...\"} or {\"met\": false, \"reason\": \"...\"}. No prose, no code fences, no second object.
-- Evidence means the transcript shows the observable end state the goal describes: command output, file contents, a test summary, an error that is demonstrably gone. A claim of success with nothing demonstrating it is not evidence — answer met: false.
-- Partial progress is not achievement. If any part of the goal is unproven, answer met: false.
-- When met is false, write reason as one sentence addressed to the worker as its next instruction, naming the missing evidence and the action that would produce it. Example: \"The tests were never run — run them and paste the output.\"
-- When met is true, write reason as one sentence stating the evidence that satisfied you. Example: \"cargo test printed 'test result: ok. 555 passed' in the transcript.\"
-- Never ask a question, never request permission, never explain yourself outside the JSON object.";
-
-/// A read verdict. `reason` is always non-empty.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Verdict {
- pub met: bool,
- pub reason: String,
-}
-
-impl Verdict {
- fn to_json(&self) -> Value {
- json!({ "met": self.met, "reason": self.reason })
- }
-}
-
-/// The verdict returned when the model's reply cannot be read.
-fn unreadable() -> Verdict {
- Verdict {
- met: false,
- reason: "The evaluator's reply could not be read as a verdict, so the goal is not confirmed — restate the goal as a concrete observable end state and try again.".into(),
- }
-}
-
-/// Load config the way the other model-backed RPCs do and ask once.
-pub async fn evaluate(
- state: &AppState,
- goal: &str,
- transcript: &str,
- project_slug: Option<&str>,
-) -> Result {
- let mut config = { state.config.read().await.clone() };
- // A per-turn verifier runs on every turn, so it follows the same `judge`
- // role routing the graph judge uses: operators who mapped a cheap model to
- // that role get it here too. Unmapped, this is a no-op.
- crate::config::apply_role_model(&mut config, &["judge".to_string()]);
- evaluate_with_config(&config, goal, transcript, project_slug).await
-}
-
-async fn evaluate_with_config(
- config: &AppConfig,
- goal: &str,
- transcript: &str,
- project_slug: Option<&str>,
-) -> Result {
- let goal = goal.trim();
- if goal.is_empty() {
- anyhow::bail!("goal must not be empty");
- }
- let messages = vec![
- json!({ "role": "system", "content": SYSTEM_PROMPT }),
- json!({ "role": "user", "content": build_prompt(goal, transcript, project_slug) }),
- ];
- // `None` for tools is the whole design: the evaluator physically cannot
- // call one, so it can only judge evidence already in the transcript.
- let result =
- model::chat_with_effort_session_once(config, &messages, None, None, None, None).await?;
- Ok(read_verdict(&result.content).to_json())
-}
-
-/// Keep the tail of an over-long excerpt: the most recent activity is the
-/// evidence a verdict turns on. Returns the kept slice and whether anything
-/// was dropped. Shared with `advisor.rs`, which caps the same excerpt for the
-/// same reason.
-pub fn clamp_transcript(transcript: &str) -> (&str, bool) {
- let total = transcript.chars().count();
- if total <= MAX_TRANSCRIPT_CHARS {
- return (transcript, false);
- }
- let start = transcript
- .char_indices()
- .nth(total - MAX_TRANSCRIPT_CHARS)
- .map(|(index, _)| index)
- .unwrap_or(0);
- (&transcript[start..], true)
-}
-
-fn build_prompt(goal: &str, transcript: &str, project_slug: Option<&str>) -> String {
- let (excerpt, truncated) = clamp_transcript(transcript);
- let mut prompt = String::new();
- if let Some(slug) = project_slug.map(str::trim).filter(|slug| !slug.is_empty()) {
- prompt.push_str("PROJECT: ");
- prompt.push_str(slug);
- prompt.push_str("\n\n");
- }
- prompt.push_str("GOAL:\n");
- prompt.push_str(goal);
- prompt.push_str("\n\nTRANSCRIPT EXCERPT (oldest first, most recent last):\n");
- if truncated {
- prompt.push_str("[earlier transcript omitted]\n");
- }
- if excerpt.trim().is_empty() {
- prompt.push_str("(the excerpt is empty — nothing has been demonstrated yet)");
- } else {
- prompt.push_str(excerpt);
- }
- prompt.push_str("\n\nAnswer with the JSON object only.");
- prompt
-}
-
-/// Read a verdict out of a model reply, falling back to `met: false`.
-pub fn read_verdict(reply: &str) -> Verdict {
- parse_verdict(reply).unwrap_or_else(unreadable)
-}
-
-/// Scan for the first balanced `{...}` block that carries a readable `met`.
-///
-/// Models wrap the answer in prose or fences, and a chatty one quotes an
-/// unrelated object first, so a merely-valid object is not enough — the block
-/// must actually look like a verdict.
-fn parse_verdict(reply: &str) -> Option {
- for candidate in json_objects(reply) {
- let Some(met) = candidate.get("met").and_then(coerce_bool) else {
- continue;
- };
- let reason = candidate
- .get("reason")
- .and_then(Value::as_str)
- .map(str::trim)
- .filter(|reason| !reason.is_empty())
- .map(str::to_string)
- .unwrap_or_else(|| {
- if met {
- "The evaluator reported the goal met but gave no reason.".into()
- } else {
- "The evaluator reported the goal not met but gave no reason — restate the goal as a concrete observable end state.".into()
- }
- });
- return Some(Verdict { met, reason });
- }
- None
-}
-
-/// `true`/`"true"`/`"yes"`/`1` and their negatives. Anything else is
-/// unreadable rather than a guess, and an unreadable field never becomes met.
-fn coerce_bool(value: &Value) -> Option {
- match value {
- Value::Bool(flag) => Some(*flag),
- Value::String(text) => match text.trim().to_ascii_lowercase().as_str() {
- "true" | "yes" | "y" => Some(true),
- "false" | "no" | "n" => Some(false),
- _ => None,
- },
- Value::Number(number) => match number.as_i64() {
- Some(1) => Some(true),
- Some(0) => Some(false),
- _ => None,
- },
- _ => None,
- }
-}
-
-/// Every balanced, parseable `{...}` block in `text`, in order. Brace matching
-/// is string-aware so `{"reason": "expected }"}` stays intact.
-fn json_objects(text: &str) -> Vec {
- let mut found = Vec::new();
- for (start, _) in text.char_indices().filter(|(_, ch)| *ch == '{') {
- let mut depth = 0usize;
- let mut in_string = false;
- let mut escaped = false;
- for (offset, ch) in text[start..].char_indices() {
- if in_string {
- if escaped {
- escaped = false;
- } else if ch == '\\' {
- escaped = true;
- } else if ch == '"' {
- in_string = false;
- }
- continue;
- }
- match ch {
- '"' => in_string = true,
- '{' => depth += 1,
- '}' => {
- depth -= 1;
- if depth == 0 {
- let candidate = &text[start..start + offset + ch.len_utf8()];
- if let Ok(value) = serde_json::from_str::(candidate) {
- found.push(value);
- }
- break;
- }
- }
- _ => {}
- }
- }
- }
- found
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use axum::response::sse::{Event, Sse};
- use axum::routing::post;
- use axum::Router;
- use std::convert::Infallible;
- use std::sync::Arc;
- use std::sync::Mutex;
-
- #[test]
- fn clean_json_parses_both_ways() {
- let met = read_verdict(r#"{"met": true, "reason": "cargo test printed 555 passed."}"#);
- assert!(met.met);
- assert_eq!(met.reason, "cargo test printed 555 passed.");
-
- let unmet = read_verdict(r#"{"met": false, "reason": "Run the tests and paste output."}"#);
- assert!(!unmet.met);
- assert_eq!(unmet.reason, "Run the tests and paste output.");
- }
-
- #[test]
- fn fenced_json_parses() {
- let verdict = read_verdict("```json\n{\"met\": false, \"reason\": \"The file was never created — create it.\"}\n```");
- assert!(!verdict.met);
- assert_eq!(verdict.reason, "The file was never created — create it.");
- }
-
- #[test]
- fn prose_wrapped_json_parses() {
- let verdict = read_verdict(
- "Looking at the evidence carefully:\n{\"met\": true, \"reason\": \"The transcript shows exit status 0.\"}\nThat is my verdict.",
- );
- assert!(verdict.met);
- assert_eq!(verdict.reason, "The transcript shows exit status 0.");
- }
-
- #[test]
- fn leading_unrelated_object_is_skipped() {
- let verdict = read_verdict(
- r#"The worker ran {"tool": "bash", "ok": true} and then {"met": false, "reason": "No test output was shown — run the suite."}"#,
- );
- assert!(!verdict.met);
- assert_eq!(verdict.reason, "No test output was shown — run the suite.");
- }
-
- #[test]
- fn stringy_and_numeric_booleans_are_tolerated() {
- assert!(read_verdict(r#"{"met": "true", "reason": "it built."}"#).met);
- assert!(!read_verdict(r#"{"met": "False", "reason": "not yet."}"#).met);
- assert!(read_verdict(r#"{"met": 1, "reason": "it built."}"#).met);
- assert!(!read_verdict(r#"{"met": 0, "reason": "not yet."}"#).met);
- }
-
- #[test]
- fn braces_inside_the_reason_do_not_break_matching() {
- let verdict = read_verdict(
- r#"{"met": false, "reason": "The literal {\"ok\": 1} was never printed."}"#,
- );
- assert!(!verdict.met);
- assert!(verdict.reason.contains("{\"ok\": 1}"));
- }
-
- #[test]
- fn unparseable_output_is_never_met() {
- for reply in [
- "",
- "Yes, the goal is definitely achieved.",
- "{ broken",
- r#"{"verdict": "pass"}"#,
- r#"{"met": "probably", "reason": "hard to say"}"#,
- r#"{"met": null, "reason": "hard to say"}"#,
- ] {
- let verdict = read_verdict(reply);
- assert!(!verdict.met, "must not fail open on {reply:?}");
- assert!(!verdict.reason.is_empty());
- }
- }
-
- #[test]
- fn a_verdict_without_a_reason_still_carries_one() {
- let verdict = read_verdict(r#"{"met": false}"#);
- assert!(!verdict.met);
- assert!(!verdict.reason.is_empty());
- }
-
- /// Longest run of `filler` in `text`. The prompt scaffolding contains
- /// ordinary letters, so counting every occurrence would measure the
- /// wrapper too; the run length measures only the excerpt.
- fn longest_run(text: &str, filler: char) -> usize {
- text.split(|ch| ch != filler)
- .map(str::len)
- .max()
- .unwrap_or(0)
- }
-
- #[test]
- fn over_long_transcripts_are_truncated_to_the_tail() {
- let transcript = format!("{}TAIL-EVIDENCE", "x".repeat(MAX_TRANSCRIPT_CHARS * 2));
- let prompt = build_prompt("ship it", &transcript, None);
-
- assert!(prompt.contains("TAIL-EVIDENCE"));
- assert!(prompt.contains("[earlier transcript omitted]"));
- let kept = longest_run(&prompt, 'x');
- assert!(
- kept < MAX_TRANSCRIPT_CHARS,
- "kept {kept} transcript chars, cap is {MAX_TRANSCRIPT_CHARS}"
- );
- }
-
- #[test]
- fn short_transcripts_are_passed_through_whole() {
- let prompt = build_prompt("ship it", "ran cargo test: ok", Some("demo"));
- assert!(prompt.contains("PROJECT: demo"));
- assert!(prompt.contains("GOAL:\nship it"));
- assert!(prompt.contains("ran cargo test: ok"));
- assert!(!prompt.contains("[earlier transcript omitted]"));
- }
-
- #[test]
- fn truncation_never_splits_a_multibyte_character() {
- let transcript = "é".repeat(MAX_TRANSCRIPT_CHARS + 500);
- let (kept, truncated) = clamp_transcript(&transcript);
- assert!(truncated);
- assert_eq!(kept.chars().count(), MAX_TRANSCRIPT_CHARS);
- }
-
- #[test]
- fn an_empty_excerpt_is_described_rather_than_left_blank() {
- let prompt = build_prompt("ship it", " ", None);
- assert!(prompt.contains("nothing has been demonstrated yet"));
- }
-
- // ---- provider round-trip (stub model over HTTP, like agent.rs tests) ----
-
- fn sse_reply(content: &str) -> Sse>> {
- let payload =
- json!({ "choices": [{ "delta": { "role": "assistant", "content": content } }] });
- Sse::new(futures::stream::iter(vec![
- Ok(Event::default().data(payload.to_string())),
- Ok(Event::default().data("[DONE]")),
- ]))
- }
-
- async fn mock_provider(
- axum::extract::State(mock): axum::extract::State,
- axum::Json(body): axum::Json,
- ) -> Sse>> {
- mock.requests.lock().unwrap().push(body);
- sse_reply(&mock.reply)
- }
-
- #[derive(Clone)]
- struct MockState {
- reply: String,
- requests: Arc>>,
- }
-
- async fn mock_config(reply: &str) -> (AppConfig, Arc>>) {
- let mock = MockState {
- reply: reply.to_string(),
- requests: Arc::new(Mutex::new(Vec::new())),
- };
- let requests = mock.requests.clone();
- let app = Router::new()
- .route("/v1/chat/completions", post(mock_provider))
- .with_state(mock);
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
- let addr = listener.local_addr().unwrap();
- tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
-
- let config = AppConfig {
- model: crate::config::ModelConfig {
- default: "mock".into(),
- provider: "mock".into(),
- base_url: format!("http://{}/v1", addr),
- api_key_env: "CALI_MOCK_KEY".into(),
- temperature: 0.0,
- max_tokens: Some(128),
- roles: Default::default(),
- },
- providers: vec![],
- ..Default::default()
- };
- (config, requests)
- }
-
- #[tokio::test]
- async fn the_evaluator_is_offered_no_tools() {
- let (config, requests) = mock_config(r#"{"met": true, "reason": "tests passed."}"#).await;
-
- let result = evaluate_with_config(&config, "tests pass", "test result: ok", None)
- .await
- .unwrap();
-
- assert_eq!(result, json!({ "met": true, "reason": "tests passed." }));
- let requests = requests.lock().unwrap();
- assert_eq!(requests.len(), 1, "a verdict costs exactly one call");
- assert!(
- requests[0].get("tools").is_none(),
- "the verifier must never be handed a tool schema: {}",
- requests[0]
- );
- }
-
- #[tokio::test]
- async fn the_transcript_is_capped_before_it_reaches_the_provider() {
- let (config, requests) = mock_config(r#"{"met": false, "reason": "run it."}"#).await;
- let transcript = "y".repeat(MAX_TRANSCRIPT_CHARS * 3);
-
- evaluate_with_config(&config, "ship it", &transcript, None)
- .await
- .unwrap();
-
- let requests = requests.lock().unwrap();
- let sent = requests[0]["messages"][1]["content"].as_str().unwrap();
- assert_eq!(longest_run(sent, 'y'), MAX_TRANSCRIPT_CHARS);
- }
-
- #[tokio::test]
- async fn an_unreadable_reply_reports_not_met() {
- let (config, _) = mock_config("Sure, that looks done to me!").await;
-
- let result = evaluate_with_config(&config, "ship it", "I finished it", None)
- .await
- .unwrap();
-
- assert_eq!(result["met"], json!(false));
- assert!(result["reason"]
- .as_str()
- .unwrap()
- .contains("could not be read"));
- }
-
- #[tokio::test]
- async fn an_empty_goal_is_rejected_before_any_provider_call() {
- let (config, requests) = mock_config(r#"{"met": true, "reason": "sure."}"#).await;
-
- let error = evaluate_with_config(&config, " ", "anything", None)
- .await
- .unwrap_err();
-
- assert!(error.to_string().contains("goal must not be empty"));
- assert!(requests.lock().unwrap().is_empty());
- }
-}
diff --git a/core/src/loop_run.rs b/core/src/loop_run.rs
index 8d108915..17889795 100644
--- a/core/src/loop_run.rs
+++ b/core/src/loop_run.rs
@@ -39,11 +39,6 @@ const AUTO_CHECKPOINT_INTERVAL_MS: u64 = 15 * 60_000;
/// Disambiguates loop ids minted inside one millisecond. See `start`.
static LOOP_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
-/// Runaway backstop, not a product limit. The real exit is the completion
-/// gate; this only bounds a loop that is making no progress at all and would
-/// otherwise bill forever. Mirrors `MAX_LOOP_ITERATIONS` in the client.
-pub const MAX_ITERATIONS: usize = 100;
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LoopProfile {
@@ -90,7 +85,6 @@ pub struct LoopSpec {
pub system: Option,
pub context_length: Option,
pub guardian_model: Option,
- pub max_iterations: usize,
}
#[derive(Debug, Clone, Serialize)]
@@ -102,7 +96,6 @@ pub struct LoopView {
pub profile: LoopProfile,
pub status: RunStatus,
pub iteration: usize,
- pub max_iterations: usize,
pub started_at_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval_ms: Option,
@@ -147,7 +140,6 @@ impl LoopManager {
profile: spec.profile,
status: RunStatus::Running,
iteration: 0,
- max_iterations: spec.max_iterations.clamp(1, MAX_ITERATIONS),
started_at_ms,
interval_ms: spec.interval_ms,
session_id: spec.session_id.clone(),
@@ -162,10 +154,6 @@ impl LoopManager {
let manager = self.clone();
let state = state.clone();
- let spec = LoopSpec {
- max_iterations: view.max_iterations,
- ..spec
- };
tokio::spawn(async move {
let outcome = manager
.drive(&state, &loop_id, spec, run.clone(), cancel)
@@ -233,7 +221,6 @@ impl LoopManager {
cancel: CancellationToken,
) -> Result {
let mut session_id = spec.session_id.clone();
- let mut completed_once = false;
let mut last_checkpoint_at: Option = None;
// Only the pipeline keeps a durable report. A `standard` loop writing
// one would be filing paperwork nobody asked for and nothing reads.
@@ -253,7 +240,8 @@ impl LoopManager {
}
}
- for iteration in 1..=spec.max_iterations {
+ let mut iteration = 0usize;
+ loop {
if cancel.is_cancelled() {
if reports {
close_report(
@@ -266,9 +254,12 @@ impl LoopManager {
}
return Ok(Outcome::Stopped(format!(
"stopped after {} iterations",
- iteration - 1
+ iteration
)));
}
+ iteration = iteration
+ .checked_add(1)
+ .context("loop iteration counter overflowed")?;
{
let mut guard = run.write().await;
guard.view.iteration = iteration;
@@ -309,7 +300,6 @@ impl LoopManager {
"type": "loop.iteration",
"loopId": loop_id,
"iteration": iteration,
- "maxIterations": spec.max_iterations,
"prompt": prompt,
}));
@@ -339,6 +329,15 @@ impl LoopManager {
let turn = tokio::select! {
biased;
_ = cancel.cancelled() => {
+ if reports {
+ close_report(
+ state,
+ &spec,
+ loop_id,
+ crate::loop_report::LoopStatus::Cancelled,
+ "Loop stopped by the user.",
+ );
+ }
return Ok(Outcome::Stopped(format!(
"stopped during iteration {iteration}"
)));
@@ -368,7 +367,6 @@ impl LoopManager {
if says_done(&reply) {
match completion_blocker(state, &spec, loop_id) {
None => {
- completed_once = true;
let _ = state.bus.send(json!({
"type": "loop.completed",
"loopId": loop_id,
@@ -404,38 +402,26 @@ impl LoopManager {
}
if let Some(interval) = spec.interval_ms {
- if iteration < spec.max_iterations {
- let wait = std::time::Duration::from_millis(interval);
- tokio::select! {
- _ = cancel.cancelled() => {
- return Ok(Outcome::Stopped(format!(
- "watch stopped after {iteration} iterations"
- )));
+ let wait = std::time::Duration::from_millis(interval);
+ tokio::select! {
+ _ = cancel.cancelled() => {
+ if reports {
+ close_report(
+ state,
+ &spec,
+ loop_id,
+ crate::loop_report::LoopStatus::Cancelled,
+ "Loop stopped by the user.",
+ );
}
- _ = tokio::time::sleep(wait) => {}
+ return Ok(Outcome::Stopped(format!(
+ "watch stopped after {iteration} iterations"
+ )));
}
+ _ = tokio::time::sleep(wait) => {}
}
}
}
- if reports && !completed_once {
- close_report(
- state,
- &spec,
- loop_id,
- crate::loop_report::LoopStatus::Blocked,
- "Loop hit its iteration cap before completion could be proven.",
- );
- }
- Ok(if completed_once {
- // A watch that met its goal and then ran out of iterations is not a
- // failure; saying "hit the cap" and nothing else would read as one.
- Outcome::Completed(format!(
- "watch reached the {}-iteration cap after meeting the goal",
- spec.max_iterations
- ))
- } else {
- Outcome::Stopped(format!("hit the {}-iteration cap", spec.max_iterations))
- })
}
}
@@ -640,7 +626,6 @@ mod tests {
system: None,
context_length: None,
guardian_model: None,
- max_iterations: 10,
}
}
@@ -740,6 +725,21 @@ mod tests {
panic!("loop {loop_id} never reached a terminal status");
}
+ /// Wait for the durable report to contain real completed iterations. The
+ /// run view advances before the provider call, so polling that counter
+ /// would race cancellation against the report write this test proves.
+ async fn wait_for_report_iterations(state: &crate::AppState, loop_id: &str, expected: usize) {
+ for _ in 0..200 {
+ if crate::loop_report::load(&state.projects_root, "demo", loop_id)
+ .is_ok_and(|report| report.iterations.len() >= expected)
+ {
+ return;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(25)).await;
+ }
+ panic!("loop {loop_id} never recorded {expected} iterations");
+ }
+
#[tokio::test]
async fn a_standard_loop_runs_until_the_model_says_done() {
let (state, calls) =
@@ -758,24 +758,25 @@ mod tests {
#[tokio::test]
async fn an_aaa_loop_refuses_done_it_cannot_prove() {
// The model claims completion on every turn; with no passing report on
- // disk the gate must refuse every one and let the cap end the run.
- let (state, _) = scripted_state(vec!["DONE".into()]).await;
+ // disk the gate must refuse every one and keep running until Stop.
+ let (state, calls) = scripted_state(vec!["DONE".into()]).await;
let view = state
.loops
- .start(
- &state,
- LoopSpec {
- profile: LoopProfile::Aaa,
- max_iterations: 3,
- ..spec(LoopProfile::Aaa)
- },
- )
+ .start(&state, spec(LoopProfile::Aaa))
.await
.unwrap();
+ wait_for_report_iterations(&state, &view.loop_id, 3).await;
+ assert_eq!(
+ state.loops.status(&view.loop_id).await.unwrap().status,
+ RunStatus::Running,
+ "three refused DONE replies must not impose a hidden cap"
+ );
+ assert!(calls.load(Ordering::SeqCst) >= 3);
+ state.loops.stop(&view.loop_id).await.unwrap();
let settled = settle(&state, &view.loop_id).await;
assert_eq!(settled.status, RunStatus::Stopped, "{settled:?}");
- assert_eq!(
- settled.iteration, 3,
+ assert!(
+ settled.iteration >= 3,
"the gate should have refused all three"
);
}
@@ -829,24 +830,15 @@ mod tests {
let (state, _) = scripted_state(vec!["worked on it".into()]).await;
let view = state
.loops
- .start(
- &state,
- LoopSpec {
- profile: LoopProfile::Aaa,
- max_iterations: 2,
- ..spec(LoopProfile::Aaa)
- },
- )
+ .start(&state, spec(LoopProfile::Aaa))
.await
.unwrap();
+ wait_for_report_iterations(&state, &view.loop_id, 2).await;
+ state.loops.stop(&view.loop_id).await.unwrap();
settle(&state, &view.loop_id).await;
let report = crate::loop_report::load(&state.projects_root, "demo", &view.loop_id).unwrap();
- assert_eq!(report.iterations.len(), 2, "{report:?}");
- assert_ne!(
- report.status,
- crate::loop_report::LoopStatus::Running,
- "a capped loop must close its report"
- );
+ assert!(report.iterations.len() >= 2, "{report:?}");
+ assert_eq!(report.status, crate::loop_report::LoopStatus::Cancelled);
}
#[tokio::test]
diff --git a/core/src/main.rs b/core/src/main.rs
index 3f969a64..003dd309 100644
--- a/core/src/main.rs
+++ b/core/src/main.rs
@@ -18,7 +18,6 @@ mod diagnostics;
mod edit_match;
mod editor_bridge;
mod fileread;
-mod goal;
mod graph;
mod guardian;
mod hooks;
@@ -122,7 +121,9 @@ async fn main() -> anyhow::Result<()> {
sandbox::init(&config.sandbox);
let projects_root = config::projects_root(&config);
std::fs::create_dir_all(&projects_root)?;
- if !projects_root.join("starter").exists() {
+ if !projects_root.join("starter").exists()
+ && !projects_root.join(store::EMPTY_PROJECTS_MARKER).exists()
+ {
store::create_project(&projects_root, "starter", "Starter")?;
}
diff --git a/core/src/rpc.rs b/core/src/rpc.rs
index 628e289e..a4091c03 100644
--- a/core/src/rpc.rs
+++ b/core/src/rpc.rs
@@ -254,8 +254,9 @@ async fn dispatch(state: &AppState, method: &str, params: Value) -> Result {
let slug = str_param(¶ms, "slug")?;
- // Delete the project first so a failed "last project" guard or
- // other store error leaves every session/worktree recoverable.
+ // Delete the project first so a store error leaves every
+ // session/worktree recoverable; deleting the last project is a
+ // supported route to the empty project hub.
let project = store::delete_project(&state.projects_root, slug)?;
let sessions = delete_project_sessions(state, slug).await?;
Ok(json!({ "project": project, "sessions": sessions }))
@@ -761,11 +762,6 @@ async fn dispatch(state: &AppState, method: &str, params: Value) -> Result Result {
- crate::goal::evaluate(
- state,
- str_param(¶ms, "goal")?,
- str_param(¶ms, "transcript")?,
- params.get("projectSlug").and_then(Value::as_str),
- )
- .await
- }
// Side conversation *about* a run. Unlike `agent_chat` this registers
// no tools and touches no session: the advisor can only read the
// transcript excerpt it is handed, so a question about a run can
@@ -2857,25 +2841,6 @@ mod tests {
);
}
- /// Both strings are required. Reaching the provider without a goal would
- /// spend a model call to verify nothing.
- #[tokio::test]
- async fn goal_evaluate_requires_a_goal_and_a_transcript() {
- let projects = tempfile::tempdir().unwrap();
- let sessions = tempfile::tempdir().unwrap();
- let state = test_state(projects.path().to_path_buf(), sessions.path().to_path_buf());
-
- let missing_goal = dispatch(&state, "goal_evaluate", json!({ "transcript": "ran it" }))
- .await
- .unwrap_err();
- assert!(missing_goal.to_string().contains("goal"));
-
- let missing_transcript = dispatch(&state, "goal_evaluate", json!({ "goal": "tests pass" }))
- .await
- .unwrap_err();
- assert!(missing_transcript.to_string().contains("transcript"));
- }
-
/// `transcript` is optional — an advisor asked before anything happened is
/// legal — but a turn with nothing to answer would spend a model call on
/// silence.
diff --git a/core/src/store.rs b/core/src/store.rs
index 9f43aeaa..1eb51d4f 100644
--- a/core/src/store.rs
+++ b/core/src/store.rs
@@ -730,8 +730,12 @@ fn git_stdout(directory: &Path, args: &[&str]) -> Result {
String::from_utf8(output.stdout).context("git returned non-UTF-8 output")
}
-/// Permanently remove one explicitly named project. Refuse to remove the last
-/// project so the editor always has a valid project to open.
+/// Marker written when the user intentionally removes the last project. Core
+/// seeds a Starter project for a brand-new install, but must not resurrect it
+/// after the user has deliberately chosen an empty project hub.
+pub const EMPTY_PROJECTS_MARKER: &str = ".empty-projects";
+
+/// Permanently remove one explicitly named project, including the last one.
pub fn delete_project(root: &Path, slug: &str) -> Result {
let clean = sanitize_slug(slug)?;
let directory = project_dir(root, &clean)?;
@@ -739,15 +743,18 @@ pub fn delete_project(root: &Path, slug: &str) -> Result {
anyhow::bail!("project {clean} not found");
}
let count = list_projects(root)?.as_array().map(Vec::len).unwrap_or(0);
- if count <= 1 {
- anyhow::bail!("cannot remove the last project");
- }
let root_real = root.canonicalize()?;
let directory_real = directory.canonicalize()?;
if directory_real.parent() != Some(root_real.as_path()) {
anyhow::bail!("refusing to remove a project outside the projects directory");
}
+ if count == 1 {
+ // Write the intent before deleting the directory: a marker failure
+ // must leave the project recoverable rather than allowing a later
+ // core restart to recreate Starter behind the user's back.
+ std::fs::write(root.join(EMPTY_PROJECTS_MARKER), b" intentionally empty\n")?;
+ }
std::fs::remove_dir_all(&directory_real)?;
Ok(json!({ "slug": clean, "deleted": true }))
}
@@ -1085,7 +1092,7 @@ mod tests {
}
#[test]
- fn delete_removes_one_project_but_not_the_last() {
+ fn delete_removes_one_project_and_can_leave_an_empty_hub() {
let root = tempfile::tempdir().unwrap();
create_project(root.path(), "one", "One").unwrap();
create_project(root.path(), "two", "Two").unwrap();
@@ -1093,7 +1100,14 @@ mod tests {
delete_project(root.path(), "one").unwrap();
assert!(!root.path().join("one").exists());
assert!(root.path().join("two/project.json").exists());
- assert!(delete_project(root.path(), "two").is_err());
+ delete_project(root.path(), "two").unwrap();
+ assert!(!root.path().join("two").exists());
+ assert!(root.path().join(EMPTY_PROJECTS_MARKER).exists());
+ assert!(list_projects(root.path())
+ .unwrap()
+ .as_array()
+ .unwrap()
+ .is_empty());
}
#[test]
diff --git a/docs/game-harness.md b/docs/game-harness.md
index cf2a9d11..b06ceb99 100644
--- a/docs/game-harness.md
+++ b/docs/game-harness.md
@@ -178,8 +178,7 @@ autocomplete.
| Command | Usage | What it does |
|---|---|---|
| `/help` | — | Lists every registered command, skills included |
-| `/loop` | `/loop [interval] ` | Autonomous run: re-sends the goal until the agent replies `DONE` alone on a line and the completion gate accepts it, the cap is hit, or you press STOP. With an interval (`30s`, `15m`, `2h`) it becomes a watch: it waits that long between checks and keeps going after the goal is met, until you stop it |
-| `/goal` | `/goal [objective \| clear]` | Keeps working toward an objective, re-checked by the evaluator after every turn. Bare prints the current goal |
+| `/loop` | `/loop [--aaa] [interval] ` | Autonomous run: keeps working until the agent replies `DONE` alone on a line and the selected completion gate accepts it, or you press STOP. With an interval (`30s`, `15m`, `2h`) it becomes a watch: it waits that long between checks and keeps going after the goal is met, until you stop it |
| `/graph` | `/graph ` | Plans a task DAG for the goal and runs it node by node |
| `/graph-template` | `/graph-template ` | Same, from a named template (`aaa-fps`, `polished-asset`, …) |
| `/graph-stop` | — | Cancels the running graph after the current node |
@@ -208,17 +207,19 @@ meter that drops with no explanation reads as lost work.
Loop mechanics worth knowing before you write one:
-- **Cap is 100 iterations** (`MAX_LOOP_ITERATIONS`), each iteration allowing up
- to **10 tool-calling turns** inside the core agent loop. Budget accordingly:
- a phase that needs 400 placements will not fit in one loop unless the agent
- writes a placement routine instead of 400 individual calls.
-- **The continuation prompt is fixed:** *"Continue toward the goal. When it is
- fully complete, reply with exactly DONE on its own line and nothing else."*
- Your goal text is only sent on iteration 1 — everything the loop needs to
- keep itself honest must be in that first message.
-- **Termination is a regex on `DONE`.** A goal with a soft finish line ends
- early. A goal with a checkable finish line does not.
-- **STOP is cooperative** — it lands between iterations, not mid-turn.
+- **There is no iteration cap.** A run-to-completion loop ends only when its
+ completion gate accepts `DONE` or you press STOP. A paced watch continues
+ after accepted completion and therefore ends only on STOP.
+- **Each iteration still has a turn budget.** That bounds one malformed agent
+ turn; it does not terminate the outer loop, which can continue in the next
+ iteration.
+- **The goal is preserved.** Iteration 1 receives the user's words verbatim;
+ later iterations repeat them with one continuation sentence.
+- **Termination is `DONE` on a line of its own.** A goal with a soft finish
+ line can end prematurely; use `--aaa` when completion needs persisted
+ evidence and a judge rather than the standard profile's self-report.
+- **STOP reaches core** and cancels either the in-flight iteration or the wait
+ between paced checks.
### Writing a `/loop` that finishes correctly
@@ -394,7 +395,7 @@ Practical thresholds for a stylized scene:
| Tell | Cause | Fix |
|---|---|---|
-| Loop hits the 25-iteration cap | Goal had no checkable DONE | Rewrite DONE as a count `editor_scene_inspect` can verify |
+| Loop keeps working without finishing | Goal had no checkable DONE | Press STOP, then rewrite DONE as a count `editor_scene_inspect` can verify or use `--aaa` |
| Loop replies `DONE` on iteration 2 | DONE was subjective | Add "before replying DONE, capture a frame and score …" |
| Everything is at `[0, 0.5, 0]` | `editor_object_add` default position, no explicit placement | Always pass `position`; verify with `scene_inspect` |
| Objects vanish past a point | Camera far = 100, fog 18–42 | Keep the world inside ~40 units or ask the human to widen the camera |
diff --git a/docs/harness-parity.md b/docs/harness-parity.md
index 412408bc..04ec1148 100644
--- a/docs/harness-parity.md
+++ b/docs/harness-parity.md
@@ -76,9 +76,9 @@ Ranked by value × fit × tractability. Each is one loop iteration.
### Tier 1 — command surface (explicitly requested)
1. **Slash-command system** — registry + `/`-autocomplete menu in the composer.
Convert the hardcoded `/model` into the first registered command. *client only.*
-2. **`/loop `** — continuous autonomous execution: re-send the goal each
- turn until the agent reports done or a max-iteration cap, streaming progress
- and stoppable. *client only (loops `agent_chat`).*
+2. **`/loop `** — continuous autonomous execution: the core-owned driver
+ re-sends the goal until the agent reports done or the user stops it,
+ streaming progress and surviving tab reloads. *core + client.*
3. **`/help`, `/clear`, `/new`** — list commands; reset transcript; new session. *client only.*
4. **`/compact`** — summarize the transcript to reclaim context. *client + one core RPC.*
5. **`/diff`** — list files the agent changed this session (reuse checkpoint data). *client + core.*