@llblab/pi-kit 0.29.1 → 0.30.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 (222) hide show
  1. package/BACKLOG.md +3 -1
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +2 -2
  6. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +17 -8
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +7 -3
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +2 -1
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +3 -2
  11. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  12. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +3 -3
  13. package/node_modules/@llblab/pi-state-flow/docs/usage.md +3 -2
  14. package/node_modules/@llblab/pi-state-flow/lib/context.ts +17 -8
  15. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +6 -3
  16. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  18. package/node_modules/@llblab/pi-telegram/AGENTS.md +13 -10
  19. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -1
  20. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +23 -0
  21. package/node_modules/@llblab/pi-telegram/README.md +10 -11
  22. package/node_modules/@llblab/pi-telegram/api/commands.ts +3 -0
  23. package/node_modules/@llblab/pi-telegram/dist/api/commands.d.ts +1 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
  25. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +15 -42
  26. package/node_modules/@llblab/pi-telegram/dist/lib/activity.d.ts +9 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/activity.js +39 -10
  28. package/node_modules/@llblab/pi-telegram/dist/lib/agent-messages.js +7 -3
  29. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +43 -5
  30. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +164 -20
  31. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.d.ts +12 -0
  32. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +181 -77
  33. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +111 -31
  34. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +1324 -359
  35. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +41 -91
  36. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +969 -637
  37. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +16 -9
  38. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +215 -18
  39. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +1048 -327
  40. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +5 -0
  41. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +434 -261
  42. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +26 -5
  43. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +92 -176
  44. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +168 -82
  45. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +1409 -291
  46. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +1 -8
  47. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +34 -76
  48. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +2 -2
  49. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +16 -16
  50. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +642 -170
  51. package/node_modules/@llblab/pi-telegram/dist/lib/generative-app-worker.mjs +19 -7
  52. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -5
  53. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.js +61 -49
  54. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +5 -0
  55. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +35 -59
  56. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +40 -4
  57. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +2102 -760
  58. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -6
  59. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +75 -58
  60. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +34 -3
  61. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +318 -119
  62. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +50 -14
  63. package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +22 -1
  64. package/node_modules/@llblab/pi-telegram/dist/lib/media.js +46 -28
  65. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +19 -5
  66. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +53 -11
  67. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.d.ts +5 -4
  68. package/node_modules/@llblab/pi-telegram/dist/lib/menu-queue.js +45 -63
  69. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +5 -4
  70. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +114 -78
  71. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +1 -1
  72. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +8 -25
  73. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +6 -1
  74. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +14 -21
  75. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +8 -61
  76. package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +92 -164
  77. package/node_modules/@llblab/pi-telegram/dist/lib/model.d.ts +1 -1
  78. package/node_modules/@llblab/pi-telegram/dist/lib/model.js +2 -16
  79. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +6 -27
  80. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +45 -38
  81. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +22 -16
  82. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +0 -2
  83. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +51 -64
  84. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-voice.js +6 -3
  85. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +6 -6
  86. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +61 -51
  87. package/node_modules/@llblab/pi-telegram/dist/lib/ownership.js +6 -2
  88. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +5 -0
  89. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +37 -21
  90. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -0
  91. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +8 -1
  92. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +23 -37
  93. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +71 -52
  94. package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +39 -19
  95. package/node_modules/@llblab/pi-telegram/dist/lib/process-identity.d.ts +24 -0
  96. package/node_modules/@llblab/pi-telegram/dist/lib/process-identity.js +133 -0
  97. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +2 -7
  98. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +16 -16
  99. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +261 -116
  100. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +2 -83
  101. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +20 -274
  102. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +16 -24
  103. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +99 -116
  104. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +121 -7
  105. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +4750 -1161
  106. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +1 -11
  107. package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +4 -29
  108. package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +7 -20
  109. package/node_modules/@llblab/pi-telegram/dist/lib/skills.js +4 -3
  110. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +86 -57
  111. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +6 -3
  112. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +112 -45
  113. package/node_modules/@llblab/pi-telegram/dist/lib/target.d.ts +4 -0
  114. package/node_modules/@llblab/pi-telegram/dist/lib/target.js +18 -0
  115. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +18 -11
  116. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +165 -49
  117. package/node_modules/@llblab/pi-telegram/dist/lib/text-groups.js +4 -39
  118. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +6 -27
  119. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +369 -186
  120. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +1 -2
  121. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +58 -38
  122. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +28 -8
  123. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +175 -66
  124. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +42 -16
  125. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +90 -50
  126. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +88 -24
  127. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +2276 -842
  128. package/node_modules/@llblab/pi-telegram/dist/lib/time-injection.js +1 -2
  129. package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -19
  130. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +178 -82
  131. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +2438 -645
  132. package/node_modules/@llblab/pi-telegram/dist/lib/voice.d.ts +7 -22
  133. package/node_modules/@llblab/pi-telegram/dist/lib/voice.js +7 -22
  134. package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +1 -1
  135. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +3 -1
  136. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +425 -211
  137. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +8 -13
  138. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +1 -1
  139. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +328 -140
  140. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +8 -4
  141. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  142. package/node_modules/@llblab/pi-telegram/docs/architecture.md +43 -35
  143. package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
  144. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +15 -3
  145. package/node_modules/@llblab/pi-telegram/docs/outbound.md +1 -1
  146. package/node_modules/@llblab/pi-telegram/docs/public-api.md +29 -1
  147. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +25 -8
  148. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +59 -82
  149. package/node_modules/@llblab/pi-telegram/lib/activity.ts +146 -102
  150. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +12 -5
  151. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +390 -82
  152. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +262 -99
  153. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +2440 -601
  154. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +2209 -1290
  155. package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +46 -12
  156. package/node_modules/@llblab/pi-telegram/lib/bus.ts +1673 -444
  157. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +848 -327
  158. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +152 -265
  159. package/node_modules/@llblab/pi-telegram/lib/commands.ts +2398 -555
  160. package/node_modules/@llblab/pi-telegram/lib/config.ts +177 -170
  161. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +41 -29
  162. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1209 -431
  163. package/node_modules/@llblab/pi-telegram/lib/generative-app-worker.mjs +19 -7
  164. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +324 -211
  165. package/node_modules/@llblab/pi-telegram/lib/inbound.ts +95 -97
  166. package/node_modules/@llblab/pi-telegram/lib/journal.ts +5329 -1712
  167. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +1 -3
  168. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +128 -78
  169. package/node_modules/@llblab/pi-telegram/lib/locks.ts +703 -212
  170. package/node_modules/@llblab/pi-telegram/lib/logging.ts +147 -58
  171. package/node_modules/@llblab/pi-telegram/lib/media.ts +82 -43
  172. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +85 -18
  173. package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +88 -129
  174. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +260 -155
  175. package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +20 -60
  176. package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +41 -56
  177. package/node_modules/@llblab/pi-telegram/lib/menu.ts +112 -285
  178. package/node_modules/@llblab/pi-telegram/lib/model.ts +8 -22
  179. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +156 -125
  180. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +76 -49
  181. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +96 -94
  182. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +33 -27
  183. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +125 -125
  184. package/node_modules/@llblab/pi-telegram/lib/ownership.ts +19 -10
  185. package/node_modules/@llblab/pi-telegram/lib/paths.ts +148 -49
  186. package/node_modules/@llblab/pi-telegram/lib/pi.ts +35 -15
  187. package/node_modules/@llblab/pi-telegram/lib/polling.ts +165 -149
  188. package/node_modules/@llblab/pi-telegram/lib/preview.ts +93 -40
  189. package/node_modules/@llblab/pi-telegram/lib/process-identity.ts +164 -0
  190. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +4 -11
  191. package/node_modules/@llblab/pi-telegram/lib/queue.ts +523 -289
  192. package/node_modules/@llblab/pi-telegram/lib/recovery.ts +50 -454
  193. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +1 -3
  194. package/node_modules/@llblab/pi-telegram/lib/replies.ts +248 -209
  195. package/node_modules/@llblab/pi-telegram/lib/routing.ts +8527 -2149
  196. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +1 -12
  197. package/node_modules/@llblab/pi-telegram/lib/sections.ts +24 -89
  198. package/node_modules/@llblab/pi-telegram/lib/setup.ts +1 -3
  199. package/node_modules/@llblab/pi-telegram/lib/skills.ts +9 -4
  200. package/node_modules/@llblab/pi-telegram/lib/status.ts +215 -110
  201. package/node_modules/@llblab/pi-telegram/lib/sync.ts +488 -359
  202. package/node_modules/@llblab/pi-telegram/lib/target.ts +22 -3
  203. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +322 -122
  204. package/node_modules/@llblab/pi-telegram/lib/text-groups.ts +5 -39
  205. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +833 -331
  206. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +213 -97
  207. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +232 -87
  208. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +155 -72
  209. package/node_modules/@llblab/pi-telegram/lib/threads.ts +5329 -1817
  210. package/node_modules/@llblab/pi-telegram/lib/time-injection.ts +1 -4
  211. package/node_modules/@llblab/pi-telegram/lib/turns.ts +15 -28
  212. package/node_modules/@llblab/pi-telegram/lib/updates.ts +5194 -1600
  213. package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -35
  214. package/node_modules/@llblab/pi-telegram/lib/wire.ts +1 -1
  215. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +996 -416
  216. package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +14 -17
  217. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +878 -376
  218. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +22 -13
  219. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  220. package/package.json +3 -3
  221. package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +0 -472
  222. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +0 -108
