@llblab/pi-telegram 0.39.2 → 0.39.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.39.3: Windows IPC And Outbound Voice Hotfix
6
+
7
+ - `Windows IPC Replacement`: Replaces an older same-process Named Pipe server before a new session generation listens on the stable endpoint, preventing `EADDRINUSE` during reload while keeping a late stop from invalidating its successor.
8
+ - `Windows IPC Fencing`: Applies the ownership commit fence before publishing a Named Pipe, so a stale generation that loses authority cannot expose an endpoint or block its replacement.
9
+ - `Windows Voice Commands`: Executes trusted `.cmd` and `.bat` outbound-handler wrappers through escaped `%ComSpec%` adaptation while preserving direct shell-free execution for native executables, bounded process controls, npm command-shim argument isolation, and paths containing spaces.
10
+ - `Windows Template Paths`: Preserves backslashes in quoted and unquoted Windows executable and artifact paths without breaking intentional escaped whitespace or quotes, allowing configured TTS handlers to produce and upload OGG/Opus voice replies through direct and follower transport.
11
+
5
12
  ## 0.39.2: Deleted Thread Receipt Hotfix
6
13
 
7
14
  - `Deleted Thread Receipt`: Terminally settles the currently executing durable update when Telegram returns exact HTTP 400 stale/deleted-thread evidence for its `{chatId, threadId}`, including leader-first shared-store invalidation followed by follower settlement. Transient, ambiguous, unrelated, and stale-looking HTTP 5xx failures retain indefinite retry authority; persisted follower records remain restart hints rather than speculative live registrations.
package/lib/bus.ts CHANGED
@@ -950,6 +950,18 @@ export interface TelegramBusLocalServer {
950
950
  ensureEndpoint: () => Promise<boolean>;
951
951
  }
952
952
 
953
+ const TELEGRAM_ACTIVE_LOCAL_SERVERS = Symbol.for(
954
+ "@llblab/pi-telegram/active-local-servers",
955
+ );
956
+ type TelegramBusServerGlobal = typeof globalThis & {
957
+ [TELEGRAM_ACTIVE_LOCAL_SERVERS]?: Map<string, TelegramBusLocalServer>;
958
+ };
959
+
960
+ function getActiveTelegramBusLocalServers(): Map<string, TelegramBusLocalServer> {
961
+ const root = globalThis as TelegramBusServerGlobal;
962
+ return (root[TELEGRAM_ACTIVE_LOCAL_SERVERS] ??= new Map());
963
+ }
964
+
953
965
  export type TelegramBusSocketPathSource = string | (() => string);
954
966
 
955
967
  const TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES = 80;
