@llblab/pi-telegram 0.52.2 → 0.53.0

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 (204) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +1 -18
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +7 -11
  5. package/api/commands.ts +3 -0
  6. package/dist/api/commands.d.ts +1 -1
  7. package/dist/lib/activity-verbosity.d.ts +1 -5
  8. package/dist/lib/activity-verbosity.js +15 -42
  9. package/dist/lib/activity.d.ts +9 -0
  10. package/dist/lib/activity.js +39 -10
  11. package/dist/lib/agent-messages.js +7 -3
  12. package/dist/lib/bindings.d.ts +43 -5
  13. package/dist/lib/bindings.js +163 -20
  14. package/dist/lib/bus-api.d.ts +12 -0
  15. package/dist/lib/bus-api.js +182 -79
  16. package/dist/lib/bus-follower.d.ts +111 -31
  17. package/dist/lib/bus-follower.js +1324 -359
  18. package/dist/lib/bus-leader.d.ts +41 -91
  19. package/dist/lib/bus-leader.js +969 -637
  20. package/dist/lib/bus-transport.js +16 -9
  21. package/dist/lib/bus.d.ts +215 -18
  22. package/dist/lib/bus.js +1048 -327
  23. package/dist/lib/channel-posts.d.ts +5 -0
  24. package/dist/lib/channel-posts.js +434 -261
  25. package/dist/lib/command-templates.d.ts +26 -5
  26. package/dist/lib/command-templates.js +92 -176
  27. package/dist/lib/commands.d.ts +168 -82
  28. package/dist/lib/commands.js +1409 -291
  29. package/dist/lib/config.d.ts +1 -8
  30. package/dist/lib/config.js +34 -76
  31. package/dist/lib/delivery.d.ts +2 -2
  32. package/dist/lib/delivery.js +16 -16
  33. package/dist/lib/extension.js +639 -171
  34. package/dist/lib/generative-app-worker.mjs +19 -7
  35. package/dist/lib/generative-apps.d.ts +0 -5
  36. package/dist/lib/generative-apps.js +61 -49
  37. package/dist/lib/inbound.d.ts +5 -0
  38. package/dist/lib/inbound.js +35 -59
  39. package/dist/lib/journal.d.ts +33 -3
  40. package/dist/lib/journal.js +2063 -782
  41. package/dist/lib/lifecycle.d.ts +2 -6
  42. package/dist/lib/lifecycle.js +75 -58
  43. package/dist/lib/locks.d.ts +29 -3
  44. package/dist/lib/locks.js +289 -108
  45. package/dist/lib/logging.js +50 -14
  46. package/dist/lib/media.d.ts +22 -1
  47. package/dist/lib/media.js +46 -28
  48. package/dist/lib/menu-model.d.ts +19 -5
  49. package/dist/lib/menu-model.js +53 -11
  50. package/dist/lib/menu-queue.d.ts +5 -4
  51. package/dist/lib/menu-queue.js +45 -63
  52. package/dist/lib/menu-settings.d.ts +5 -4
  53. package/dist/lib/menu-settings.js +114 -78
  54. package/dist/lib/menu-status.d.ts +1 -1
  55. package/dist/lib/menu-status.js +8 -25
  56. package/dist/lib/menu-thinking.d.ts +6 -1
  57. package/dist/lib/menu-thinking.js +14 -21
  58. package/dist/lib/menu.d.ts +8 -61
  59. package/dist/lib/menu.js +92 -164
  60. package/dist/lib/model.d.ts +1 -1
  61. package/dist/lib/model.js +2 -16
  62. package/dist/lib/outbound-attachments.d.ts +6 -27
  63. package/dist/lib/outbound-attachments.js +45 -38
  64. package/dist/lib/outbound-buttons.js +22 -16
  65. package/dist/lib/outbound-markup.d.ts +0 -2
  66. package/dist/lib/outbound-markup.js +51 -64
  67. package/dist/lib/outbound-voice.js +6 -3
  68. package/dist/lib/outbound.d.ts +6 -6
  69. package/dist/lib/outbound.js +61 -51
  70. package/dist/lib/ownership.js +6 -2
  71. package/dist/lib/paths.d.ts +5 -0
  72. package/dist/lib/paths.js +37 -21
  73. package/dist/lib/pi.d.ts +2 -0
  74. package/dist/lib/pi.js +8 -1
  75. package/dist/lib/polling.d.ts +21 -37
  76. package/dist/lib/polling.js +59 -50
  77. package/dist/lib/preview.js +39 -19
  78. package/dist/lib/process-identity.d.ts +24 -0
  79. package/dist/lib/process-identity.js +133 -0
  80. package/dist/lib/prompts.js +2 -7
  81. package/dist/lib/queue.d.ts +14 -16
  82. package/dist/lib/queue.js +249 -126
  83. package/dist/lib/recovery.d.ts +2 -83
  84. package/dist/lib/recovery.js +20 -274
  85. package/dist/lib/replies.d.ts +16 -24
  86. package/dist/lib/replies.js +99 -116
  87. package/dist/lib/routing.d.ts +120 -7
  88. package/dist/lib/routing.js +4667 -1152
  89. package/dist/lib/runtime.d.ts +1 -11
  90. package/dist/lib/sections.d.ts +4 -29
  91. package/dist/lib/sections.js +7 -20
  92. package/dist/lib/skills.js +4 -3
  93. package/dist/lib/status.js +86 -57
  94. package/dist/lib/sync.d.ts +1 -3
  95. package/dist/lib/sync.js +67 -43
  96. package/dist/lib/target.d.ts +4 -0
  97. package/dist/lib/target.js +18 -0
  98. package/dist/lib/telegram-api.d.ts +18 -13
  99. package/dist/lib/telegram-api.js +148 -53
  100. package/dist/lib/text-groups.js +4 -39
  101. package/dist/lib/thread-cleanup-manager.d.ts +6 -27
  102. package/dist/lib/thread-cleanup-manager.js +369 -186
  103. package/dist/lib/thread-display.d.ts +1 -2
  104. package/dist/lib/thread-display.js +58 -38
  105. package/dist/lib/thread-naming.d.ts +28 -8
  106. package/dist/lib/thread-naming.js +175 -66
  107. package/dist/lib/thread-reconciler.d.ts +42 -16
  108. package/dist/lib/thread-reconciler.js +90 -50
  109. package/dist/lib/threads.d.ts +82 -20
  110. package/dist/lib/threads.js +2247 -855
  111. package/dist/lib/time-injection.js +1 -2
  112. package/dist/lib/turns.js +1 -19
  113. package/dist/lib/updates.d.ts +169 -80
  114. package/dist/lib/updates.js +2418 -641
  115. package/dist/lib/voice.d.ts +7 -22
  116. package/dist/lib/voice.js +7 -22
  117. package/dist/lib/wire.js +1 -1
  118. package/dist/lib/workspace-admission.d.ts +3 -1
  119. package/dist/lib/workspace-admission.js +425 -211
  120. package/dist/lib/workspace-identity.js +8 -13
  121. package/dist/lib/workspace-retirement.d.ts +1 -1
  122. package/dist/lib/workspace-retirement.js +328 -140
  123. package/dist/lib/workspace-slots.js +8 -4
  124. package/dist/package.json +1 -1
  125. package/docs/architecture.md +31 -29
  126. package/docs/inbound.md +1 -1
  127. package/docs/multi-instance-bus.md +15 -3
  128. package/docs/outbound.md +1 -1
  129. package/docs/public-api.md +29 -1
  130. package/docs/ui-style.md +25 -8
  131. package/lib/activity-verbosity.ts +59 -82
  132. package/lib/activity.ts +146 -102
  133. package/lib/agent-messages.ts +12 -5
  134. package/lib/bindings.ts +389 -82
  135. package/lib/bus-api.ts +263 -102
  136. package/lib/bus-follower.ts +2440 -601
  137. package/lib/bus-leader.ts +2209 -1290
  138. package/lib/bus-transport.ts +46 -12
  139. package/lib/bus.ts +1673 -444
  140. package/lib/channel-posts.ts +848 -327
  141. package/lib/command-templates.ts +152 -265
  142. package/lib/commands.ts +2398 -555
  143. package/lib/config.ts +177 -170
  144. package/lib/delivery.ts +41 -29
  145. package/lib/extension.ts +1206 -433
  146. package/lib/generative-app-worker.mjs +19 -7
  147. package/lib/generative-apps.ts +324 -211
  148. package/lib/inbound.ts +95 -97
  149. package/lib/journal.ts +5311 -1763
  150. package/lib/keyboard.ts +1 -3
  151. package/lib/lifecycle.ts +128 -78
  152. package/lib/locks.ts +677 -203
  153. package/lib/logging.ts +147 -58
  154. package/lib/media.ts +82 -43
  155. package/lib/menu-model.ts +85 -18
  156. package/lib/menu-queue.ts +88 -129
  157. package/lib/menu-settings.ts +260 -155
  158. package/lib/menu-status.ts +20 -60
  159. package/lib/menu-thinking.ts +41 -56
  160. package/lib/menu.ts +112 -285
  161. package/lib/model.ts +8 -22
  162. package/lib/outbound-attachments.ts +156 -125
  163. package/lib/outbound-buttons.ts +76 -49
  164. package/lib/outbound-markup.ts +96 -94
  165. package/lib/outbound-voice.ts +33 -27
  166. package/lib/outbound.ts +125 -125
  167. package/lib/ownership.ts +19 -10
  168. package/lib/paths.ts +148 -49
  169. package/lib/pi.ts +35 -15
  170. package/lib/polling.ts +144 -147
  171. package/lib/preview.ts +93 -40
  172. package/lib/process-identity.ts +164 -0
  173. package/lib/prompts.ts +4 -11
  174. package/lib/queue.ts +511 -300
  175. package/lib/recovery.ts +50 -454
  176. package/lib/rendering.ts +1 -3
  177. package/lib/replies.ts +248 -209
  178. package/lib/routing.ts +8509 -2186
  179. package/lib/runtime.ts +1 -12
  180. package/lib/sections.ts +24 -89
  181. package/lib/setup.ts +1 -3
  182. package/lib/skills.ts +9 -4
  183. package/lib/status.ts +215 -110
  184. package/lib/sync.ts +448 -361
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +302 -127
  187. package/lib/text-groups.ts +5 -39
  188. package/lib/thread-cleanup-manager.ts +833 -331
  189. package/lib/thread-display.ts +213 -97
  190. package/lib/thread-naming.ts +232 -87
  191. package/lib/thread-reconciler.ts +155 -72
  192. package/lib/threads.ts +5318 -1845
  193. package/lib/time-injection.ts +1 -4
  194. package/lib/turns.ts +15 -28
  195. package/lib/updates.ts +5184 -1612
  196. package/lib/voice.ts +7 -35
  197. package/lib/wire.ts +1 -1
  198. package/lib/workspace-admission.ts +996 -416
  199. package/lib/workspace-identity.ts +14 -17
  200. package/lib/workspace-retirement.ts +878 -376
  201. package/lib/workspace-slots.ts +22 -13
  202. package/package.json +1 -1
  203. package/scripts/check-downgrade.mjs +0 -472
  204. package/scripts/measure-workspace.mjs +0 -108
