@caupulican/pi-adaptative 0.85.0 → 0.85.3

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 (88) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/bundled-resources/runtimes/pi-shell-engine/arithmetic.py +364 -0
  3. package/dist/bundled-resources/runtimes/pi-shell-engine/context.py +2 -1
  4. package/dist/bundled-resources/runtimes/pi-shell-engine/errors.py +16 -0
  5. package/dist/bundled-resources/runtimes/pi-shell-engine/exec.py +137 -14
  6. package/dist/bundled-resources/runtimes/pi-shell-engine/nodes.py +23 -2
  7. package/dist/bundled-resources/runtimes/pi-shell-engine/parser.py +134 -6
  8. package/dist/bundled-resources/runtimes/pi-shell-engine/tokens.py +25 -2
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +8 -0
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/compaction-controller.d.ts +2 -0
  13. package/dist/core/compaction-controller.d.ts.map +1 -1
  14. package/dist/core/compaction-controller.js +35 -13
  15. package/dist/core/compaction-controller.js.map +1 -1
  16. package/dist/core/failure-corpus.d.ts +26 -1
  17. package/dist/core/failure-corpus.d.ts.map +1 -1
  18. package/dist/core/failure-corpus.js +17 -0
  19. package/dist/core/failure-corpus.js.map +1 -1
  20. package/dist/core/model-tool-protocol.d.ts +3 -2
  21. package/dist/core/model-tool-protocol.d.ts.map +1 -1
  22. package/dist/core/model-tool-protocol.js +5 -1
  23. package/dist/core/model-tool-protocol.js.map +1 -1
  24. package/dist/core/models/runtime-process.d.ts +7 -1
  25. package/dist/core/models/runtime-process.d.ts.map +1 -1
  26. package/dist/core/models/runtime-process.js +4 -2
  27. package/dist/core/models/runtime-process.js.map +1 -1
  28. package/dist/core/process-matrix/runtime.js +43 -11
  29. package/dist/core/process-matrix/runtime.js.map +1 -1
  30. package/dist/core/resource-loader.d.ts +2 -0
  31. package/dist/core/resource-loader.d.ts.map +1 -1
  32. package/dist/core/resource-loader.js +18 -6
  33. package/dist/core/resource-loader.js.map +1 -1
  34. package/dist/core/tool-recovery-log-records.d.ts +33 -1
  35. package/dist/core/tool-recovery-log-records.d.ts.map +1 -1
  36. package/dist/core/tool-recovery-log-records.js +59 -12
  37. package/dist/core/tool-recovery-log-records.js.map +1 -1
  38. package/dist/core/tool-recovery-logger.d.ts +12 -1
  39. package/dist/core/tool-recovery-logger.d.ts.map +1 -1
  40. package/dist/core/tool-recovery-logger.js +24 -1
  41. package/dist/core/tool-recovery-logger.js.map +1 -1
  42. package/dist/core/tool-repair-health.d.ts.map +1 -1
  43. package/dist/core/tool-repair-health.js +6 -0
  44. package/dist/core/tool-repair-health.js.map +1 -1
  45. package/dist/core/tools/bash.d.ts +2 -2
  46. package/dist/core/tools/bash.d.ts.map +1 -1
  47. package/dist/core/tools/bash.js +2 -2
  48. package/dist/core/tools/bash.js.map +1 -1
  49. package/dist/core/tools/find.d.ts +2 -1
  50. package/dist/core/tools/find.d.ts.map +1 -1
  51. package/dist/core/tools/find.js +2 -6
  52. package/dist/core/tools/find.js.map +1 -1
  53. package/dist/core/tools/grep.d.ts +2 -1
  54. package/dist/core/tools/grep.d.ts.map +1 -1
  55. package/dist/core/tools/grep.js +2 -6
  56. package/dist/core/tools/grep.js.map +1 -1
  57. package/dist/core/tools/managed-search-tool.d.ts +8 -0
  58. package/dist/core/tools/managed-search-tool.d.ts.map +1 -0
  59. package/dist/core/tools/managed-search-tool.js +10 -0
  60. package/dist/core/tools/managed-search-tool.js.map +1 -0
  61. package/dist/core/tools/shell-command-parser.d.ts +4 -3
  62. package/dist/core/tools/shell-command-parser.d.ts.map +1 -1
  63. package/dist/core/tools/shell-command-parser.js +32 -7
  64. package/dist/core/tools/shell-command-parser.js.map +1 -1
  65. package/dist/core/tools/shell-contract-router.d.ts.map +1 -1
  66. package/dist/core/tools/shell-contract-router.js +23 -1
  67. package/dist/core/tools/shell-contract-router.js.map +1 -1
  68. package/dist/core/tools/windows-shell-engine.js +1 -1
  69. package/dist/core/tools/windows-shell-engine.js.map +1 -1
  70. package/dist/utils/paths.js +1 -1
  71. package/dist/utils/paths.js.map +1 -1
  72. package/dist/utils/tools-manager.d.ts +18 -1
  73. package/dist/utils/tools-manager.d.ts.map +1 -1
  74. package/dist/utils/tools-manager.js +73 -43
  75. package/dist/utils/tools-manager.js.map +1 -1
  76. package/docs/models.md +1 -1
  77. package/docs/settings.md +1 -1
  78. package/docs/tool-repair.md +3 -3
  79. package/docs/windows.md +7 -5
  80. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  81. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  82. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  83. package/examples/extensions/sandbox/package-lock.json +2 -2
  84. package/examples/extensions/sandbox/package.json +1 -1
  85. package/examples/extensions/with-deps/package-lock.json +2 -2
  86. package/examples/extensions/with-deps/package.json +1 -1
  87. package/npm-shrinkwrap.json +12 -12
  88. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## [0.85.3] - 2026-08-03
