@llblab/pi-telegram 0.13.1 → 0.14.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/lib/bindings.ts CHANGED
@@ -41,6 +41,16 @@ interface TelegramCommandsAndToolsBindingDeps {
41
41
  activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
42
42
  lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
43
43
  getStatusLines: () => string[];
44
+ buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
45
+ sendMarkdownReply: (
46
+ chatId: number,
47
+ replyToMessageId: number | undefined,
48
+ markdown: string,
49
+ options?: { replyMarkup?: unknown },
50
+ ) => Promise<number | undefined>;
51
+ callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
52
+ getDefaultChatId: () => number | undefined;
53
+ canSendDirect: () => boolean;
44
54
  updateStatus: TelegramBridgeStatusUpdater;
45
55
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
46
56
  }
@@ -52,11 +62,29 @@ export function registerTelegramCommandsAndTools({
52
62
  activeTurnRuntime,
53
63
  lockedPollingRuntime,
54
64
  getStatusLines,
65
+ buttonActionStore,
66
+ sendMarkdownReply,
67
+ callMultipart,
68
+ getDefaultChatId,
69
+ canSendDirect,
55
70
  updateStatus,
56
71
  recordRuntimeEvent,
57
72
  }: TelegramCommandsAndToolsBindingDeps): void {
58
73
  OutboundAttachments.registerTelegramOutboundAttachmentTool(pi, {
59
74
  getActiveTurn: activeTurnRuntime.get,
75
+ getDefaultChatId,
76
+ canSendDirect,
77
+ sendMultipart: callMultipart,
78
+ recordRuntimeEvent,
79
+ });
80
+ OutboundAttachments.registerTelegramOutboundMessageTool(pi, {
81
+ getDefaultChatId,
82
+ canSendDirect,
83
+ planMessage: OutboundHandlers.createTelegramOutboundReplyPlanner(
84
+ buttonActionStore,
85
+ ),
86
+ sendMarkdownMessage: (chatId, markdown, options) =>
87
+ sendMarkdownReply(chatId, undefined, markdown, options),
60
88
  recordRuntimeEvent,
61
89
  });
62
90
  Commands.registerTelegramBridgeCommands(pi, {
@@ -229,15 +257,17 @@ export function registerTelegramLifecycleRuntimeHooks({
229
257
  resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
230
258
  setQueuedItems: telegramQueueStore.setQueuedItems,
231
259
  clearDispatchPending: lifecycle.clearDispatchPending,
260
+ setFoldQueuedPromptsIntoHistory: lifecycle.setFoldQueuedPromptsIntoHistory,
232
261
  setActiveTurn: activeTurnRuntime.set,
233
262
  createPreviewState: previewRuntime.resetState,
234
263
  startTypingLoop: promptDispatchRuntime.startTypingLoop,
235
264
  updateStatus,
236
265
  getActiveTurn: activeTurnRuntime.get,
237
266
  extractAssistant: Replies.extractLatestAssistantMessageText,
238
- getPreserveQueuedTurnsAsHistory:
239
- lifecycle.shouldPreserveQueuedTurnsAsHistory,
267
+ getFoldQueuedPromptsIntoHistory:
268
+ lifecycle.shouldFoldQueuedPromptsIntoHistory,
240
269
  resetRuntimeState: agentEndResetter,
270
+ waitForTypingIdle: typing.waitForIdle,
241
271
  dispatchNextQueuedTelegramTurn,
242
272
  requestDeferredDispatchNextQueuedTelegramTurn:
243
273
  deferredQueueDispatchRuntime.request,
@@ -251,7 +281,6 @@ export function registerTelegramLifecycleRuntimeHooks({
251
281
  sendGuestReply,
252
282
  planOutboundReply: outboundReplyPlanner,
253
283
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
254
- isCurrentOwner: lockOwnershipGuard.ownsContext,
255
284
  getDefaultChatId: proactivePushChatIdGetter,
256
285
  isProactivePushEnabled,
257
286
  recordRuntimeEvent,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Command-template standard helpers
3
- * Zones: shared utils, local process execution, automation standard
4
- * Owns shell-free command-template splitting, placeholder defaults, composition expansion, executable path expansion, and direct execution
2
+ * Command-template execution standard.
3
+ * Zones: shell-free command parsing, placeholder expansion, local process execution, composition semantics
4
+ * Owns portable command-template parsing, expansion, risk checks, retries, timeouts, and direct execution.
5
5
  */
6
6
 
7
7
  import { spawn } from "node:child_process";
@@ -10,7 +10,16 @@ import { isAbsolute, resolve } from "node:path";
10
10
 
11
11
  export type CommandTemplateFailureScope = "continue" | "branch" | "root";
12
12
 
13
+ export interface CommandTemplateActorRecipeContext {
14
+ alias?: string;
15
+ file?: string;
16
+ name?: string;
17
+ path?: string;
18
+ role?: string;
19
+ }
20
+
13
21
  export interface CommandTemplateObjectConfig {
22
+ actorRecipeContext?: CommandTemplateActorRecipeContext;
14
23
  label?: string;
15
24
  parallel?: boolean;
16
25
  when?: boolean | string;
@@ -58,6 +67,29 @@ export interface CommandTemplateExecResult {
58
67
  killed: boolean;
59
68
  }
60
69
 
70
+ export type CommandTemplateRiskLabel =
71
+ | "risk.shell"
72
+ | "risk.eval"
73
+ | "risk.broad_fs_write"
74
+ | "risk.destructive_fs"
75
+ | "risk.network"
76
+ | "risk.external_side_effect"
77
+ | "risk.long_running"
78
+ | "risk.platform_specific"
79
+ | "risk.secret_touching";
80
+
81
+ const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
82
+ "risk.shell",
83
+ "risk.eval",
84
+ "risk.destructive_fs",
85
+ "risk.broad_fs_write",
86
+ "risk.external_side_effect",
87
+ "risk.secret_touching",
88
+ "risk.network",
89
+ "risk.long_running",
90
+ "risk.platform_specific",
91
+ ];
92
+
61
93
  export type CommandTemplateExecCommand = (
62
94
  command: string,
63
95
  args: string[],
@@ -148,8 +180,15 @@ function getExecutableName(command: string | undefined): string {
148
180
  return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
149
181
  }
150
182
 
183
+ function matchesFlag(arg: string, flag: string): boolean {
184
+ if (arg === flag) return true;
185
+ if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
186
+ return arg.slice(1).includes(flag.slice(1));
187
+ return false;
188
+ }
189
+
151
190
  function hasAnyFlag(args: string[], flags: string[]): boolean {
152
- return args.some((arg) => flags.includes(arg));
191
+ return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
153
192
  }
154
193
 
155
194
  function hasRiskyPathArg(args: string[]): boolean {
@@ -165,6 +204,97 @@ function hasRiskyPathArg(args: string[]): boolean {
165
204
  );
166
205
  }
167
206
 
207
+ function sortRiskLabels(
208
+ labels: Iterable<CommandTemplateRiskLabel>,
209
+ ): CommandTemplateRiskLabel[] {
210
+ const unique = new Set(labels);
211
+ return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
212
+ }
213
+
214
+ function hasAnyArg(args: string[], values: string[]): boolean {
215
+ return args.some((arg) => values.includes(arg.toLowerCase()));
216
+ }
217
+
218
+ function hasSecretTouchingText(parts: string[]): boolean {
219
+ return parts.some((part) =>
220
+ /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
221
+ part,
222
+ ),
223
+ );
224
+ }
225
+
226
+ function getLeafCommandTemplateRiskLabels(
227
+ config: CommandTemplateLeafConfig,
228
+ ): CommandTemplateRiskLabel[] {
229
+ const parts = splitCommandTemplate(config.template);
230
+ const command = getExecutableName(parts[0]);
231
+ const args = parts.slice(1);
232
+ const labels = new Set<CommandTemplateRiskLabel>();
233
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
234
+ labels.add("risk.shell");
235
+ if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
236
+ }
237
+ if (
238
+ ["node", "deno", "bun"].includes(command) &&
239
+ hasAnyFlag(args, ["-e", "--eval"])
240
+ ) {
241
+ labels.add("risk.eval");
242
+ }
243
+ if (
244
+ ["python", "python3", "perl", "ruby"].includes(command) &&
245
+ hasAnyFlag(args, ["-c", "-e"])
246
+ ) {
247
+ labels.add("risk.eval");
248
+ }
249
+ if (
250
+ command === "rm" &&
251
+ (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
252
+ hasRiskyPathArg(args))
253
+ ) {
254
+ labels.add("risk.destructive_fs");
255
+ }
256
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
257
+ labels.add("risk.broad_fs_write");
258
+ }
259
+ if (
260
+ ["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
261
+ command,
262
+ ) ||
263
+ (command === "git" &&
264
+ hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
265
+ ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
266
+ ) {
267
+ labels.add("risk.network");
268
+ }
269
+ if (
270
+ ["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
271
+ (command === "git" && hasAnyArg(args, ["push"])) ||
272
+ (["npm", "pnpm", "yarn"].includes(command) &&
273
+ hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
274
+ ) {
275
+ labels.add("risk.external_side_effect");
276
+ }
277
+ if (
278
+ command === "sleep" ||
279
+ command === "watch" ||
280
+ (command === "tail" && hasAnyFlag(args, ["-f"])) ||
281
+ hasAnyArg(args, ["--watch", "--serve", "serve"])
282
+ ) {
283
+ labels.add("risk.long_running");
284
+ }
285
+ if (
286
+ ["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
287
+ command,
288
+ )
289
+ ) {
290
+ labels.add("risk.platform_specific");
291
+ }
292
+ if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
293
+ labels.add("risk.secret_touching");
294
+ }
295
+ return sortRiskLabels(labels);
296
+ }
297
+
168
298
  function getLeafCommandTemplateWarnings(
169
299
  config: CommandTemplateLeafConfig,
170
300
  ): string[] {
@@ -177,7 +307,7 @@ function getLeafCommandTemplateWarnings(
177
307
  ? "shell command strings"
178
308
  : "shell scripts";
179
309
  warnings.push(
180
- `${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting.`,
310
+ `${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting. Mitigation: keep scripts local, reviewed, and parameterized with explicit placeholders.`,
181
311
  );
182
312
  }
183
313
  if (
@@ -185,7 +315,7 @@ function getLeafCommandTemplateWarnings(
185
315
  hasAnyFlag(args, ["-e", "--eval"])
186
316
  ) {
187
317
  warnings.push(
188
- `${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`,
318
+ `${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
189
319
  );
190
320
  }
191
321
  if (
@@ -193,7 +323,7 @@ function getLeafCommandTemplateWarnings(
193
323
  hasAnyFlag(args, ["-c", "-e"])
194
324
  ) {
195
325
  warnings.push(
196
- `${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`,
326
+ `${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`,
197
327
  );
198
328
  }
199
329
  if (
@@ -202,12 +332,12 @@ function getLeafCommandTemplateWarnings(
202
332
  hasRiskyPathArg(args))
203
333
  ) {
204
334
  warnings.push(
205
- `${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`,
335
+ `${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands. Mitigation: constrain path placeholders and consider dry-run or explicit confirmation.`,
206
336
  );
207
337
  }
208
338
  if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
209
339
  warnings.push(
210
- `${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`,
340
+ `${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands. Mitigation: constrain path placeholders and prefer narrow source/destination paths.`,
211
341
  );
212
342
  }
213
343
  return warnings;
@@ -331,6 +461,16 @@ export function getCommandTemplateWarnings(
331
461
  ];
332
462
  }
333
463
 
464
+ export function getCommandTemplateRiskLabels(
465
+ config: CommandTemplateConfig,
466
+ ): CommandTemplateRiskLabel[] {
467
+ return sortRiskLabels(
468
+ expandCommandTemplateConfigs(config).flatMap((leaf) =>
469
+ getLeafCommandTemplateRiskLabels(leaf),
470
+ ),
471
+ );
472
+ }
473
+
334
474
  function parseCommandTemplateArgToken(value: string): {
335
475
  name: string;
336
476
  defaultValue?: string;
@@ -541,7 +681,12 @@ function shouldResolveEmbeddedCommandTemplateToken(
541
681
  function isFalsyCommandTemplateValue(value: unknown): boolean {
542
682
  if (value === undefined || value === null || value === false) return true;
543
683
  const normalized = String(value).trim().toLowerCase();
544
- return normalized === "" || normalized === "0" || normalized === "false" || normalized === "no";
684
+ return (
685
+ normalized === "" ||
686
+ normalized === "0" ||
687
+ normalized === "false" ||
688
+ normalized === "no"
689
+ );
545
690
  }
546
691
 
547
692
  function resolveCommandTemplateCondition(
package/lib/commands.ts CHANGED
@@ -293,7 +293,7 @@ export interface TelegramStopCommandDeps {
293
293
  hasAbortHandler: () => boolean;
294
294
  clearPendingModelSwitch: () => void;
295
295
  clearQueuedTelegramItems: () => number;
296
- setPreserveQueuedTurnsAsHistory: (preserve: boolean) => void;
296
+ setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
297
297
  abortCurrentTurn: () => void;
298
298
  updateStatus: () => void;
299
299
  sendTextReply: (text: string) => Promise<void>;
@@ -582,7 +582,7 @@ export interface TelegramCommandRuntimeDeps<
582
582
  clearPendingModelSwitch: () => void;
583
583
  hasQueuedTelegramItems: () => boolean;
584
584
  clearQueuedTelegramItems: (ctx: TContext) => number;
585
- setPreserveQueuedTurnsAsHistory: (preserve: boolean) => void;
585
+ setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
586
586
  abortCurrentTurn: () => void;
587
587
  isIdle: (ctx: TContext) => boolean;
588
588
  hasPendingMessages: (ctx: TContext) => boolean;
@@ -727,7 +727,7 @@ export async function handleTelegramStopCommand(
727
727
  ): Promise<void> {
728
728
  deps.clearPendingModelSwitch();
729
729
  const clearedCount = deps.clearQueuedTelegramItems();
730
- deps.setPreserveQueuedTurnsAsHistory(false);
730
+ deps.setFoldQueuedPromptsIntoHistory(false);
731
731
  if (!deps.hasAbortHandler()) {
732
732
  const clearedSuffix =
733
733
  clearedCount > 0
@@ -748,9 +748,10 @@ export async function handleTelegramStopCommand(
748
748
 
749
749
  export async function handleTelegramAbortCommand(deps: {
750
750
  hasAbortHandler: () => boolean;
751
+ hasActiveTelegramTurn: () => boolean;
751
752
  clearPendingModelSwitch: () => void;
752
753
  abortCurrentTurn: () => void;
753
- setPreserveForIdle: () => void;
754
+ setFoldQueuedPromptsIntoHistory: (fold: boolean) => void;
754
755
  updateStatus: () => void;
755
756
  sendTextReply: (text: string) => Promise<void>;
756
757
  }): Promise<void> {
@@ -759,7 +760,7 @@ export async function handleTelegramAbortCommand(deps: {
759
760
  await deps.sendTextReply("No active turn.");
760
761
  return;
761
762
  }
762
- deps.setPreserveForIdle();
763
+ deps.setFoldQueuedPromptsIntoHistory(deps.hasActiveTelegramTurn());
763
764
  deps.abortCurrentTurn();
764
765
  deps.updateStatus();
765
766
  await deps.sendTextReply("Aborted current turn.");
@@ -772,7 +773,7 @@ export async function handleTelegramNextCommand(deps: {
772
773
  clearPendingModelSwitch: () => void;
773
774
  abortCurrentTurn: () => void;
774
775
  dispatchNextQueuedTurn: () => void;
775
- setPreserveForDispatch: () => void;
776
+ clearFoldForDispatch: () => void;
776
777
  updateStatus: () => void;
777
778
  sendTextReply: (text: string) => Promise<void>;
778
779
  }): Promise<void> {
@@ -782,7 +783,7 @@ export async function handleTelegramNextCommand(deps: {
782
783
  return;
783
784
  }
784
785
  if (!deps.isIdle() && deps.hasAbortHandler()) {
785
- deps.setPreserveForDispatch();
786
+ deps.clearFoldForDispatch();
786
787
  deps.abortCurrentTurn();
787
788
  deps.updateStatus();
788
789
  await deps.sendTextReply(
@@ -1070,7 +1071,7 @@ export function createTelegramCommandHandlerTargetRuntime<
1070
1071
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1071
1072
  hasQueuedTelegramItems: deps.hasQueuedTelegramItems,
1072
1073
  clearQueuedTelegramItems: deps.clearQueuedTelegramItems,
1073
- setPreserveQueuedTurnsAsHistory: deps.setPreserveQueuedTurnsAsHistory,
1074
+ setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1074
1075
  abortCurrentTurn: deps.abortCurrentTurn,
1075
1076
  isIdle: deps.isIdle,
1076
1077
  hasPendingMessages: deps.hasPendingMessages,
@@ -1177,7 +1178,7 @@ async function handleTelegramCommandRuntime<
1177
1178
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1178
1179
  clearQueuedTelegramItems: () =>
1179
1180
  deps.clearQueuedTelegramItems(commandCtx),
1180
- setPreserveQueuedTurnsAsHistory: deps.setPreserveQueuedTurnsAsHistory,
1181
+ setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1181
1182
  abortCurrentTurn: deps.abortCurrentTurn,
1182
1183
  updateStatus: updateStatusFor(commandCtx),
1183
1184
  sendTextReply: sendReplyFor(nextMessage),
@@ -1186,9 +1187,10 @@ async function handleTelegramCommandRuntime<
1186
1187
  handleAbort: async (nextMessage, commandCtx) => {
1187
1188
  await handleTelegramAbortCommand({
1188
1189
  hasAbortHandler: deps.hasAbortHandler,
1190
+ hasActiveTelegramTurn: deps.hasActiveTelegramTurn,
1189
1191
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
1190
1192
  abortCurrentTurn: deps.abortCurrentTurn,
1191
- setPreserveForIdle: () => deps.setPreserveQueuedTurnsAsHistory(true),
1193
+ setFoldQueuedPromptsIntoHistory: deps.setFoldQueuedPromptsIntoHistory,
1192
1194
  updateStatus: updateStatusFor(commandCtx),
1193
1195
  sendTextReply: sendReplyFor(nextMessage),
1194
1196
  });
@@ -1202,8 +1204,8 @@ async function handleTelegramCommandRuntime<
1202
1204
  abortCurrentTurn: deps.abortCurrentTurn,
1203
1205
  dispatchNextQueuedTurn: () =>
1204
1206
  deps.dispatchNextQueuedTelegramTurn(commandCtx),
1205
- setPreserveForDispatch: () =>
1206
- deps.setPreserveQueuedTurnsAsHistory(false),
1207
+ clearFoldForDispatch: () =>
1208
+ deps.setFoldQueuedPromptsIntoHistory(false),
1207
1209
  updateStatus: updateStatusFor(commandCtx),
1208
1210
  sendTextReply: sendReplyFor(nextMessage),
1209
1211
  });
package/lib/config.ts CHANGED
@@ -84,6 +84,17 @@ export interface TelegramConfigStoreOptions {
84
84
  initialConfig?: TelegramConfig;
85
85
  agentDir?: string;
86
86
  configPath?: string;
87
+ recordRuntimeEvent?: (
88
+ category: string,
89
+ error: unknown,
90
+ details?: Record<string, unknown>,
91
+ ) => void;
92
+ }
93
+
94
+ export interface TelegramInvalidConfigRecovery {
95
+ configPath: string;
96
+ recoveryPath: string;
97
+ error: unknown;
87
98
  }
88
99
 
89
100
  export interface TelegramConfigRuntime {
@@ -125,12 +136,26 @@ export function bindGlobalTelegramConfigRuntime(
125
136
  });
126
137
  }
127
138
 
139
+ function getInvalidTelegramConfigRecoveryPath(configPath: string): string {
140
+ return `${configPath}.invalid-${process.pid}-${Date.now()}`;
141
+ }
142
+
128
143
  export async function readTelegramConfig(
129
144
  configPath: string,
145
+ options: {
146
+ onInvalidConfig?: (recovery: TelegramInvalidConfigRecovery) => void;
147
+ } = {},
130
148
  ): Promise<TelegramConfig> {
131
149
  if (!existsSync(configPath)) return {};
132
150
  const content = await readFile(configPath, "utf8");
133
- return JSON.parse(content) as TelegramConfig;
151
+ try {
152
+ return JSON.parse(content) as TelegramConfig;
153
+ } catch (error) {
154
+ const recoveryPath = getInvalidTelegramConfigRecoveryPath(configPath);
155
+ await rename(configPath, recoveryPath);
156
+ options.onInvalidConfig?.({ configPath, recoveryPath, error });
157
+ return {};
158
+ }
134
159
  }
135
160
 
136
161
  export async function writeTelegramConfig(
@@ -176,7 +201,15 @@ export function createTelegramConfigStore(
176
201
  config.allowedUserId = userId;
177
202
  },
178
203
  load: async () => {
179
- config = await readTelegramConfig(configPath);
204
+ config = await readTelegramConfig(configPath, {
205
+ onInvalidConfig: (recovery) => {
206
+ options.recordRuntimeEvent?.("config", recovery.error, {
207
+ phase: "load",
208
+ configPath: recovery.configPath,
209
+ recoveryPath: recovery.recoveryPath,
210
+ });
211
+ },
212
+ });
180
213
  },
181
214
  persist: async (nextConfig = config) => {
182
215
  await writeTelegramConfig(agentDir, configPath, nextConfig);
package/lib/inbound.ts CHANGED
@@ -19,6 +19,8 @@ import { getTelegramVoiceTranscriptionProviders } from "./voice.ts";
19
19
 
20
20
  const DEFAULT_INBOUND_HANDLER_TIMEOUT_MS = 120_000;
21
21
  const INBOUND_HANDLER_REGISTRY_KEY = "__piTelegramInboundHandlers__";
22
+ const MAX_INBOUND_HANDLER_OUTPUT_LENGTH = 12_000;
23
+ const MAX_INBOUND_HANDLER_FAILURE_STREAM_LENGTH = 4_000;
22
24
 
23
25
  type TelegramInboundCommandTemplateConfig =
24
26
  | string
@@ -187,12 +189,28 @@ export function clearTelegramInboundHandlers(): void {
187
189
  getOrCreateInboundHandlerRegistry().handlers.clear();
188
190
  }
189
191
 
192
+ function truncateTelegramInboundText(text: string, maxLength: number): string {
193
+ if (text.length <= maxLength) return text;
194
+ return `${text.slice(0, maxLength).trimEnd()}… [truncated ${text.length - maxLength} chars]`;
195
+ }
196
+
197
+ function truncateTelegramInboundOutput(text: string): string {
198
+ return truncateTelegramInboundText(text, MAX_INBOUND_HANDLER_OUTPUT_LENGTH);
199
+ }
200
+
201
+ function truncateTelegramInboundFailureStream(text: string): string {
202
+ return truncateTelegramInboundText(
203
+ text.trimEnd(),
204
+ MAX_INBOUND_HANDLER_FAILURE_STREAM_LENGTH,
205
+ );
206
+ }
207
+
190
208
  function normalizeInboundProgrammaticHandlerText(
191
209
  result: TelegramInboundProgrammaticHandlerResult,
192
210
  ): string | undefined {
193
211
  const text = typeof result === "string" ? result : result?.text;
194
212
  const normalized = text?.trim();
195
- return normalized || undefined;
213
+ return normalized ? truncateTelegramInboundOutput(normalized) : undefined;
196
214
  }
197
215
 
198
216
  function normalizeStringList(value: string | string[] | undefined): string[] {
@@ -401,8 +419,10 @@ function formatTelegramInboundHandlerFailure(
401
419
  const parts = [
402
420
  `Inbound handler exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
403
421
  ];
404
- if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
405
- if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
422
+ if (result.stderr.trim())
423
+ parts.push(`stderr:\n${truncateTelegramInboundFailureStream(result.stderr)}`);
424
+ if (result.stdout.trim())
425
+ parts.push(`stdout:\n${truncateTelegramInboundFailureStream(result.stdout)}`);
406
426
  return parts.join("\n\n");
407
427
  }
408
428
 
@@ -431,7 +451,7 @@ async function executeTelegramInboundHandlerInvocation(
431
451
  });
432
452
  if (result.code !== 0)
433
453
  throw new Error(formatTelegramInboundHandlerFailure(result));
434
- return result.stdout;
454
+ return truncateTelegramInboundOutput(result.stdout);
435
455
  }
436
456
 
437
457
  function getTelegramInboundHandlerCompositionSteps(
@@ -504,7 +524,7 @@ async function executeTelegramTextHandlerInvocation(
504
524
  });
505
525
  if (result.code !== 0)
506
526
  throw new Error(formatTelegramInboundHandlerFailure(result));
507
- return result.stdout;
527
+ return truncateTelegramInboundOutput(result.stdout);
508
528
  }
509
529
 
510
530
  async function executeTelegramTextHandler(
@@ -633,7 +653,7 @@ async function transcribeTelegramVoiceFileWithProviders(
633
653
  try {
634
654
  const result = await provider(file, {});
635
655
  const text = typeof result === "string" ? result : result?.text;
636
- if (text?.trim()) return text.trim();
656
+ if (text?.trim()) return truncateTelegramInboundOutput(text.trim());
637
657
  } catch (error) {
638
658
  options.recordRuntimeEvent?.("voice-transcription-provider", error, {
639
659
  fileName: file.fileName || basename(file.path),
@@ -656,7 +676,7 @@ async function readBuiltInTelegramTextAttachment(
656
676
  return undefined;
657
677
  }
658
678
  const name = file.fileName || basename(file.path);
659
- return `[${name}]\n${normalized}`;
679
+ return truncateTelegramInboundOutput(`[${name}]\n${normalized}`);
660
680
  }
661
681
 
662
682
  async function executeTelegramInboundHandler(
package/lib/lifecycle.ts CHANGED
@@ -102,6 +102,40 @@ export interface TelegramSessionLifecycleHooks {
102
102
  ) => Promise<void>;
103
103
  }
104
104
 
105
+ export interface TelegramSessionContextStore<TContext> {
106
+ get: () => TContext | undefined;
107
+ set: (ctx: TContext) => void;
108
+ clear: () => void;
109
+ }
110
+
111
+ export function createTelegramSessionContextStore<
112
+ TContext,
113
+ >(): TelegramSessionContextStore<TContext> {
114
+ let currentContext: TContext | undefined;
115
+ return {
116
+ get: () => currentContext,
117
+ set: (ctx) => {
118
+ currentContext = ctx;
119
+ },
120
+ clear: () => {
121
+ currentContext = undefined;
122
+ },
123
+ };
124
+ }
125
+
126
+ export function createTelegramSessionContextTracker(
127
+ store: Pick<TelegramSessionContextStore<ExtensionContext>, "set" | "clear">,
128
+ ): TelegramSessionLifecycleHooks {
129
+ return {
130
+ onSessionStart: async (_event, ctx) => {
131
+ store.set(ctx);
132
+ },
133
+ onSessionShutdown: async () => {
134
+ store.clear();
135
+ },
136
+ };
137
+ }
138
+
105
139
  type TelegramLifecycleTimer = number | ReturnType<typeof setTimeout>;
106
140
 
107
141
  export interface TelegramCompactionObserverRuntimeDeps<TContext> {
package/lib/locks.ts CHANGED
@@ -62,10 +62,13 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
62
62
  }
63
63
 
64
64
  export interface TelegramLockOwnershipGuard<TContext extends TelegramLockContext> {
65
- ownsCurrentProcess: () => boolean;
66
65
  ownsContext: (ctx: TContext) => boolean;
67
66
  }
68
67
 
68
+ export interface TelegramLockContextStore<TContext extends TelegramLockContext> {
69
+ get: () => TContext | undefined;
70
+ }
71
+
69
72
  export interface TelegramLockRuntimeOptions {
70
73
  key?: string;
71
74
  locksPath?: string;
@@ -248,11 +251,22 @@ export function createTelegramLockOwnershipGuard<
248
251
  lock: TelegramLockRuntime<TContext>,
249
252
  ): TelegramLockOwnershipGuard<TContext> {
250
253
  return {
251
- ownsCurrentProcess: () => lock.owns(),
252
254
  ownsContext: (ctx) => lock.owns(ctx),
253
255
  };
254
256
  }
255
257
 
258
+ export function createTelegramDirectDeliveryOwnershipChecker<
259
+ TContext extends TelegramLockContext,
260
+ >(deps: {
261
+ lock: TelegramLockRuntime<TContext>;
262
+ contextStore: TelegramLockContextStore<TContext>;
263
+ }): () => boolean {
264
+ return () => {
265
+ const ctx = deps.contextStore.get();
266
+ return ctx ? deps.lock.owns(ctx) : false;
267
+ };
268
+ }
269
+
256
270
  export function createTelegramLockedPollingRuntime<
257
271
  TContext extends TelegramLockContext,
258
272
  >(