@@ -42,8 +42,15 @@ export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
42
42
 
43
43
  export interface CommandTemplateLeafConfig extends CommandTemplateObjectConfig {
44
44
  template: string;
45
+ /** `when` guards of the composition nodes enclosing this leaf; every one must pass. */
46
+ inheritedWhen?: readonly (boolean | string)[];
45
47
  }
46
48
 
49
+ type CommandTemplateExpansionContext = Pick<
50
+ CommandTemplateLeafConfig,
51
+ "args" | "defaults" | "inheritedWhen"
52
+ >;
53
+
47
54
  export interface CommandTemplateInvocation {
48
55
  command: string;
49
56
  args: string[];
@@ -56,6 +63,8 @@ export interface CommandTemplateExecOptions {
56
63
  stdin?: string;
57
64
  killGrace?: number;
58
65
  retry?: number;
66
+ /** Cleanup between a failed attempt and the next retry; a thrown error stops retries. */
67
+ recover?: () => Promise<void>;
59
68
  }
60
69
 
61
70
  export interface CommandTemplateExecResult {
@@ -65,29 +74,6 @@ export interface CommandTemplateExecResult {
65
74
  killed: boolean;
66
75
  }
67
76
 
68
- export type CommandTemplateRiskLabel =
69
- | "risk.shell"
70
- | "risk.eval"
71
- | "risk.broad_fs_write"
72
- | "risk.destructive_fs"
73
- | "risk.network"
74
- | "risk.external_side_effect"
75
- | "risk.long_running"
76
- | "risk.platform_specific"
77
- | "risk.secret_touching";
78
-
79
- const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
80
- "risk.shell",
81
- "risk.eval",
82
- "risk.destructive_fs",
83
- "risk.broad_fs_write",
84
- "risk.external_side_effect",
85
- "risk.secret_touching",
86
- "risk.network",
87
- "risk.long_running",
88
- "risk.platform_specific",
89
- ];
90
-
91
77
  function normalizeCommandTemplateArgs(value: string[] | undefined): string[] {
92
78
  if (!Array.isArray(value)) return [];
93
79
  return value.map(String).map((item) => item.trim());
@@ -167,202 +153,6 @@ function resolveCommandTemplateRepeat(
167
153
  );
168
154
  }
169
155
 
170
- function getExecutableName(command: string | undefined): string {
171
- if (!command) return "";
172
- return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
173
- }
174
-
175
- function matchesFlag(arg: string, flag: string): boolean {
176
- if (arg === flag) return true;
177
- if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
178
- return arg.slice(1).includes(flag.slice(1));
179
- return false;
180
- }
181
-
182
- function hasAnyFlag(args: string[], flags: string[]): boolean {
183
- return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
184
- }
185
-
186
- function hasRiskyPathArg(args: string[]): boolean {
187
- return args.some(
188
- (arg) =>
189
- arg === "/" ||
190
- arg === "~" ||
191
- arg === "./" ||
192
- arg === "../" ||
193
- arg.includes("{") ||
194
- arg.startsWith("~/") ||
195
- arg.startsWith("/"),
196
- );
197
- }
198
-
199
- function sortRiskLabels(
200
- labels: Iterable<CommandTemplateRiskLabel>,
201
- ): CommandTemplateRiskLabel[] {
202
- const unique = new Set(labels);
203
- return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
204
- }
205
-
206
- function hasAnyArg(args: string[], values: string[]): boolean {
207
- return args.some((arg) => values.includes(arg.toLowerCase()));
208
- }
209
-
210
- function hasSecretTouchingText(parts: string[]): boolean {
211
- return parts.some((part) =>
212
- /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
213
- part,
214
- ),
215
- );
216
- }
217
-
218
- function getLeafCommandTemplateRiskLabels(
219
- config: CommandTemplateLeafConfig,
220
- ): CommandTemplateRiskLabel[] {
221
- const parts = splitCommandTemplate(config.template);
222
- const command = getExecutableName(parts[0]);
223
- const args = parts.slice(1);
224
- const labels = new Set<CommandTemplateRiskLabel>();
225
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
226
- labels.add("risk.shell");
227
- if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
228
- }
229
- if (
230
- ["node", "deno", "bun"].includes(command) &&
231
- hasAnyFlag(args, ["-e", "--eval"])
232
- ) {
233
- labels.add("risk.eval");
234
- }
235
- if (
236
- ["python", "python3", "perl", "ruby"].includes(command) &&
237
- hasAnyFlag(args, ["-c", "-e"])
238
- ) {
239
- labels.add("risk.eval");
240
- }
241
- if (
242
- command === "rm" &&
243
- (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
244
- hasRiskyPathArg(args))
245
- ) {
246
- labels.add("risk.destructive_fs");
247
- }
248
- if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
249
- labels.add("risk.broad_fs_write");
250
- }
251
- if (
252
- [
253
- "curl",
254
- "wget",
255
- "ssh",
256
- "scp",
257
- "sftp",
258
- "rsync",
259
- "nc",
260
- "ncat",
261
- "telnet",
262
- "ftp",
263
- ].includes(command) ||
264
- (command === "git" &&
265
- hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
266
- ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
267
- ) {
268
- labels.add("risk.network");
269
- }
270
- if (
271
- ["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
272
- (command === "git" && hasAnyArg(args, ["push"])) ||
273
- (["npm", "pnpm", "yarn"].includes(command) &&
274
- hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
275
- ) {
276
- labels.add("risk.external_side_effect");
277
- }
278
- if (
279
- command === "sleep" ||
280
- command === "watch" ||
281
- (command === "tail" && hasAnyFlag(args, ["-f"])) ||
282
- hasAnyArg(args, ["--watch", "--serve", "serve"])
283
- ) {
284
- labels.add("risk.long_running");
285
- }
286
- if (
287
- [
288
- "systemctl",
289
- "launchctl",
290
- "osascript",
291
- "open",
292
- "xdg-open",
293
- "powershell",
294
- "pwsh",
295
- "cmd.exe",
296
- "apt",
297
- "apt-get",
298
- "dnf",
299
- "yum",
300
- "brew",
301
- "pacman",
302
- "apk",
303
- "xclip",
304
- "wl-copy",
305
- ].includes(command)
306
- ) {
307
- labels.add("risk.platform_specific");
308
- }
309
- if (
310
- ["pass", "gpg", "ssh-add"].includes(command) ||
311
- hasSecretTouchingText(parts)
312
- ) {
313
- labels.add("risk.secret_touching");
314
- }
315
- return sortRiskLabels(labels);
316
- }
317
-
318
- function getLeafCommandTemplateWarnings(
319
- config: CommandTemplateLeafConfig,
320
- ): string[] {
321
- const parts = splitCommandTemplate(config.template);
322
- const command = getExecutableName(parts[0]);
323
- const args = parts.slice(1);
324
- const warnings: string[] = [];
325
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
326
- const shellContent = hasAnyFlag(args, ["-c"])
327
- ? "shell command strings"
328
- : "shell scripts";
329
- warnings.push(
330
- `${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.`,
331
- );
332
- }
333
- if (
334
- ["node", "deno", "bun"].includes(command) &&
335
- hasAnyFlag(args, ["-e", "--eval"])
336
- ) {
337
- warnings.push(
338
- `${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.`,
339
- );
340
- }
341
- if (
342
- ["python", "python3", "perl", "ruby"].includes(command) &&
343
- hasAnyFlag(args, ["-c", "-e"])
344
- ) {
345
- warnings.push(
346
- `${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.`,
347
- );
348
- }
349
- if (
350
- command === "rm" &&
351
- (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
352
- hasRiskyPathArg(args))
353
- ) {
354
- warnings.push(
355
- `${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.`,
356
- );
357
- }
358
- if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
359
- warnings.push(
360
- `${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.`,
361
- );
362
- }
363
- return warnings;
364
- }
365
-
366
156
  function pad(value: number, width: number): string {
367
157
  return String(value).padStart(width, "0");
368
158
  }