2
+
3
+ ### Fixed
4
+
5
+ - Fixed the bundled Windows incident collector on PowerShell 5.1 hosts where the module-provided `Get-FileHash` command is unavailable.
6
+ - Fixed native Windows path canonicalization so 8.3 short paths and their long-path spellings resolve to one resource identity.
7
+ - Fixed cooperative worker cleanup under Windows file-lock contention so terminal state is durably persisted, including cleanup requested during the adoption grace window.
8
+ - Fixed system Python detection incorrectly accepting the Windows Store execution alias or another non-Python executable as an installed interpreter.
9
+
10
+ ## [0.85.2] - 2026-08-03
11
+
12
+ ### Fixed
13
+
14
+ - Fixed fresh Windows search-tool provisioning by pinning checksum-verified `fd` release assets instead of relying on GitHub's rate-limited latest-release API, and hardened Windows release-gate portability without enabling unsupported native tmux.
15
+
16
+ ## [0.85.1] - 2026-08-03
17
+
18
+ ### Fixed
19
+
20
+ - Prevented concurrent and ineffective repeated auto-compaction runs in image-heavy sessions and contexts with protected recent messages.
21
+ - Fixed Windows runtime probes and case-insensitive context-file identity, plus Bash-like path parsing, portable `printf`, word-list and arithmetic `for` loops, and nested `break`/`continue` in the managed shell.
22
+ - Preserved managed-tool provisioning causes and bounded execution-failure guidance in recovery logs, failure corpora, and `/toolhealth`.
23
+ - Kept proven native tool-call models off the phone/text protocol and routed phone validation, repair teaching, and failure guidance through the shared tool pipeline.
24
+ - Required the exact release ref to pass Linux and Windows CI before publishing binaries.
25
+
1
26
  ## [0.85.0] - 2026-08-02
2
27
 
3
28
  ### Breaking Changes
