pi-telegram-plus 0.0.2 → 0.0.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/lib/markdown.ts CHANGED
@@ -74,6 +74,166 @@ function padCell(text: string, width: number, bold = false): string {
74
74
  return cell + " ".repeat(padding);
75
75
  }
76
76
 
77
+ /** True if text contains only printable ASCII (width is unambiguous = 1/char). */
78
+ function isAscii(text: string): boolean {
79
+ return /^[\x20-\x7e]*$/.test(text);
80
+ }
81
+
82
+ /** Collapse newlines inside a table cell so each row renders as one line. */
83
+ function sanitizeCellHtml(html: string): string {
84
+ return html.replace(/\n+/g, " ").trim();
85
+ }
86
+
87
+ /** Wrap inline HTML in <b> unless it already starts with one (avoids <b><b>). */
88
+ function wrapBold(html: string): string {
89
+ return html && !html.startsWith("<b>") ? `<b>${html}</b>` : html;
90
+ }
91
+
92
+ // ── Table strategies (mobile-first) ─────────────────────────────────────────
93
+ // Telegram has no <table>; these three layouts trade off density vs. mobile
94
+ // readability. See PR-1c plan: card (default) / box (narrow ASCII) / transpose (wide).
95
+
96
+ /** Box-drawing grid in <pre> — plain text only (no inline tags inside <pre>). */
97
+ function renderBoxTable(headerText: string[], rowsText: string[][], colWidths: number[]): string {
98
+ const lines: string[] = [];
99
+ lines.push(`┌─${colWidths.map((w) => "─".repeat(w)).join("─┬─")}─┐`);
100
+ lines.push(`│ ${headerText.map((h, i) => padCell(h, colWidths[i])).join(" │ ")} │`);
101
+ lines.push(`├─${colWidths.map((w) => "─".repeat(w)).join("─┼─")}─┤`);
102
+ for (const row of rowsText) {
103
+ const cells = colWidths.map((_, i) => padCell(i < row.length ? row[i] : "", colWidths[i]));
104
+ lines.push(`│ ${cells.join(" │ ")} │`);
105
+ }
106
+ lines.push(`└─${colWidths.map((w) => "─".repeat(w)).join("─┴─")}─┘`);
107
+ return `<pre>${lines.join("\n")}</pre>\n`;
108
+ }
109
+
110
+ /** Card-style: bold header row, bold first column (primary key), 2-space sep. */
111
+ function renderCardTable(headerHtml: string[], rowsHtml: string[][]): string {
112
+ const lines: string[] = [];
113
+ lines.push(headerHtml.map((h) => wrapBold(h)).join(" "));
114
+ lines.push("──");
115
+ for (const row of rowsHtml) {
116
+ const cells = headerHtml.map((_, i) => (i < row.length ? row[i] : ""));
117
+ const first = cells[0] ? wrapBold(cells[0]) : "";
118
+ const rest = cells.slice(1).filter((c) => c.length > 0).join(" ");
119
+ lines.push([first, rest].filter(Boolean).join(" "));
120
+ }
121
+ return lines.join("\n") + "\n\n";
122
+ }
123
+
124
+ /** Transposed: each row becomes a block (first col = bold title, rest = H: v). */
125
+ function renderTransposedTable(headerHtml: string[], rowsHtml: string[][]): string {
126
+ // Each data row becomes a block of `label: value` lines (all columns
127
+ // included, labels bolded), blocks separated by a blank line. No orphaned
128
+ // title / no indent / no heavy separator — the most mobile-readable layout
129
+ // for wide tables (many columns, few rows).
130
+ const blocks: string[] = [];
131
+ for (const row of rowsHtml) {
132
+ const cells = headerHtml.map((_, i) => (i < row.length ? row[i] : ""));
133
+ const lines: string[] = [];
134
+ for (let i = 0; i < headerHtml.length; i++) {
135
+ const label = wrapBold(headerHtml[i]) || `col${i + 1}`;
136
+ lines.push(`${label}: ${cells[i]}`);
137
+ }
138
+ blocks.push(lines.join("\n"));
139
+ }
140
+ return blocks.join("\n\n") + "\n\n";
141
+ }
142
+
143
+ // ── Pseudo-table protection ──────────────────────────────────────────────────
144
+ // Detects aligned pipe text that looks like a markdown table but has no
145
+ // `| --- |` separator row (e.g. raw `ls`/SQL/CSV-style output an agent pasted
146
+ // as prose). marked would tokenize such text as a paragraph and the pipes /
147
+ // column alignment would be lost in Telegram's proportional font. We wrap the
148
+ // run in a fenced code block so it renders as <pre><code> (monospace, aligned).
149
+
150
+ function isTableSeparator(line: string): boolean {
151
+ // A markdown table separator row: only spaces, dashes, colons, pipes; must
152
+ // contain at least one dash.
153
+ return /^\s*\|?[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
154
+ }
155
+
156
+ /** A markdown table row/header line: starts with a pipe (our agent always emits `| ... |`). */
157
+ function isTableRow(line: string): boolean {
158
+ return /^\s*\|/.test(line);
159
+ }
160
+
161
+ function isPseudoTableRow(line: string): boolean {
162
+ if (!line.includes("|")) return false;
163
+ const pipeCount = (line.match(/\|/g) || []).length;
164
+ if (pipeCount < 2) return false;
165
+ // Must have some real content (not a separator-only line).
166
+ return line.replace(/[\s|:-]/g, "").length > 0;
167
+ }
168
+
169
+ /** Count consecutive pseudo-table rows at `start`; 0 if it's a real table. */
170
+ function pseudoTableRun(lines: string[], start: number, inRealTable: Uint8Array): number {
171
+ if (inRealTable[start]) return 0;
172
+ if (!isPseudoTableRow(lines[start])) return 0;
173
+ // If the next line is a markdown table separator, this is a real table —
174
+ // leave it for marked's table tokenizer.
175
+ if (start + 1 < lines.length && isTableSeparator(lines[start + 1])) return 0;
176
+ let n = 1;
177
+ while (start + n < lines.length && inRealTable[start + n] === 0 && isPseudoTableRow(lines[start + n])) n++;
178
+ return n >= 2 ? n : 0;
179
+ }
180
+
181
+ /**
182
+ * Mark every line that belongs to a real markdown table (header + separator +
183
+ * its consecutive data rows). These must be left for marked's table tokenizer;
184
+ * otherwise the data rows after the first would be mis-detected as a
185
+ * pseudo-table (their preceding line is a data row, not a separator).
186
+ */
187
+ function markRealTableLines(lines: string[]): Uint8Array {
188
+ const flags = new Uint8Array(lines.length);
189
+ for (let i = 0; i + 1 < lines.length; i++) {
190
+ if (isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) {
191
+ flags[i] = 1; // header
192
+ flags[i + 1] = 1; // separator
193
+ let j = i + 2;
194
+ while (j < lines.length && isTableRow(lines[j])) { flags[j] = 1; j++; }
195
+ }
196
+ }
197
+ return flags;
198
+ }
199
+
200
+ /**
201
+ * Wrap pseudo-table runs (outside code fences) in fenced code blocks.
202
+ * Code-fence state is tracked so content already inside ``` is untouched.
203
+ */
204
+ function protectPseudoTables(md: string): string {
205
+ const lines = md.split("\n");
206
+ const inRealTable = markRealTableLines(lines);
207
+ const out: string[] = [];
208
+ let inFence = false;
209
+ let fenceChar = "";
210
+ let i = 0;
211
+ while (i < lines.length) {
212
+ const line = lines[i];
213
+ const fence = /^\s*(`{3,}|~{3,})/.exec(line);
214
+ if (fence) {
215
+ const ch = fence[1][0];
216
+ if (!inFence) { inFence = true; fenceChar = ch; }
217
+ else if (ch === fenceChar) { inFence = false; fenceChar = ""; }
218
+ out.push(line);
219
+ i++;
220
+ continue;
221
+ }
222
+ if (inFence) { out.push(line); i++; continue; }
223
+ const run = pseudoTableRun(lines, i, inRealTable);
224
+ if (run > 1) {
225
+ out.push("```");
226
+ for (let j = i; j < i + run; j++) out.push(lines[j]);
227
+ out.push("```");
228
+ i += run;
229
+ continue;
230
+ }
231
+ out.push(line);
232
+ i++;
233
+ }
234
+ return out.join("\n");
235
+ }
236
+
77
237
  /**
78
238
  * Track list nesting depth for proper indentation.
79
239
  * Resets per top-level markdownToTelegramHtml call.
@@ -112,21 +272,16 @@ const renderer = {
112
272
 
113
273
  heading(this: TelegramRendererContext, token: Tokens.Heading): string {
114
274
  const content = inlineFromTokens.call(this, token.tokens);
115
- if (token.depth === 1) {
116
- // H1: bold + underline, matching TUI style
117
- return `<b><u>${content}</u></b>\n`;
118
- }
119
- if (token.depth >= 3) {
120
- // H3+: bold with # prefix, matching TUI style
121
- const prefix = "#".repeat(token.depth) + " ";
122
- return `<b>${escapeHtml(prefix)}${content}</b>\n`;
123
- }
124
- // H2: bold only
125
- return `<b>${content}</b>\n`;
275
+ // Telegram has a single font/size, so hierarchy is conveyed by bold + a
276
+ // blank line. Drop the pi-tui `###` prefix (noise on mobile) and <u>
277
+ // (which reads like a link). Uniform <b> + trailing blank line for all depths.
278
+ return `<b>${content}</b>\n\n`;
126
279
  },
127
280
 
128
281
  paragraph(this: TelegramRendererContext, { tokens }: Tokens.Paragraph): string {
129
- return `${inlineFromTokens.call(this, tokens)}\n`;
282
+ // Double newline after each paragraph so mobile readers get visual
283
+ // breathing room between paragraphs (single \n reads as run-together).
284
+ return `${inlineFromTokens.call(this, tokens)}\n\n`;
130
285
  },
131
286
 
132
287
  strong(this: TelegramRendererContext, { tokens }: Tokens.Strong): string {
@@ -146,69 +301,82 @@ const renderer = {
146
301
  },
147
302
 
148
303
  code(this: TelegramRendererContext, { text, lang }: Tokens.Code): string {
149
- const codeContent = escapeHtml(text);
150
- const langTag = lang ? escapeHtml(lang) : "";
151
- const openMarker = langTag ? `\`\`\`${langTag}` : "\`\`\`";
152
- return `<pre><code>${openMarker}\n${codeContent}\n\`\`\`\n</code></pre>`;
304
+ // Drop the literal ``` fences (visual noise + wasted bytes) and emit the
305
+ // language as a Telegram-recognized `class="language-xxx"` attribute.
306
+ // Telegram validates the class value against [a-zA-Z][a-zA-Z0-9_-]*.
307
+ const codeContent = escapeHtml(text.replace(/\n+$/, ""));
308
+ const safeLang = (lang || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
309
+ const langAttr = safeLang ? ` class="language-${safeLang}"` : "";
310
+ return `<pre><code${langAttr}>${codeContent}\n</code></pre>\n`;
153
311
  },
154
312
 
155
313
  table(this: TelegramRendererContext, token: Tokens.Table): string {
156
314
  const numCols = token.header.length;
157
315
  if (numCols === 0) return "";
158
316
 
159
- // Calculate column widths based on all cells (header + rows)
317
+ // Pre-render every cell to inline HTML (card / transpose strategies).
318
+ // Multi-line cell content is collapsed to a single line so each table
319
+ // row stays one visual line that wraps naturally on mobile.
320
+ const headerHtml = token.header.map((c) =>
321
+ sanitizeCellHtml(inlineFromTokens.call(this, c.tokens, c.text)));
322
+ const rowsHtml = token.rows.map((row) =>
323
+ row.map((c) => sanitizeCellHtml(inlineFromTokens.call(this, c.tokens, c.text))),
324
+ );
325
+ // Plain escaped text for box-drawing (Telegram rejects <b>/<code> nested
326
+ // inside <pre>, so box cells must be tag-free).
327
+ const headerText = token.header.map((c) => escapeHtml(c.text));
328
+ const rowsText = token.rows.map((row) => row.map((c) => escapeHtml(c.text)));
329
+
330
+ // Natural column widths for strategy selection + box layout.
160
331
  const colWidths: number[] = [];
332
+ let allAscii = true;
161
333
  for (let i = 0; i < numCols; i++) {
162
- let maxW = 0;
163
- const hText = stripHtmlTags(inlineFromTokens.call(this, token.header[i].tokens, token.header[i].text));
164
- maxW = Math.max(maxW, visibleWidth(hText));
165
- for (const row of token.rows) {
334
+ let maxW = Math.max(1, visibleWidth(stripHtmlTags(headerHtml[i])));
335
+ for (const row of rowsHtml) {
166
336
  if (i < row.length) {
167
- const cText = stripHtmlTags(inlineFromTokens.call(this, row[i].tokens, row[i].text));
168
- maxW = Math.max(maxW, visibleWidth(cText));
337
+ const w = visibleWidth(stripHtmlTags(row[i]));
338
+ if (w > maxW) maxW = w;
339
+ if (!isAscii(stripHtmlTags(row[i]))) allAscii = false;
169
340
  }
170
341
  }
171
- colWidths.push(Math.max(1, maxW));
342
+ if (!isAscii(stripHtmlTags(headerHtml[i]))) allAscii = false;
343
+ colWidths.push(maxW);
172
344
  }
173
-
174
- const lines: string[] = [];
175
-
176
- // Top border
177
- lines.push(`┌─${colWidths.map(w => "─".repeat(w)).join("─┬─")}─┐`);
178
-
179
- // Header (bold)
180
- const headerCells = token.header.map((c, i) =>
181
- padCell(inlineFromTokens.call(this, c.tokens, c.text), colWidths[i], true),
182
- );
183
- lines.push(`│ ${headerCells.join(" │ ")} │`);
184
-
185
- // Separator
186
- lines.push(`├─${colWidths.map(w => "─".repeat(w)).join("─┼─")}─┤`);
187
-
188
- // Data rows
189
- for (const row of token.rows) {
190
- const cells = row.map((c, i) =>
191
- padCell(inlineFromTokens.call(this, c.tokens, c.text), colWidths[i], false),
192
- );
193
- while (cells.length < numCols) {
194
- cells.push(" ".repeat(colWidths[cells.length]));
195
- }
196
- lines.push(`│ ${cells.join(" │ ")} │`);
345
+ const naturalWidth = colWidths.reduce((a, b) => a + b, 0) + 3 * numCols + 1;
346
+
347
+ // T4: narrow 2-column all-ASCII table that fits a mobile screen -> box-drawing.
348
+ // ASCII width is unambiguous (1 char = 1 cell) in Telegram's monospace font,
349
+ // so the <pre> grid aligns reliably. Bound by total natural width (~40
350
+ // chars fits an iPhone portrait <pre>) so normal command/desc tables qualify;
351
+ // longer cells fall through to card which wraps naturally.
352
+ if (numCols === 2 && allAscii && naturalWidth <= 40) {
353
+ return renderBoxTable(headerText, rowsText, colWidths);
197
354
  }
198
-
199
- // Bottom border
200
- lines.push(`└─${colWidths.map(w => "─".repeat(w)).join("─┴─")}─┘`);
201
-
202
- return `<pre>${lines.join("\n")}</pre>\n`;
355
+ // T5: wide table -> transpose to vertical cards (one block per row), so a
356
+ // many-column table never forces horizontal scroll on mobile.
357
+ if (numCols > 4 || naturalWidth > 60) {
358
+ return renderTransposedTable(headerHtml, rowsHtml);
359
+ }
360
+ // T1 default: card-style - bold header + bold first column (primary key),
361
+ // 2-space column separator, wraps naturally, never overflows.
362
+ return renderCardTable(headerHtml, rowsHtml);
203
363
  },
204
364
 
205
365
  list(this: TelegramRendererContext, token: Tokens.List): string {
206
366
  _listDepth++;
207
367
  try {
208
368
  const start = typeof token.start === "number" ? token.start : 1;
209
- const baseIndent = " ".repeat(Math.max(0, _listDepth - 1));
369
+ // Cap indent growth at depth 2: deeper levels add no base indent of
370
+ // their own and switch to a `› ` bullet (unordered), so deep nesting
371
+ // grows only ~2 cols/level (parent continuation indent) instead of 4.
372
+ // Ordered lists keep their numbering at every depth.
373
+ const baseIndent = _listDepth <= 2
374
+ ? " ".repeat(Math.max(0, _listDepth - 1))
375
+ : "";
210
376
  const lines = token.items.map((item, index) => {
211
- const bullet = token.ordered ? `${start + index}. ` : "- ";
377
+ const bullet = token.ordered
378
+ ? `${start + index}. `
379
+ : (_listDepth >= 3 ? "› " : "- ");
212
380
  const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
213
381
  const marker = bullet + taskMarker;
214
382
  const content = blockFromTokens.call(this, item.tokens, item.text);
@@ -233,18 +401,50 @@ const renderer = {
233
401
 
234
402
  blockquote(this: TelegramRendererContext, { tokens }: Tokens.Blockquote): string {
235
403
  const content = this.parser.parse(tokens as unknown[]).trim();
236
- // Only wrap in <i> if content doesn't contain block-level tags
237
- const shouldItalic = !/^<blockquote|^<pre|^<ul|^<ol|^<table/i.test(content);
404
+ // Only wrap in <i> if content doesn't start with a block-level / inline-format
405
+ // tag. Wrapping `<b>`/`<pre>`/`<blockquote>` in `<i>` produces nesting that
406
+ // Telegram rejects (bad request), which forces a plain-text fallback for the
407
+ // ENTIRE message chunk — silently dropping all formatting.
408
+ const shouldItalic = !/^<blockquote|^<pre|^<ul|^<ol|^<table|^<b|^<i|^<a/i.test(content);
238
409
  const wrapped = shouldItalic ? `<i>${content}</i>` : content;
239
410
  return `<blockquote>${wrapped}</blockquote>\n`;
240
411
  },
241
412
 
242
413
  hr(this: TelegramRendererContext): string {
243
- return `<pre>─────────────────────</pre>\n`;
414
+ // Slim separator: 3 fullwidth dashes, no <pre> (avoids the heavy
415
+ // monospace block that eats a full line on mobile).
416
+ return "━━━\n\n";
417
+ },
418
+
419
+ image(this: TelegramRendererContext, { href, text, title }: Tokens.Image): string {
420
+ const alt = text || "image";
421
+ if (href) return `🖼 <a href="${escapeAttr(href)}">${escapeHtml(alt)}</a>`;
422
+ if (title) return `🖼 ${escapeHtml(alt)}: ${escapeHtml(title)}`;
423
+ return `🖼 ${escapeHtml(alt)}`;
424
+ },
425
+
426
+ // ── Inline / passthrough tokens previously left to marked's default ──
427
+ // marked's default renderers emit attributes/constructs Telegram's HTML
428
+ // parser rejects (`title=` on <a>, `<br>`, raw HTML, unescaped `&` in href),
429
+ // which causes the whole message chunk to fall back to plain text.
430
+ // Override each so the output is always valid Telegram HTML.
431
+
432
+ link(this: TelegramRendererContext, { href, tokens }: Tokens.Link): string {
433
+ const safeHref = escapeAttr(href);
434
+ if (!safeHref) return inlineFromTokens.call(this, tokens);
435
+ return `<a href="${safeHref}">${inlineFromTokens.call(this, tokens)}</a>`;
436
+ },
437
+
438
+ html(this: TelegramRendererContext, token: Tokens.HTML | Tokens.Tag): string {
439
+ // pi-tui renders raw HTML as plain text; mirror that here instead of
440
+ // letting it pass through and trip Telegram's parser.
441
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
442
+ return escapeHtml(raw.trim());
244
443
  },
245
444
 
246
- image(this: TelegramRendererContext, { text, title }: Tokens.Image): string {
247
- return title ? `[${text}: ${title}]` : `[${text}]`;
445
+ br(this: TelegramRendererContext): string {
446
+ // Telegram HTML has no <br>; a literal newline is the line break.
447
+ return "\n";
248
448
  },
249
449
  };
250
450
 
@@ -258,7 +458,13 @@ marked.use({ renderer });
258
458
  export function markdownToTelegramHtml(markdown: string): string {
259
459
  _listDepth = 0;
260
460
  _textAppendNewline = true;
261
- return (marked.parse(markdown, { async: false }) as string).replace(/\n+$/, "");
461
+ // Pre-scan: wrap "pseudo-tables" (aligned pipe text that looks like a
462
+ // markdown table but lacks the `| --- |` separator row) in fenced code so
463
+ // marked renders them as <pre><code> — preserving column alignment in
464
+ // Telegram's monospace font instead of mangling pipes in a proportional-font
465
+ // paragraph. Real markdown tables (with a separator) are left for marked.
466
+ const protected_ = protectPseudoTables(markdown);
467
+ return (marked.parse(protected_, { async: false }) as string).replace(/\n+$/, "");
262
468
  }
263
469
 
264
470
  export function escapeAttr(text: string): string {
@@ -1,5 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { setTelegramMyCommands } from "./telegram-api.ts";
3
+ import { log } from "./logger.ts";
4
+
5
+ const menuLog = log.child("menu-commands");
3
6
 
4
7
  const TELEGRAM_MENU_COMMANDS: Array<{ command: string; description: string }> = [
5
8
  // Keep the built-in pi commands in the same order as the TUI slash menu.
@@ -69,5 +72,5 @@ export async function syncTelegramCommands(botToken: string | undefined, pi: Ext
69
72
  if (!botToken) return;
70
73
  try {
71
74
  await setTelegramMyCommands(botToken, buildTelegramMenuCommands(pi));
72
- } catch { /* non-critical */ }
75
+ } catch (err) { menuLog.warn("syncTelegramCommands failed", { err }); }
73
76
  }
