feat(coding-agent): added /loop count/duration arg parsing and budgeting
- Added optional `/loop` `count|duration` command arguments and wired `command.args` into loop handling. - Implemented `loop-limit` parsing and runtime types/helpers for iteration and duration budget limits with validation. - Updated interactive mode to enforce loop limits per iteration, check duration expiry, and clear budget state on disable. - Added loop-limit parse/runtime tests and fixed `/loop` arg errors plus macOS `MallocStackLogging` environment leakage.
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added optional `/loop` limits: `/loop 10` stops after 10 auto-iterations, while duration forms such as `/loop 10m` and `/loop 10min` stop after the time limit.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `/loop` to include the configured limit and remaining budget in the enabled status message
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/loop` handling of malformed count or duration arguments by showing usage errors instead of enabling unbounded loop mode
|
||||
- Fixed inherited disabled macOS malloc stack logging variables leaking into shell sessions and spamming Bun subprocess output with `MallocStackLogging` warnings.
|
||||
|
||||
## [14.7.4] - 2026-05-07
|
||||
|
||||
|
||||
@@ -73,6 +73,15 @@ import { MCPCommandController } from "./controllers/mcp-command-controller";
|
||||
import { SelectorController } from "./controllers/selector-controller";
|
||||
import { SSHCommandController } from "./controllers/ssh-command-controller";
|
||||
import { TodoCommandController } from "./controllers/todo-command-controller";
|
||||
import {
|
||||
consumeLoopLimitIteration,
|
||||
createLoopLimitRuntime,
|
||||
describeLoopLimit,
|
||||
describeLoopLimitRuntime,
|
||||
isLoopDurationExpired,
|
||||
type LoopLimitRuntime,
|
||||
parseLoopLimitArgs,
|
||||
} from "./loop-limit";
|
||||
import { OAuthManualInputManager } from "./oauth-manual-input";
|
||||
import { SessionObserverRegistry } from "./session-observer-registry";
|
||||
import type { Theme } from "./theme/theme";
|
||||
@@ -158,6 +167,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
planModePlanFilePath: string | undefined = undefined;
|
||||
loopModeEnabled = false;
|
||||
loopPrompt: string | undefined = undefined;
|
||||
loopLimit: LoopLimitRuntime | undefined = undefined;
|
||||
#loopAutoSubmitTimer: NodeJS.Timeout | undefined;
|
||||
todoPhases: TodoPhase[] = [];
|
||||
hideThinkingBlock = false;
|
||||
@@ -535,25 +545,35 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
|
||||
if (!consumeLoopLimitIteration(this.loopLimit)) {
|
||||
this.disableLoopMode("Loop limit reached. Loop mode disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "compact") {
|
||||
await this.handleCompactCommand();
|
||||
} else if (action === "reset") {
|
||||
await this.handleClearCommand();
|
||||
}
|
||||
if (!this.loopModeEnabled || !this.onInputCallback) return;
|
||||
if (isLoopDurationExpired(this.loopLimit)) {
|
||||
this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
|
||||
return;
|
||||
}
|
||||
this.onInputCallback(this.startPendingSubmission({ text: prompt }));
|
||||
}
|
||||
|
||||
disableLoopMode(): void {
|
||||
disableLoopMode(message = "Loop mode disabled."): void {
|
||||
const wasEnabled = this.loopModeEnabled;
|
||||
this.loopModeEnabled = false;
|
||||
this.loopPrompt = undefined;
|
||||
this.loopLimit = undefined;
|
||||
this.#cancelLoopAutoSubmit();
|
||||
this.statusLine.setLoopModeStatus(undefined);
|
||||
this.updateEditorTopBorder();
|
||||
this.ui.requestRender();
|
||||
if (wasEnabled) {
|
||||
this.showStatus("Loop mode disabled.");
|
||||
this.showStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,18 +587,26 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#cancelLoopAutoSubmit();
|
||||
}
|
||||
|
||||
async handleLoopCommand(): Promise<void> {
|
||||
async handleLoopCommand(args = ""): Promise<void> {
|
||||
if (this.loopModeEnabled) {
|
||||
this.disableLoopMode();
|
||||
return;
|
||||
}
|
||||
const parsedLimit = parseLoopLimitArgs(args);
|
||||
if (typeof parsedLimit === "string") {
|
||||
this.showError(parsedLimit);
|
||||
return;
|
||||
}
|
||||
this.loopModeEnabled = true;
|
||||
this.loopPrompt = undefined;
|
||||
this.loopLimit = createLoopLimitRuntime(parsedLimit);
|
||||
this.statusLine.setLoopModeStatus({ enabled: true });
|
||||
this.updateEditorTopBorder();
|
||||
this.ui.requestRender();
|
||||
const limitSuffix = parsedLimit ? ` Limited to ${describeLoopLimit(parsedLimit)}.` : "";
|
||||
const remainingSuffix = this.loopLimit ? ` ${describeLoopLimitRuntime(this.loopLimit)}.` : "";
|
||||
this.showStatus(
|
||||
"Loop mode enabled. Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.",
|
||||
`Loop mode enabled.${limitSuffix}${remainingSuffix} Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
export type LoopLimitConfig =
|
||||
| {
|
||||
kind: "iterations";
|
||||
iterations: number;
|
||||
}
|
||||
| {
|
||||
kind: "duration";
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type LoopLimitRuntime =
|
||||
| {
|
||||
kind: "iterations";
|
||||
initial: number;
|
||||
remaining: number;
|
||||
}
|
||||
| {
|
||||
kind: "duration";
|
||||
durationMs: number;
|
||||
deadlineMs: number;
|
||||
};
|
||||
|
||||
const TIME_UNITS_MS = new Map<string, number>([
|
||||
["s", 1_000],
|
||||
["sec", 1_000],
|
||||
["secs", 1_000],
|
||||
["second", 1_000],
|
||||
["seconds", 1_000],
|
||||
["m", 60_000],
|
||||
["min", 60_000],
|
||||
["mins", 60_000],
|
||||
["minute", 60_000],
|
||||
["minutes", 60_000],
|
||||
["h", 3_600_000],
|
||||
["hr", 3_600_000],
|
||||
["hrs", 3_600_000],
|
||||
["hour", 3_600_000],
|
||||
["hours", 3_600_000],
|
||||
]);
|
||||
|
||||
export function parseLoopLimitArgs(args: string): LoopLimitConfig | undefined | string {
|
||||
const trimmed = args.trim().toLowerCase();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length > 2) {
|
||||
return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min.";
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return parseDurationParts(parts[0], parts[1]);
|
||||
}
|
||||
|
||||
const token = parts[0];
|
||||
const iterationMatch = /^(\d+)$/.exec(token);
|
||||
if (iterationMatch) {
|
||||
const iterations = Number(iterationMatch[1]);
|
||||
if (!Number.isSafeInteger(iterations) || iterations <= 0) {
|
||||
return "Loop count must be a positive integer.";
|
||||
}
|
||||
return { kind: "iterations", iterations };
|
||||
}
|
||||
|
||||
const durationMatch = /^(\d+)([a-z]+)$/.exec(token);
|
||||
if (durationMatch) {
|
||||
return parseDurationParts(durationMatch[1], durationMatch[2]);
|
||||
}
|
||||
|
||||
return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min.";
|
||||
}
|
||||
|
||||
function parseDurationParts(amountText: string, unitText: string): LoopLimitConfig | string {
|
||||
if (!/^\d+$/.test(amountText)) {
|
||||
return "Loop duration must use a positive integer amount.";
|
||||
}
|
||||
|
||||
const amount = Number(amountText);
|
||||
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
||||
return "Loop duration must be positive.";
|
||||
}
|
||||
|
||||
const unitMs = TIME_UNITS_MS.get(unitText);
|
||||
if (unitMs === undefined) {
|
||||
return "Loop duration unit must be seconds, minutes, or hours.";
|
||||
}
|
||||
|
||||
return { kind: "duration", durationMs: amount * unitMs };
|
||||
}
|
||||
|
||||
export function createLoopLimitRuntime(
|
||||
config: LoopLimitConfig | undefined,
|
||||
nowMs = Date.now(),
|
||||
): LoopLimitRuntime | undefined {
|
||||
if (!config) return undefined;
|
||||
if (config.kind === "iterations") {
|
||||
return { kind: "iterations", initial: config.iterations, remaining: config.iterations };
|
||||
}
|
||||
return { kind: "duration", durationMs: config.durationMs, deadlineMs: nowMs + config.durationMs };
|
||||
}
|
||||
|
||||
export function consumeLoopLimitIteration(limit: LoopLimitRuntime | undefined, nowMs = Date.now()): boolean {
|
||||
if (!limit) return true;
|
||||
if (limit.kind === "duration") {
|
||||
return nowMs < limit.deadlineMs;
|
||||
}
|
||||
if (limit.remaining <= 0) return false;
|
||||
limit.remaining -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isLoopDurationExpired(limit: LoopLimitRuntime | undefined, nowMs = Date.now()): boolean {
|
||||
return limit?.kind === "duration" && nowMs >= limit.deadlineMs;
|
||||
}
|
||||
|
||||
export function describeLoopLimit(config: LoopLimitConfig): string {
|
||||
if (config.kind === "iterations") {
|
||||
return `${config.iterations} ${config.iterations === 1 ? "iteration" : "iterations"}`;
|
||||
}
|
||||
return formatDuration(config.durationMs);
|
||||
}
|
||||
|
||||
export function describeLoopLimitRuntime(limit: LoopLimitRuntime): string {
|
||||
if (limit.kind === "iterations") {
|
||||
return `${limit.remaining} of ${limit.initial} ${limit.initial === 1 ? "iteration" : "iterations"} remaining`;
|
||||
}
|
||||
return `${formatDuration(limit.durationMs)} limit`;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
if (durationMs % 3_600_000 === 0) {
|
||||
const hours = durationMs / 3_600_000;
|
||||
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
||||
}
|
||||
if (durationMs % 60_000 === 0) {
|
||||
const minutes = durationMs / 60_000;
|
||||
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
||||
}
|
||||
const seconds = durationMs / 1_000;
|
||||
return `${seconds} ${seconds === 1 ? "second" : "seconds"}`;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { HookInputComponent } from "./components/hook-input";
|
||||
import type { HookSelectorComponent } from "./components/hook-selector";
|
||||
import type { StatusLineComponent } from "./components/status-line";
|
||||
import type { ToolExecutionHandle } from "./components/tool-execution";
|
||||
import type { LoopLimitRuntime } from "./loop-limit";
|
||||
import type { OAuthManualInputManager } from "./oauth-manual-input";
|
||||
import type { Theme } from "./theme/theme";
|
||||
|
||||
@@ -86,6 +87,7 @@ export interface InteractiveModeContext {
|
||||
planModeEnabled: boolean;
|
||||
loopModeEnabled: boolean;
|
||||
loopPrompt?: string;
|
||||
loopLimit?: LoopLimitRuntime;
|
||||
planModePlanFilePath?: string;
|
||||
hideThinkingBlock: boolean;
|
||||
pendingImages: ImageContent[];
|
||||
@@ -248,7 +250,7 @@ export interface InteractiveModeContext {
|
||||
openExternalEditor(): void;
|
||||
registerExtensionShortcuts(): void;
|
||||
handlePlanModeCommand(initialPrompt?: string): Promise<void>;
|
||||
handleLoopCommand(): Promise<void>;
|
||||
handleLoopCommand(args?: string): Promise<void>;
|
||||
disableLoopMode(): void;
|
||||
pauseLoop(): void;
|
||||
handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
|
||||
|
||||
@@ -119,8 +119,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
||||
name: "loop",
|
||||
description:
|
||||
"Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.",
|
||||
handle: async (_command, runtime) => {
|
||||
await runtime.ctx.handleLoopCommand();
|
||||
inlineHint: "[count|duration]",
|
||||
allowArgs: true,
|
||||
handle: async (command, runtime) => {
|
||||
await runtime.ctx.handleLoopCommand(command.args);
|
||||
runtime.ctx.editor.setText("");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import {
|
||||
consumeLoopLimitIteration,
|
||||
createLoopLimitRuntime,
|
||||
isLoopDurationExpired,
|
||||
parseLoopLimitArgs,
|
||||
} from "@oh-my-pi/pi-coding-agent/modes/loop-limit";
|
||||
import type { BuiltinSlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
|
||||
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
|
||||
|
||||
describe("/loop slash command", () => {
|
||||
test("accepts an optional limit argument", async () => {
|
||||
const handleLoopCommand = vi.fn(async (_args?: string) => {});
|
||||
const runtime = {
|
||||
ctx: {
|
||||
handleLoopCommand,
|
||||
editor: { setText: vi.fn() },
|
||||
},
|
||||
handleBackgroundCommand: vi.fn(),
|
||||
} as unknown as BuiltinSlashCommandRuntime;
|
||||
const result = await executeBuiltinSlashCommand("/loop 10min", runtime);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(handleLoopCommand).toHaveBeenCalledWith("10min");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop limit parsing", () => {
|
||||
test("parses a bare positive integer as an iteration limit", () => {
|
||||
expect(parseLoopLimitArgs("10")).toEqual({ kind: "iterations", iterations: 10 });
|
||||
});
|
||||
|
||||
test("parses minute duration aliases", () => {
|
||||
expect(parseLoopLimitArgs("10m")).toEqual({ kind: "duration", durationMs: 600_000 });
|
||||
expect(parseLoopLimitArgs("10min")).toEqual({ kind: "duration", durationMs: 600_000 });
|
||||
expect(parseLoopLimitArgs("10 minutes")).toEqual({ kind: "duration", durationMs: 600_000 });
|
||||
});
|
||||
|
||||
test("rejects zero, negative, and unknown limits", () => {
|
||||
expect(parseLoopLimitArgs("0")).toBe("Loop count must be a positive integer.");
|
||||
expect(parseLoopLimitArgs("-1")).toContain("Usage: /loop");
|
||||
expect(parseLoopLimitArgs("10fortnights")).toBe("Loop duration unit must be seconds, minutes, or hours.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop limit runtime", () => {
|
||||
test("allows exactly the configured number of auto-submitted iterations", () => {
|
||||
const config = parseLoopLimitArgs("3");
|
||||
expect(config).toEqual({ kind: "iterations", iterations: 3 });
|
||||
if (!config || typeof config === "string") throw new Error("expected parsed config");
|
||||
|
||||
const limit = createLoopLimitRuntime(config);
|
||||
expect(consumeLoopLimitIteration(limit)).toBe(true);
|
||||
expect(consumeLoopLimitIteration(limit)).toBe(true);
|
||||
expect(consumeLoopLimitIteration(limit)).toBe(true);
|
||||
expect(consumeLoopLimitIteration(limit)).toBe(false);
|
||||
expect(limit).toEqual({ kind: "iterations", initial: 3, remaining: 0 });
|
||||
});
|
||||
|
||||
test("stops duration-limited loops at the configured deadline", () => {
|
||||
const config = parseLoopLimitArgs("10m");
|
||||
expect(config).toEqual({ kind: "duration", durationMs: 600_000 });
|
||||
if (!config || typeof config === "string") throw new Error("expected parsed config");
|
||||
|
||||
const limit = createLoopLimitRuntime(config, 1_000);
|
||||
expect(consumeLoopLimitIteration(limit, 600_999)).toBe(true);
|
||||
expect(isLoopDurationExpired(limit, 600_999)).toBe(false);
|
||||
expect(consumeLoopLimitIteration(limit, 601_000)).toBe(false);
|
||||
expect(isLoopDurationExpired(limit, 601_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user