@@ -0,0 +1,364 @@
1
+ """Safe integer-expression parser/evaluator for arithmetic ``for ((...))`` loops.
2
+
3
+ The evaluator deliberately does not call Python ``eval``. It accepts the integer,
4
+ variable, update, assignment, comparison, logical, bitwise, and conditional operators
5
+ used by Bash loop headers, then applies signed 64-bit wrapping after arithmetic writes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from state import ShellState
14
+
15
+ _MASK = (1 << 64) - 1
16
+ _SIGN_BIT = 1 << 63
17
+ _ASSIGNMENT_OPERATORS = frozenset(
18
+ {"=", "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", "&=", "^=", "|="}
19
+ )
20
+ _TOKEN_RE = re.compile(
21
+ r"\s*(?:"
22
+ r"(?P<number>0[xX][0-9A-Fa-f]+|0[bB][01]+|0[0-7]+|[0-9]+)"
23
+ r"|(?P<variable>\$?[A-Za-z_][A-Za-z0-9_]*)"
24
+ r"|(?P<operator>\*\*|\+\+|--|<<=|>>=|\+=|-=|\*=|/=|%=|&=|\^=|\|="
25
+ r"|\|\||&&|==|!=|<=|>=|<<|>>|[=+\-*/%<>()!~&^|?:,])"
26
+ r")"
27
+ )
28
+
29
+
30
+ class ArithmeticError(ValueError):
31
+ """A bounded syntax or runtime failure in a loop arithmetic expression."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class _Token:
36
+ kind: str
37
+ text: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class _Number:
42
+ value: int
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _Variable:
47
+ name: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _Unary:
52
+ operator: str
53
+ operand: "_Expression"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class _Binary:
58
+ operator: str
59
+ left: "_Expression"
60
+ right: "_Expression"
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _Conditional:
65
+ condition: "_Expression"
66
+ when_true: "_Expression"
67
+ when_false: "_Expression"
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class _Assignment:
72
+ operator: str
73
+ target: _Variable
74
+ value: "_Expression"
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class _Update:
79
+ operator: str
80
+ target: _Variable
81
+ prefix: bool
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class _Sequence:
86
+ expressions: tuple["_Expression", ...]
87
+
88
+
89
+ _Expression = _Number | _Variable | _Unary | _Binary | _Conditional | _Assignment | _Update | _Sequence
90
+
91
+
92
+ def _wrap(value: int) -> int:
93
+ unsigned = value & _MASK
94
+ return unsigned - (1 << 64) if unsigned & _SIGN_BIT else unsigned
95
+
96
+
97
+ def _parse_integer(text: str) -> int:
98
+ if text.lower().startswith(("0x", "0b")):
99
+ return _wrap(int(text, 0))
100
+ if len(text) > 1 and text.startswith("0"):
101
+ return _wrap(int(text, 8))
102
+ return _wrap(int(text, 10))
103
+
104
+
105
+ def _tokenize(source: str) -> list[_Token]:
106
+ tokens: list[_Token] = []
107
+ position = 0
108
+ while position < len(source):
109
+ match = _TOKEN_RE.match(source, position)
110
+ if match is None:
111
+ excerpt = source[position : position + 16]
112
+ raise ArithmeticError(f"unexpected arithmetic token near {excerpt!r}")
113
+ kind = match.lastgroup
114
+ assert kind is not None
115
+ text = match.group(kind)
116
+ if kind == "variable" and text.startswith("$"):
117
+ text = text[1:]
118
+ tokens.append(_Token(kind, text))
119
+ position = match.end()
120
+ tokens.append(_Token("end", ""))
121
+ return tokens
122
+
123
+
124
+ class _Parser:
125
+ def __init__(self, source: str) -> None:
126
+ self.tokens = _tokenize(source)
127
+ self.index = 0
128
+
129
+ def current(self) -> _Token:
130
+ return self.tokens[self.index]
131
+
132
+ def accept(self, operator: str) -> bool:
133
+ if self.current().kind == "operator" and self.current().text == operator:
134
+ self.index += 1
135
+ return True
136
+ return False
137
+
138
+ def require(self, operator: str) -> None:
139
+ if not self.accept(operator):
140
+ raise ArithmeticError(f"expected {operator!r} in arithmetic expression")
141
+
142
+ def parse(self) -> _Expression:
143
+ expression = self.parse_sequence()
144
+ if self.current().kind != "end":
145
+ raise ArithmeticError(f"unexpected arithmetic token {self.current().text!r}")
146
+ return expression
147
+
148
+ def parse_sequence(self) -> _Expression:
149
+ expressions = [self.parse_assignment()]
150
+ while self.accept(","):
151
+ expressions.append(self.parse_assignment())
152
+ return expressions[0] if len(expressions) == 1 else _Sequence(tuple(expressions))
153
+
154
+ def parse_assignment(self) -> _Expression:
155
+ target = self.parse_conditional()
156
+ token = self.current()
157
+ if token.kind != "operator" or token.text not in _ASSIGNMENT_OPERATORS:
158
+ return target
159
+ self.index += 1
160
+ if not isinstance(target, _Variable):
161
+ raise ArithmeticError("the left side of an arithmetic assignment must be a variable")
162
+ return _Assignment(token.text, target, self.parse_assignment())
163
+
164
+ def parse_conditional(self) -> _Expression:
165
+ condition = self.parse_logical_or()
166
+ if not self.accept("?"):
167
+ return condition
168
+ when_true = self.parse_sequence()
169
+ self.require(":")
170
+ return _Conditional(condition, when_true, self.parse_assignment())
171
+
172
+ def parse_logical_or(self) -> _Expression:
173
+ return self._parse_left_associative(self.parse_logical_and, frozenset({"||"}))
174
+
175
+ def parse_logical_and(self) -> _Expression:
176
+ return self._parse_left_associative(self.parse_bitwise_or, frozenset({"&&"}))
177
+
178
+ def parse_bitwise_or(self) -> _Expression:
179
+ return self._parse_left_associative(self.parse_bitwise_xor, frozenset({"|"}))
180
+
181
+ def parse_bitwise_xor(self) -> _Expression:
182
+ return self._parse_left_associative(self.parse_bitwise_and, frozenset({"^"}))
183
+
184
+ def parse_bitwise_and(self) -> _Expression:
185
+ return self._parse_left_associative(self.parse_equality, frozenset({"&"}))
186
+
187
+ def parse_equality(self) -> _Expression:
188
+ return self._parse_left_associative(self.parse_relational, frozenset({"==", "!="}))
189
+
190
+ def parse_relational(self) -> _Expression:
191
+ return self._parse_left_associative(self.parse_shift, frozenset({"<", "<=", ">", ">="}))
192
+
193
+ def parse_shift(self) -> _Expression:
194
+ return self._parse_left_associative(self.parse_additive, frozenset({"<<", ">>"}))
195
+
196
+ def parse_additive(self) -> _Expression:
197
+ return self._parse_left_associative(self.parse_multiplicative, frozenset({"+", "-"}))
198
+
199
+ def parse_multiplicative(self) -> _Expression:
200
+ return self._parse_left_associative(self.parse_power, frozenset({"*", "/", "%"}))
201
+
202
+ def parse_power(self) -> _Expression:
203
+ left = self.parse_unary()
204
+ if self.accept("**"):
205
+ return _Binary("**", left, self.parse_power())
206
+ return left
207
+
208
+ def parse_unary(self) -> _Expression:
209
+ token = self.current()
210
+ if token.kind == "operator" and token.text in ("+", "-", "!", "~"):
211
+ self.index += 1
212
+ return _Unary(token.text, self.parse_unary())
213
+ if token.kind == "operator" and token.text in ("++", "--"):
214
+ self.index += 1
215
+ target = self.parse_unary()
216
+ if not isinstance(target, _Variable):
217
+ raise ArithmeticError(f"{token.text} requires a variable")
218
+ return _Update(token.text, target, True)
219
+ return self.parse_postfix()
220
+
221
+ def parse_postfix(self) -> _Expression:
222
+ expression = self.parse_primary()
223
+ token = self.current()
224
+ if token.kind == "operator" and token.text in ("++", "--"):
225
+ self.index += 1
226
+ if not isinstance(expression, _Variable):
227
+ raise ArithmeticError(f"{token.text} requires a variable")
228
+ return _Update(token.text, expression, False)
229
+ return expression
230
+
231
+ def parse_primary(self) -> _Expression:
232
+ token = self.current()
233
+ if token.kind == "number":
234
+ self.index += 1
235
+ return _Number(_parse_integer(token.text))
236
+ if token.kind == "variable":
237
+ self.index += 1
238
+ return _Variable(token.text)
239
+ if self.accept("("):
240
+ expression = self.parse_sequence()
241
+ self.require(")")
242
+ return expression
243
+ raise ArithmeticError(f"expected an integer, variable, or '('; found {token.text!r}")
244
+
245
+ def _parse_left_associative(self, operand_parser, operators: frozenset[str]) -> _Expression:
246
+ expression = operand_parser()
247
+ while self.current().kind == "operator" and self.current().text in operators:
248
+ operator = self.current().text
249
+ self.index += 1
250
+ expression = _Binary(operator, expression, operand_parser())
251
+ return expression
252
+
253
+
254
+ def compile_arithmetic(source: str) -> _Expression | None:
255
+ stripped = source.strip()
256
+ return None if not stripped else _Parser(stripped).parse()
257
+
258
+
259
+ def _variable_value(variable: _Variable, state: ShellState) -> int:
260
+ text = state.env.get(variable.name, "").strip()
261
+ if not text:
262
+ return 0
263
+ try:
264
+ return _parse_integer(text)
265
+ except ValueError:
266
+ return 0
267
+
268
+
269
+ def _set_variable(variable: _Variable, value: int, state: ShellState) -> int:
270
+ wrapped = _wrap(value)
271
+ state.setenv(variable.name, str(wrapped))
272
+ return wrapped
273
+
274
+
275
+ def _divide(left: int, right: int) -> int:
276
+ if right == 0:
277
+ raise ArithmeticError("division by zero")
278
+ quotient = abs(left) // abs(right)
279
+ return -quotient if (left < 0) != (right < 0) else quotient
280
+
281
+
282
+ def _binary(operator: str, left: int, right: int) -> int:
283
+ if operator == "+":
284
+ return _wrap(left + right)
285
+ if operator == "-":
286
+ return _wrap(left - right)
287
+ if operator == "*":
288
+ return _wrap(left * right)
289
+ if operator == "/":
290
+ return _wrap(_divide(left, right))
291
+ if operator == "%":
292
+ return _wrap(left - _divide(left, right) * right)
293
+ if operator == "**":
294
+ if right < 0:
295
+ raise ArithmeticError("negative exponent")
296
+ return _wrap(pow(left & _MASK, right, 1 << 64))
297
+ if operator in ("<<", ">>"):
298
+ if right < 0 or right >= 64:
299
+ raise ArithmeticError("shift count must be between 0 and 63")
300
+ return _wrap(left << right) if operator == "<<" else _wrap(left >> right)
301
+ if operator == "&":
302
+ return _wrap(left & right)
303
+ if operator == "^":
304
+ return _wrap(left ^ right)
305
+ if operator == "|":
306
+ return _wrap(left | right)
307
+ if operator == "==":
308
+ return int(left == right)
309
+ if operator == "!=":
310
+ return int(left != right)
311
+ if operator == "<":
312
+ return int(left < right)
313
+ if operator == "<=":
314
+ return int(left <= right)
315
+ if operator == ">":
316
+ return int(left > right)
317
+ if operator == ">=":
318
+ return int(left >= right)
319
+ raise ArithmeticError(f"unsupported arithmetic operator {operator!r}")
320
+
321
+
322
+ def evaluate_arithmetic(expression: _Expression | None, state: ShellState) -> int:
323
+ if expression is None:
324
+ return 0
325
+ if isinstance(expression, _Number):
326
+ return expression.value
327
+ if isinstance(expression, _Variable):
328
+ return _variable_value(expression, state)
329
+ if isinstance(expression, _Sequence):
330
+ result = 0
331
+ for item in expression.expressions:
332
+ result = evaluate_arithmetic(item, state)
333
+ return result
334
+ if isinstance(expression, _Conditional):
335
+ branch = expression.when_true if evaluate_arithmetic(expression.condition, state) != 0 else expression.when_false
336
+ return evaluate_arithmetic(branch, state)
337
+ if isinstance(expression, _Unary):
338
+ value = evaluate_arithmetic(expression.operand, state)
339
+ if expression.operator == "+":
340
+ return value
341
+ if expression.operator == "-":
342
+ return _wrap(-value)
343
+ if expression.operator == "!":
344
+ return int(value == 0)
345
+ if expression.operator == "~":
346
+ return _wrap(~value)
347
+ if isinstance(expression, _Binary):
348
+ left = evaluate_arithmetic(expression.left, state)
349
+ if expression.operator == "&&":
350
+ return int(left != 0 and evaluate_arithmetic(expression.right, state) != 0)
351
+ if expression.operator == "||":
352
+ return int(left != 0 or evaluate_arithmetic(expression.right, state) != 0)
353
+ return _binary(expression.operator, left, evaluate_arithmetic(expression.right, state))
354
+ if isinstance(expression, _Assignment):
355
+ right = evaluate_arithmetic(expression.value, state)
356
+ if expression.operator == "=":
357
+ return _set_variable(expression.target, right, state)
358
+ left = _variable_value(expression.target, state)
359
+ return _set_variable(expression.target, _binary(expression.operator[:-1], left, right), state)
360
+ if isinstance(expression, _Update):
361
+ old = _variable_value(expression.target, state)
362
+ new = _set_variable(expression.target, old + (1 if expression.operator == "++" else -1), state)
363
+ return new if expression.prefix else old
364
+ raise ArithmeticError("unrecognized arithmetic expression")
@@ -44,9 +44,10 @@ class ExecContext:
44
44
  builtins: Mapping[str, BuiltinFn]
