@llblab/pi-telegram 0.11.0 → 0.11.2

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.
@@ -4,12 +4,14 @@ Command templates are the portable integration format for deterministic local au
4
4
 
5
5
  **Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
6
6
 
7
- **Scope:** portable synchronous command execution format — shell-free exec, composition/pipes, timeout (30s default), delay-before-start, retry, critical-step branching, output artifact selection, and handler-level fallback. Single JSON standard; no platform lock-in.
7
+ **Scope:** portable synchronous command execution format — shell-free exec, composition/pipes, optional timeout, delay-before-start, bounded retry, failure propagation, recover cleanup, output artifact selection, and handler-level fallback. Single JSON standard; no platform lock-in.
8
8
 
9
9
  ---
10
10
 
11
11
  Extensions may choose their own config files, selectors, placeholder sources, and examples, but should preserve this core contract.
12
12
 
13
+ Layer boundary: command templates own only the synchronous execution graph. Recipe imports, import-reference expressions, recipe lookup, `async: true`, run ids, state dirs, FIFO controls, and outbox events are host/recipe/async-run configuration layers, not portable command-template syntax.
14
+
13
15
  ## Shape
14
16
 
15
17
  A command template is either a command-line string or an ordered array of command-template leaves:
@@ -32,13 +34,15 @@ Common object fields:
32
34
 
33
35
  - `label`: Optional human label for diagnostics and parallel branch reports.
34
36
  - `mode`: Optional execution mode for array templates. Default is `"sequence"`; `"parallel"` runs children concurrently.
35
- - `args`: Optional placeholder-name declarations only. Never stores defaults.
37
+ - `args`: Optional placeholder declarations. Untyped names remain valid; compact typed forms such as `file:path`, `timeout:int`, `speed:number`, `dry_run:bool`, `prompts:array`, and `mode:enum(check,fix)` are valid when the host supports typed tool schemas. Defaults belong in `defaults` or inline placeholder defaults; hosts may normalize interactive shorthand such as `timeout:int=60000` before persistence.
36
38
  - `defaults`: Placeholder default values by name.
37
- - `timeout`: Optional execution timeout in milliseconds. Default is `30000`. Long-running agent calls should set this explicitly.
39
+ - `timeout`: Optional execution timeout in milliseconds. Omit it, or set `0`, to leave the command unbounded. Set an explicit positive timeout when a tool must fail closed instead of waiting indefinitely.
38
40
  - `delay`: Optional wait in milliseconds before starting this node. Default is no delay.
39
41
  - `output`: Optional result selector. Default is `"stdout"`; runtime values such as `"ogg"` are valid.
40
42
  - `retry`: Optional max attempts including the first. Default is `1`.
41
- - `critical`: Optional boolean. When `true`, failure aborts the root composition.
43
+ - `critical`: Optional boolean. Backward-compatible alias for `failure: "root"`.
44
+ - `failure`: Optional failure propagation scope: `continue`, `branch`, or `root`. Default is `continue`.
45
+ - `recover`: Optional command template run between failed retry attempts. Recovery output is ignored; recovery failure stops retries.
42
46
  - `template`: Required command string or ordered composition array.
43
47
 
44
48
  For object form, write `template` last. Read the node flags first, then the executable content. Storage paths, labels, selectors, descriptions, and registry-specific metadata belong to each extension's local schema.
@@ -63,8 +67,9 @@ Supported forms:
63
67
  | ---------------- | ------------------------------------------------ |
64
68
  | `{name}` | Required value from runtime values or `defaults` |
65
69
  | `{name=default}` | Inline default when no value is provided |
70
+ | `{items[index]}` | Array item selected by literal or repeat index |
66
71
 
67
- Resolution order is runtime values → `defaults` → inline default → error.
72
+ Resolution order is runtime values → `defaults` → inline default → error. Default values that are themselves a single placeholder, such as `{prompt}` resolving to `{prompts[index]}`, are resolved recursively with a small depth guard. A repeat node may set `repeat` to `{items.length}` when an array arg should determine fanout width.
68
73
 
