@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.
- package/AGENTS.md +17 -12
- package/BACKLOG.md +0 -10
- package/CHANGELOG.md +33 -2
- package/README.md +42 -25
- package/api/inbound.ts +14 -0
- package/api/keyboard.ts +10 -0
- package/api/outbound.ts +11 -0
- package/api/sections.ts +17 -0
- package/api/updates.ts +11 -0
- package/api/voice.ts +24 -0
- package/docs/README.md +7 -5
- package/docs/architecture.md +176 -135
- package/docs/callback-namespaces.md +3 -3
- package/docs/command-templates.md +81 -24
- package/docs/{inbound-handlers.md → inbound.md} +13 -10
- package/docs/locks.md +3 -3
- package/docs/{outbound-handlers.md → outbound.md} +13 -10
- package/docs/public-api.md +266 -0
- package/docs/{extension-sections.md → sections.md} +31 -27
- package/docs/ui-style.md +165 -0
- package/docs/{external-handlers.md → updates.md} +33 -31
- package/docs/voice.md +17 -14
- package/index.ts +86 -261
- package/lib/bindings.ts +301 -0
- package/lib/command-templates.ts +163 -32
- package/lib/commands.ts +114 -1
- package/lib/config.ts +45 -4
- package/lib/{inbound-handlers.ts → inbound.ts} +5 -4
- package/lib/lifecycle.ts +122 -1
- package/lib/menu-model.ts +3 -3
- package/lib/menu-queue.ts +1 -1
- package/lib/menu-settings.ts +63 -32
- package/lib/menu-status.ts +1 -1
- package/lib/menu.ts +1 -1
- package/lib/{outbound-handlers.ts → outbound.ts} +21 -11
- package/lib/pi.ts +4 -0
- package/lib/polling.ts +4 -3
- package/lib/preview.ts +1 -1
- package/lib/routing.ts +45 -13
- package/lib/{extension-sections.ts → sections.ts} +37 -8
- package/lib/time-injection.ts +1 -1
- package/lib/updates.ts +121 -1
- package/lib/voice.ts +33 -14
- package/package.json +11 -1
- package/lib/external-handlers.ts +0 -166
package/lib/bindings.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/command-templates.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
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 =
|
|
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
|
|
169
|
-
|
|
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
|
|
194
|
-
if (!name ||
|
|
195
|
-
defaults[name
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
335
|
-
if (
|
|
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,
|
|
561
|
+
values: Record<string, unknown>,
|
|
431
562
|
cwd: string,
|
|
432
563
|
options: { emptyMessage?: string; missingLabel?: string } = {},
|
|
433
564
|
): CommandTemplateInvocation {
|