45
45
  deadline: float | None
46
46
  stderr: BinaryIO | int | None = None
47
+ loop_depth: int = 0
47
48
 
48
49
 
49
50
  # Executor-owned builtins are NOT in commands/REGISTRY: state mutators and `exit`
50
51
  # need ShellState/control-flow access, while the runner needs the executor.
51
- STATE_BUILTINS = {"cd", "export", "unset", "exit"}
52
+ STATE_BUILTINS = {"cd", "export", "unset", "exit", "break", "continue"}
52
53
  RUNNER_BUILTINS = {"xargs"}
@@ -55,3 +55,19 @@ class ShellExit(Exception):
55
55
  def __init__(self, exit_code: int) -> None:
56
56
  super().__init__(exit_code)
57
57
  self.exit_code = exit_code
58
+
59
+
60
+ class LoopControl(Exception):
61
+ """Internal non-local control transfer consumed by enclosing loop nodes."""
62
+
63
+ def __init__(self, levels: int) -> None:
64
+ super().__init__(levels)
65
+ self.levels = levels
66
+
67
+
68
+ class LoopBreak(LoopControl):
69
+ """Leave one or more enclosing loops."""
70
+
71
+
72
+ class LoopContinue(LoopControl):
73
+ """Continue one or more enclosing loops."""
@@ -10,17 +10,20 @@ import io
10
10
  import os