@@ -1563,6 +1575,11 @@ export function createTelegramBusLocalServer(
1563
1575
  start: async () => {
1564
1576
  if (server) return;
1565
1577
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
1578
+ const activeServers = getActiveTelegramBusLocalServers();
1579
+ const replacedServer = activeServers.get(socketPath);
1580
+ if (replacedServer && replacedServer !== runtime) {
1581
+ await replacedServer.stop();
1582
+ }
1566
1583
  const usesWindowsPipe = isTelegramBusPipePath(socketPath);
1567
1584
  const endpointGeneration = randomBytes(8).toString("hex");
1568
1585
  const listenPath = usesWindowsPipe
@@ -1601,6 +1618,19 @@ export function createTelegramBusLocalServer(
1601
1618
  await delayTelegramBusTransportRetry(25);
1602
1619
  }
1603
1620
  }
1621
+ if (usesWindowsPipe) {
1622
+ await deps.beforeEndpointPublication?.();
1623
+ const committed = deps.commitEndpointPublication
1624
+ ? deps.commitEndpointPublication(() => {})
1625
+ : true;
1626
+ if (!committed) {
1627
+ activeSocketPath = undefined;
1628
+ activeListenPath = undefined;
1629
+ throw new Error(
1630
+ "Telegram bus endpoint publication lost transport ownership.",
1631
+ );
1632
+ }
1633
+ }
1604
1634
  server = createServer((socket) => {
1605
1635
  sockets.add(socket);
1606
1636
  let buffer = "";
@@ -1634,6 +1664,7 @@ export function createTelegramBusLocalServer(
1634
1664
  server?.once("error", reject);
1635
1665
  server?.listen(listenPath, resolve);
1636
1666
  });
1667
+ activeServers.set(socketPath, runtime);
1637
1668
  deps.recordTransportEvent?.(
1638
1669
  "server-started",
1639
1670
  getTelegramBusEndpointDiagnostics(socketPath),
@@ -1674,6 +1705,9 @@ export function createTelegramBusLocalServer(
1674
1705
  server = undefined;
1675
1706
  activeSocketPath = undefined;
1676
1707
  activeListenPath = undefined;
1708
+ if (activeServers.get(socketPath) === runtime) {
1709
+ activeServers.delete(socketPath);
1710
+ }
1677
1711
  if (failedServer) {
1678
1712
  await new Promise<void>((resolve) =>
1679
1713
  failedServer.close(() => resolve()),
@@ -1689,6 +1723,12 @@ export function createTelegramBusLocalServer(
1689
1723
  const activeServer = server;
1690
1724
  const socketPath = activeSocketPath;
1691
1725
  const listenPath = activeListenPath;
1726
+ if (
1727
+ socketPath &&
1728
+ getActiveTelegramBusLocalServers().get(socketPath) === runtime
1729
+ ) {
1730
+ getActiveTelegramBusLocalServers().delete(socketPath);
1731
+ }
1692
1732
  server = undefined;
1693
1733
  activeSocketPath = undefined;
1694
1734
  activeListenPath = undefined;
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { spawn } from "node:child_process";
8
8
  import { homedir } from "node:os";
9
- import { isAbsolute, resolve } from "node:path";
9
+ import { extname, isAbsolute, normalize, resolve } from "node:path";
10
10
 
11
11
  export type CommandTemplateFailureScope = "continue" | "branch" | "root";
12
12
 
@@ -541,7 +541,8 @@ export function splitCommandTemplate(input: string): string[] {
541
541
  let quote: "'" | '"' | undefined;
542
542
  let escaped = false;
543
543
  let active = false;
544
- for (const char of input) {
544
+ for (let index = 0; index < input.length; index += 1) {
545
+ const char = input[index] ?? "";
545
546
  if (escaped) {
546
547
  current += char;
547
548
  escaped = false;
@@ -549,7 +550,16 @@ export function splitCommandTemplate(input: string): string[] {
549
550
  continue;
550
551
  }
551
552
  if (char === "\\" && quote !== "'") {
552
- escaped = true;
553
+ const next = input[index + 1];
554
+ const escapesNext = quote === '"'
555
+ ? next === '"' || next === "\\"
556
+ : next !== undefined &&
557
+ (/\s/u.test(next) || next === "'" || next === '"' || next === "\\");
558
+ if (escapesNext) {
559
+ escaped = true;
560
+ } else {
561
+ current += "\\";
562
+ }
553
563
  active = true;
554
564
  continue;
555
565
  }
@@ -844,15 +854,66 @@ export async function execCommandTemplate(
844
854
  return lastResult;
845
855
  }
846
856
 
857
+ const WINDOWS_COMMAND_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
858
+
859
+ function escapeWindowsCommand(value: string): string {
860
+ return value.replace(WINDOWS_COMMAND_META_CHARS, "^$1");
861
+ }
862
+
863
+ function escapeWindowsCommandArgument(
864
+ value: string,
865
+ doubleEscapeMetaChars: boolean,
866
+ ): string {
867
+ let escaped = value
868
+ .replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
869
+ .replace(/(?=(\\+?)?)\1$/g, "$1$1");
870
+ escaped = `"${escaped}"`.replace(WINDOWS_COMMAND_META_CHARS, "^$1");
871
+ return doubleEscapeMetaChars
872
+ ? escaped.replace(WINDOWS_COMMAND_META_CHARS, "^$1")
873
+ : escaped;
874
+ }
875
+
876
+ function resolveCommandTemplateSpawn(
877
+ command: string,
878
+ args: string[],
879
+ ): {
880
+ command: string;
881
+ args: string[];
882
+ windowsVerbatimArguments?: boolean;
883
+ } {
884
+ if (
885
+ process.platform !== "win32" ||
886
+ ![".bat", ".cmd"].includes(extname(command).toLowerCase())
887
+ ) {
888
+ return { command, args };
889
+ }
890
+ const normalizedCommand = normalize(command);
891
+ const isNodeModulesShim = /[\\/]node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu
892
+ .test(normalizedCommand);
893
+ const shellCommand = [
894
+ escapeWindowsCommand(normalizedCommand),
895
+ ...args.map((arg) =>
896
+ escapeWindowsCommandArgument(arg, isNodeModulesShim)
897
+ ),
898
+ ].join(" ");
899
+ return {
900
+ command: process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe",
901
+ args: ["/d", "/s", "/c", `"${shellCommand}"`],
902
+ windowsVerbatimArguments: true,
903
+ };
904
+ }
905
+
847
906
  function execCommandTemplateOnce(
848
907
  command: string,
849
908
  args: string[],
850
909
  options: CommandTemplateExecOptions = {},
851
910
  ): Promise<CommandTemplateExecResult> {
852
911
  return new Promise((resolve) => {
853
- const proc = spawn(command, args, {
912
+ const invocation = resolveCommandTemplateSpawn(command, args);
913
+ const proc = spawn(invocation.command, invocation.args, {
854
914
  cwd: options.cwd,
855
915
  shell: false,
916
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
856
917
  stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
857
918
  });
858
919
  let stdout = "";
package/lib/locks.ts CHANGED
@@ -423,7 +423,12 @@ function reclaimAbandonedDirectoryGuard(
423
423
  // Claim inside the still-occupied guard before making its stable path free.
424
424
  renameRecovery(sourcePath, reclaimPath);
425
425
  } catch (error) {
426
- if ((error as { code?: unknown })?.code === "ENOENT") return false;
426
+ const code = (error as { code?: unknown })?.code;
427
+ if (code === "ENOENT") return false;
428
+ // macOS may report EINVAL instead of ENOENT when another process wins
429
+ // the same source rename. Only classify it as contention once the
430
+ // observed source is actually gone; preserve unrelated EINVAL failures.
431
+ if (code === "EINVAL" && !existsSync(sourcePath)) return false;
427
432
  throw error;
428
433
  }
429
434
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.39.2",
3
+ "version": "0.39.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"