@@ -31,7 +31,10 @@ export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTem
31
31
  export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
32
32
  export interface CommandTemplateLeafConfig extends CommandTemplateObjectConfig {
33
33
  template: string;
34
+ /** `when` guards of the composition nodes enclosing this leaf; every one must pass. */
35
+ inheritedWhen?: readonly (boolean | string)[];
34
36
  }
37
+ type CommandTemplateExpansionContext = Pick<CommandTemplateLeafConfig, "args" | "defaults" | "inheritedWhen">;
35
38
  export interface CommandTemplateInvocation {
36
39
  command: string;
37
40
  args: string[];
@@ -43,6 +46,8 @@ export interface CommandTemplateExecOptions {
43
46
  stdin?: string;
44
47
  killGrace?: number;
45
48
  retry?: number;
49
+ /** Cleanup between a failed attempt and the next retry; a thrown error stops retries. */
50
+ recover?: () => Promise<void>;
46
51
  }
47
52
  export interface CommandTemplateExecResult {
48
53
  stdout: string;
@@ -50,16 +55,32 @@ export interface CommandTemplateExecResult {
50
55
  code: number;
51
56
  killed: boolean;
52
57
  }
53
- export type CommandTemplateRiskLabel = "risk.shell" | "risk.eval" | "risk.broad_fs_write" | "risk.destructive_fs" | "risk.network" | "risk.external_side_effect" | "risk.long_running" | "risk.platform_specific" | "risk.secret_touching";
54
58
  export declare function normalizeCommandTemplateConfig(config: CommandTemplateConfig): CommandTemplateObjectConfig;
55
- export declare function expandCommandTemplateConfigs(config: CommandTemplateConfig, inherited?: Pick<CommandTemplateObjectConfig, "args" | "defaults">): CommandTemplateLeafConfig[];
56
- export declare function getCommandTemplateWarnings(config: CommandTemplateConfig): string[];
57
- export declare function getCommandTemplateRiskLabels(config: CommandTemplateConfig): CommandTemplateRiskLabel[];
59
+ export declare function expandCommandTemplateConfigs(config: CommandTemplateConfig, inherited?: CommandTemplateExpansionContext): CommandTemplateLeafConfig[];
60
+ /** Whether a node's own and enclosing `when` guards all pass for its resolved placeholder values. */
61
+ export declare function shouldRunCommandTemplateConfig(config: CommandTemplateConfig | CommandTemplateLeafConfig, values: Record<string, unknown>): boolean;
62
+ /** A node's `recover` template as one cleanup run: output ignored, failure stops retries unless a leaf opts into `continue`. */
63
+ export declare function createCommandTemplateRecovery(config: CommandTemplateConfig, values: Record<string, unknown>, options: {
64
+ cwd: string;
65
+ timeout?: number;
66
+ execCommand: (command: string, args: string[], options?: CommandTemplateExecOptions) => Promise<CommandTemplateExecResult>;
67
+ }): (() => Promise<void>) | undefined;
58
68
  export declare function splitCommandTemplate(input: string): string[];
59
69
  export declare function shouldRunCommandTemplateNode(value: boolean | string | undefined, values: Record<string, unknown>): boolean;
60
- export declare function substituteCommandTemplateToken(token: string, values: Record<string, unknown>, missingLabel?: string, depth?: number): string;
70
+ /** Resolve one optional non-negative numeric control field, substituting a template token when given as text. */
71
+ export declare function resolveCommandTemplateNumericField(value: number | string | undefined, values: Record<string, unknown>, label: string): number | undefined;
72
+ /** A node's own `timeout`; string leaves carry none. */
73
+ export declare function getCommandTemplateConfiguredTimeout(config: CommandTemplateConfig | undefined): number | undefined;
74
+ /**
75
+ * Bound one composed step by its own timeout and the handler budget left after `elapsedMs`.
76
+ * Without `elapsedMs` the step receives the full budget.
77
+ */
78
+ export declare function getCommandTemplateStepTimeout(budgetMs: number, step: CommandTemplateConfig, elapsedMs?: number): number;
79
+ /** Array templates expand into ordered composition steps; single-command templates have none. */
80
+ export declare function getCommandTemplateCompositionSteps(config: CommandTemplateObjectConfig): CommandTemplateLeafConfig[];
61
81
  export declare function execCommandTemplate(command: string, args: string[], options?: CommandTemplateExecOptions): Promise<CommandTemplateExecResult>;
62
82
  export declare function buildCommandTemplateInvocation(config: CommandTemplateConfig, values: Record<string, unknown>, cwd: string, options?: {
63
83
  emptyMessage?: string;
64
84
  missingLabel?: string;
65
85
  }): CommandTemplateInvocation;
86
+ export {};
@@ -6,17 +6,6 @@
6
6
  import { spawn } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { extname, isAbsolute, normalize, resolve } from "node:path";
9
- const COMMAND_TEMPLATE_RISK_LABEL_ORDER = [
10
- "risk.shell",
11
- "risk.eval",
12
- "risk.destructive_fs",
13
- "risk.broad_fs_write",
14
- "risk.external_side_effect",
15
- "risk.secret_touching",
16
- "risk.network",
17
- "risk.long_running",
18
- "risk.platform_specific",
19
- ];
20
9
  function normalizeCommandTemplateArgs(value) {
21
10
  if (!Array.isArray(value))
22
11
  return [];
@@ -80,151 +69,6 @@ function resolveCommandTemplateRepeat(value, values = {}) {
80
69
  }
81
70
  throw new Error("Command template repeat must be a positive integer or {array.length}.");
82
71
  }
83
- function getExecutableName(command) {
84
- if (!command)
85
- return "";
86
- return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
87
- }
88
- function matchesFlag(arg, flag) {
89
- if (arg === flag)
90
- return true;
91
- if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
92
- return arg.slice(1).includes(flag.slice(1));
93
- return false;
94
- }
95
- function hasAnyFlag(args, flags) {
96
- return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
97
- }
98
- function hasRiskyPathArg(args) {
99
- return args.some((arg) => arg === "/" ||
100
- arg === "~" ||
101
- arg === "./" ||
102
- arg === "../" ||
103
- arg.includes("{") ||
104
- arg.startsWith("~/") ||
105
- arg.startsWith("/"));
106
- }
107
- function sortRiskLabels(labels) {
108
- const unique = new Set(labels);
109
- return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
110
- }
111
- function hasAnyArg(args, values) {
112
- return args.some((arg) => values.includes(arg.toLowerCase()));
113
- }
114
- function hasSecretTouchingText(parts) {
115
- return parts.some((part) => /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(part));
116
- }
117
- function getLeafCommandTemplateRiskLabels(config) {
118
- const parts = splitCommandTemplate(config.template);
119
- const command = getExecutableName(parts[0]);
120
- const args = parts.slice(1);
121
- const labels = new Set();
122
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
123
- labels.add("risk.shell");
124
- if (hasAnyFlag(args, ["-c"]))
125
- labels.add("risk.eval");
126
- }
127
- if (["node", "deno", "bun"].includes(command) &&
128
- hasAnyFlag(args, ["-e", "--eval"])) {
129
- labels.add("risk.eval");
130
- }
131
- if (["python", "python3", "perl", "ruby"].includes(command) &&
132
- hasAnyFlag(args, ["-c", "-e"])) {
133
- labels.add("risk.eval");
134
- }
135
- if (command === "rm" &&
136
- (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
137
- hasRiskyPathArg(args))) {
138
- labels.add("risk.destructive_fs");
139
- }
140
- if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
141
- labels.add("risk.broad_fs_write");
142
- }
143
- if ([
144
- "curl",
145
- "wget",
146
- "ssh",
147
- "scp",
148
- "sftp",
149
- "rsync",
150
- "nc",
151
- "ncat",
152
- "telnet",
153
- "ftp",
154
- ].includes(command) ||
155
- (command === "git" &&
156
- hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
157
- ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)) {
158
- labels.add("risk.network");
159
- }
160
- if (["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
161
- (command === "git" && hasAnyArg(args, ["push"])) ||
162
- (["npm", "pnpm", "yarn"].includes(command) &&
163
- hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))) {
164
- labels.add("risk.external_side_effect");
165
- }
166
- if (command === "sleep" ||
167
- command === "watch" ||
168
- (command === "tail" && hasAnyFlag(args, ["-f"])) ||
169
- hasAnyArg(args, ["--watch", "--serve", "serve"])) {
170
- labels.add("risk.long_running");
171
- }
172
- if ([
173
- "systemctl",
174
- "launchctl",
175
- "osascript",
176
- "open",
177
- "xdg-open",
178
- "powershell",
179
- "pwsh",
180
- "cmd.exe",
181
- "apt",
182
- "apt-get",
183
- "dnf",
184
- "yum",
185
- "brew",
186
- "pacman",
187
- "apk",
188
- "xclip",
189
- "wl-copy",
190
- ].includes(command)) {
191
- labels.add("risk.platform_specific");
192
- }
193
- if (["pass", "gpg", "ssh-add"].includes(command) ||
194
- hasSecretTouchingText(parts)) {
195
- labels.add("risk.secret_touching");
196
- }
197
- return sortRiskLabels(labels);
198
- }
199
- function getLeafCommandTemplateWarnings(config) {
200
- const parts = splitCommandTemplate(config.template);
201
- const command = getExecutableName(parts[0]);
202
- const args = parts.slice(1);
203
- const warnings = [];
204
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
205
- const shellContent = hasAnyFlag(args, ["-c"])
206
- ? "shell command strings"
207
- : "shell scripts";
208
- warnings.push(`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting. Mitigation: keep scripts local, reviewed, and parameterized with explicit placeholders.`);
209
- }
210
- if (["node", "deno", "bun"].includes(command) &&
211
- hasAnyFlag(args, ["-e", "--eval"])) {
212
- warnings.push(`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`);
213
- }
214
- if (["python", "python3", "perl", "ruby"].includes(command) &&
215
- hasAnyFlag(args, ["-c", "-e"])) {
216
- warnings.push(`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`);
217
- }
218
- if (command === "rm" &&
219
- (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
220
- hasRiskyPathArg(args))) {
221
- warnings.push(`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands. Mitigation: constrain path placeholders and consider dry-run or explicit confirmation.`);
222
- }
223
- if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
224
- warnings.push(`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands. Mitigation: constrain path placeholders and prefer narrow source/destination paths.`);
225
- }
226
- return warnings;
227
- }
228
72
  function pad(value, width) {
229
73
  return String(value).padStart(width, "0");
230
74
  }