11
11
  import subprocess
12
12
  import threading
13
+ import time
13
14
  from typing import BinaryIO
14
15
 
16
+ from arithmetic import ArithmeticError, compile_arithmetic, evaluate_arithmetic
15
17
  import nodes
16
18
  import parser as parser_module
17
19
  import proc
18
20
  import tokens as tokens_module
19
21
  from context import RUNNER_BUILTINS, STATE_BUILTINS, BuiltinContext, ExecContext
20
- from errors import ShellExit, UnsupportedConstruct
22
+ from errors import LoopBreak, LoopContinue, LoopControl, ShellExit, UnsupportedConstruct
21
23
  from state import ShellState
22
24
 
23
25
  DEVNULL = os.devnull
26
+ MAX_LOOP_ITERATIONS = 1_000_000
24
27
 
25
28
 
26
29
  class _Redirected:
@@ -344,7 +347,7 @@ def _run_pipeline_elements(elements: list, ctx: ExecContext) -> int:
344
347
  try:
345
348
  # Pipeline stages are subshell-like: state mutations belong to the stage,
346
349
  # never to the parent command context or a sibling stage.
347
- stage_ctx = _sub_ctx(ctx, ctx.state.copy(), sin, sout, stderr_base)
350
+ stage_ctx = _sub_ctx(ctx, ctx.state.copy(), sin, sout, stderr_base, loop_depth=0)
348
351
  results[idx] = _dispatch_element(elements[idx], stage_ctx, sin, sout, stderr_base, is_pipeline=True)
