@dungle-scrubs/tallow 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/dist/cli.js +35 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -5,14 +5,23 @@
5
5
  * current session. The interval timer starts after the previous iteration
6
6
  * completes (post-completion delay), preventing overlapping runs.
7
7
  *
8
+ * Supports optional limits and stop conditions:
9
+ * - `x<N>` — stop after N iterations
10
+ * - `until "<condition>"` — the model evaluates the condition each
11
+ * iteration and calls the `loop_stop` tool when it's met
12
+ *
8
13
  * Usage:
9
14
  * /loop 5m check the deploy status
15
+ * /loop 1m x10 run the test suite
16
+ * /loop 2m until "build is done" check fuse index progress
17
+ * /loop 1m x100 until "tests pass" run tests
10
18
  * /loop 30s /stats
11
19
  * /loop stop
12
20
  * /loop status
13
21
  */
14
22
 
15
23
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
24
+ import { type Static, Type } from "@sinclair/typebox";
16
25
 
17
26
  // ── Constants ────────────────────────────────────────────────────────────────
18
27
 
@@ -28,8 +37,10 @@ const RESET = "\x1b[0m";
28
37
 
29
38
  /** Mutable state for an active loop. */
30
39
  interface LoopState {
31
- /** The prompt text sent each iteration. */
40
+ /** The base prompt text (without condition suffix). */
32
41
  prompt: string;
42
+ /** The full prompt sent each iteration (includes condition instruction). */
43
+ fullPrompt: string;
33
44
  /** Interval in milliseconds between iterations. */
34
45
  intervalMs: number;
35
46
  /** Original interval string (e.g. "5m") for display. */
@@ -44,6 +55,10 @@ interface LoopState {
44
55
  awaitingCompletion: boolean;
45
56
  /** Number of completed iterations. */
46
57
  iterationCount: number;
58
+ /** Maximum iterations before auto-stop, or null for unlimited. */
59
+ maxIterations: number | null;
60
+ /** Stop condition for the model to evaluate, or null for none. */
61
+ untilCondition: string | null;
47
62
  }
48
63
 
49
64
  // ── Module State ─────────────────────────────────────────────────────────────
@@ -79,6 +94,86 @@ export function parseInterval(s: string): number | null {
79
94
  return value * multiplier;
80
95
  }
81
96
 
97
+ /**
98
+ * Parse an iteration count from a string like "x100" or "x5".
99
+ *
100
+ * @param s - Token to parse
101
+ * @returns Positive integer count, or null if not a count token
102
+ */
103
+ export function parseMaxIterations(s: string): number | null {
104
+ const match = s.match(/^x(\d+)$/);
105
+ if (!match) return null;
106
+ const value = parseInt(match[1], 10);
107
+ return value > 0 ? value : null;
108
+ }
109
+
110
+ /**
111
+ * Extract an `until "..."` condition from a token array.
112
+ *
113
+ * Looks for the word "until" followed by a quoted string. Supports
114
+ * both single and double quotes. Returns the condition text and the
115
+ * remaining tokens with the `until "..."` portion removed.
116
+ *
117
+ * @param tokens - Array of whitespace-split tokens
118
+ * @returns Object with condition (or null) and remaining tokens
119
+ */
120
+ export function extractUntilCondition(tokens: string[]): {
121
+ condition: string | null;
122
+ remaining: string[];
123
+ } {
124
+ const untilIdx = tokens.findIndex((t) => t.toLowerCase() === "until");
125
+ if (untilIdx === -1) {
126
+ return { condition: null, remaining: tokens };
127
+ }
128
+
129
+ // Everything after "until" is the condition + prompt
130
+ const afterUntil = tokens.slice(untilIdx + 1);
131
+ const beforeUntil = tokens.slice(0, untilIdx);
132
+
133
+ if (afterUntil.length === 0) {
134
+ return { condition: null, remaining: tokens };
135
+ }
136
+
137
+ // Check if the condition is quoted
138
+ const first = afterUntil[0];
139
+ const quoteChar = first[0] === '"' || first[0] === "'" ? first[0] : null;
140
+
141
+ if (quoteChar) {
142
+ // Find the closing quote
143
+ const conditionTokens: string[] = [];
144
+ let closingIdx = -1;
145
+
146
+ for (let i = 0; i < afterUntil.length; i++) {
147
+ conditionTokens.push(afterUntil[i]);
148
+ if (i > 0 && afterUntil[i].endsWith(quoteChar)) {
149
+ closingIdx = i;
150
+ break;
151
+ }
152
+ if (i === 0 && afterUntil[i].length > 1 && afterUntil[i].endsWith(quoteChar)) {
153
+ closingIdx = i;
154
+ break;
155
+ }
156
+ }
157
+
158
+ if (closingIdx === -1) {
159
+ // No closing quote — treat everything as condition
160
+ const raw = conditionTokens.join(" ");
161
+ const condition = raw.slice(1); // strip opening quote
162
+ return { condition, remaining: beforeUntil };
163
+ }
164
+
165
+ const raw = conditionTokens.join(" ");
166
+ const condition = raw.slice(1, -1); // strip both quotes
167
+ const afterCondition = afterUntil.slice(closingIdx + 1);
168
+ return { condition, remaining: [...beforeUntil, ...afterCondition] };
169
+ }
170
+
171
+ // No quotes — single word is the condition
172
+ const condition = first;
173
+ const afterCondition = afterUntil.slice(1);
174
+ return { condition, remaining: [...beforeUntil, ...afterCondition] };
175
+ }
176
+
82
177
  // ── Countdown Formatting ─────────────────────────────────────────────────────
83
178
 
84
179
  /**
@@ -107,11 +202,20 @@ export function formatCountdown(ms: number): string {
107
202
  export type LoopArgs =
108
203
  | { action: "status" }
109
204
  | { action: "stop" }
110
- | { action: "start"; intervalMs: number; intervalLabel: string; prompt: string };
205
+ | {
206
+ action: "start";
207
+ intervalMs: number;
208
+ intervalLabel: string;
209
+ prompt: string;
210
+ maxIterations: number | null;
211
+ untilCondition: string | null;
212
+ };
111
213
 
112
214
  /**
113
215
  * Parse the argument string passed to `/loop`.
114
216
  *
217
+ * Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt...>
218
+ *
115
219
  * @param args - Raw argument text (everything after `/loop `)
116
220
  * @returns Parsed action with relevant parameters
117
221
  */
@@ -130,20 +234,10 @@ export function parseLoopArgs(args: string): LoopArgs | { action: "error"; messa
130
234
  return { action: "status" };
131
235
  }
132
236
 
133
- // Expect: <interval> <prompt...>
134
- const spaceIdx = trimmed.indexOf(" ");
135
- if (spaceIdx === -1) {
136
- // Could be just an interval with no prompt
137
- const ms = parseInterval(trimmed);
138
- if (ms !== null) {
139
- return { action: "error", message: "Missing prompt. Usage: /loop 5m <prompt>" };
140
- }
141
- return { action: "error", message: `Invalid interval "${trimmed}". Use format: 30s, 5m, 1h` };
142
- }
143
-
144
- const intervalStr = trimmed.slice(0, spaceIdx);
145
- const prompt = trimmed.slice(spaceIdx + 1).trim();
237
+ const tokens = trimmed.split(/\s+/);
146
238
 
239
+ // First token must be the interval
240
+ const intervalStr = tokens[0];
147
241
  const ms = parseInterval(intervalStr);
148
242
  if (ms === null) {
149
243
  return {
@@ -152,15 +246,56 @@ export function parseLoopArgs(args: string): LoopArgs | { action: "error"; messa
152
246
  };
153
247
  }
154
248
 
249
+ let rest = tokens.slice(1);
250
+ if (rest.length === 0) {
251
+ return { action: "error", message: "Missing prompt. Usage: /loop 5m <prompt>" };
252
+ }
253
+
254
+ // Check for x<N> max iterations (can appear anywhere before the prompt)
255
+ let maxIterations: number | null = null;
256
+ const maxIdx = rest.findIndex((t) => parseMaxIterations(t) !== null);
257
+ if (maxIdx !== -1) {
258
+ maxIterations = parseMaxIterations(rest[maxIdx]);
259
+ rest = [...rest.slice(0, maxIdx), ...rest.slice(maxIdx + 1)];
260
+ }
261
+
262
+ // Check for until "condition"
263
+ const { condition, remaining } = extractUntilCondition(rest);
264
+
265
+ const prompt = remaining.join(" ").trim();
155
266
  if (!prompt) {
156
267
  return { action: "error", message: "Missing prompt. Usage: /loop 5m <prompt>" };
157
268
  }
158
269
 
159
- return { action: "start", intervalMs: ms, intervalLabel: intervalStr, prompt };
270
+ return {
271
+ action: "start",
272
+ intervalMs: ms,
273
+ intervalLabel: intervalStr,
274
+ prompt,
275
+ maxIterations,
276
+ untilCondition: condition,
277
+ };
160
278
  }
161
279
 
162
280
  // ── Loop Lifecycle ───────────────────────────────────────────────────────────
163
281
 
282
+ /**
283
+ * Build a display label summarizing the loop configuration.
284
+ *
285
+ * @param loop - Active loop state
286
+ * @returns Human-readable summary like "every 5m x10 until 'build done'"
287
+ */
288
+ function buildLabel(loop: LoopState): string {
289
+ let label = `every ${loop.intervalLabel}`;
290
+ if (loop.maxIterations !== null) {
291
+ label += ` x${loop.maxIterations}`;
292
+ }
293
+ if (loop.untilCondition) {
294
+ label += ` until "${loop.untilCondition}"`;
295
+ }
296
+ return label;
297
+ }
298
+
164
299
  /**
165
300
  * Update the status bar with the current loop state.
166
301
  *
@@ -178,9 +313,17 @@ function updateStatus(ctx: ExtensionContext): void {
178
313
  const promptPreview =
179
314
  activeLoop.prompt.length > 30 ? `${activeLoop.prompt.slice(0, 27)}...` : activeLoop.prompt;
180
315
 
316
+ const iterInfo =
317
+ activeLoop.maxIterations !== null
318
+ ? ` ${activeLoop.iterationCount}/${activeLoop.maxIterations}`
319
+ : ` #${activeLoop.iterationCount}`;
320
+
181
321
  if (activeLoop.awaitingCompletion) {
182
322
  const iter = activeLoop.iterationCount + 1;
183
- ctx.ui.setStatus(STATUS_SLOT, `${FG_CYAN}🔄 running: "${promptPreview}" (#${iter})${RESET}`);
323
+ ctx.ui.setStatus(
324
+ STATUS_SLOT,
325
+ `${FG_CYAN}🔄 running: "${promptPreview}" (#${iter}${activeLoop.maxIterations ? `/${activeLoop.maxIterations}` : ""})${RESET}`
326
+ );
184
327
  return;
185
328
  }
186
329
 
@@ -188,7 +331,7 @@ function updateStatus(ctx: ExtensionContext): void {
188
331
  const countdown = formatCountdown(remaining);
189
332
  ctx.ui.setStatus(
190
333
  STATUS_SLOT,
191
- `${FG_CYAN}🔄 ${activeLoop.intervalLabel}: ${FG_DIM}"${promptPreview}"${RESET}${FG_CYAN} (next in ${countdown})${RESET}`
334
+ `${FG_CYAN}🔄 ${activeLoop.intervalLabel}${iterInfo}: ${FG_DIM}"${promptPreview}"${RESET}${FG_CYAN} (next in ${countdown})${RESET}`
192
335
  );
193
336
  }
194
337
 
@@ -254,9 +397,12 @@ function scheduleNext(pi: ExtensionAPI, ctx: ExtensionContext): void {
254
397
 
255
398
  updateStatus(ctx);
256
399
 
257
- ctx.ui.notify(`Loop iteration #${activeLoop.iterationCount + 1}: ${activeLoop.prompt}`, "info");
400
+ const iterLabel = activeLoop.maxIterations
401
+ ? `#${activeLoop.iterationCount + 1}/${activeLoop.maxIterations}`
402
+ : `#${activeLoop.iterationCount + 1}`;
403
+ ctx.ui.notify(`Loop iteration ${iterLabel}: ${activeLoop.prompt}`, "info");
258
404
 
259
- pi.sendUserMessage(activeLoop.prompt, { deliverAs: "followUp" });
405
+ pi.sendUserMessage(activeLoop.fullPrompt, { deliverAs: "followUp" });
260
406
  }, activeLoop.intervalMs);
261
407
  }
262
408
 
@@ -278,19 +424,52 @@ function stopLoop(ctx: ExtensionContext, reason: string = "Loop stopped"): void
278
424
 
279
425
  // ── Extension Entry Point ────────────────────────────────────────────────────
280
426
 
427
+ /** TypeBox schema for the loop_stop tool parameters. */
428
+ const LoopStopParams = Type.Object({
429
+ reason: Type.String({ description: "Why the stop condition was met" }),
430
+ });
431
+
281
432
  /**
282
433
  * Loop extension factory.
283
434
  *
284
- * Registers the `/loop` command, `agent_end` handler for iteration
285
- * scheduling, and cleanup handlers for session lifecycle events.
435
+ * Registers the `/loop` command, `loop_stop` tool, `agent_end` handler
436
+ * for iteration scheduling, and cleanup handlers for session lifecycle.
286
437
  *
287
438
  * @param pi - Extension API
288
439
  */
289
440
  export default function loopExtension(pi: ExtensionAPI): void {
441
+ // ── loop_stop tool — lets the model stop the loop when a condition is met
442
+
443
+ pi.registerTool({
444
+ name: "loop_stop",
445
+ label: "Stop Loop",
446
+ description:
447
+ "Stop the active /loop because its stop condition has been met. " +
448
+ "Only call this tool when a /loop is running with an `until` condition " +
449
+ "and you have determined the condition is satisfied.",
450
+ parameters: LoopStopParams,
451
+ async execute(_toolCallId, params: Static<typeof LoopStopParams>, _signal, _onUpdate, ctx) {
452
+ if (!activeLoop) {
453
+ return {
454
+ content: [{ type: "text" as const, text: "No active loop to stop." }],
455
+ details: undefined,
456
+ };
457
+ }
458
+ const reason = `Loop stopped: condition met — ${params.reason}`;
459
+ stopLoop(ctx, reason);
460
+ return {
461
+ content: [{ type: "text" as const, text: `Loop stopped. Reason: ${params.reason}` }],
462
+ details: undefined,
463
+ };
464
+ },
465
+ });
466
+
290
467
  // ── /loop command ────────────────────────────────────────────────────
291
468
 
292
469
  pi.registerCommand("loop", {
293
- description: "Run a prompt on a recurring interval (e.g. /loop 5m check deploy)",
470
+ description:
471
+ "Run a prompt on a recurring interval. " +
472
+ 'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt>',
294
473
  handler: async (args, ctx) => {
295
474
  const parsed = parseLoopArgs(args);
296
475
 
@@ -309,7 +488,10 @@ export default function loopExtension(pi: ExtensionAPI): void {
309
488
 
310
489
  case "status":
311
490
  if (!activeLoop) {
312
- ctx.ui.notify("No active loop. Usage: /loop 5m <prompt>", "info");
491
+ ctx.ui.notify(
492
+ 'No active loop. Usage: /loop 5m <prompt>\n Options: x<N> (max iterations), until "<condition>" (auto-stop)',
493
+ "info"
494
+ );
313
495
  return;
314
496
  }
315
497
  {
@@ -317,10 +499,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
317
499
  const state = activeLoop.awaitingCompletion
318
500
  ? `running iteration #${activeLoop.iterationCount + 1}`
319
501
  : `next in ${formatCountdown(remaining)}`;
320
- ctx.ui.notify(
321
- `Loop: every ${activeLoop.intervalLabel} → "${activeLoop.prompt}" | ${state} | ${activeLoop.iterationCount} completed`,
322
- "info"
323
- );
502
+ let info = `Loop: ${buildLabel(activeLoop)} → "${activeLoop.prompt}" | ${state} | ${activeLoop.iterationCount} completed`;
503
+ if (activeLoop.maxIterations) {
504
+ info += ` of ${activeLoop.maxIterations}`;
505
+ }
506
+ ctx.ui.notify(info, "info");
324
507
  }
325
508
  return;
326
509
 
@@ -329,13 +512,24 @@ export default function loopExtension(pi: ExtensionAPI): void {
329
512
  if (activeLoop) {
330
513
  clearTimers();
331
514
  ctx.ui.notify(
332
- `Replacing active loop (was: ${activeLoop.intervalLabel} → "${activeLoop.prompt}")`,
515
+ `Replacing active loop (was: ${buildLabel(activeLoop)} → "${activeLoop.prompt}")`,
333
516
  "info"
334
517
  );
335
518
  }
336
519
 
520
+ // Build the full prompt with condition instruction
521
+ let fullPrompt = parsed.prompt;
522
+ if (parsed.untilCondition) {
523
+ fullPrompt +=
524
+ `\n\n---\nLoop stop condition: "${parsed.untilCondition}"\n` +
525
+ `After completing the task above, evaluate whether this condition is now met. ` +
526
+ `If it IS met, call the loop_stop tool with the reason. ` +
527
+ `If it is NOT yet met, do nothing — the loop will continue automatically.`;
528
+ }
529
+
337
530
  activeLoop = {
338
531
  prompt: parsed.prompt,
532
+ fullPrompt,
339
533
  intervalMs: parsed.intervalMs,
340
534
  intervalLabel: parsed.intervalLabel,
341
535
  timer: null,
@@ -343,9 +537,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
343
537
  nextRunAt: 0,
344
538
  awaitingCompletion: false,
345
539
  iterationCount: 0,
540
+ maxIterations: parsed.maxIterations,
541
+ untilCondition: parsed.untilCondition,
346
542
  };
347
543
 
348
- ctx.ui.notify(`Loop started: every ${parsed.intervalLabel} → "${parsed.prompt}"`, "info");
544
+ ctx.ui.notify(`Loop started: ${buildLabel(activeLoop)} → "${parsed.prompt}"`, "info");
349
545
 
350
546
  scheduleNext(pi, ctx);
351
547
  return;
@@ -366,7 +562,22 @@ export default function loopExtension(pi: ExtensionAPI): void {
366
562
  activeLoop.awaitingCompletion = false;
367
563
  activeLoop.iterationCount++;
368
564
 
369
- ctx.ui.notify(`Loop iteration #${activeLoop.iterationCount} complete`, "info");
565
+ // Check max iterations limit
566
+ if (
567
+ activeLoop.maxIterations !== null &&
568
+ activeLoop.iterationCount >= activeLoop.maxIterations
569
+ ) {
570
+ stopLoop(
571
+ ctx,
572
+ `Loop complete: ${activeLoop.iterationCount}/${activeLoop.maxIterations} iterations`
573
+ );
574
+ return;
575
+ }
576
+
577
+ const iterLabel = activeLoop.maxIterations
578
+ ? `${activeLoop.iterationCount}/${activeLoop.maxIterations}`
579
+ : `${activeLoop.iterationCount}`;
580
+ ctx.ui.notify(`Loop iteration ${iterLabel} complete`, "info");
370
581
 
371
582
  scheduleNext(pi, ctx);
372
583
  });