69
74
  ```json
70
75
  {
@@ -80,6 +85,8 @@ With runtime values `{ "text": "hello" }`, argv is:
80
85
 
81
86
  Use `defaults` for visible configuration data; use inline defaults for compact local literals. Prefer flag-style examples such as `/path/to/tool --file {file} --lang {lang=ru}` for readability, but positional forms such as `/path/to/tool {file} {lang=ru}` are valid when the invoked script defines that CLI contract.
82
87
 
88
+ Typed declarations annotate the public tool interface, not the shell command. They may live in `args` or inline placeholders such as `{timeout:int=60000}` and `{mode:enum(check,fix)=check}`. Use metadata-first authoring (`args` plus `defaults`) when long templates should stay visually short; use inline-first authoring when one self-contained `template` property is clearer. They do not sandbox or reinterpret the executable; they only let the host generate narrower input schemas and normalize runtime values before placeholder substitution. Untyped `args` and untyped placeholders continue to work unchanged.
89
+
83
90
  ## Quoting
84
91
 
85
92
  Placeholder values are not shell-escaped because no shell is used. A value containing spaces remains one argv item when it replaces one split word:
@@ -122,12 +129,12 @@ Composition rules:
122
129
 
123
130
  - Execute leaves in order when `mode` is omitted or set to `"sequence"`
124
131
  - Execute child templates concurrently when `mode` is set to `"parallel"`
125
- - Parallel composition uses soft-quorum semantics by default: failed non-critical children are reported but do not abort siblings or the next sequence step
126
- - Non-critical failures are recorded and execution continues, while `critical: true` failures abort the root composition
132
+ - Parallel composition uses soft-quorum semantics by default: failed children are reported as degraded branches unless failure propagation escalates
133
+ - Non-critical failures are recorded and execution continues, while `failure: "branch"` stops the current branch and `failure: "root"` aborts the root composition
127
134
  - Treat the whole composition as one handler for selector matching and fallback
128
135
  - Top-level `args` and `defaults` apply to every leaf unless the leaf defines private values
129
136
  - Leaf `args` replace inherited `args`; leaf `defaults` merge over inherited defaults; `timeout` and `output` are not inherited into leaves
130
- - Default `30000` (30s) timeout applies automatically; configure `timeout` only for exceptional long-running commands
137
+ - Timeout is disabled by default; configure a positive `timeout` for bounded commands that should fail closed
131
138
  - Each sequence leaf receives the previous leaf's stdout on stdin by default, while the final leaf stdout remains the default composition result
132
139
  - Each parallel child receives the same stdin, and child stdout values are joined in stable array order before flowing to the next sequence leaf
133
140
  - Parallel branch joins include branch label and status, and tool details include branch metadata plus coverage summary
@@ -231,38 +238,76 @@ Legacy local schemas may accept `pipe` as an alias, but the portable standard is
231
238
 
232
239
  By default, composition continues on failure: the failed step is logged and the next step executes. This is analogous to `make -k` — the user sees all failures at once and decides what to fix.
233
240
 
234
- ## Critical Steps
241
+ ## Failure Propagation
242
+
243
+ By default, failed steps use `failure: "continue"`: record the failure, clear stdout for that step, and continue the current sequence. This preserves the fail-open profile.
244
+
245
+ Use `failure` when a node should stop more aggressively:
235
246
 
236
- Set `critical: true` on any leaf to abort the entire root composition on failure. One `critical` leaf can halt the whole pipeline.
247
+ - `"continue"`: record the failure and continue the current sequence.
248
+ - `"branch"`: stop the current sequence/subtree and return a failed branch to the nearest parent. In a parallel node, sibling branches keep running and the join becomes degraded. At the root, branch failure is still a tool failure.
249
+ - `"root"`: abort the outermost composition.
237
250
 
238
251
  ```json
239
252
  {
253
+ "mode": "parallel",
240
254
  "template": [
241
- { "template": "cargo build" },
242
- { "template": "cargo fmt --check" },
243
- { "critical": true, "template": "cargo test" }
255
+ {
256
+ "label": "agent-a",
257
+ "failure": "branch",
258
+ "template": [
259
+ "agent-a-work {scope}",
260
+ "agent-a-validate {scope}",
261
+ "agent-a-push {scope}"
262
+ ]
263
+ },
264
+ {
265
+ "label": "agent-b",
266
+ "failure": "branch",
267
+ "template": [
268
+ "agent-b-work {scope}",
269
+ "agent-b-validate {scope}",
270
+ "agent-b-push {scope}"
271
+ ]
272
+ }
244
273
  ]
245
274
  }
