@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 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",
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.4",
50
- "@oh-my-pi/pi-agent-core": "14.7.4",
51
- "@oh-my-pi/pi-ai": "14.7.4",
52
- "@oh-my-pi/pi-natives": "14.7.4",
53
- "@oh-my-pi/pi-tui": "14.7.4",
54
- "@oh-my-pi/pi-utils": "14.7.4",
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("Loop mode disabled.");
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
- "Loop mode enabled. Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.",
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
+ }
@@ -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
- handle: async (_command, runtime) => {
123
- await runtime.ctx.handleLoopCommand();
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
  },