@llblab/pi-telegram 0.11.1 → 0.12.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 (45) hide show
  1. package/AGENTS.md +17 -12
  2. package/BACKLOG.md +0 -10
  3. package/CHANGELOG.md +33 -2
  4. package/README.md +42 -25
  5. package/api/inbound.ts +14 -0
  6. package/api/keyboard.ts +10 -0
  7. package/api/outbound.ts +11 -0
  8. package/api/sections.ts +17 -0
  9. package/api/updates.ts +11 -0
  10. package/api/voice.ts +24 -0
  11. package/docs/README.md +7 -5
  12. package/docs/architecture.md +176 -135
  13. package/docs/callback-namespaces.md +3 -3
  14. package/docs/command-templates.md +81 -24
  15. package/docs/{inbound-handlers.md → inbound.md} +13 -10
  16. package/docs/locks.md +3 -3
  17. package/docs/{outbound-handlers.md → outbound.md} +13 -10
  18. package/docs/public-api.md +266 -0
  19. package/docs/{extension-sections.md → sections.md} +31 -27
  20. package/docs/ui-style.md +165 -0
  21. package/docs/{external-handlers.md → updates.md} +33 -31
  22. package/docs/voice.md +17 -14
  23. package/index.ts +86 -261
  24. package/lib/bindings.ts +301 -0
  25. package/lib/command-templates.ts +163 -32
  26. package/lib/commands.ts +114 -1
  27. package/lib/config.ts +45 -4
  28. package/lib/{inbound-handlers.ts → inbound.ts} +5 -4
  29. package/lib/lifecycle.ts +122 -1
  30. package/lib/menu-model.ts +3 -3
  31. package/lib/menu-queue.ts +1 -1
  32. package/lib/menu-settings.ts +63 -32
  33. package/lib/menu-status.ts +1 -1
  34. package/lib/menu.ts +1 -1
  35. package/lib/{outbound-handlers.ts → outbound.ts} +21 -11
  36. package/lib/pi.ts +4 -0
  37. package/lib/polling.ts +4 -3
  38. package/lib/preview.ts +1 -1
  39. package/lib/routing.ts +45 -13
  40. package/lib/{extension-sections.ts → sections.ts} +37 -8
  41. package/lib/time-injection.ts +1 -1
  42. package/lib/updates.ts +121 -1
  43. package/lib/voice.ts +33 -14
  44. package/package.json +11 -1
  45. package/lib/external-handlers.ts +0 -166
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Telegram bridge binding composition
3
+ * Zones: telegram, pi agent, orchestration
4
+ * Owns pi-facing tool, command, and lifecycle hook registration for the entrypoint
5
+ */
6
+
7
+ import * as Api from "./api.ts";
8
+ import * as CommandTemplates from "./command-templates.ts";
9
+ import * as Commands from "./commands.ts";
10
+ import * as Config from "./config.ts";
11
+ import * as Keyboard from "./keyboard.ts";
12
+ import * as Lifecycle from "./lifecycle.ts";
13
+ import * as Locks from "./locks.ts";
14
+ import * as Model from "./model.ts";
15
+ import * as OutboundAttachments from "./outbound-attachments.ts";
16
+ import * as OutboundHandlers from "./outbound.ts";
17
+ import * as Pi from "./pi.ts";
18
+ import * as Preview from "./preview.ts";
19
+ import * as Prompts from "./prompts.ts";
20
+ import * as Queue from "./queue.ts";
21
+ import * as Replies from "./replies.ts";
22
+ import * as Runtime from "./runtime.ts";
23
+ import * as Setup from "./setup.ts";
24
+ import * as Status from "./status.ts";
25
+
26
+ type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
27
+
28
+ type TelegramRuntimeEventRecorder = (
29
+ category: string,
30
+ error: unknown,
31
+ details?: Record<string, unknown>,
32
+ ) => void;
33
+
34
+ type TelegramBridgeStatusUpdater =
35
+ Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
36
+
37
+ interface TelegramCommandsAndToolsBindingDeps {
38
+ pi: Pi.ExtensionAPI;
39
+ configStore: Config.TelegramConfigStore;
40
+ setup: Setup.TelegramSetupGuard;
41
+ activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
42
+ lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
43
+ getStatusLines: () => string[];
44
+ updateStatus: TelegramBridgeStatusUpdater;
45
+ recordRuntimeEvent: TelegramRuntimeEventRecorder;
46
+ }
47
+
48
+ export function registerTelegramCommandsAndTools({
49
+ pi,
50
+ configStore,
51
+ setup,
52
+ activeTurnRuntime,
53
+ lockedPollingRuntime,
54
+ getStatusLines,
55
+ updateStatus,
56
+ recordRuntimeEvent,
57
+ }: TelegramCommandsAndToolsBindingDeps): void {
58
+ OutboundAttachments.registerTelegramOutboundAttachmentTool(pi, {
59
+ getActiveTurn: activeTurnRuntime.get,
60
+ recordRuntimeEvent,
61
+ });
62
+
63
+ Commands.registerTelegramBridgeCommands(pi, {
64
+ promptForConfig: Setup.createTelegramSetupPromptRuntime({
65
+ getConfig: configStore.get,
66
+ setConfig: configStore.set,
67
+ setupGuard: setup,
68
+ getMe: Api.fetchTelegramBotIdentity,
69
+ persistConfig: configStore.persist,
70
+ startPolling: lockedPollingRuntime.start,
71
+ updateStatus,
72
+ recordRuntimeEvent,
73
+ }),
74
+ getStatusLines,
75
+ reloadConfig: configStore.load,
76
+ hasBotToken: configStore.hasBotToken,
77
+ startPolling: lockedPollingRuntime.start,
78
+ stopPolling: lockedPollingRuntime.stop,
79
+ updateStatus,
80
+ });
81
+ }
82
+
83
+ interface TelegramLifecycleBindingDeps {
84
+ pi: Pi.ExtensionAPI;
85
+ sessionLifecycleRuntime: Pick<
86
+ Lifecycle.TelegramLifecycleRegistrationDeps,
87
+ "onSessionStart" | "onSessionShutdown" | "onModelSelect"
88
+ >;
89
+ queueSessionLifecycle: Pick<
90
+ Lifecycle.TelegramLifecycleRegistrationDeps,
91
+ "onSessionShutdown"
92
+ >;
93
+ configStore: Pick<Config.TelegramConfigStore, "getOutboundHandlers">;
94
+ abort: Runtime.TelegramRuntimeAbortPort;
95
+ typing: Runtime.TelegramRuntimeTypingPort;
96
+ lifecycle: Runtime.TelegramRuntimeLifecyclePort;
97
+ activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
98
+ telegramQueueStore: Queue.TelegramQueueStore<Pi.ExtensionContext>;
99
+ modelSwitchController: Model.TelegramModelSwitchController<
100
+ Pi.ExtensionContext,
101
+ Model.ScopedTelegramModel<ActivePiModel>
102
+ >;
103
+ previewRuntime: Preview.TelegramAssistantPreviewRuntime<
104
+ Pi.AgentEndEvent["messages"][number],
105
+ Keyboard.TelegramInlineKeyboardMarkup
106
+ >;
107
+ promptDispatchRuntime: Runtime.TelegramPromptDispatchRuntime<Pi.ExtensionContext>;
108
+ deferredQueueDispatchRuntime: Queue.TelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>;
109
+ lockOwnershipGuard: Pick<
110
+ Locks.TelegramLockOwnershipGuard<Pi.ExtensionContext>,
111
+ "ownsContext"
112
+ >;
113
+ buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
114
+ callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
115
+ sendChatAction: NonNullable<
116
+ OutboundHandlers.TelegramVoiceReplySenderDeps["sendChatAction"]
117
+ >;
118
+ sendRecordVoiceAction: NonNullable<
119
+ OutboundHandlers.TelegramVoiceReplySenderDeps["sendRecordVoiceAction"]
120
+ >;
121
+ sendMarkdownReply: Queue.TelegramAgentEndHookRuntimeDeps<
122
+ Queue.PendingTelegramTurn,
123
+ Pi.ExtensionContext,
124
+ Pi.AgentEndEvent["messages"][number],
125
+ Keyboard.TelegramInlineKeyboardMarkup
126
+ >["sendMarkdownReply"];
127
+ sendTextReply: Queue.TelegramAgentEndHookRuntimeDeps<
128
+ Queue.PendingTelegramTurn,
129
+ Pi.ExtensionContext,
130
+ Pi.AgentEndEvent["messages"][number],
131
+ Keyboard.TelegramInlineKeyboardMarkup
132
+ >["sendTextReply"] &
133
+ NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
134
+ dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
135
+ answerGuestQuery: NonNullable<
136
+ Queue.TelegramAgentEndHookRuntimeDeps<
137
+ Queue.PendingTelegramTurn,
138
+ Pi.ExtensionContext,
139
+ Pi.AgentEndEvent["messages"][number],
140
+ Keyboard.TelegramInlineKeyboardMarkup
141
+ >["answerGuestQuery"]
142
+ >;
143
+ sendGuestReply: NonNullable<
144
+ Queue.TelegramAgentEndHookRuntimeDeps<
145
+ Queue.PendingTelegramTurn,
146
+ Pi.ExtensionContext,
147
+ Pi.AgentEndEvent["messages"][number],
148
+ Keyboard.TelegramInlineKeyboardMarkup
149
+ >["sendGuestReply"]
150
+ >;
151
+ finalizeMarkdownPreview: Queue.TelegramAgentEndHookRuntimeDeps<
152
+ Queue.PendingTelegramTurn,
153
+ Pi.ExtensionContext,
154
+ Pi.AgentEndEvent["messages"][number],
155
+ Keyboard.TelegramInlineKeyboardMarkup
156
+ >["finalizeMarkdownPreview"];
157
+ proactivePushChatIdGetter: () => number | undefined;
158
+ isProactivePushEnabled: () => boolean;
159
+ updateStatus: TelegramBridgeStatusUpdater;
160
+ recordRuntimeEvent: TelegramRuntimeEventRecorder;
161
+ }
162
+
163
+ export function registerTelegramLifecycleRuntimeHooks({
164
+ pi,
165
+ sessionLifecycleRuntime,
166
+ queueSessionLifecycle,
167
+ configStore,
168
+ abort,
169
+ typing,
170
+ lifecycle,
171
+ activeTurnRuntime,
172
+ telegramQueueStore,
173
+ modelSwitchController,
174
+ previewRuntime,
175
+ promptDispatchRuntime,
176
+ deferredQueueDispatchRuntime,
177
+ lockOwnershipGuard,
178
+ buttonActionStore,
179
+ callMultipart,
180
+ sendChatAction,
181
+ sendRecordVoiceAction,
182
+ sendMarkdownReply,
183
+ sendTextReply,
184
+ dispatchNextQueuedTelegramTurn,
185
+ answerGuestQuery,
186
+ sendGuestReply,
187
+ finalizeMarkdownPreview,
188
+ proactivePushChatIdGetter,
189
+ isProactivePushEnabled,
190
+ updateStatus,
191
+ recordRuntimeEvent,
192
+ }: TelegramLifecycleBindingDeps): void {
193
+ const agentEndResetter = Runtime.createTelegramAgentEndResetter({
194
+ abort,
195
+ typing,
196
+ clearActiveTurn: activeTurnRuntime.clear,
197
+ resetToolExecutions: lifecycle.resetActiveToolExecutions,
198
+ clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
199
+ clearDispatchPending: lifecycle.clearDispatchPending,
200
+ });
201
+ const queuedAttachmentSender =
202
+ OutboundAttachments.createTelegramQueuedOutboundAttachmentSender({
203
+ sendMultipart: callMultipart,
204
+ sendTextReply,
205
+ recordRuntimeEvent,
206
+ });
207
+ const outboundReplyPlanner =
208
+ OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
209
+ const outboundReplyArtifactSender =
210
+ OutboundHandlers.createTelegramOutboundReplyArtifactSender({
211
+ execCommand: CommandTemplates.execCommandTemplate,
212
+ sendMultipart: callMultipart,
213
+ sendTextReply,
214
+ sendChatAction,
215
+ sendRecordVoiceAction,
216
+ getHandlers: configStore.getOutboundHandlers,
217
+ recordRuntimeEvent,
218
+ });
219
+ const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
220
+ Queue.PendingTelegramTurn,
221
+ Pi.ExtensionContext,
222
+ unknown,
223
+ Keyboard.TelegramInlineKeyboardMarkup
224
+ >({
225
+ setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(abort),
226
+ getQueuedItems: telegramQueueStore.getQueuedItems,
227
+ hasPendingDispatch: lifecycle.hasDispatchPending,
228
+ hasActiveTurn: activeTurnRuntime.has,
229
+ resetToolExecutions: lifecycle.resetActiveToolExecutions,
230
+ resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
231
+ setQueuedItems: telegramQueueStore.setQueuedItems,
232
+ clearDispatchPending: lifecycle.clearDispatchPending,
233
+ setActiveTurn: activeTurnRuntime.set,
234
+ createPreviewState: previewRuntime.resetState,
235
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
236
+ updateStatus,
237
+ getActiveTurn: activeTurnRuntime.get,
238
+ extractAssistant: Replies.extractLatestAssistantMessageText,
239
+ getPreserveQueuedTurnsAsHistory:
240
+ lifecycle.shouldPreserveQueuedTurnsAsHistory,
241
+ resetRuntimeState: agentEndResetter,
242
+ dispatchNextQueuedTelegramTurn,
243
+ requestDeferredDispatchNextQueuedTelegramTurn:
244
+ deferredQueueDispatchRuntime.request,
245
+ clearPreview: previewRuntime.clear,
246
+ setPreviewPendingText: previewRuntime.setPendingText,
247
+ finalizeMarkdownPreview,
248
+ sendMarkdownReply,
249
+ sendTextReply,
250
+ sendQueuedAttachments: queuedAttachmentSender,
251
+ answerGuestQuery,
252
+ sendGuestReply,
253
+ planOutboundReply: outboundReplyPlanner,
254
+ sendOutboundReplyArtifacts: outboundReplyArtifactSender,
255
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
256
+ getDefaultChatId: proactivePushChatIdGetter,
257
+ isProactivePushEnabled,
258
+ recordRuntimeEvent,
259
+ getActiveToolExecutions: lifecycle.getActiveToolExecutions,
260
+ setActiveToolExecutions: lifecycle.setActiveToolExecutions,
261
+ triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
262
+ });
263
+
264
+ Lifecycle.setResetTransportReplyDedup(Replies.resetTransportReplyDedup);
265
+ const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
266
+ agentLifecycleHooks.onAgentStart,
267
+ );
268
+ const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
269
+ setCompactionInProgress: lifecycle.setCompactionInProgress,
270
+ updateStatus,
271
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
272
+ stopTypingLoop: typing.stop,
273
+ requestDeferredDispatchNextQueuedTelegramTurn:
274
+ deferredQueueDispatchRuntime.request,
275
+ dispatchNextQueuedTelegramTurn,
276
+ recordRuntimeEvent,
277
+ });
278
+ const messageActivityTypingHooks =
279
+ Lifecycle.createTelegramMessageActivityTypingHooks({
280
+ hasActiveTurn: activeTurnRuntime.has,
281
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
282
+ onMessageStart: previewRuntime.onMessageStart,
283
+ onMessageUpdate: previewRuntime.onMessageUpdate,
284
+ });
285
+ Lifecycle.registerTelegramLifecycleHooks(pi, {
286
+ ...sessionLifecycleRuntime,
287
+ ...agentLifecycleHooks,
288
+ async onSessionShutdown(event, ctx) {
289
+ compactionObserver.onSessionShutdown();
290
+ await queueSessionLifecycle.onSessionShutdown(event, ctx);
291
+ },
292
+ onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
293
+ onSessionCompact: compactionObserver.onSessionCompact,
294
+ onAgentStart: agentStartWithDedupReset,
295
+ onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
296
+ isProactivePushEnabled,
297
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
298
+ }),
299
+ ...messageActivityTypingHooks,
300
+ });
301
+ }
@@ -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 {