246
275
  ```
247
276
 
248
- `build` / `fmt` failures are logged, execution continues. `test` failure aborts the root composition immediately.
277
+ If `agent-a-validate` fails, `agent-a-push` is skipped, `agent-b` can still finish, and the parallel join reports degraded branch coverage.
249
278
 
250
- A `critical` leaf in a nested composition still aborts the outermost root `template: [...]`. There is no per-branch scoping in the current standard.
279
+ `critical: true` remains a backward-compatible alias for `failure: "root"`. Prefer `failure` for new templates because it names the propagation scope directly.
251
280
 
252
281
  ## Retry
253
282
 
254
- Set `retry: N` on a leaf to attempt execution up to `N` times (including the first). Retries happen immediately on non-zero exit. The first successful attempt stops the retry loop.
283
+ Set `retry: N` to attempt execution up to `N` times including the first. The first successful attempt stops the retry loop.
284
+
285
+ On leaf commands, retry repeats that command. On sequence or parallel nodes, retry repeats the whole node. A retried group only retries when the group returns a failure, so validator checkpoints normally pair group retry with `failure: "branch"` or `failure: "root"`.
255
286
 
256
287
  ```json
257
288
  {
258
- "template": [
259
- { "retry": 3, "template": "npm install" },
260
- { "retry": 2, "critical": true, "template": "npm test" }
261
- ]
289
+ "failure": "branch",
290
+ "retry": 3,
291
+ "template": ["implement {scope}", "npm test", "git diff --check"]
292
+ }
293
+ ```
294
+
295
+ Here the whole group runs again when a validator fails. Without `failure: "branch"`, the failed validator would be logged and the group would continue by default.
296
+
297
+ ## Recover
298
+
299
+ Set `recover` on a retried node to run cleanup after a failed attempt and before the next attempt. `recover` is another command template: it can be a string command, sequence, or mode tree. Its output is ignored and the next retry receives the original stdin.
300
+
301
+ ```json
302
+ {
303
+ "failure": "branch",
304
+ "retry": 3,
305
+ "recover": "git -C {work_dir} reset --hard HEAD",
306
+ "template": ["pi -p --tools read,edit,bash {scope_file}", "npm test"]
262
307
  }
263
308
  ```
264
309
 
265
- `npm install` is retried up to 3 times. `npm test` is retried up to 2 times; if all attempts fail, the critical step aborts the pipeline.
310
+ `recover` is not a fallback success path. It is cleanup between attempts. Practical uses include resetting a worktree, removing temp files, clearing generated output, releasing a local lock, or stopping a helper process before trying the node again. If recovery fails, retries stop and the recovery failure is returned. Recovery uses fail-closed semantics by default; set an explicit `failure` inside a recover template only when a softer cleanup failure is intentional.
266
311
 
267
312
  ## Delay
268
313
 
@@ -290,12 +335,24 @@ string → leaf command
290
335
  string[] → sequential composition
291
336
  { template } → leaf command object
292
337
  { mode, template } → sequence or parallel subtree
293
- { mode, args, defaults, delay, retry, critical, output, template } → full node
338
+ { mode, args, defaults, delay, retry, failure, recover, output, template } → full node
294
339
  ```
295
340
 
296
- Start with a string. Add composition when needed. Add `mode: "parallel"` when independent work can run concurrently. Add delay when launch pacing matters. Add retry when flaky. Add critical when safety matters. Same contract, growing capability, no dead weight.
341
+ Start with a string. Add composition when needed. Add `mode: "parallel"` when independent work can run concurrently. Add delay when launch pacing matters. Add retry when flaky. Add `failure` when propagation scope matters. Add `recover` when a retried node needs cleanup before another attempt. Same contract, growing capability, no dead weight.
342
+
343
+ `mode: "parallel"` is the synchronous fanout shape. Saved JSON recipes and detached lifecycle concerns such as logs, cancellation, and durable state belong to host-specific recipe/async-run standards, not to command templates.
344
+
345
+ ## Trust Boundary
346
+
347
+ Command templates avoid shell interpolation by splitting the template into argv first and substituting placeholders per arg. A placeholder value containing spaces remains one argv value, not a shell fragment.
348
+
349
+ This is not a sandbox. The executable still runs with the same user permissions as the host agent. Shells, interpreter eval modes, destructive filesystem commands, and local scripts remain trusted code. Examples that deserve extra operator attention:
350
+
351
+ - `bash`, `sh`, `zsh`, or `fish`, especially with `-c`.
352
+ - `node -e`, `python -c`, `ruby -e`, `perl -e`, or similar eval modes.
353
+ - `rm`, `mv`, `cp`, or `rsync` over broad paths or placeholder-derived paths.
297
354
 