package/lib/polling.ts CHANGED
@@ -4,6 +4,9 @@ import { join } from "node:path";
4
4
  import { getAgentDir } from "./config.ts";
5
5
  import { getTelegramUpdates } from "./telegram-api.ts";
6
6
  import type { TelegramConfig, TelegramUpdate } from "./types.ts";
7
+ import { log } from "./logger.ts";
8
+
9
+ const pollLog = log.child("polling");
7
10
 
8
11
  export type TelegramPollingRuntime = {
9
12
  start(): void;
@@ -42,7 +45,8 @@ function isPidAlive(pid: unknown): boolean {
42
45
  try {
43
46
  process.kill(pid, 0);
44
47
  return true;
45
- } catch {
48
+ } catch (err) {
49
+ pollLog.debug("isPollingLockStale stat failed (treated as not stale)", { err });
46
50
  return false;
47
51
  }
48
52
  }
@@ -61,7 +65,8 @@ async function readPollingLockOwner(ownerPath: string): Promise<PollingLockOwner
61
65
  at: typeof owner.at === "string" ? owner.at : "",
62
66
  touchedAt: typeof owner.touchedAt === "string" ? owner.touchedAt : "",
63
67
  };
64
- } catch {
68
+ } catch (err) {
69
+ pollLog.debug("readPollingLockOwner failed (treated as no owner)", { err });
65
70
  return undefined;
66
71
  }