349
352
  except UnsupportedConstruct as exc:
350
353
  # A structured refusal from an inline stage means the WHOLE command
@@ -356,6 +359,10 @@ def _run_pipeline_elements(elements: list, ctx: ExecContext) -> int:
356
359
  results[idx] = 1
357
360
  except ShellExit as exc:
358
361
  results[idx] = exc.exit_code
362
+ except LoopControl:
363
+ # A pipeline stage is a subshell boundary. Loop control in that
364
+ # stage cannot alter an enclosing loop in the parent context.
365
+ results[idx] = 0
359
366
  except BaseException as exc: # noqa: BLE001 - any other stage failure must not crash the engine
360
367
  # Any OTHER exception is a real per-stage failure, not a refusal: it
361
368
  # gets a named, actionable one-line message on the merged sink (never
@@ -487,6 +494,8 @@ def _dispatch_element(
487
494
  return _execute_subshell(element, ctx, stdin_stream, stdout_stream, stderr_stream)
488
495
  if isinstance(element, nodes.BraceGroup):
489
496
  return _execute_brace_group(element, ctx, stdin_stream, stdout_stream, stderr_stream)
497
+ if isinstance(element, (nodes.ForCommand, nodes.ArithmeticForCommand)):
498
+ return _execute_for_command(element, ctx, stdin_stream, stdout_stream, stderr_stream)
490
499
  if isinstance(element, nodes.SimpleCommand):
491
500
  return _dispatch_simple_command(element, ctx, stdin_stream, stdout_stream, stderr_stream)
492
501
  raise UnsupportedConstruct("malformed-syntax", f"unrecognized pipeline element {type(element)!r}")
@@ -498,6 +507,8 @@ def _sub_ctx(
498
507
  stdin_stream: BinaryIO | int,
499
508
  stdout_stream: BinaryIO | int,
500
509
  stderr_stream: BinaryIO | int,
510
+ *,
511
+ loop_depth: int | None = None,
501
512
  ) -> ExecContext:
502
513
  return ExecContext(
503
514
  state=state,
@@ -508,11 +519,12 @@ def _sub_ctx(
508
519
  builtins=ctx.builtins,
509
520
  deadline=ctx.deadline,
510
521
  stderr=stderr_stream,
522
+ loop_depth=ctx.loop_depth if loop_depth is None else loop_depth,
511
523
  )
512
524
 
513
525
 
514
- def _execute_redirected_group(
515
- node: nodes.Subshell | nodes.BraceGroup,
526
+ def _execute_redirected_compound(
527
+ node: nodes.Subshell | nodes.BraceGroup | nodes.ForCommand | nodes.ArithmeticForCommand,
516
528
  ctx: ExecContext,
517
529
  stdin_stream,
518
530
  stdout_stream,
@@ -526,8 +538,18 @@ def _execute_redirected_group(
526
538
  node.redirects, ctx, stdin_stream, stdout_stream, stderr_stream, tracker
527
539
  )
528
540
  state = ctx.state.copy() if isolated else ctx.state
529
- inner_ctx = _sub_ctx(ctx, state, r_in, r_out, r_err)
541
+ inner_ctx = _sub_ctx(
542
+ ctx,
543
+ state,
544
+ r_in,
545
+ r_out,
546
+ r_err,
547
+ loop_depth=0 if isolated else ctx.loop_depth,
548
+ )
530
549
  try:
550
+ if isinstance(node, (nodes.ForCommand, nodes.ArithmeticForCommand)):
551
+ inner_ctx.loop_depth += 1
552
+ return _execute_loop(node, inner_ctx)
531
553
  return execute(node.body, inner_ctx)
532
554
  except ShellExit as exc:
533
555
  if isolated:
@@ -538,17 +560,89 @@ def _execute_redirected_group(
538
560
 
539
561
 
540
562
  def _execute_subshell(node: nodes.Subshell, ctx: ExecContext, stdin_stream, stdout_stream, stderr_stream) -> int:
541
- return _execute_redirected_group(
563
+ return _execute_redirected_compound(
542
564
  node, ctx, stdin_stream, stdout_stream, stderr_stream, isolated=True
543
565
  )
544
566
 
545
567
 
546
568
  def _execute_brace_group(node: nodes.BraceGroup, ctx: ExecContext, stdin_stream, stdout_stream, stderr_stream) -> int:
547
- return _execute_redirected_group(
569
+ return _execute_redirected_compound(
570
+ node, ctx, stdin_stream, stdout_stream, stderr_stream, isolated=False
571
+ )
572
+
573
+
574
+ def _execute_for_command(
575
+ node: nodes.ForCommand | nodes.ArithmeticForCommand,
576
+ ctx: ExecContext,
577
+ stdin_stream,
578
+ stdout_stream,
579
+ stderr_stream,
580
+ ) -> int:
581
+ return _execute_redirected_compound(
548
582
  node, ctx, stdin_stream, stdout_stream, stderr_stream, isolated=False
549
583
  )
550
584
 
551
585
 
586
+ def _check_loop_budget(ctx: ExecContext, iteration: int) -> None:
587
+ if ctx.deadline is not None and time.monotonic() >= ctx.deadline:
588
+ raise ShellExit(124)
589
+ if iteration >= MAX_LOOP_ITERATIONS:
590
+ _write_merged(
591
+ _merged_sink(ctx),
592
+ f"for: iteration limit ({MAX_LOOP_ITERATIONS}) exceeded\n".encode("utf-8"),
593
+ ctx,
594
+ )
595
+ raise ShellExit(124)
596
+
597
+
598
+ def _execute_loop_body(body: nodes.CommandList, ctx: ExecContext) -> tuple[int, str | None]:
599
+ try:
600
+ return execute(body, ctx), None
601
+ except LoopBreak as exc:
602
+ if exc.levels > 1:
603
+ raise LoopBreak(exc.levels - 1) from None
604
+ return 0, "break"
605
+ except LoopContinue as exc:
606
+ if exc.levels > 1:
607
+ raise LoopContinue(exc.levels - 1) from None
608
+ return 0, "continue"
609
+
610
+
611
+ def _execute_loop(node: nodes.ForCommand | nodes.ArithmeticForCommand, ctx: ExecContext) -> int:
612
+ if isinstance(node, nodes.ForCommand):
613
+ values: list[str] = []
614
+ for item in node.items:
615
+ values.extend(ctx.expand_word(item, ctx))
616
+
617
+ exit_code = 0
618
+ for iteration, value in enumerate(values):
619
+ _check_loop_budget(ctx, iteration)
620
+ ctx.state.setenv(node.name, value)
621
+ exit_code, action = _execute_loop_body(node.body, ctx)
622
+ if action == "break":
623
+ return exit_code
624
+ return exit_code
625
+
626
+ try:
627
+ initializer = compile_arithmetic(node.initializer)
628
+ condition = compile_arithmetic(node.condition)
629
+ update = compile_arithmetic(node.update)
630
+ evaluate_arithmetic(initializer, ctx.state)
631
+ exit_code = 0
632
+ iteration = 0
633
+ while condition is None or evaluate_arithmetic(condition, ctx.state) != 0:
634
+ _check_loop_budget(ctx, iteration)
635
+ iteration += 1
636
+ exit_code, action = _execute_loop_body(node.body, ctx)
637
+ if action == "break":
638
+ return exit_code
639
+ evaluate_arithmetic(update, ctx.state)
640
+ return exit_code
641
+ except ArithmeticError as exc:
642
+ _write_merged(_merged_sink(ctx), f"bash: arithmetic: {exc}\n".encode("utf-8"), ctx)
643
+ return 1
644
+
645
+
552
646
  def _dispatch_simple_command(
553
647
  command: nodes.SimpleCommand,
554
648
  ctx: ExecContext,
@@ -577,7 +671,7 @@ def _dispatch_simple_command(
577
671
  name = argv[0]
578
672
 
579
673
  if name in STATE_BUILTINS:
580
- return _run_state_builtin(name, argv, ctx, r_out)
674
+ return _run_state_builtin(name, argv, ctx, r_out, r_err)
581
675
 
582
676
  if name in RUNNER_BUILTINS:
583
677
  return _run_xargs(argv, ctx, r_in, r_out)
@@ -621,17 +715,46 @@ def _as_stream(fd_or_stream: BinaryIO | int, mode: str) -> BinaryIO:
621
715
  return fd_or_stream
622
716
 
623
717
 
624
- def _run_state_builtin(name: str, argv: list[str], ctx: ExecContext, out_stream: BinaryIO | int) -> int:
718
+ def _run_state_builtin(
719
+ name: str,
720
+ argv: list[str],
721
+ ctx: ExecContext,
722
+ out_stream: BinaryIO | int,
723
+ error_stream: BinaryIO | int | None = None,
724
+ ) -> int:
725
+ diagnostic_stream = error_stream if error_stream is not None else out_stream
726
+ if name in ("break", "continue"):
727
+ if len(argv) > 2:
728
+ _write_merged(diagnostic_stream, f"{name}: too many arguments\n".encode("utf-8"), ctx)
729
+ return 1
730
+ try:
731
+ levels = int(argv[1], 10) if len(argv) == 2 else 1
732
+ except ValueError:
733
+ _write_merged(diagnostic_stream, f"{name}: {argv[1]}: numeric argument required\n".encode("utf-8"), ctx)
734
+ return 1
735
+ if levels <= 0:
736
+ _write_merged(diagnostic_stream, f"{name}: loop count must be positive\n".encode("utf-8"), ctx)
737
+ return 1
738
+ if ctx.loop_depth == 0:
739
+ _write_merged(
740
+ diagnostic_stream,
741
+ f"bash: {name}: only meaningful in a 'for', 'while', or 'until' loop\n".encode("utf-8"),
742
+ ctx,
743
+ )
744
+ return 0
745
+ control = LoopBreak if name == "break" else LoopContinue
746
+ raise control(min(levels, ctx.loop_depth))
747
+
625
748
  if name == "exit":
626
749
  if len(argv) > 2:
627
- _write_merged(out_stream, b"exit: too many arguments\n", ctx)
750
+ _write_merged(diagnostic_stream, b"exit: too many arguments\n", ctx)
628
751
  raise ShellExit(1)
629
752
  if len(argv) == 1:
630
753
  raise ShellExit(ctx.state.last_exit_code)
631
754
  try:
632
755
  exit_code = int(argv[1], 10)
633
756
  except ValueError:
634
- _write_merged(out_stream, f"exit: {argv[1]}: numeric argument required\n".encode("utf-8"), ctx)
757
+ _write_merged(diagnostic_stream, f"exit: {argv[1]}: numeric argument required\n".encode("utf-8"), ctx)
635
758
  raise ShellExit(2)
636
759
  raise ShellExit(exit_code & 0xFF)
637
760
 
@@ -641,16 +764,16 @@ def _run_state_builtin(name: str, argv: list[str], ctx: ExecContext, out_stream:
641
764
  if print_new_cwd:
642
765
  oldpwd = ctx.state.env.get("OLDPWD")
643
766
  if not oldpwd:
644
- _write_merged(out_stream, b"cd: OLDPWD not set\n", ctx)
767
+ _write_merged(diagnostic_stream, b"cd: OLDPWD not set\n", ctx)
645
768
  return 1
646
769
  target = oldpwd
647
770
  if target is None:
648
- _write_merged(out_stream, b"cd: HOME not set\n", ctx)
771
+ _write_merged(diagnostic_stream, b"cd: HOME not set\n", ctx)
649
772
  return 1
650
773
  try:
651
774
  ctx.state.chdir(target)
652
775
  except FileNotFoundError:
653
- _write_merged(out_stream, f"cd: {target}: No such file or directory\n".encode("utf-8"), ctx)
776
+ _write_merged(diagnostic_stream, f"cd: {target}: No such file or directory\n".encode("utf-8"), ctx)
654
777
  return 1
655
778
  if print_new_cwd:
656
779
  _write_merged(out_stream, f"{ctx.state.cwd}\n".encode("utf-8"), ctx)