298
- `mode: "parallel"` is the synchronous fanout shape. Detached lifecycle, logs, cancellation, and durable state belong to host-specific async job or runtime-envelope standards, not to command templates.
355
+ Hosts may surface lightweight warnings for these obvious high-risk shapes. Warnings should inform review without blocking existing tools, because many trusted local wrappers intentionally use shells or filesystem mutation.
299
356
 
300
357
  ## Tool Boundary
301
358
 
package/index.ts CHANGED
@@ -11,7 +11,6 @@ import * as Config from "./lib/config.ts";
11
11
  import {
12
12
  createTelegramExtensionSectionRegistry,
13
13
  setGlobalTelegramSectionRegistry,
14
- registerTelegramSection,
15
14
  type TelegramSectionRegistry,
16
15
  } from "./lib/extension-sections.ts";
17
16
  import { createTelegramExternalHandleUpdate } from "./lib/external-handlers.ts";
@@ -38,6 +37,7 @@ import * as Runtime from "./lib/runtime.ts";
38
37
  import * as Setup from "./lib/setup.ts";
39
38
  import * as Status from "./lib/status.ts";
40
39
  import * as TextGroups from "./lib/text-groups.ts";
40
+ import * as TimeInjection from "./lib/time-injection.ts";
41
41
  import * as Voice from "./lib/voice.ts";
42
42
 
43
43
  const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
@@ -45,42 +45,6 @@ const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
45
45
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
46
46
  type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
47
47
 
48
- export {
49
- registerTelegramOutboundHandler,
50
- hasTelegramOutboundHandler,
51
- getTelegramOutboundProgrammaticHandlers,
52
- recordTelegramRuntimeEvent,
53
- } from "./lib/outbound-handlers.ts";
54
-
55
- // --- Voice Integration Exports ---
56
- // Prefer domain imports from ./lib/voice.ts; root exports stay for compatibility.
57
- export {
58
- registerTelegramVoiceSynthesisProvider,
59
- getTelegramVoiceSynthesisProviders,
60
- hasTelegramVoiceSynthesisProvider,
61
- clearTelegramVoiceSynthesisProviders,
62
- planTelegramVoiceReply,
63
- getTelegramVoiceReplyMode,
64
- computeVoiceTurnFlags,
65
- isVoiceTurn,
66
- shouldSuppressPreviewForVoice,
67
- computeVoicePromptContribution,
68
- type TelegramVoiceSynthesisProvider,
69
- type TelegramVoiceTurnView,
70
- type TelegramVoiceSynthesisProviderResult,
71
- type TelegramVoiceReplyMode,
72
- } from "./lib/voice.ts";
73
-
74
- // --- Extension Section Exports ---
75
- export {
76
- registerTelegramSection,
77
- type TelegramSectionRegistration,
78
- type TelegramSectionContext,
79
- type TelegramSectionCallbackContext,
80
- type TelegramSectionView,
81
- type TelegramSectionSettingsRegistration,
82
- } from "./lib/extension-sections.ts";
83
-
84
48
  // --- Extension Runtime ---
85
49
 