67
72
  }
@@ -93,7 +98,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
93
98
 
94
99
  // Clean up stale lock if it exists.
95
100
  if (!await isPollingLockStale(lockPath)) return undefined;
96
- await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
101
+ await rm(lockPath, { recursive: true, force: true }).catch(pollLog.swallow("debug", "rm stale polling lock failed", { lockPath }));
97
102
 
98
103
  // Create lock directory; if another process won, bail out.
99
104
  try {
@@ -110,7 +115,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
110
115
  await writeFile(tmpPath, ownerText(owner), { mode: 0o600, flag: "wx" });
111
116
  } catch (error) {
112
117
  // Another process's candidate won; clean up and bail.
113
- await rm(tmpPath).catch(() => undefined);
118
+ await rm(tmpPath).catch(pollLog.swallow("debug", "rm polling candidate tmp failed", { tmpPath }));
114
119
  if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
115
120
  throw error;
116
121
  }
@@ -120,7 +125,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
120
125
  await nodeRename(tmpPath, ownerPath);
121
126
  } catch {
122
127
  // rename failed (e.g. cross-device or another process already wrote owner.json).
123
- await rm(tmpPath).catch(() => undefined);
128
+ await rm(tmpPath).catch(pollLog.swallow("debug", "rm polling candidate tmp after rename failure", { tmpPath }));
124
129
  return undefined;
125
130
  }
