@oh-my-pi/pi-coding-agent 14.7.4 → 14.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +7 -7
- package/src/cli.ts +7 -1
- package/src/modes/interactive-mode.ts +32 -4
- package/src/modes/loop-limit.ts +140 -0
- package/src/modes/types.ts +3 -1
- package/src/slash-commands/builtin-registry.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.7.5] - 2026-05-07
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Changed `/loop` to include the configured limit and remaining budget in the enabled status message
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fixed `/loop` handling of malformed count or duration arguments by showing usage errors instead of enabling unbounded loop mode
|
|
17
|
+
- Fixed inherited disabled macOS malloc stack logging variables leaking into shell sessions and spamming Bun subprocess output with `MallocStackLogging` warnings.
|
|
18
|
+
|
|
5
19
|
## [14.7.4] - 2026-05-07
|
|
6
20
|
|
|
7
21
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.7.
|
|
4
|
+
"version": "14.7.5",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.7.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.7.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.7.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.7.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.7.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.7.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.7.5",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.7.5",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.7.5",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.7.5",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.7.5",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.7.5",
|
|
55
55
|
"@puppeteer/browsers": "^2.13.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34.49",
|
|
57
57
|
"@types/turndown": "5.0.6",
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { APP_NAME, MIN_BUN_VERSION, procmgr, VERSION } from "@oh-my-pi/pi-utils";
|
|
3
|
+
|
|
4
|
+
// Strip macOS malloc-stack-logging env vars before any subprocess is spawned.
|
|
5
|
+
// Otherwise every child bun process (subagents, plugin installs, ptree spawns,
|
|
6
|
+
// etc.) prints a `MallocStackLogging: can't turn off …` warning to stderr.
|
|
7
|
+
procmgr.scrubProcessEnv();
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* CLI entry point — registers all commands explicitly and delegates to the
|
|
5
11
|
* lightweight CLI runner from pi-utils.
|
|
@@ -73,6 +73,15 @@ import { MCPCommandController } from "./controllers/mcp-command-controller";
|
|
|
73
73
|
import { SelectorController } from "./controllers/selector-controller";
|
|
74
74
|
import { SSHCommandController } from "./controllers/ssh-command-controller";
|
|
75
75
|
import { TodoCommandController } from "./controllers/todo-command-controller";
|
|
76
|
+
import {
|
|
77
|
+
consumeLoopLimitIteration,
|
|
78
|
+
createLoopLimitRuntime,
|
|
79
|
+
describeLoopLimit,
|
|
80
|
+
describeLoopLimitRuntime,
|
|
81
|
+
isLoopDurationExpired,
|
|
82
|
+
type LoopLimitRuntime,
|
|
83
|
+
parseLoopLimitArgs,
|
|
84
|
+
} from "./loop-limit";
|
|
76
85
|
import { OAuthManualInputManager } from "./oauth-manual-input";
|
|
77
86
|
import { SessionObserverRegistry } from "./session-observer-registry";
|
|
78
87
|
import type { Theme } from "./theme/theme";
|
|
@@ -158,6 +167,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
158
167
|
planModePlanFilePath: string | undefined = undefined;
|
|
159
168
|
loopModeEnabled = false;
|
|
160
169
|
loopPrompt: string | undefined = undefined;
|
|
170
|
+
loopLimit: LoopLimitRuntime | undefined = undefined;
|
|
161
171
|
#loopAutoSubmitTimer: NodeJS.Timeout | undefined;
|
|
162
172
|
todoPhases: TodoPhase[] = [];
|
|
163
173
|
hideThinkingBlock = false;
|
|
@@ -535,25 +545,35 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
535
545
|
}
|
|
536
546
|
|
|
537
547
|
async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
|
|
548
|
+
if (!consumeLoopLimitIteration(this.loopLimit)) {
|
|
549
|
+
this.disableLoopMode("Loop limit reached. Loop mode disabled.");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
538
553
|
if (action === "compact") {
|
|
539
554
|
await this.handleCompactCommand();
|
|
540
555
|
} else if (action === "reset") {
|
|
541
556
|
await this.handleClearCommand();
|
|
542
557
|
}
|
|
543
558
|
if (!this.loopModeEnabled || !this.onInputCallback) return;
|
|
559
|
+
if (isLoopDurationExpired(this.loopLimit)) {
|
|
560
|
+
this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
544
563
|
this.onInputCallback(this.startPendingSubmission({ text: prompt }));
|
|
545
564
|
}
|
|
546
565
|
|
|
547
|
-
disableLoopMode(): void {
|
|
566
|
+
disableLoopMode(message = "Loop mode disabled."): void {
|
|
548
567
|
const wasEnabled = this.loopModeEnabled;
|
|
549
568
|
this.loopModeEnabled = false;
|
|
550
569
|
this.loopPrompt = undefined;
|
|
570
|
+
this.loopLimit = undefined;
|
|
551
571
|
this.#cancelLoopAutoSubmit();
|
|
552
572
|
this.statusLine.setLoopModeStatus(undefined);
|
|
553
573
|
this.updateEditorTopBorder();
|
|
554
574
|
this.ui.requestRender();
|
|
555
575
|
if (wasEnabled) {
|
|
556
|
-
this.showStatus(
|
|
576
|
+
this.showStatus(message);
|
|
557
577
|
}
|
|
558
578
|
}
|
|
559
579
|
|
|
@@ -567,18 +587,26 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
567
587
|
this.#cancelLoopAutoSubmit();
|
|
568
588
|
}
|
|
569
589
|
|
|
570
|
-
async handleLoopCommand(): Promise<void> {
|
|
590
|
+
async handleLoopCommand(args = ""): Promise<void> {
|
|
571
591
|
if (this.loopModeEnabled) {
|
|
572
592
|
this.disableLoopMode();
|
|
573
593
|
return;
|
|
574
594
|
}
|
|
595
|
+
const parsedLimit = parseLoopLimitArgs(args);
|
|
596
|
+
if (typeof parsedLimit === "string") {
|
|
597
|
+
this.showError(parsedLimit);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
575
600
|
this.loopModeEnabled = true;
|
|
576
601
|
this.loopPrompt = undefined;
|
|
602
|
+
this.loopLimit = createLoopLimitRuntime(parsedLimit);
|
|
577
603
|
this.statusLine.setLoopModeStatus({ enabled: true });
|
|
578
604
|
this.updateEditorTopBorder();
|
|
579
605
|
this.ui.requestRender();
|
|
606
|
+
const limitSuffix = parsedLimit ? ` Limited to ${describeLoopLimit(parsedLimit)}.` : "";
|
|
607
|
+
const remainingSuffix = this.loopLimit ? ` ${describeLoopLimitRuntime(this.loopLimit)}.` : "";
|
|
580
608
|
this.showStatus(
|
|
581
|
-
|
|
609
|
+
`Loop mode enabled.${limitSuffix}${remainingSuffix} Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.`,
|
|
582
610
|
);
|
|
583
611
|
}
|
|
584
612
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export type LoopLimitConfig =
|
|
2
|
+
| {
|
|
3
|
+
kind: "iterations";
|
|
4
|
+
iterations: number;
|
|
5
|
+
}
|
|
6
|
+
| {
|
|
7
|
+
kind: "duration";
|
|
8
|
+
durationMs: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type LoopLimitRuntime =
|
|
12
|
+
| {
|
|
13
|
+
kind: "iterations";
|
|
14
|
+
initial: number;
|
|
15
|
+
remaining: number;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
kind: "duration";
|
|
19
|
+
durationMs: number;
|
|
20
|
+
deadlineMs: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TIME_UNITS_MS = new Map<string, number>([
|
|
24
|
+
["s", 1_000],
|
|
25
|
+
["sec", 1_000],
|
|
26
|
+
["secs", 1_000],
|
|
27
|
+
["second", 1_000],
|
|
28
|
+
["seconds", 1_000],
|
|
29
|
+
["m", 60_000],
|
|
30
|
+
["min", 60_000],
|
|
31
|
+
["mins", 60_000],
|
|
32
|
+
["minute", 60_000],
|
|
33
|
+
["minutes", 60_000],
|
|
34
|
+
["h", 3_600_000],
|
|
35
|
+
["hr", 3_600_000],
|
|
36
|
+
["hrs", 3_600_000],
|
|
37
|
+
["hour", 3_600_000],
|
|
38
|
+
["hours", 3_600_000],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function parseLoopLimitArgs(args: string): LoopLimitConfig | undefined | string {
|
|
42
|
+
const trimmed = args.trim().toLowerCase();
|
|
43
|
+
if (!trimmed) return undefined;
|
|
44
|
+
|
|
45
|
+
const parts = trimmed.split(/\s+/);
|
|
46
|
+
if (parts.length > 2) {
|
|
47
|
+
return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min.";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parts.length === 2) {
|
|
51
|
+
return parseDurationParts(parts[0], parts[1]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const token = parts[0];
|
|
55
|
+
const iterationMatch = /^(\d+)$/.exec(token);
|
|
56
|
+
if (iterationMatch) {
|
|
57
|
+
const iterations = Number(iterationMatch[1]);
|
|
58
|
+
if (!Number.isSafeInteger(iterations) || iterations <= 0) {
|
|
59
|
+
return "Loop count must be a positive integer.";
|
|
60
|
+
}
|
|
61
|
+
return { kind: "iterations", iterations };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const durationMatch = /^(\d+)([a-z]+)$/.exec(token);
|
|
65
|
+
if (durationMatch) {
|
|
66
|
+
return parseDurationParts(durationMatch[1], durationMatch[2]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min.";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseDurationParts(amountText: string, unitText: string): LoopLimitConfig | string {
|
|
73
|
+
if (!/^\d+$/.test(amountText)) {
|
|
74
|
+
return "Loop duration must use a positive integer amount.";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const amount = Number(amountText);
|
|
78
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
|
79
|
+
return "Loop duration must be positive.";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const unitMs = TIME_UNITS_MS.get(unitText);
|
|
83
|
+
if (unitMs === undefined) {
|
|
84
|
+
return "Loop duration unit must be seconds, minutes, or hours.";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { kind: "duration", durationMs: amount * unitMs };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createLoopLimitRuntime(
|
|
91
|
+
config: LoopLimitConfig | undefined,
|
|
92
|
+
nowMs = Date.now(),
|
|
93
|
+
): LoopLimitRuntime | undefined {
|
|
94
|
+
if (!config) return undefined;
|
|
95
|
+
if (config.kind === "iterations") {
|
|
96
|
+
return { kind: "iterations", initial: config.iterations, remaining: config.iterations };
|
|
97
|
+
}
|
|
98
|
+
return { kind: "duration", durationMs: config.durationMs, deadlineMs: nowMs + config.durationMs };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function consumeLoopLimitIteration(limit: LoopLimitRuntime | undefined, nowMs = Date.now()): boolean {
|
|
102
|
+
if (!limit) return true;
|
|
103
|
+
if (limit.kind === "duration") {
|
|
104
|
+
return nowMs < limit.deadlineMs;
|
|
105
|
+
}
|
|
106
|
+
if (limit.remaining <= 0) return false;
|
|
107
|
+
limit.remaining -= 1;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isLoopDurationExpired(limit: LoopLimitRuntime | undefined, nowMs = Date.now()): boolean {
|
|
112
|
+
return limit?.kind === "duration" && nowMs >= limit.deadlineMs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function describeLoopLimit(config: LoopLimitConfig): string {
|
|
116
|
+
if (config.kind === "iterations") {
|
|
117
|
+
return `${config.iterations} ${config.iterations === 1 ? "iteration" : "iterations"}`;
|
|
118
|
+
}
|
|
119
|
+
return formatDuration(config.durationMs);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function describeLoopLimitRuntime(limit: LoopLimitRuntime): string {
|
|
123
|
+
if (limit.kind === "iterations") {
|
|
124
|
+
return `${limit.remaining} of ${limit.initial} ${limit.initial === 1 ? "iteration" : "iterations"} remaining`;
|
|
125
|
+
}
|
|
126
|
+
return `${formatDuration(limit.durationMs)} limit`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatDuration(durationMs: number): string {
|
|
130
|
+
if (durationMs % 3_600_000 === 0) {
|
|
131
|
+
const hours = durationMs / 3_600_000;
|
|
132
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
133
|
+
}
|
|
134
|
+
if (durationMs % 60_000 === 0) {
|
|
135
|
+
const minutes = durationMs / 60_000;
|
|
136
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
137
|
+
}
|
|
138
|
+
const seconds = durationMs / 1_000;
|
|
139
|
+
return `${seconds} ${seconds === 1 ? "second" : "seconds"}`;
|
|
140
|
+
}
|
package/src/modes/types.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { HookInputComponent } from "./components/hook-input";
|
|
|
24
24
|
import type { HookSelectorComponent } from "./components/hook-selector";
|
|
25
25
|
import type { StatusLineComponent } from "./components/status-line";
|
|
26
26
|
import type { ToolExecutionHandle } from "./components/tool-execution";
|
|
27
|
+
import type { LoopLimitRuntime } from "./loop-limit";
|
|
27
28
|
import type { OAuthManualInputManager } from "./oauth-manual-input";
|
|
28
29
|
import type { Theme } from "./theme/theme";
|
|
29
30
|
|
|
@@ -86,6 +87,7 @@ export interface InteractiveModeContext {
|
|
|
86
87
|
planModeEnabled: boolean;
|
|
87
88
|
loopModeEnabled: boolean;
|
|
88
89
|
loopPrompt?: string;
|
|
90
|
+
loopLimit?: LoopLimitRuntime;
|
|
89
91
|
planModePlanFilePath?: string;
|
|
90
92
|
hideThinkingBlock: boolean;
|
|
91
93
|
pendingImages: ImageContent[];
|
|
@@ -248,7 +250,7 @@ export interface InteractiveModeContext {
|
|
|
248
250
|
openExternalEditor(): void;
|
|
249
251
|
registerExtensionShortcuts(): void;
|
|
250
252
|
handlePlanModeCommand(initialPrompt?: string): Promise<void>;
|
|
251
|
-
handleLoopCommand(): Promise<void>;
|
|
253
|
+
handleLoopCommand(args?: string): Promise<void>;
|
|
252
254
|
disableLoopMode(): void;
|
|
253
255
|
pauseLoop(): void;
|
|
254
256
|
handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
|
|
@@ -119,8 +119,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
|
|
119
119
|
name: "loop",
|
|
120
120
|
description:
|
|
121
121
|
"Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.",
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
inlineHint: "[count|duration]",
|
|
123
|
+
allowArgs: true,
|
|
124
|
+
handle: async (command, runtime) => {
|
|
125
|
+
await runtime.ctx.handleLoopCommand(command.args);
|
|
124
126
|
runtime.ctx.editor.setText("");
|
|
125
127
|
},
|
|
126
128
|
},
|