86
50
  export default function (pi: Pi.ExtensionAPI) {
@@ -98,7 +62,10 @@ export default function (pi: Pi.ExtensionAPI) {
98
62
  Config.setGlobalTelegramConfigRuntime({
99
63
  updateVoiceConfig(voice) {
100
64
  const current = configStore.get();
101
- const next = { ...current, voice: { ...(current.voice ?? {}), ...voice } };
65
+ const next = {
66
+ ...current,
67
+ voice: { ...(current.voice ?? {}), ...voice },
68
+ };
102
69
  configStore.set(next);
103
70
  void configStore.persist(next);
104
71
  },
@@ -113,6 +80,10 @@ export default function (pi: Pi.ExtensionAPI) {
113
80
  Config.createTelegramVoiceReplyModeConfiguredChecker(configStore);
114
81
  const setVoiceReplyMode =
115
82
  Config.createTelegramVoiceReplyModeSetter(configStore);
83
+ const getTimeInjectionMode =
84
+ Config.createTelegramTimeInjectionModeGetter(configStore);
85
+ const setTimeInjectionMode =
86
+ Config.createTelegramTimeInjectionModeSetter(configStore);
116
87
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
117
88
  const lockOwnershipGuard =
118
89
  Locks.createTelegramLockOwnershipGuard(lockRuntime);
@@ -132,14 +103,16 @@ export default function (pi: Pi.ExtensionAPI) {
132
103
  createTelegramExtensionSectionRegistry();
133
104
  setGlobalTelegramSectionRegistry(sectionRegistry);
134
105
 
135
-
136
106
  const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
137
107
  getBotToken: configStore.getBotToken,
138
108
  });
139
109
  const recordRuntimeEvent = runtimeEvents.record;
140
- (globalThis as Record<string, unknown>)[
141
- VOICE_EVENT_RECORDER_KEY
142
- ] = recordRuntimeEvent;
110
+ const timeInjectionRuntime = TimeInjection.createTimeInjectionRuntime({
111
+ getConfig: Config.createTelegramTimeConfigGetter(configStore),
112
+ recordRuntimeEvent,
113
+ });
114
+ (globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] =
115
+ recordRuntimeEvent;
143
116
  const getContextModel = Pi.getExtensionContextModel;
144
117
  const isIdle = Pi.isExtensionContextIdle;
145
118
  const hasPendingMessages = Pi.hasExtensionContextPendingMessages;
@@ -156,6 +129,7 @@ export default function (pi: Pi.ExtensionAPI) {
156
129
  Queue.createTelegramQueueStore<Pi.ExtensionContext>();
157
130
  const deferredQueueDispatchRuntime =
158
131
  Queue.createTelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>({
132
+ delayMs: 50,
159
133
  recordRuntimeEvent,
160
134
  });
161
135
  const pollingControllerState = Polling.createTelegramPollingControllerState();
@@ -386,8 +360,10 @@ export default function (pi: Pi.ExtensionAPI) {
386
360
  isProactivePushEnabled,
387
361
  getVoiceReplyMode,
388
362
  isVoiceReplyModeConfigured,
363
+ getTimeInjectionMode,
389
364
  setProactivePushEnabled,
390
365
  setVoiceReplyMode,
366
+ setTimeInjectionMode,
391
367
  },
392
368
  sectionRegistry,
393
369
  );
@@ -435,6 +411,7 @@ export default function (pi: Pi.ExtensionAPI) {
435
411
  setMyCommands,
436
412
  getCommands,
437
413
  downloadFile: downloadTelegramBridgeFile,
414
+ resolveTimeLine: timeInjectionRuntime.resolveLine,
438
415
  getThinkingLevel,
439
416
  setThinkingLevel,
440
417
  persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
@@ -611,9 +588,23 @@ export default function (pi: Pi.ExtensionAPI) {
611
588
  const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
612
589
  agentLifecycleHooks.onAgentStart,
613
590
  );
591
+ const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
592
+ setCompactionInProgress: lifecycle.setCompactionInProgress,
593
+ updateStatus,
594
+ requestDeferredDispatchNextQueuedTelegramTurn:
595
+ deferredQueueDispatchRuntime.request,
596
+ dispatchNextQueuedTelegramTurn,
597
+ recordRuntimeEvent,
598
+ });
614
599
  Lifecycle.registerTelegramLifecycleHooks(pi, {
615
600
  ...sessionLifecycleRuntime,
616
601
  ...agentLifecycleHooks,
602
+ async onSessionShutdown(event, ctx) {
603
+ compactionObserver.onSessionShutdown();
604
+ await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
605
+ },
606
+ onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
607
+ onSessionCompact: compactionObserver.onSessionCompact,
617
608
  onAgentStart: agentStartWithDedupReset,
618
609
  onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
619
610
  isProactivePushEnabled,
@@ -8,9 +8,8 @@ import { spawn } from "node:child_process";
8
8
  import { homedir } from "node:os";
9
9
  import { isAbsolute, resolve } from "node:path";
10
10
 
11
- export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
12
-
13
11
  export type CommandTemplateMode = "sequence" | "parallel";
12
+ export type CommandTemplateFailureScope = "continue" | "branch" | "root";
14
13
 
15
14
  export interface CommandTemplateObjectConfig {
16
15
  label?: string;
@@ -23,7 +22,9 @@ export interface CommandTemplateObjectConfig {
23
22
  output?: string;
24
23
  retry?: number;
25
24
  critical?: boolean;
26
- repeat?: number;
25
+ failure?: CommandTemplateFailureScope;
26
+ recover?: CommandTemplateValue;
27
+ repeat?: number | string;
27
28
  }
28
29
 
29
30
  export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
@@ -72,23 +73,92 @@ export function normalizeCommandTemplateConfig(
72
73
  return typeof config === "string" ? { template: config } : config;
73
74
  }
74
75
 
76
+ function normalizeRecoverConfig(
77
+ config: CommandTemplateValue | undefined,
78
+ ): CommandTemplateConfig | undefined {
79
+ if (config === undefined) return undefined;
80
+ return Array.isArray(config) ? { template: config } : config;
81
+ }
82
+
75
83
  function normalizeCommandTemplateDefaults(
76
84
  defaults: Record<string, unknown> | undefined,
77
85
  ): Record<string, unknown> | undefined {
78
86
  if (!defaults) return undefined;
79
87
  const normalized: Record<string, unknown> = {};
80
88
  for (const [key, value] of Object.entries(defaults)) {
81
- normalized[key] =
82
- value === undefined || value === null ? "" : String(value);
89
+ normalized[key] = Array.isArray(value)
90
+ ? value
91
+ : value === undefined || value === null ? "" : String(value);
83
92
  }
84
93
  return normalized;
85
94
  }
86
95
 
87
- function normalizeRepeat(value: number | undefined): number | undefined {
96
+ export function resolveCommandTemplateRepeat(
97
+ value: number | string | undefined,
98
+ values: Record<string, unknown> = {},
99
+ ): number | undefined {
88
100
  if (value === undefined) return undefined;
89
- if (!Number.isInteger(value) || value < 1)
90
- throw new Error("Command template repeat must be a positive integer.");
91
- return value;
101
+ if (typeof value === "number") {
102
+ if (!Number.isInteger(value) || value < 1)
103
+ throw new Error("Command template repeat must be a positive integer.");
104
+ return value;
105
+ }
106
+ const trimmed = value.trim();
107
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
108
+ const lengthMatch = trimmed.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/);
109
+ if (lengthMatch) {
110
+ const source = values[lengthMatch[1]];
111
+ if (Array.isArray(source)) return source.length;
112
+ if (source === undefined) return undefined;
113
+ }
114
+ throw new Error("Command template repeat must be a positive integer or {array.length}.");
115
+ }
116
+
117
+ function getExecutableName(command: string | undefined): string {
118
+ if (!command) return "";
119
+ return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
120
+ }
121
+
122
+ function hasAnyFlag(args: string[], flags: string[]): boolean {
123
+ return args.some((arg) => flags.includes(arg));
124
+ }
125
+
126
+ function hasRiskyPathArg(args: string[]): boolean {
127
+ return args.some((arg) =>
128
+ arg === "/" ||
129
+ arg === "~" ||
130
+ arg === "./" ||
131
+ arg === "../" ||
132
+ arg.includes("{") ||
133
+ arg.startsWith("~/") ||
134
+ arg.startsWith("/"),
135
+ );
136
+ }
137
+
138
+ function getLeafCommandTemplateWarnings(
139
+ config: CommandTemplateLeafConfig,
140
+ ): string[] {
141
+ const parts = splitCommandTemplate(config.template);
142
+ const command = getExecutableName(parts[0]);
143
+ const args = parts.slice(1);
144
+ const warnings: string[] = [];
145
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
146
+ const mode = hasAnyFlag(args, ["-c"]) ? "shell command strings" : "shell scripts";
147
+ warnings.push(`${config.label ?? command}: invokes ${command}; ${mode} are trusted executable content and are not sandboxed by command-template argv splitting.`);
148
+ }
149
+ if (["node", "deno", "bun"].includes(command) && hasAnyFlag(args, ["-e", "--eval"])) {
150
+ warnings.push(`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`);
151
+ }
152
+ if (["python", "python3", "perl", "ruby"].includes(command) && hasAnyFlag(args, ["-c", "-e"])) {
153
+ warnings.push(`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`);
154
+ }
155
+ if (command === "rm" && (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) || hasRiskyPathArg(args))) {
156
+ warnings.push(`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`);
157
+ }
158
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
159
+ warnings.push(`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`);
160
+ }
161
+ return warnings;
92
162
  }
93
163
 
94
164
  function pad(value: number, width: number): string {
@@ -124,7 +194,7 @@ function expandRepeatConfig(
124
194
  config: CommandTemplateObjectConfig,
125
195
  context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
126
196
  ): CommandTemplateObjectConfig[] | undefined {
127
- const repeat = normalizeRepeat(config.repeat);
197
+ const repeat = resolveCommandTemplateRepeat(config.repeat, context.defaults ?? {});
128
198
  if (repeat === undefined) return undefined;
129
199
  return Array.from({ length: repeat }, (_unused, index0) => {
130
200
  const { repeat: _repeat, ...rest } = config;
@@ -164,12 +234,19 @@ export function expandCommandTemplateConfigs(
164
234
  if (repeated) {
165
235
  return repeated.flatMap((step) => expandCommandTemplateConfigs(step, context));
166
236
  }
237
+ const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
238
+ const recoverSteps = recoverConfig
239
+ ? expandCommandTemplateConfigs(recoverConfig, context)
240
+ : [];
167
241
  if (Array.isArray(normalizedConfig.template)) {
168
- return normalizedConfig.template.flatMap((step) =>
169
- expandCommandTemplateConfigs(step, context),
170
- );
242
+ return [
243
+ ...normalizedConfig.template.flatMap((step) =>
244
+ expandCommandTemplateConfigs(step, context),
245
+ ),
246
+ ...recoverSteps,
247
+ ];
171
248
  }
172
- if (typeof normalizedConfig.template !== "string") return [];
249
+ if (typeof normalizedConfig.template !== "string") return recoverSteps;
173
250
  return [
174
251
  {
175
252
  ...normalizedConfig,
@@ -178,9 +255,37 @@ export function expandCommandTemplateConfigs(
178
255
  retry: normalizedConfig.retry,
179
256
  critical: normalizedConfig.critical,
180
257
  },
258
+ ...recoverSteps,
181
259
  ];
182
260
  }
183
261
 
262
+ export function getCommandTemplateWarnings(
263
+ config: CommandTemplateConfig,
264
+ ): string[] {
265
+ return [
266
+ ...new Set(
267
+ expandCommandTemplateConfigs(config)
268
+ .flatMap((leaf) => getLeafCommandTemplateWarnings(leaf)),
269
+ ),
270
+ ];
271
+ }
272
+
273
+ function parseCommandTemplateArgToken(value: string): { name: string; defaultValue?: string } {
274
+ const separatorIndex = value.indexOf("=");
275
+ const rawName = separatorIndex === -1 ? value : value.slice(0, separatorIndex);
276
+ const colonIndex = rawName.indexOf(":");
277
+ return {
278
+ name: (colonIndex === -1 ? rawName : rawName.slice(0, colonIndex)).trim(),
279
+ ...(separatorIndex === -1 ? {} : { defaultValue: value.slice(separatorIndex + 1).trim() }),
280
+ };
281
+ }
282
+
283
+ function parseCommandTemplatePlaceholderContent(content: string): { name: string; inlineDefault?: string } | undefined {
284
+ const match = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?::(?:string|path|int|number|bool|array|enum\([^)]*\)))?(?:=([^}]*))?$/);
285
+ if (!match) return undefined;
286
+ return { name: match[1], ...(match[2] !== undefined ? { inlineDefault: match[2] } : {}) };
287
+ }
288
+
184
289
  export function getCommandTemplateDefaults(
185
290
  config: CommandTemplateConfig | undefined,
186
291
  ): Record<string, string> {
@@ -190,9 +295,9 @@ export function getCommandTemplateDefaults(
190
295
  const defaults: Record<string, string> = {};
191
296
  for (const item of normalizeCommandTemplateArgs(normalizedConfig?.args)) {
192
297
  if (!item) continue;
193
- const [name, ...defaultParts] = item.split("=");
194
- if (!name || defaultParts.length === 0) continue;
195
- defaults[name.trim()] = defaultParts.join("=").trim();
298
+ const parsed = parseCommandTemplateArgToken(item);
299
+ if (!parsed.name || parsed.defaultValue === undefined) continue;
300
+ defaults[parsed.name] = parsed.defaultValue;
196
301
  }
197
302
  for (const [key, value] of Object.entries(normalizedConfig?.defaults ?? {})) {
198
303
  defaults[key] = value === undefined || value === null ? "" : String(value);
@@ -256,7 +361,7 @@ export function expandCommandTemplateExecutable(
256
361
 
257
362
  function evaluateCommandTemplateExpression(
258
363
  expression: string,
259
- values: Record<string, string>,
364
+ values: Record<string, unknown>,
260
365
  ): number {
261
366
  let index = 0;
262
367
  const source = expression.replace(/\s+/g, "");
@@ -283,7 +388,7 @@ function evaluateCommandTemplateExpression(
283
388
  if (nameMatch) {
284
389
  index += nameMatch[0].length;
285
390
  const value = values[nameMatch[0]];
286
- if (value === undefined || !/^-?\d+$/.test(value))
391
+ if (value === undefined || !/^-?\d+$/.test(String(value)))
287
392
  throw new Error(`Invalid command template expression variable: ${nameMatch[0]}`);
288
393
  return Number(value);
289
394
  }
@@ -313,7 +418,7 @@ function evaluateCommandTemplateExpression(
313
418
 
314
419
  function substituteCommandTemplateExpression(
315
420
  content: string,
316
- values: Record<string, string>,
421
+ values: Record<string, unknown>,
317
422
  ): string | undefined {
318
423
  const padded = content.match(/^(_{1,6})\((.+)\)$/);
319
424
  if (padded) {
@@ -323,22 +428,50 @@ function substituteCommandTemplateExpression(
323
428
  return String(evaluateCommandTemplateExpression(content, values));
324
429
  }
325
430
 
431
+ function resolveCommandTemplateValue(
432
+ content: string,
433
+ values: Record<string, unknown>,
434
+ missingLabel: string,
435
+ depth = 0,
436
+ ): string | undefined {
437
+ if (depth > 5) throw new Error(`Command template value recursion exceeded: ${content}`);
438
+ const indexed = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/);
439
+ if (indexed) {
440
+ const source = values[indexed[1]];
441
+ const indexValue = /^\d+$/.test(indexed[2]) ? indexed[2] : values[indexed[2]];
442
+ const index = Number(indexValue);
443
+ if (!Array.isArray(source) || !Number.isInteger(index) || index < 0 || index >= source.length) {
444
+ throw new Error(`Missing ${missingLabel} value: ${content}`);
445
+ }
446
+ return String(source[index] ?? "");
447
+ }
448
+ const simple = parseCommandTemplatePlaceholderContent(content);
449
+ if (simple) {
450
+ if (Object.hasOwn(values, simple.name)) {
451
+ const raw = values[simple.name] ?? "";
452
+ if (typeof raw === "string" && /^\{[^{}]+\}$/.test(raw)) {
453
+ return substituteCommandTemplateToken(raw, values, missingLabel, depth + 1);
454
+ }
455
+ return Array.isArray(raw) ? JSON.stringify(raw) : String(raw);
456
+ }
457
+ if (simple.inlineDefault !== undefined) return simple.inlineDefault;
458
+ }
459
+ const expression = substituteCommandTemplateExpression(content, values);
460
+ if (expression !== undefined) return expression;
461
+ return undefined;
462
+ }
463
+
326
464
  export function substituteCommandTemplateToken(
327
465
  token: string,
328
- values: Record<string, string>,
466
+ values: Record<string, unknown>,
329
467
  missingLabel = "command template",
468
+ depth = 0,
330
469
  ): string {
331
470
  return token.replace(
332
471
  /\{([^{}]+)\}/g,
333
472
  (_match, content: string) => {
334
- const simple = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:=([^}]*))?$/);
335
- if (simple) {
336
- const [, name, inlineDefault] = simple;
337
- if (Object.hasOwn(values, name)) return values[name] ?? "";
338
- if (inlineDefault !== undefined) return inlineDefault;
339
- }
340
- const expression = substituteCommandTemplateExpression(content, values);
341
- if (expression !== undefined) return expression;
473
+ const resolved = resolveCommandTemplateValue(content, values, missingLabel, depth);
474
+ if (resolved !== undefined) return resolved;
342
475
  throw new Error(`Missing ${missingLabel} value: ${content}`);
343
476
  },
344
477
  );
@@ -405,8 +538,6 @@ function execCommandTemplateOnce(
405
538
  }
406
539
  if (options.timeout !== undefined && options.timeout > 0)
407
540
  timeoutId = setTimeout(killProcess, options.timeout);
408
- else if (options.timeout === undefined)
409
- timeoutId = setTimeout(killProcess, DEFAULT_COMMAND_TIMEOUT_MS);
410
541
  proc.stdout?.on("data", (data) => {
411
542
  stdout += data.toString();
412
543
  });
@@ -427,7 +558,7 @@ function execCommandTemplateOnce(
427
558
 
428
559
  export function buildCommandTemplateInvocation(
429
560
  config: CommandTemplateConfig,
430
- values: Record<string, string>,
561
+ values: Record<string, unknown>,
431
562
  cwd: string,
432
563
  options: { emptyMessage?: string; missingLabel?: string } = {},
433
564
  ): CommandTemplateInvocation {