126
131
 
@@ -129,7 +134,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
129
134
  const current = await readPollingLockOwner(ownerPath);
130
135
  if (current?.id !== owner.id) return;
131
136
  owner.touchedAt = new Date().toISOString();
132
- await writeFile(ownerPath, ownerText(owner), { mode: 0o600 }).catch(() => undefined);
137
+ await writeFile(ownerPath, ownerText(owner), { mode: 0o600 }).catch(pollLog.swallow("warn", "polling lock touch write failed", { ownerPath }));
133
138
  })();
134
139
  }, POLL_LOCK_TOUCH_MS);
135
140
 
@@ -138,7 +143,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
138
143
  release: async () => {
139
144
  clearInterval(touch);
140
145
  const current = await readPollingLockOwner(ownerPath);
141
- if (current?.id === owner.id) await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
146
+ if (current?.id === owner.id) await rm(lockPath, { recursive: true, force: true }).catch(pollLog.swallow("debug", "rm polling lock on release failed", { lockPath }));
142
147
  },
143
148
  };
144
149
  }
@@ -159,7 +164,7 @@ export function createTelegramPollingRuntime(deps: {
159
164
  const releasePollLock = async () => {
160
165
  const lock = pollLock;
161
166
  pollLock = undefined;
162
- await lock?.release().catch(() => undefined);
167
+ await lock?.release().catch(pollLog.swallow("warn", "poll lock release failed"));
163
168
  };
164
169
 
165
170
  const ensurePollLock = async (token: string): Promise<boolean> => {