@llblab/pi-telegram 0.24.0 → 0.24.1

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.
@@ -105,6 +105,8 @@ Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures sho
105
105
 
106
106
  Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
107
107
 
108
+ `telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers, while `lastUpdateId` additionally merges monotonically. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
109
+
108
110
  ### Setup Flow
109
111
 
110
112
  `/telegram-setup` progressively resolves the bot token:
@@ -123,9 +125,19 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identit
123
125
  - Session replacement suspends polling/watchers without releasing ownership so the next session in the same process can resume. A registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread.
124
126
  - Live external owners require explicit takeover confirmation. Long-lived timers compare against snapshotted owner identity and stop local transport work when the slot no longer matches.
125
127
  - `owners.json` owns only Telegram transport control. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and delayed Bot API work fail closed until exact direct or follower authority becomes valid again.
126
- - Every acquisition, refresh, release, takeover, and stale recovery serializes through the sibling `owners.json.transaction` guard. The guard publishes one private generation-named owner record atomically, validates filename/payload generation agreement, fences stale recovery and delayed release against replacement-owner ABA, and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. The JSON store publishes through a private same-directory temporary file and atomic rename; atomic payload replacement does not replace transaction serialization.
128
+ - Exact ownership remains checked every second, while the durable owner heartbeat refresh runs every two seconds and becomes stale after eight seconds. This keeps replacement detection responsive while halving steady-state atomic `owners.json` rewrites without changing the cross-platform file-transaction authority. Every acquisition, refresh, release, takeover, and stale recovery serializes through the sibling `owners.json.transaction` guard. The guard publishes one private generation-named owner record atomically, validates filename/payload generation agreement, fences stale recovery and delayed release against replacement-owner ABA, and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. The JSON store publishes through a private same-directory temporary file and atomic rename; atomic payload replacement does not replace transaction serialization.
127
129
  - `owners.json` is authoritative and private. `state.json` remains an observable snapshot, `logs.jsonl` remains diagnostics, and followers remain authenticated bus registrations rather than ownership-file writers.
128
130
 
131
+ ### Persistence I/O Baseline
132
+
133
+ The three runtime files have different authority and write pressure. Preserve that distinction when optimizing them:
134
+
135
+ - `owners.json` is safety-critical transport authority. Acquire, release, takeover, stale recovery, and two-second leader lease refresh mutate it. The steady-state baseline is one cached atomic rewrite every two seconds per active profile, or 43,200 refreshes/day; one-second ownership checks are read-only. Every mutation serializes the full cross-process read/check/write through `owners.json.transaction`.
136
+ - `state.json` combines recovery-critical thread/capability state with observational runtime projections. Every explicit thread-store `persist()` builds a semantic snapshot, but an unchanged payload skips temporary-file creation and rename after ignoring `writtenAtMs`; changed snapshots retain the full atomic replacement path. Diagnostics scheduling coalesces requests across a bounded 100 ms window. Only the exact transport owner commits; non-owners reload current disk state instead of publishing.
137
+ - `logs.jsonl` is fail-soft observational evidence, never routing authority. Runtime events admitted in one JavaScript turn batch by captured profile path into one size check, one profile-wide file transaction, and one append while preserving event order. Batching adds no timer or shutdown-loss window; separate profiles remain isolated, and one failed group does not drop another. Scope reset and rotation retain their serialized copy/replace path. The 5 MiB value is a rotation threshold: an authorized writer rotates between batched records before the next record crosses it, so overshoot is bounded to one admitted record plus reset metadata; a writer without reset authority defers rotation to the owner.
138
+
139
+ This baseline counts write-producing code paths rather than filesystem implementation details that vary between ext4, APFS, NTFS, and network-backed home directories. Optimization evidence should compare these deterministic triggers first, then use platform smoke evidence for rename, named-pipe, crash, and cleanup behavior. Recovery-critical `state.json` fields are `bot`, `identities`, `reservations`, `pendingProvisions`, `syncObservations`, and `threads`; `runtime`, `liveRoster`, `diagnostics`, and `writtenAtMs` are observational and may use bounded coalescing when authority checks remain unchanged.
140
+
129
141
  Version `0.24.0` intentionally does not read or migrate the former agent-level `locks.json`; upgrading resets Telegram ownership. Run `/telegram-connect` when a fresh owner is not elected automatically. Delete `~/.pi/agent/tmp/telegram/owners.json` and its stale transaction guard, when no owner process is live, to reset only pi-telegram transport ownership without deleting configuration, diagnostics, or other extensions' state.
130
142
 
131
143
  ### Threaded Mode Multi-Instance Bus
@@ -147,7 +147,7 @@ A registered instance exposes:
147
147
 
148
148
  ## Leader Election
149
149
 
150
- Leader election is heartbeat-gated and lock-backed:
150
+ Leader election is heartbeat-gated and lock-backed. The polling owner checks exact lock ownership every second, refreshes its durable lease every two seconds, and becomes stale after eight seconds; the serialized expected-owner transaction remains the final cross-platform election authority.
151
151
 
152
152
  1. On startup, read the Telegram lock.
153
153
  2. If no leader exists, acquire leadership and start polling.
@@ -161,9 +161,9 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
161
161
 
162
162
  Implemented transport:
163
163
 
164
- ### Local IPC endpoint under agent temp dir
164
+ ### Local IPC endpoint with bounded native paths
165
165
 
166
- Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. On Unix, each server listens on a private generation socket and atomically publishes the stable profile path as a relative symlink; delayed shutdown closes only its private path and cannot remove a replacement generation's link. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
166
+ Leader opens a local Node `net` endpoint: a Unix-domain socket under the agent temp directory on Unix-like platforms, or a deterministic Windows named pipe (`\\.\pipe\pi-telegram-...`) on native Windows. A filesystem-style endpoint supplied by legacy state or a transport harness normalizes deterministically to the same Windows pipe boundary before listen/connect. On Unix, an endpoint that would exceed conservative domain-socket pathname limits maps to a private user-scoped, hash-derived path under the OS temp directory; ordinary agent paths remain under `tmp/telegram`. On Unix, each server listens on a private generation socket and atomically publishes the stable profile path as a relative symlink; delayed shutdown closes only its private path and cannot remove a replacement generation's link. Followers register, heartbeat, and exchange routed events. The transport boundary owns endpoint derivation, socket-vs-pipe detection, bounded operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, and request-scoped transport events. Follower registration uses a longer registration-specific response timeout than ordinary heartbeat/forwarding calls because the leader may need to provision a Telegram thread before it can return the assigned target; timing out that handshake leaves a visible tab with no follower heartbeat. Keep this handshake to the true critical path: create/reuse the target, persist the live binding, and return it. Connected notices and replaced-thread reconciliation cleanup are non-critical and should run after registration so a follower becomes routable before Telegram client/server UI convergence work finishes.
167
167
 
168
168
  Pros:
169
169
 
@@ -109,6 +109,8 @@ interface TelegramConfig {
109
109
 
110
110
  Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, and `time` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
111
111
 
112
+ The file is global across Pi instances. Cooperating instances serialize recursive delta merges through `telegram.json.transaction`, preserve unrelated global/profile changes from newer disk snapshots, and merge `lastUpdateId` monotonically. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
113
+
112
114
  Hidden/default semantics are represented by absence:
113
115
 
114
116
  - `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