@@ -266,6 +110,9 @@ export function expandCommandTemplateConfigs(config, inherited = {}) {
266
110
  const inheritedDefaults = normalizeCommandTemplateDefaults(inherited.defaults);
267
111
  const ownDefaults = resolveInheritedDefaultReferences(normalizeCommandTemplateDefaults(normalizedConfig.defaults), inheritedDefaults);
268
112
  const context = {
113
+ ...(inherited.inheritedWhen?.length
114
+ ? { inheritedWhen: inherited.inheritedWhen }
115
+ : {}),
269
116
  ...(inherited.args !== undefined ? { args: inherited.args } : {}),
270
117
  ...(inheritedDefaults ? { defaults: inheritedDefaults } : {}),
271
118
  ...(normalizedConfig.args !== undefined
@@ -279,18 +126,21 @@ export function expandCommandTemplateConfigs(config, inherited = {}) {
279
126
  if (repeated) {
280
127
  return repeated.flatMap((step) => expandCommandTemplateConfigs(step, context));
281
128
  }
282
- const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
283
- const recoverSteps = recoverConfig
284
- ? expandCommandTemplateConfigs(recoverConfig, context)
285
- : [];
129
+ // `recover` is cleanup between failed retries, never a sequence step; runners attach it per leaf.
286
130
  if (Array.isArray(normalizedConfig.template)) {
287
- return [
288
- ...normalizedConfig.template.flatMap((step) => expandCommandTemplateConfigs(step, context)),
289
- ...recoverSteps,
290
- ];
131
+ const childContext = normalizedConfig.when === undefined
132
+ ? context
133
+ : {
134
+ ...context,
135
+ inheritedWhen: [
136
+ ...(context.inheritedWhen ?? []),
137
+ normalizedConfig.when,
138
+ ],
139
+ };
140
+ return normalizedConfig.template.flatMap((step) => expandCommandTemplateConfigs(step, childContext));
291
141
  }
292
142
  if (typeof normalizedConfig.template !== "string")
293
- return recoverSteps;
143
+ return [];
294
144
  return [
295
145
  {
296
146
  ...normalizedConfig,
@@ -298,16 +148,45 @@ export function expandCommandTemplateConfigs(config, inherited = {}) {
298
148
  template: normalizedConfig.template,
299
149
  retry: normalizedConfig.retry,
300
150
  },
301
- ...recoverSteps,
302
151
  ];
303
152
  }
304
- export function getCommandTemplateWarnings(config) {
305
- return [
306
- ...new Set(expandCommandTemplateConfigs(config).flatMap((leaf) => getLeafCommandTemplateWarnings(leaf))),
307
- ];
153
+ /** Whether a node's own and enclosing `when` guards all pass for its resolved placeholder values. */
154
+ export function shouldRunCommandTemplateConfig(config, values) {
155
+ if (typeof config === "string")
156
+ return true;
157
+ const inheritedWhen = "inheritedWhen" in config ? (config.inheritedWhen ?? []) : [];
158
+ if (config.when === undefined && inheritedWhen.length === 0)
159
+ return true;
160
+ const resolved = { ...getCommandTemplateDefaults(config), ...values };
161
+ return [...inheritedWhen, config.when].every((condition) => shouldRunCommandTemplateNode(condition, resolved));
308
162
  }
309
- export function getCommandTemplateRiskLabels(config) {
310
- return sortRiskLabels(expandCommandTemplateConfigs(config).flatMap((leaf) => getLeafCommandTemplateRiskLabels(leaf)));
163
+ /** A node's `recover` template as one cleanup run: output ignored, failure stops retries unless a leaf opts into `continue`. */
164
+ export function createCommandTemplateRecovery(config, values, options) {
165
+ if (typeof config === "string")
166
+ return undefined;
167
+ const recoverConfig = normalizeRecoverConfig(config.recover);
168
+ if (!recoverConfig)
169
+ return undefined;
170
+ const steps = expandCommandTemplateConfigs(recoverConfig, {
171
+ ...(config.args !== undefined ? { args: config.args } : {}),
172
+ ...(config.defaults !== undefined ? { defaults: config.defaults } : {}),
173
+ });
174
+ return async () => {
175
+ for (const step of steps) {
176
+ if (!shouldRunCommandTemplateConfig(step, values))
177
+ continue;
178
+ const invocation = buildCommandTemplateInvocation(step, values, options.cwd, { missingLabel: "command template recover" });
179
+ const result = await options.execCommand(invocation.command, invocation.args, {
180
+ cwd: options.cwd,
181
+ ...(options.timeout !== undefined
182
+ ? { timeout: options.timeout }
183
+ : {}),
184
+ });
185
+ if (result.code !== 0 && step.failure !== "continue") {
186
+ throw new Error(`Command template recover exited with code ${result.code}${result.killed ? " (killed)" : ""}`);
187
+ }
188
+ }
189
+ };
311
190
  }
312
191
  function parseCommandTemplateArgToken(value) {
313
192
  const separatorIndex = value.indexOf("=");
@@ -597,7 +476,43 @@ function resolveCommandTemplateValue(content, values, missingLabel, depth = 0) {
597
476
  return expression;
598
477
  return undefined;
599
478
  }
600
- export function substituteCommandTemplateToken(token, values, missingLabel = "command template", depth = 0) {
479
+ /** Resolve one optional non-negative numeric control field, substituting a template token when given as text. */
480
+ export function resolveCommandTemplateNumericField(value, values, label) {
481
+ if (value === undefined)
482
+ return undefined;
483
+ const resolved = typeof value === "string"
484
+ ? substituteCommandTemplateToken(value, values, label)
485
+ : value;
486
+ if (resolved === "")
487
+ return undefined;
488
+ const numeric = Number(resolved);
489
+ if (!Number.isFinite(numeric) || numeric < 0)
490
+ throw new Error(`Command template ${label} must be a non-negative number.`);
491
+ return numeric;
492
+ }
493
+ /** A node's own `timeout`; string leaves carry none. */
494
+ export function getCommandTemplateConfiguredTimeout(config) {
495
+ const timeout = typeof config === "string" ? undefined : config?.timeout;
496
+ return resolveCommandTemplateNumericField(timeout, {}, "timeout");
497
+ }
498
+ /**
499
+ * Bound one composed step by its own timeout and the handler budget left after `elapsedMs`.
500
+ * Without `elapsedMs` the step receives the full budget.
501
+ */
502
+ export function getCommandTemplateStepTimeout(budgetMs, step, elapsedMs) {
503
+ const remaining = elapsedMs === undefined ? budgetMs : Math.max(1, budgetMs - elapsedMs);
504
+ const stepTimeout = getCommandTemplateConfiguredTimeout(step);
505
+ return stepTimeout === undefined
506
+ ? remaining
507
+ : Math.min(stepTimeout, remaining);
508
+ }
509
+ /** Array templates expand into ordered composition steps; single-command templates have none. */
510
+ export function getCommandTemplateCompositionSteps(config) {
511
+ return Array.isArray(config.template)
512
+ ? expandCommandTemplateConfigs(config)
513
+ : [];
514
+ }
515
+ function substituteCommandTemplateToken(token, values, missingLabel = "command template", depth = 0) {
601
516
  return token.replace(/\{([^{}]+)\}/g, (_match, content) => {
602
517
  const resolved = resolveCommandTemplateValue(content, values, missingLabel, depth);
603
518
  if (resolved !== undefined)
@@ -618,6 +533,8 @@ export async function execCommandTemplate(command, args, options = {}) {
618
533
  if (result.code === 0)
619
534
  return result;
620
535
  lastResult = result;
536
+ if (attempt < maxAttempts)
537
+ await options.recover?.();
621
538
  }
622
539
  return lastResult;
623
540
  }
@@ -627,7 +544,7 @@ function escapeWindowsCommand(value) {
627
544
  }
628
545
  function escapeWindowsCommandArgument(value, doubleEscapeMetaChars) {
629
546
  let escaped = value
630
- .replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
547
+ .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
631
548
  .replace(/(?=(\\+?)?)\1$/g, "$1$1");
632
549
  escaped = `"${escaped}"`.replace(WINDOWS_COMMAND_META_CHARS, "^$1");
633
550
  return doubleEscapeMetaChars
@@ -640,8 +557,7 @@ function resolveCommandTemplateSpawn(command, args) {
640
557
  return { command, args };
641
558
  }
642
559
  const normalizedCommand = normalize(command);
643
- const isNodeModulesShim = /[\\/]node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu
644
- .test(normalizedCommand);
560
+ const isNodeModulesShim = /[\\/]node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu.test(normalizedCommand);
645
561
  const shellCommand = [
646
562
  escapeWindowsCommand(normalizedCommand),
647
563
  ...args.map((arg) => escapeWindowsCommandArgument(arg, isNodeModulesShim)),