pi-telegram-plus 0.0.1 → 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
@@ -11,7 +11,14 @@ interface TelegramRendererContext {
11
11
 
12
12
  function inlineFromTokens(this: TelegramRendererContext, tokens?: unknown[], fallback = ""): string {
13
13
  if (Array.isArray(tokens) && tokens.length > 0) {
14
- return this.parser.parseInline(tokens as unknown[]);
14
+ // Temporarily disable \\n appending since this is inline context
15
+ const prev = _textAppendNewline;
16
+ _textAppendNewline = false;
17
+ try {
18
+ return this.parser.parseInline(tokens as unknown[]);
19
+ } finally {
20
+ _textAppendNewline = prev;
21
+ }
15
22
  }
16
23
  return escapeHtml(fallback);
17
24
  }
@@ -23,13 +30,258 @@ function blockFromTokens(this: TelegramRendererContext, tokens?: unknown[], fall
23
30
  return escapeHtml(fallback);
24
31
  }
25
32
 
33
+ /**
34
+ * Calculate visible width of text in a monospace environment.
35
+ * ASCII = 1, CJK / fullwidth / emoji = 2.
36
+ */
37
+ function visibleWidth(text: string): number {
38
+ let width = 0;
39
+ for (const char of text) {
40
+ const code = char.codePointAt(0)!;
41
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue;
42
+ if (
43
+ (code >= 0x2e80 && code <= 0x9fff) || // CJK, Yi, Hangul syllables
44
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul
45
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility
46
+ (code >= 0xfe30 && code <= 0xfe6f) || // CJK compat forms
47
+ (code >= 0xff01 && code <= 0xff60) || // fullwidth forms
48
+ (code >= 0xffe0 && code <= 0xffe6) || // fullwidth signs
49
+ (code >= 0x1f000 && code <= 0x1f9ff) || // emoji
50
+ code >= 0x20000 // CJK extension B
51
+ ) {
52
+ width += 2;
53
+ } else {
54
+ width += 1;
55
+ }
56
+ }
57
+ return width;
58
+ }
59
+
60
+ /** Strip HTML tags for visible width calculation. */
61
+ function stripHtmlTags(html: string): string {
62
+ return html.replace(/<[^>]+>/g, "");
63
+ }
64
+
65
+ /**
66
+ * Pad cell text (which may contain HTML tags) to the given visible width.
67
+ * Optionally wraps the whole cell in <b> for header styling.
68
+ */
69
+ function padCell(text: string, width: number, bold = false): string {
70
+ const currentWidth = visibleWidth(stripHtmlTags(text));
71
+ const padding = Math.max(0, width - currentWidth);
72
+ // Only wrap in <b> if the text doesn't already start with <b>
73
+ const cell = bold && !text.startsWith("<b>") ? `<b>${text}</b>` : text;
74
+ return cell + " ".repeat(padding);
75
+ }
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
+
237
+ /**
238
+ * Track list nesting depth for proper indentation.
239
+ * Resets per top-level markdownToTelegramHtml call.
240
+ */
241
+ let _listDepth = 0;
242
+
243
+ /**
244
+ * Flag: when true, the text renderer appends \\n after content.
245
+ * True during block-level parse (parser.parse), false during inline
246
+ * (parser.parseInline) so inline text tokens don't get spurious newlines.
247
+ */
248
+ let _textAppendNewline = true;
249
+
26
250
  const renderer = {
27
- heading(this: TelegramRendererContext, { tokens }: Tokens.Heading): string {
28
- return `<b>${inlineFromTokens.call(this, tokens)}</b>\n`;
251
+ text(this: TelegramRendererContext, token: Tokens.Text | Tokens.Escape): string {
252
+ if ('tokens' in token && token.tokens && token.tokens.length > 0) {
253
+ // Text with nested inline tokens — parse inline (disable \\n for nested),
254
+ // then add \\n only if we are at block level
255
+ const prev = _textAppendNewline;
256
+ _textAppendNewline = false;
257
+ try {
258
+ return this.parser.parseInline(token.tokens as unknown[]) + (prev ? '\n' : '');
259
+ } finally {
260
+ _textAppendNewline = prev;
261
+ }
262
+ }
263
+ // Plain text / escape token
264
+ return escapeHtml(token.text) + (_textAppendNewline ? '\n' : '');
265
+ },
266
+
267
+ checkbox(this: TelegramRendererContext): string {
268
+ // Suppress default <input> rendering; custom [x]/[ ] marker is added by list renderer
269
+ return "";
270
+ },
271
+
272
+
273
+ heading(this: TelegramRendererContext, token: Tokens.Heading): string {
274
+ const content = inlineFromTokens.call(this, token.tokens);
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`;
29
279
  },
30
280
 
31
281
  paragraph(this: TelegramRendererContext, { tokens }: Tokens.Paragraph): string {
32
- 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`;
33
285
  },
34
286
 
35
287
  strong(this: TelegramRendererContext, { tokens }: Tokens.Strong): string {
@@ -49,41 +301,150 @@ const renderer = {
49
301
  },
50
302
 
51
303
  code(this: TelegramRendererContext, { text, lang }: Tokens.Code): string {
52
- const language = lang ? ` class="language-${escapeHtml(lang)}"` : "";
53
- return `<pre><code${language}>${escapeHtml(text)}\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`;
54
311
  },
55
312
 
56
313
  table(this: TelegramRendererContext, token: Tokens.Table): string {
57
- const rows: string[] = [];
58
- if (token.header.length > 0) {
59
- rows.push(token.header.map((c) => inlineFromTokens.call(this, c.tokens, c.text)).join(" | "));
60
- rows.push(token.header.map(() => "---").join("-+-"));
314
+ const numCols = token.header.length;
315
+ if (numCols === 0) return "";
316
+
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.
331
+ const colWidths: number[] = [];
332
+ let allAscii = true;
333
+ for (let i = 0; i < numCols; i++) {
334
+ let maxW = Math.max(1, visibleWidth(stripHtmlTags(headerHtml[i])));
335
+ for (const row of rowsHtml) {
336
+ if (i < row.length) {
337
+ const w = visibleWidth(stripHtmlTags(row[i]));
338
+ if (w > maxW) maxW = w;
339
+ if (!isAscii(stripHtmlTags(row[i]))) allAscii = false;
340
+ }
341
+ }
342
+ if (!isAscii(stripHtmlTags(headerHtml[i]))) allAscii = false;
343
+ colWidths.push(maxW);
61
344
  }
62
- for (const row of token.rows) {
63
- rows.push(row.map((c) => inlineFromTokens.call(this, c.tokens, c.text)).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);
64
354
  }
65
- return `<pre>${rows.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);
66
363
  },
67
364
 
68
365
  list(this: TelegramRendererContext, token: Tokens.List): string {
69
- const start = typeof token.start === "number" ? token.start : 1;
70
- const lines = token.items.map((item, index) => {
71
- const bullet = token.ordered ? `${start + index}. ` : "• ";
72
- return bullet + blockFromTokens.call(this, item.tokens, item.text);
73
- });
74
- return lines.join("\n") + "\n";
366
+ _listDepth++;
367
+ try {
368
+ const start = typeof token.start === "number" ? token.start : 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
+ : "";
376
+ const lines = token.items.map((item, index) => {
377
+ const bullet = token.ordered
378
+ ? `${start + index}. `
379
+ : (_listDepth >= 3 ? "› " : "- ");
380
+ const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
381
+ const marker = bullet + taskMarker;
382
+ const content = blockFromTokens.call(this, item.tokens, item.text);
383
+ const contentLines = content.trimEnd().split("\n");
384
+ const contIndent = " ".repeat(visibleWidth(marker));
385
+ return contentLines.map((line, i) => {
386
+ const prefix = i === 0 ? baseIndent + marker : baseIndent + contIndent;
387
+ return prefix + line;
388
+ }).join("\n");
389
+ });
390
+ return lines.join("\n") + "\n";
391
+ } finally {
392
+ _listDepth--;
393
+ }
75
394
  },
76
395
 
77
396
  listitem(this: TelegramRendererContext, token: Tokens.ListItem): string {
397
+ // Used when a list item contains block-level children (e.g. nested lists).
398
+ // blockFromTokens will re-enter `list` for nested lists via parser.parse().
78
399
  return blockFromTokens.call(this, token.tokens, token.text);
79
400
  },
80
401
 
81
402
  blockquote(this: TelegramRendererContext, { tokens }: Tokens.Blockquote): string {
82
- return `<blockquote>${this.parser.parse(tokens as unknown[])}</blockquote>`;
403
+ const content = this.parser.parse(tokens as unknown[]).trim();
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);
409
+ const wrapped = shouldItalic ? `<i>${content}</i>` : content;
410
+ return `<blockquote>${wrapped}</blockquote>\n`;
411
+ },
412
+
413
+ hr(this: TelegramRendererContext): string {
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());
83
443
  },
84
444
 
85
- image(this: TelegramRendererContext, { text, title }: Tokens.Image): string {
86
- 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";
87
448
  },
88
449
  };
89
450
 
@@ -95,7 +456,15 @@ marked.use({ renderer });
95
456
  * formatting within the Telegram supported subset.
96
457
  */
97
458
  export function markdownToTelegramHtml(markdown: string): string {
98
- return (marked.parse(markdown, { async: false }) as string).replace(/\n+$/, "");
459
+ _listDepth = 0;
460
+ _textAppendNewline = true;
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+$/, "");
99
468
  }
100
469
 
101
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.
@@ -31,14 +34,15 @@ const TELEGRAM_MENU_COMMANDS: Array<{ command: string; description: string }> =
31
34
  { command: "thinking", description: "Show or change thinking level" },
32
35
  { command: "stop", description: "Stop the current agent turn" },
33
36
  { command: "debug", description: "Show debug information" },
37
+ { command: "status", description: "Show runtime snapshot (workspace, model, context, messages)" },
34
38
  // tg-* commands visible in the Telegram bot menu.
35
39
  // tg-bind-cwd / tg-unbind-cwd are workspace-management commands that
36
40
  // require local cwd context and do not belong in the bot command list.
37
- { command: "tg-setup", description: "Configure Telegram bot token" },
38
- { command: "tg-connect", description: "Enable/start Telegram connection" },
39
- { command: "tg-disconnect", description: "Disable/stop Telegram connection" },
40
- { command: "tg-config", description: "Configure Telegram message rendering" },
41
- { command: "tg-list", description: "List Telegram bot bindings" },
41
+ { command: "tg_global_setup", description: "Configure global Telegram bot token" },
42
+ { command: "tg_global_connect", description: "Enable/start global Telegram bot" },
43
+ { command: "tg_global_disconnect", description: "Disable/stop global Telegram bot" },
44
+ { command: "tg_config", description: "Configure Telegram message rendering" },
45
+ { command: "tg_list", description: "List Telegram bot bindings" },
42
46
  ];
43
47
 
44
48
  const toTelegramCommandName = (name: string): string | undefined => {
@@ -68,5 +72,5 @@ export async function syncTelegramCommands(botToken: string | undefined, pi: Ext
68
72
  if (!botToken) return;
69
73
  try {
70
74
  await setTelegramMyCommands(botToken, buildTelegramMenuCommands(pi));
71
- } catch { /* non-critical */ }
75
+ } catch (err) { menuLog.warn("syncTelegramCommands failed", { err }); }
72
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> => {