@@ -35,21 +35,17 @@ export interface TelegramBusTransportRetryPolicyOverrides {
35
35
 
36
36
  export type TelegramBusTransportOperation = "registration" | "operation";
37
37
 
38
- export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy = {
39
- attempts: 10,
40
- delayMs: 150,
41
- };
38
+ export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy =
39
+ {
40
+ attempts: 10,
41
+ delayMs: 150,
42
+ };
42
43
 
43
44
  export const TELEGRAM_BUS_OPERATION_RETRY: TelegramBusTransportRetryPolicy = {
44
45
  attempts: 3,
45
46
  delayMs: 100,
46
47
  };
47
48
 
48
- export const TELEGRAM_BUS_PIPE_REGISTRATION_RETRY =
49
- TELEGRAM_BUS_REGISTRATION_RETRY;
50
-
51
- export const TELEGRAM_BUS_PIPE_OPERATION_RETRY = TELEGRAM_BUS_OPERATION_RETRY;
52
-
53
49
  export function getTelegramBusPipePath(input: {
54
50
  agentDir: string;
55
51
  scope: string;
@@ -200,13 +196,11 @@ export function createTelegramBusTransportTimeoutError(
200
196
 
201
197
  export function delayTelegramBusTransportRetry(ms: number): Promise<void> {
202
198
  return new Promise((resolve) => {
203
- const timer = setTimeout(resolve, ms);
204
- timer.unref?.();
199
+ setTimeout(resolve, ms);
205
200
  });
206
201
  }
207
202
 
208
- export interface TelegramBusTransportProbeResult
209
- extends TelegramBusTransportEndpointDiagnostics {
203
+ export interface TelegramBusTransportProbeResult extends TelegramBusTransportEndpointDiagnostics {
210
204
  reachable: boolean;
211
205
  error?: TelegramBusTransportErrorInfo;
212
206
  }
@@ -238,7 +232,6 @@ export function probeTelegramBusEndpoint(input: {
238
232
  ),
239
233
  });
240
234
  }, timeoutMs);
241
- timeout.unref?.();
242
235
  socket.once("connect", () => settle({ ...diagnostics, reachable: true }));
243
236
  socket.once("error", (error) =>
244
237
  settle({
package/lib/bus.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * cross-instance forwarding helpers, and the live follower registry model.
6
6
  */
7
7
 
8
- import { randomBytes } from "node:crypto";
8
+ import { createHash, randomBytes } from "node:crypto";
9
9
  import {
10
10
  chmodSync,
11
11
  existsSync,
@@ -23,7 +23,7 @@ import {
23
23
  type Server,
24
24
  type Socket,
25
25
  } from "node:net";
26
- import { platform as getPlatform } from "node:os";
26
+ import { platform as getPlatform, tmpdir } from "node:os";
27
27
  import { basename, dirname, join } from "node:path";
28
28
 
29
29
  import {
@@ -33,6 +33,7 @@ import {
33
33
  getTelegramBusEndpointDiagnostics,
34
34
  getTelegramBusFollowerEndpoint,
35
35
  getTelegramBusLeaderEndpoint,
36
+ getTelegramBusPipePath,
36
37
  getTelegramBusTransportRetryPolicy,
37
38
  isTelegramBusPipePath,
38
39
  isRetryableTelegramBusTransportError,
@@ -43,8 +44,6 @@ import {
43
44
  import type { TelegramTarget } from "./target.ts";
44
45
  import { resolveAgentDir } from "./paths.ts";
45
46
 
46
- export type TelegramBusRole = "leader" | "follower";
47
-
48
47
  export interface TelegramBusProcessRuntime {
49
48
  instanceId: string;
50
49
  manualFollowerOwnerId: string;
@@ -524,10 +523,38 @@ export interface TelegramBusLocalServer {
524
523
 
525
524
  export type TelegramBusSocketPathSource = string | (() => string);
526
525
 
526
+ const TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES = 80;
527
+
527
528
  export function resolveTelegramBusSocketPath(
528
529
  source: TelegramBusSocketPathSource,
530
+ platform: NodeJS.Platform | string = getPlatform(),
529
531
  ): string {
530
- return typeof source === "function" ? source() : source;
532
+ const endpoint = typeof source === "function" ? source() : source;
533
+ if (platform === "win32") {
534
+ if (isTelegramBusPipePath(endpoint)) return endpoint;
535
+ return getTelegramBusPipePath({
536
+ agentDir: dirname(endpoint),
537
+ scope: basename(endpoint),
538
+ });
539
+ }
540
+ const ownerScope = process.getuid?.() ?? "user";
541
+ const fallbackDir = join(tmpdir(), `pi-telegram-${ownerScope}`);
542
+ if (
543
+ dirname(endpoint) === fallbackDir &&
544
+ /^[0-9a-f]{16}\.sock$/u.test(basename(endpoint))
545
+ ) {
546
+ return endpoint;
547
+ }
548
+ if (
549
+ Buffer.byteLength(endpoint) <= TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES
550
+ ) {
551
+ return endpoint;
552
+ }
553
+ const digest = createHash("sha256")
554
+ .update(endpoint)
555
+ .digest("hex")
556
+ .slice(0, 16);
557
+ return join(fallbackDir, `${digest}.sock`);
531
558
  }
532
559
 
533
560
  export interface TelegramBusLocalServerDeps {
@@ -617,7 +644,7 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
617
644
  deps.recordRuntimeEvent?.(
618
645
  "bus",
619
646
  response?.kind === "bus.ack"
620
- ? response.message ?? "Follower rejected forwarded Telegram update."
647
+ ? (response.message ?? "Follower rejected forwarded Telegram update.")
621
648
  : "Follower returned no forwarding acknowledgement.",
622
649
  {
623
650
  phase: "foreign-update-forward-rejected",
@@ -885,7 +912,7 @@ export function createTelegramBusLocalServer(
885
912
  const endpointGeneration = randomBytes(8).toString("hex");
886
913
  const listenPath = usesWindowsPipe
887
914
  ? socketPath
888
- : join(dirname(socketPath), `.pi-telegram-${endpointGeneration}.sock`);
915
+ : join(dirname(socketPath), `.pt-${endpointGeneration}.sock`);
889
916
  activeSocketPath = socketPath;
890
917
  activeListenPath = listenPath;
891
918
  deps.recordTransportEvent?.(
@@ -1110,7 +1137,6 @@ function sendTelegramBusLocalEnvelopeOnce(
1110
1137
  ),
1111
1138
  );
1112
1139
  }, timeoutMs);
1113
- timeout.unref?.();
1114
1140
  socket.setEncoding("utf8");
1115
1141
  socket.once("connect", () => {
1116
1142
  socket.write(encodeTelegramBusEnvelope(options.envelope));
@@ -1130,16 +1156,20 @@ function sendTelegramBusLocalEnvelopeOnce(
1130
1156
  export async function sendTelegramBusLocalEnvelope(
1131
1157
  options: TelegramBusLocalClientOptions,
1132
1158
  ): Promise<TelegramBusEnvelope | undefined> {
1133
- const attempts = Math.max(1, options.retry?.attempts ?? 1);
1134
- const delayMs = Math.max(0, options.retry?.delayMs ?? 0);
1159
+ const resolvedOptions = {
1160
+ ...options,
1161
+ socketPath: resolveTelegramBusSocketPath(options.socketPath),
1162
+ };
1163
+ const attempts = Math.max(1, resolvedOptions.retry?.attempts ?? 1);
1164
+ const delayMs = Math.max(0, resolvedOptions.retry?.delayMs ?? 0);
1135
1165
  for (let attempt = 1; ; attempt += 1) {
1136
1166
  try {
1137
- return await sendTelegramBusLocalEnvelopeOnce(options);
1167
+ return await sendTelegramBusLocalEnvelopeOnce(resolvedOptions);
1138
1168
  } catch (error) {
1139
1169
  const info = classifyTelegramBusTransportError(error);
1140
- options.recordTransportEvent?.("client-failed", {
1141
- ...getTelegramBusEndpointDiagnostics(options.socketPath),
1142
- ...getTelegramBusEnvelopeDiagnostics(options.envelope),
1170
+ resolvedOptions.recordTransportEvent?.("client-failed", {
1171
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1172
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1143
1173
  attempt,
1144
1174
  attempts,
1145
1175
  ...info,
@@ -1147,9 +1177,9 @@ export async function sendTelegramBusLocalEnvelope(
1147
1177
  if (attempt >= attempts || !isRetryableTelegramBusTransportError(error)) {
1148
1178
  throw error;
1149
1179
  }
1150
- options.recordTransportEvent?.("client-retry", {
1151
- ...getTelegramBusEndpointDiagnostics(options.socketPath),
1152
- ...getTelegramBusEnvelopeDiagnostics(options.envelope),
1180
+ resolvedOptions.recordTransportEvent?.("client-retry", {
1181
+ ...getTelegramBusEndpointDiagnostics(resolvedOptions.socketPath),
1182
+ ...getTelegramBusEnvelopeDiagnostics(resolvedOptions.envelope),
1153
1183
  attempt,
1154
1184
  attempts,
1155
1185
  delayMs,
@@ -1408,8 +1438,7 @@ function parseForwardMessageEnvelope(
1408
1438
  (value.forwardCommentBatchPosition === "comment" ||
1409
1439
  value.forwardCommentBatchPosition === "forward")
1410
1440
  ? {
1411
- forwardCommentBatchPosition:
1412
- value.forwardCommentBatchPosition,
1441
+ forwardCommentBatchPosition: value.forwardCommentBatchPosition,
1413
1442
  }
1414
1443
  : {}),
1415
1444
  sentAtMs: value.sentAtMs,
@@ -36,9 +36,7 @@ export interface CommandTemplateObjectConfig {
36
36
  }
37
37
 
38
38
  export type CommandTemplateValue =
39
- | string
40
- | CommandTemplateConfig[]
41
- | CommandTemplateObjectConfig;
39
+ string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
42
40
 
43
41
  export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
44
42
 
@@ -90,12 +88,6 @@ const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
90
88
  "risk.platform_specific",
91
89
  ];
92
90
 
93
- export type CommandTemplateExecCommand = (
94
- command: string,
95
- args: string[],
96
- options?: CommandTemplateExecOptions,
97
- ) => Promise<CommandTemplateExecResult>;
98
-
99
91
  function normalizeCommandTemplateArgs(value: string[] | undefined): string[] {
100
92
  if (!Array.isArray(value)) return [];
101
93
  return value.map(String).map((item) => item.trim());
@@ -257,9 +249,18 @@ function getLeafCommandTemplateRiskLabels(
257
249
  labels.add("risk.broad_fs_write");
258
250
  }
259
251
  if (
260
- ["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
261
- command,
262
- ) ||
252
+ [
253
+ "curl",
254
+ "wget",
255
+ "ssh",
256
+ "scp",
257
+ "sftp",
258
+ "rsync",
259
+ "nc",
260
+ "ncat",
261
+ "telnet",
262
+ "ftp",
263
+ ].includes(command) ||
263
264
  (command === "git" &&
264
265
  hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
265
266
  ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
@@ -283,13 +284,32 @@ function getLeafCommandTemplateRiskLabels(
283
284
  labels.add("risk.long_running");
284
285
  }
285
286
  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
- )
287
+ [
288
+ "systemctl",
289
+ "launchctl",
290
+ "osascript",
291
+ "open",
292
+ "xdg-open",
293
+ "powershell",
294
+ "pwsh",
295
+ "cmd.exe",
296
+ "apt",
297
+ "apt-get",
298
+ "dnf",
299
+ "yum",
300
+ "brew",
301
+ "pacman",
302
+ "apk",
303
+ "xclip",
304
+ "wl-copy",
305
+ ].includes(command)
289
306
  ) {
290
307
  labels.add("risk.platform_specific");
291
308
  }
292
- if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
309
+ if (
310
+ ["pass", "gpg", "ssh-add"].includes(command) ||
311
+ hasSecretTouchingText(parts)
312
+ ) {
293
313
  labels.add("risk.secret_touching");
294
314
  }
295
315
  return sortRiskLabels(labels);
@@ -347,10 +367,6 @@ function pad(value: number, width: number): string {
347
367
  return String(value).padStart(width, "0");
348
368
  }
349
369
 
350
- export function isCommandTemplateRepeatPlaceholder(name: string): boolean {
351
- return /^_{0,6}(?:index|prev|next|repeat)$/.test(name);
352
- }
353
-
354
370
  export function getCommandTemplateRepeatDefaults(
355
371
  index: number,
356
372
  repeat: number,
package/lib/config.ts CHANGED
@@ -119,32 +119,6 @@ export function isValidTelegramProfileName(name: string): boolean {
119
119
  );
120
120
  }
121
121
 
122
- /**
123
- * Resolve bot/session identity from the canonical default or named profile.
124
- * Shared bridge settings always remain top-level.
125
- */
126
- export function resolveTelegramActiveProfile(
127
- config: TelegramConfig,
128
- profileName?: string,
129
- ): {
130
- botToken?: string;
131
- botUsername?: string;
132
- botId?: number;
133
- allowedUserId?: number;
134
- lastUpdateId?: number;
135
- } {
136
- const effectiveProfileName = profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
137
- const profile = config.profiles?.[effectiveProfileName];
138
- if (!profile) return {};
139
- return {
140
- botToken: profile.botToken,
141
- botUsername: profile.botUsername,
142
- botId: profile.botId,
143
- allowedUserId: profile.allowedUserId,
144
- lastUpdateId: profile.lastUpdateId,
145
- };
146
- }
147
-
148
122
  /** List defined profile names. */
149
123
  export function getTelegramProfileNames(config: TelegramConfig): string[] {
150
124
  return Object.keys(config.profiles ?? {}).sort();
@@ -560,10 +534,7 @@ export function createTelegramConfigStore(
560
534
  !profileName || profileName === TELEGRAM_DEFAULT_PROFILE_NAME
561
535
  ? undefined
562
536
  : profileName;
563
- if (
564
- normalizedProfileName &&
565
- !config.profiles?.[normalizedProfileName]
566
- ) {
537
+ if (normalizedProfileName && !config.profiles?.[normalizedProfileName]) {
567
538
  return false;
568
539
  }
569
540
  activeProfileName = normalizedProfileName;
@@ -607,9 +578,8 @@ export function createTelegramConfigStore(
607
578
  config = normalized.changed
608
579
  ? withTelegramFileTransaction(`${configPath}.transaction`, () => {
609
580
  const latestConfig = readTelegramConfigForTransaction(configPath);
610
- const latestNormalized = normalizeTelegramDefaultProfileConfig(
611
- latestConfig,
612
- );
581
+ const latestNormalized =
582
+ normalizeTelegramDefaultProfileConfig(latestConfig);
613
583
  if (latestNormalized.changed) {
614
584
  writeTelegramConfigInTransaction(
615
585
  agentDir,
@@ -642,7 +612,9 @@ export function createTelegramConfigStore(
642
612
  desiredConfig as Record<string, unknown>,
643
613
  latestConfig as Record<string, unknown>,
644
614
  ) as TelegramConfig;
645
- writeTelegramConfigInTransaction(agentDir, configPath, merged);
615
+ if (!configValuesEqual(latestConfig, merged)) {
616
+ writeTelegramConfigInTransaction(agentDir, configPath, merged);
617
+ }
646
618
  return merged;
647
619
  },
648
620
  );
package/lib/inbound.ts CHANGED
@@ -23,8 +23,7 @@ const MAX_INBOUND_HANDLER_OUTPUT_LENGTH = 12_000;
23
23
  const MAX_INBOUND_HANDLER_FAILURE_STREAM_LENGTH = 4_000;
24
24
 
25
25
  type TelegramInboundCommandTemplateConfig =
26
- | string
27
- | CommandTemplateObjectConfig;
26
+ string | CommandTemplateObjectConfig;
28
27
 
29
28
  export interface TelegramInboundHandlerConfig {
30
29
  match?: string | string[];
@@ -74,10 +73,6 @@ export interface TelegramInboundHandlerExecResult {
74
73
  killed: boolean;
75
74
  }
76
75
 
77
- export interface TelegramInboundHandlerRuntimeContext {
78
- cwd: string;
79
- }
80
-
81
76
  export interface TelegramInboundHandlerRuntimeDeps<TContext> {
82
77
  getHandlers: () => TelegramInboundHandlerConfig[] | undefined;
83
78
  execCommand: (
@@ -102,9 +97,7 @@ export interface TelegramInboundHandlerRuntime<TContext> {
102
97
  }
103
98
 
104
99
  export type TelegramInboundProgrammaticHandlerResult =
105
- | string
106
- | { text: string }
107
- | undefined;
100
+ string | { text: string } | undefined;
108
101
 
109
102
  export interface TelegramInboundProgrammaticHandlerInput {
110
103
  kind: string;
@@ -179,12 +172,6 @@ export function getTelegramInboundProgrammaticHandlers(
179
172
  ];
180
173
  }
181
174
 
182
- export function hasTelegramInboundHandler(kind?: string): boolean {
183
- const registry = getOrCreateInboundHandlerRegistry();
184
- if (kind !== undefined) return (registry.handlers.get(kind)?.length ?? 0) > 0;
185
- return Array.from(registry.handlers.values()).some((list) => list.length > 0);
186
- }
187
-
188
175
  export function clearTelegramInboundHandlers(): void {
189
176
  getOrCreateInboundHandlerRegistry().handlers.clear();
190
177
  }
@@ -281,13 +268,6 @@ export function findTelegramInboundHandlers(
281
268
  );
282
269
  }
283
270
 
284
- export function findTelegramInboundHandler(
285
- handlers: TelegramInboundHandlerConfig[] | undefined,
286
- file: TelegramInboundHandlerFile,
287
- ): TelegramInboundHandlerConfig | undefined {
288
- return findTelegramInboundHandlers(handlers, file)[0];
289
- }
290
-
291
271
  function hasInboundFilePlaceholder(value: string): boolean {
292
272
  return /\{file\}/.test(value);
293
273
  }
@@ -420,9 +400,13 @@ function formatTelegramInboundHandlerFailure(
420
400
  `Inbound handler exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
421
401
  ];
422
402
  if (result.stderr.trim())
423
- parts.push(`stderr:\n${truncateTelegramInboundFailureStream(result.stderr)}`);
403
+ parts.push(
404
+ `stderr:\n${truncateTelegramInboundFailureStream(result.stderr)}`,
405
+ );
424
406
  if (result.stdout.trim())
425
- parts.push(`stdout:\n${truncateTelegramInboundFailureStream(result.stdout)}`);
407
+ parts.push(
408
+ `stdout:\n${truncateTelegramInboundFailureStream(result.stdout)}`,
409
+ );
426
410
  return parts.join("\n\n");
427
411
  }
428
412
 
@@ -445,7 +429,13 @@ async function executeTelegramInboundHandlerInvocation(
445
429
  cwd,
446
430
  timeout,
447
431
  ...(typeof handler === "object" && handler.retry !== undefined
448
- ? { retry: resolveTelegramInboundNumericControlField(handler.retry, {}, "retry") }
432
+ ? {
433
+ retry: resolveTelegramInboundNumericControlField(
434
+ handler.retry,
435
+ {},
436
+ "retry",
437
+ ),
438
+ }
449
439
  : {}),
450
440
  ...(stdin !== undefined ? { stdin } : {}),
451
441
  });
@@ -519,7 +509,13 @@ async function executeTelegramTextHandlerInvocation(
519
509
  timeout,
520
510
  stdin: text,
521
511
  ...(typeof handler === "object" && handler.retry !== undefined
522
- ? { retry: resolveTelegramInboundNumericControlField(handler.retry, {}, "retry") }
512
+ ? {
513
+ retry: resolveTelegramInboundNumericControlField(
514
+ handler.retry,
515
+ {},
516
+ "retry",
517
+ ),
518
+ }
523
519
  : {}),
524
520
  });
525
521
  if (result.code !== 0)
package/lib/lifecycle.ts CHANGED
@@ -196,19 +196,6 @@ export function createTelegramSessionContextStore<TContext>(
196
196
  };
197
197
  }
198
198
 
199
- export function createTelegramSessionContextTracker(
200
- store: Pick<TelegramSessionContextStore<ExtensionContext>, "set" | "clear">,
201
- ): TelegramSessionLifecycleHooks {
202
- return {
203
- onSessionStart: async (_event, ctx) => {
204
- store.set(ctx);
205
- },
206
- onSessionShutdown: async (_event, ctx) => {
207
- store.clear(ctx);
208
- },
209
- };
210
- }
211
-
212
199
  export function createTelegramSessionGenerationFence(
213
200
  store: TelegramSessionContextStore<ExtensionContext>,
214
201
  hooks: TelegramSessionLifecycleHooks,