@@ -415,7 +205,7 @@ function expandRepeatConfig(
415
205
 
416
206
  export function expandCommandTemplateConfigs(
417
207
  config: CommandTemplateConfig,
418
- inherited: Pick<CommandTemplateObjectConfig, "args" | "defaults"> = {},
208
+ inherited: CommandTemplateExpansionContext = {},
419
209
  ): CommandTemplateLeafConfig[] {
420
210
  const normalizedConfig = normalizeCommandTemplateConfig(config);
421
211
  const inheritedDefaults = normalizeCommandTemplateDefaults(
@@ -425,7 +215,10 @@ export function expandCommandTemplateConfigs(
425
215
  normalizeCommandTemplateDefaults(normalizedConfig.defaults),
426
216
  inheritedDefaults,
427
217
  );
428
- const context = {
218
+ const context: CommandTemplateExpansionContext = {
219
+ ...(inherited.inheritedWhen?.length
220
+ ? { inheritedWhen: inherited.inheritedWhen }
221
+ : {}),
429
222
  ...(inherited.args !== undefined ? { args: inherited.args } : {}),
430
223
  ...(inheritedDefaults ? { defaults: inheritedDefaults } : {}),
431
224
  ...(normalizedConfig.args !== undefined
@@ -441,19 +234,23 @@ export function expandCommandTemplateConfigs(
441
234
  expandCommandTemplateConfigs(step, context),
442
235
  );
443
236
  }
444
- const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
445
- const recoverSteps = recoverConfig
446
- ? expandCommandTemplateConfigs(recoverConfig, context)
447
- : [];
237
+ // `recover` is cleanup between failed retries, never a sequence step; runners attach it per leaf.
448
238
  if (Array.isArray(normalizedConfig.template)) {
449
- return [
450
- ...normalizedConfig.template.flatMap((step) =>
451
- expandCommandTemplateConfigs(step, context),
452
- ),
453
- ...recoverSteps,
454
- ];
239
+ const childContext: CommandTemplateExpansionContext =
240
+ normalizedConfig.when === undefined
241
+ ? context
242
+ : {
243
+ ...context,
244
+ inheritedWhen: [
245
+ ...(context.inheritedWhen ?? []),
246
+ normalizedConfig.when,
247
+ ],
248
+ };
249
+ return normalizedConfig.template.flatMap((step) =>
250
+ expandCommandTemplateConfigs(step, childContext),
251
+ );
455
252
  }
456
- if (typeof normalizedConfig.template !== "string") return recoverSteps;
253
+ if (typeof normalizedConfig.template !== "string") return [];
457
254
  return [
458
255
  {
459
256
  ...normalizedConfig,
@@ -461,30 +258,71 @@ export function expandCommandTemplateConfigs(
461
258
  template: normalizedConfig.template,
462
259
  retry: normalizedConfig.retry,
463
260
  },
464
- ...recoverSteps,
465
261
  ];
466
262
  }
467
263
 
468
- export function getCommandTemplateWarnings(
469
- config: CommandTemplateConfig,
470
- ): string[] {
471
- return [
472
- ...new Set(
473
- expandCommandTemplateConfigs(config).flatMap((leaf) =>
474
- getLeafCommandTemplateWarnings(leaf),
475
- ),
476
- ),
477
- ];
264
+ /** Whether a node's own and enclosing `when` guards all pass for its resolved placeholder values. */
265
+ export function shouldRunCommandTemplateConfig(
266
+ config: CommandTemplateConfig | CommandTemplateLeafConfig,
267
+ values: Record<string, unknown>,
268
+ ): boolean {
269
+ if (typeof config === "string") return true;
270
+ const inheritedWhen =
271
+ "inheritedWhen" in config ? (config.inheritedWhen ?? []) : [];
272
+ if (config.when === undefined && inheritedWhen.length === 0) return true;
273
+ const resolved = { ...getCommandTemplateDefaults(config), ...values };
274
+ return [...inheritedWhen, config.when].every((condition) =>
275
+ shouldRunCommandTemplateNode(condition, resolved),
276
+ );
478
277
  }
479
278
 
480
- export function getCommandTemplateRiskLabels(
279
+ /** A node's `recover` template as one cleanup run: output ignored, failure stops retries unless a leaf opts into `continue`. */
280
+ export function createCommandTemplateRecovery(
481
281
  config: CommandTemplateConfig,
482
- ): CommandTemplateRiskLabel[] {
483
- return sortRiskLabels(
484
- expandCommandTemplateConfigs(config).flatMap((leaf) =>
485
- getLeafCommandTemplateRiskLabels(leaf),
486
- ),
487
- );
282
+ values: Record<string, unknown>,
283
+ options: {
284
+ cwd: string;
285
+ timeout?: number;
286
+ execCommand: (
287
+ command: string,
288
+ args: string[],
289
+ options?: CommandTemplateExecOptions,
290
+ ) => Promise<CommandTemplateExecResult>;
291
+ },
292
+ ): (() => Promise<void>) | undefined {
293
+ if (typeof config === "string") return undefined;
294
+ const recoverConfig = normalizeRecoverConfig(config.recover);
295
+ if (!recoverConfig) return undefined;
296
+ const steps = expandCommandTemplateConfigs(recoverConfig, {
297
+ ...(config.args !== undefined ? { args: config.args } : {}),
298
+ ...(config.defaults !== undefined ? { defaults: config.defaults } : {}),
299
+ });
300
+ return async () => {
301
+ for (const step of steps) {
302
+ if (!shouldRunCommandTemplateConfig(step, values)) continue;
303
+ const invocation = buildCommandTemplateInvocation(
304
+ step,
305
+ values,
306
+ options.cwd,
307
+ { missingLabel: "command template recover" },
308
+ );
309
+ const result = await options.execCommand(
310
+ invocation.command,
311
+ invocation.args,
312
+ {
313
+ cwd: options.cwd,
314
+ ...(options.timeout !== undefined
315
+ ? { timeout: options.timeout }
316
+ : {}),
317
+ },
318
+ );
319
+ if (result.code !== 0 && step.failure !== "continue") {
320
+ throw new Error(
321
+ `Command template recover exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
322
+ );
323
+ }
324
+ }
325
+ };
488
326
  }
489
327
 
490
328
  function parseCommandTemplateArgToken(value: string): {
@@ -551,10 +389,11 @@ export function splitCommandTemplate(input: string): string[] {
551
389
  }
552
390
  if (char === "\\" && quote !== "'") {
553
391
  const next = input[index + 1];
554
- const escapesNext = quote === '"'
555
- ? next === '"' || next === "\\"
556
- : next !== undefined &&
557
- (/\s/u.test(next) || next === "'" || next === '"' || next === "\\");
392
+ const escapesNext =
393
+ quote === '"'
394
+ ? next === '"' || next === "\\"
395
+ : next !== undefined &&
396
+ (/\s/u.test(next) || next === "'" || next === '"' || next === "\\");
558
397
  if (escapesNext) {
559
398
  escaped = true;
560
399
  } else {
@@ -588,10 +427,7 @@ export function splitCommandTemplate(input: string): string[] {
588
427
  return words;
589
428
  }
590
429
 
591
- function expandCommandTemplateExecutable(
592
- command: string,
593
- cwd: string,
594
- ): string {
430
+ function expandCommandTemplateExecutable(command: string, cwd: string): string {
595
431
  if (command === "~") return homedir();
596
432
  if (command.startsWith("~/")) return resolve(homedir(), command.slice(2));
597
433
  if (command.includes("/") && !isAbsolute(command))
@@ -816,7 +652,59 @@ function resolveCommandTemplateValue(
816
652
  return undefined;
817
653
  }
818
654
 
819
- export function substituteCommandTemplateToken(
655
+ /** Resolve one optional non-negative numeric control field, substituting a template token when given as text. */
656
+ export function resolveCommandTemplateNumericField(
657
+ value: number | string | undefined,
658
+ values: Record<string, unknown>,
659
+ label: string,
660
+ ): number | undefined {
661
+ if (value === undefined) return undefined;
662
+ const resolved =
663
+ typeof value === "string"
664
+ ? substituteCommandTemplateToken(value, values, label)
665
+ : value;
666
+ if (resolved === "") return undefined;
667
+ const numeric = Number(resolved);
668
+ if (!Number.isFinite(numeric) || numeric < 0)
669
+ throw new Error(`Command template ${label} must be a non-negative number.`);
670
+ return numeric;
671
+ }
672
+
673
+ /** A node's own `timeout`; string leaves carry none. */
674
+ export function getCommandTemplateConfiguredTimeout(
675
+ config: CommandTemplateConfig | undefined,
676
+ ): number | undefined {
677
+ const timeout = typeof config === "string" ? undefined : config?.timeout;
678
+ return resolveCommandTemplateNumericField(timeout, {}, "timeout");
679
+ }
680
+
681
+ /**
682
+ * Bound one composed step by its own timeout and the handler budget left after `elapsedMs`.
683
+ * Without `elapsedMs` the step receives the full budget.
684
+ */
685
+ export function getCommandTemplateStepTimeout(
686
+ budgetMs: number,
687
+ step: CommandTemplateConfig,
688
+ elapsedMs?: number,
689
+ ): number {
690
+ const remaining =
691
+ elapsedMs === undefined ? budgetMs : Math.max(1, budgetMs - elapsedMs);
692
+ const stepTimeout = getCommandTemplateConfiguredTimeout(step);
693
+ return stepTimeout === undefined
694
+ ? remaining
695
+ : Math.min(stepTimeout, remaining);
696
+ }
697
+
698
+ /** Array templates expand into ordered composition steps; single-command templates have none. */
699
+ export function getCommandTemplateCompositionSteps(
700
+ config: CommandTemplateObjectConfig,
701
+ ): CommandTemplateLeafConfig[] {
702
+ return Array.isArray(config.template)
703
+ ? expandCommandTemplateConfigs(config)
704
+ : [];
705
+ }
706
+
707
+ function substituteCommandTemplateToken(
820
708
  token: string,
821
709
  values: Record<string, unknown>,
822
710
  missingLabel = "command template",
@@ -850,6 +738,7 @@ export async function execCommandTemplate(
850
738
  const result = await execCommandTemplateOnce(command, args, options);
851
739
  if (result.code === 0) return result;
852
740
  lastResult = result;
741
+ if (attempt < maxAttempts) await options.recover?.();
853
742
  }
854
743
  return lastResult;
855
744
  }
@@ -865,7 +754,7 @@ function escapeWindowsCommandArgument(
865
754
  doubleEscapeMetaChars: boolean,
866
755
  ): string {
867
756
  let escaped = value
868
- .replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
757
+ .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
869
758
  .replace(/(?=(\\+?)?)\1$/g, "$1$1");
870
759
  escaped = `"${escaped}"`.replace(WINDOWS_COMMAND_META_CHARS, "^$1");
871
760
  return doubleEscapeMetaChars
@@ -888,13 +777,11 @@ function resolveCommandTemplateSpawn(
888
777
  return { command, args };
889
778
  }
890
779
  const normalizedCommand = normalize(command);
891
- const isNodeModulesShim = /[\\/]node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu
892
- .test(normalizedCommand);
780
+ const isNodeModulesShim =
781
+ /[\\/]node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu.test(normalizedCommand);
893
782
  const shellCommand = [
894
783
  escapeWindowsCommand(normalizedCommand),
895
- ...args.map((arg) =>
896
- escapeWindowsCommandArgument(arg, isNodeModulesShim)
897
- ),
784
+ ...args.map((arg) => escapeWindowsCommandArgument(arg, isNodeModulesShim)),
898
785
  ].join(" ");
899
786
  return {
900
787
  command: process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe",