@link-assistant/hive-mind 1.56.19 → 1.57.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.57.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e4ece4d: Treat Codex app-server stream-lag item errors as non-fatal warnings when the turn otherwise completes successfully, preventing successful Codex runs from being reported as failed solution drafts.
8
+
9
+ ## 1.57.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 272a2d4: Add live terminal watch support for hive-telegram-bot
14
+
15
+ This feature adds `/terminal_watch` plus the experimental `--auto-start-screen-watch-message` option. The command watches the log reported by `$ --status <uuid>` and updates a separate Telegram message with a terminal-sized text snapshot.
16
+
17
+ Key features:
18
+ - Manual `/terminal_watch <uuid>` command, including reply-based usage
19
+ - Configurable terminal snapshot size with `--size`, `--width`, and `--height`
20
+ - Auto-freezes the watch message and attaches the full log when the session ends
21
+ - Public repository logs can update in chat; private/unknown visibility uses DM for manual watches
22
+ - Auto-start remains off by default and never starts for private or unknown-visibility repositories
23
+
24
+ Based on the proof-of-concept from konard/telegram-terminal-bot.
25
+
3
26
  ## 1.56.19
4
27
 
5
28
  ### Patch Changes
package/README.hi.md CHANGED
@@ -431,9 +431,21 @@ Hive Mind को क्रिया में देखना चाहते
431
431
  hive-telegram-bot 2>&1 | tee -a "logs/bot-$(date +%Y%m%d-%H%M%S).log"
432
432
  ```
433
433
 
434
+ **Experimental: live terminal watch**
435
+
436
+ ```bash
437
+ hive-telegram-bot --auto-start-screen-watch-message
438
+ ```
439
+
440
+ यह opt-in flag सार्वजनिक `/solve` sessions के लिए एक अलग live terminal
441
+ message शुरू करता है। Private या unknown-visibility repositories के लिए watch
442
+ message अपने आप शुरू नहीं होता।
443
+
434
444
  ### बॉट कमांड
435
445
 
436
- सभी कमांड केवल **ग्रुप चैट में** काम करते हैं (बॉट के साथ निजी संदेशों में नहीं):
446
+ अधिकांश operational commands केवल **ग्रुप चैट में** काम करते हैं (बॉट के साथ
447
+ निजी संदेशों में नहीं)। `/terminal_watch` जैसे commands, जो जानबूझकर private
448
+ updates भेजते हैं, direct messages में भी उपयोग किए जा सकते हैं:
437
449
 
438
450
  #### `/solve` - GitHub इश्यू हल करें
439
451
 
@@ -494,6 +506,22 @@ Shows:
494
506
  - Claude usage limits (session and weekly)
495
507
  ```
496
508
 
509
+ #### `/terminal_watch` - Live Session Log
510
+
511
+ ```
512
+ /terminal_watch <uuid> [--size 120x25]
513
+
514
+ Examples:
515
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12
516
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12 --width 100 --height 20
517
+ ```
518
+
519
+ आप bot session message पर `/terminal_watch` के साथ reply भी कर सकते हैं। यह
520
+ command `$ --status <uuid>` द्वारा report किए गए session log की latest lines के
521
+ साथ एक अलग Telegram message update करता है और session खत्म होने पर पूरी log file
522
+ attach करता है। Public repository logs chat में watch किए जा सकते हैं; private या
523
+ unknown-visibility repository logs केवल direct message से भेजे जाते हैं।
524
+
497
525
  #### `/help` - सहायता और डायग्नोस्टिक जानकारी प्राप्त करें
498
526
 
499
527
  ```
@@ -508,12 +536,25 @@ Shows:
508
536
 
509
537
  ### विशेषताएँ
510
538
 
511
- - ✅ **केवल ग्रुप चैट**: कमांड केवल ग्रुप चैट में काम करते हैं (निजी संदेश नहीं)
539
+ - ✅ **Group Chat Execution**: `/solve` और `/hive` workflows authorized group chats से चलते हैं
512
540
  - ✅ **पूर्ण विकल्प सपोर्ट**: सभी कमांड-लाइन विकल्प Telegram में काम करते हैं
513
541
  - ✅ **Screen सत्र**: कमांड डिटैच्ड screen सत्रों में चलते हैं
542
+ - ✅ **Live Terminal Watch**: `/terminal_watch` और opt-in auto-start live session logs दिखाते हैं
514
543
  - ✅ **चैट प्रतिबंध**: अनुमत चैट ID की वैकल्पिक सफेद सूची
515
544
  - ✅ **डायग्नोस्टिक टूल**: चैट ID और कॉन्फ़िगरेशन जानकारी प्राप्त करें
516
545
 
546
+ #### Live Terminal Watch
547
+
548
+ `--auto-start-screen-watch-message` से enabled होने पर, bot public `/solve`
549
+ sessions के लिए अपने आप एक अलग live terminal watch message शुरू करता है:
550
+
551
+ - **Manual Watch**: `/terminal_watch <uuid>` या `/terminal_watch` के साथ reply
552
+ - **Real-time Updates**: Commands execute होते समय live session log output देखें
553
+ - **Auto-freeze**: Command पूरा होने पर message freeze हो जाता है
554
+ - **Log Attachment**: Session खत्म होने पर full logs अपने आप attach होते हैं
555
+ - **Security**: Private या unknown-visibility repositories के लिए auto-start disabled है
556
+ - **Smart Updates**: केवल वास्तविक बदलाव मिलने पर update करता है (API limits से बचने के लिए rate-limited)
557
+
517
558
  ### सुरक्षा नोट
518
559
 
519
560
  - केवल उन ग्रुप चैट में काम करता है जहाँ बॉट एडमिन है
package/README.md CHANGED
@@ -440,9 +440,19 @@ Want to see the Hive Mind in action? Request a free demo or get faster support b
440
440
  hive-telegram-bot 2>&1 | tee -a "logs/bot-$(date +%Y%m%d-%H%M%S).log"
441
441
  ```
442
442
 
443
+ **Experimental: live terminal watch**
444
+
445
+ ```bash
446
+ hive-telegram-bot --auto-start-screen-watch-message
447
+ ```
448
+
449
+ This opt-in flag starts a separate live terminal message for public `/solve`
450
+ sessions. Private or unknown-visibility repositories never auto-start a
451
+ watch message.
452
+
443
453
  ### Bot Commands
444
454
 
445
- All commands work in **group chats only** (not in private messages with the bot):
455
+ Most operational commands work in **group chats only** (not in private messages with the bot). Commands that intentionally deliver private updates, such as `/terminal_watch`, may also be used in direct messages:
446
456
 
447
457
  #### `/solve` - Solve GitHub Issues
448
458
 
@@ -514,6 +524,22 @@ Shows:
514
524
  - Claude usage limits (session and weekly)
515
525
  ```
516
526
 
527
+ #### `/terminal_watch` - Live Session Log
528
+
529
+ ```
530
+ /terminal_watch <uuid> [--size 120x25]
531
+
532
+ Examples:
533
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12
534
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12 --width 100 --height 20
535
+ ```
536
+
537
+ You can also reply to a bot session message with `/terminal_watch`. The command
538
+ updates a separate Telegram message with the latest lines from the session log
539
+ reported by `$ --status <uuid>` and attaches the full log file when the session
540
+ finishes. Public repository logs can be watched in the chat; private or
541
+ unknown-visibility repository logs are delivered by direct message only.
542
+
517
543
  #### `/help` - Get Help and Diagnostic Info
518
544
 
519
545
  ```
@@ -528,12 +554,24 @@ Shows:
528
554
 
529
555
  ### Features
530
556
 
531
- - ✅ **Group Chat Only**: Commands work only in group chats (not private messages)
557
+ - ✅ **Group Chat Execution**: `/solve` and `/hive` workflows run from authorized group chats
532
558
  - ✅ **Full Options Support**: All command-line options work in Telegram
533
559
  - ✅ **Screen Sessions**: Commands run in detached screen sessions
560
+ - ✅ **Live Terminal Watch**: `/terminal_watch` and opt-in auto-start show live session logs
534
561
  - ✅ **Chat Restrictions**: Optional whitelist of allowed chat IDs
535
562
  - ✅ **Diagnostic Tools**: Get chat ID and configuration info
536
563
 
564
+ #### Live Terminal Watch
565
+
566
+ When enabled with `--auto-start-screen-watch-message`, the bot automatically starts a separate live terminal watch message for public `/solve` sessions:
567
+
568
+ - **Manual Watch**: `/terminal_watch <uuid>` or reply with `/terminal_watch`
569
+ - **Real-time Updates**: See live session log output as commands execute
570
+ - **Auto-freeze**: Message freezes when command completes
571
+ - **Log Attachment**: Full logs attached automatically when session ends
572
+ - **Security**: Auto-start is disabled for private or unknown-visibility repositories
573
+ - **Smart Updates**: Only updates when actual changes detected (rate-limited to avoid API limits)
574
+
537
575
  ### Security Notes
538
576
 
539
577
  - Only works in group chats where the bot is admin
package/README.ru.md CHANGED
@@ -431,9 +431,22 @@ Hive Mind включает интерфейс Telegram-бота (SwarmMindBot)
431
431
  hive-telegram-bot 2>&1 | tee -a "logs/bot-$(date +%Y%m%d-%H%M%S).log"
432
432
  ```
433
433
 
434
+ **Экспериментально: live terminal watch**
435
+
436
+ ```bash
437
+ hive-telegram-bot --auto-start-screen-watch-message
438
+ ```
439
+
440
+ Этот opt-in флаг запускает отдельное live terminal сообщение для публичных
441
+ сессий `/solve`. Для приватных репозиториев или репозиториев с неизвестной
442
+ видимостью watch-сообщение автоматически не запускается.
443
+
434
444
  ### Команды бота
435
445
 
436
- Все команды работают **только в групповых чатах** (не в личных сообщениях боту):
446
+ Большинство операционных команд работают **только в групповых чатах** (не в
447
+ личных сообщениях боту). Команды, которые намеренно доставляют приватные
448
+ обновления, например `/terminal_watch`, также можно использовать в личных
449
+ сообщениях:
437
450
 
438
451
  #### `/solve` — Решение задач GitHub
439
452
 
@@ -494,6 +507,23 @@ Shows:
494
507
  - Claude usage limits (session and weekly)
495
508
  ```
496
509
 
510
+ #### `/terminal_watch` — Live Session Log
511
+
512
+ ```
513
+ /terminal_watch <uuid> [--size 120x25]
514
+
515
+ Examples:
516
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12
517
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12 --width 100 --height 20
518
+ ```
519
+
520
+ Также можно ответить на сообщение сессии бота командой `/terminal_watch`. Команда
521
+ обновляет отдельное сообщение Telegram последними строками лога сессии,
522
+ полученного через `$ --status <uuid>`, и прикрепляет полный файл лога после
523
+ завершения сессии. Логи публичных репозиториев можно смотреть в чате; логи
524
+ приватных репозиториев или репозиториев с неизвестной видимостью доставляются
525
+ только личным сообщением.
526
+
497
527
  #### `/help` — Получить справку и диагностическую информацию
498
528
 
499
529
  ```
@@ -508,12 +538,25 @@ Shows:
508
538
 
509
539
  ### Возможности
510
540
 
511
- - ✅ **Только групповые чаты**: команды работают исключительно в групповых чатах (не в личных сообщениях)
541
+ - ✅ **Запуск из групповых чатов**: workflows `/solve` и `/hive` запускаются из авторизованных групповых чатов
512
542
  - ✅ **Полная поддержка параметров**: все параметры командной строки работают в Telegram
513
543
  - ✅ **Screen-сессии**: команды запускаются в отсоединённых screen-сессиях
544
+ - ✅ **Live Terminal Watch**: `/terminal_watch` и opt-in auto-start показывают live session logs
514
545
  - ✅ **Ограничения по чатам**: опциональный белый список разрешённых ID чатов
515
546
  - ✅ **Диагностические инструменты**: получение ID чата и информации о конфигурации
516
547
 
548
+ #### Live Terminal Watch
549
+
550
+ Если включить `--auto-start-screen-watch-message`, бот автоматически запускает
551
+ отдельное live terminal watch сообщение для публичных сессий `/solve`:
552
+
553
+ - **Manual Watch**: `/terminal_watch <uuid>` или ответ командой `/terminal_watch`
554
+ - **Real-time Updates**: смотрите live session log output во время выполнения команд
555
+ - **Auto-freeze**: сообщение замораживается после завершения команды
556
+ - **Log Attachment**: полные логи автоматически прикрепляются после завершения сессии
557
+ - **Security**: auto-start отключён для приватных репозиториев и репозиториев с неизвестной видимостью
558
+ - **Smart Updates**: обновляет сообщение только при реальных изменениях (rate-limited для защиты от API limits)
559
+
517
560
  ### Замечания по безопасности
518
561
 
519
562
  - Работает только в групповых чатах, где бот является администратором
package/README.zh.md CHANGED
@@ -431,9 +431,18 @@ Hive Mind 内置 Telegram 机器人接口(SwarmMindBot),支持远程命令
431
431
  hive-telegram-bot 2>&1 | tee -a "logs/bot-$(date +%Y%m%d-%H%M%S).log"
432
432
  ```
433
433
 
434
+ **实验性:live terminal watch**
435
+
436
+ ```bash
437
+ hive-telegram-bot --auto-start-screen-watch-message
438
+ ```
439
+
440
+ 这个 opt-in 标志会为公开仓库的 `/solve` 会话启动一条单独的 live terminal
441
+ 消息。私有仓库或可见性未知的仓库不会自动启动 watch 消息。
442
+
434
443
  ### 机器人命令
435
444
 
436
- 所有命令仅在**群聊中**有效(不支持与机器人的私聊):
445
+ 大多数操作类命令仅在**群聊中**有效(不支持与机器人的私聊)。有意发送私密更新的命令(例如 `/terminal_watch`)也可以在私聊中使用:
437
446
 
438
447
  #### `/solve` - 解决 GitHub Issue
439
448
 
@@ -494,6 +503,21 @@ Shows:
494
503
  - Claude usage limits (session and weekly)
495
504
  ```
496
505
 
506
+ #### `/terminal_watch` - Live Session Log
507
+
508
+ ```
509
+ /terminal_watch <uuid> [--size 120x25]
510
+
511
+ Examples:
512
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12
513
+ /terminal_watch 4d934f71-4cdb-4b8c-b474-582116d12c12 --width 100 --height 20
514
+ ```
515
+
516
+ 您也可以回复机器人会话消息并发送 `/terminal_watch`。该命令会用
517
+ `$ --status <uuid>` 报告的会话日志最新内容更新一条单独的 Telegram
518
+ 消息,并在会话结束时附加完整日志文件。公开仓库日志可以在群聊中 watch;
519
+ 私有仓库或可见性未知仓库的日志只会通过私聊发送。
520
+
497
521
  #### `/help` - 获取帮助和诊断信息
498
522
 
499
523
  ```
@@ -508,12 +532,25 @@ Shows:
508
532
 
509
533
  ### 功能特性
510
534
 
511
- - ✅ **仅限群聊**:命令仅在群聊中有效(不支持私聊)
535
+ - ✅ **群聊执行**:`/solve` 和 `/hive` workflow 从已授权群聊中运行
512
536
  - ✅ **完整选项支持**:所有命令行选项均可在 Telegram 中使用
513
537
  - ✅ **Screen 会话**:命令在后台 Screen 会话中运行
538
+ - ✅ **Live Terminal Watch**:`/terminal_watch` 和 opt-in auto-start 显示 live session logs
514
539
  - ✅ **聊天限制**:可选配置允许的聊天 ID 白名单
515
540
  - ✅ **诊断工具**:获取聊天 ID 和配置信息
516
541
 
542
+ #### Live Terminal Watch
543
+
544
+ 启用 `--auto-start-screen-watch-message` 后,机器人会为公开仓库的 `/solve`
545
+ 会话自动启动一条单独的 live terminal watch 消息:
546
+
547
+ - **Manual Watch**:`/terminal_watch <uuid>` 或回复 `/terminal_watch`
548
+ - **Real-time Updates**:在命令执行时查看 live session log output
549
+ - **Auto-freeze**:命令完成时消息会被冻结
550
+ - **Log Attachment**:会话结束时自动附加完整日志
551
+ - **Security**:私有仓库或可见性未知的仓库禁用 auto-start
552
+ - **Smart Updates**:仅在实际内容变化时更新(rate-limited 以避免 API limits)
553
+
517
554
  ### 安全注意事项
518
555
 
519
556
  - 仅在机器人为管理员的群聊中有效
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.56.19",
3
+ "version": "1.57.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  "hive-telegram-bot": "./src/telegram-bot.mjs"
16
16
  },
17
17
  "scripts": {
18
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1686-log-command.mjs && node tests/test-issue-1688-subscribe-and-pr-link.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
18
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-467-terminal-watch.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1686-log-command.mjs && node tests/test-issue-1688-subscribe-and-pr-link.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
19
19
  "test:queue": "node tests/solve-queue.test.mjs",
20
20
  "test:limits-display": "node tests/limits-display.test.mjs",
21
21
  "test:usage-limit": "node tests/test-usage-limit.mjs",
package/src/codex.lib.mjs CHANGED
@@ -210,12 +210,23 @@ const unwrapCodexErrorMessage = value => {
210
210
  return text;
211
211
  };
212
212
 
213
+ const isNonFatalCodexItemErrorMessage = message => /^in-process app-server event stream lagged; dropped \d+ events?$/i.test(message || '');
214
+
213
215
  export const getCodexErrorEventSummary = codexJsonState => {
214
216
  const events = [];
217
+ const ignoredEvents = [];
215
218
  const addEvents = (type, items = []) => {
216
219
  for (const item of items) {
217
220
  const message = unwrapCodexErrorMessage(item?.message);
218
- events.push({ type, message: message || 'Codex emitted an error event' });
221
+ const event = { type, message: message || 'Codex emitted an error event' };
222
+ if (type === 'item' && isNonFatalCodexItemErrorMessage(message)) {
223
+ ignoredEvents.push({
224
+ ...event,
225
+ reason: 'Codex app-server backpressure warning; the turn can still complete successfully',
226
+ });
227
+ continue;
228
+ }
229
+ events.push(event);
219
230
  }
220
231
  };
221
232
 
@@ -223,11 +234,20 @@ export const getCodexErrorEventSummary = codexJsonState => {
223
234
  addEvents('turn', codexJsonState?.turnFailures);
224
235
  addEvents('stream', codexJsonState?.streamErrors);
225
236
 
237
+ const countByType = items => ({
238
+ item: items.filter(item => item.type === 'item').length,
239
+ turn: items.filter(item => item.type === 'turn').length,
240
+ stream: items.filter(item => item.type === 'stream').length,
241
+ });
242
+
226
243
  return {
227
244
  hasError: events.length > 0,
228
245
  message: events[0]?.message || null,
229
246
  events,
230
- counts: {
247
+ ignoredEvents,
248
+ counts: countByType(events),
249
+ ignoredCounts: countByType(ignoredEvents),
250
+ observedCounts: {
231
251
  item: codexJsonState?.itemErrors?.length || 0,
232
252
  turn: codexJsonState?.turnFailures?.length || 0,
233
253
  stream: codexJsonState?.streamErrors?.length || 0,
@@ -928,6 +948,10 @@ export const executeCodexCommand = async params => {
928
948
  }
929
949
 
930
950
  const codexErrorSummary = getCodexErrorEventSummary(codexJsonState);
951
+ if (codexErrorSummary.ignoredEvents.length > 0) {
952
+ const ignoredMessages = [...new Set(codexErrorSummary.ignoredEvents.map(event => event.message))].join('; ');
953
+ await log(`⚠️ Ignoring non-fatal Codex item error event(s): ${ignoredMessages}`, { level: 'warning', verbose: true });
954
+ }
931
955
  if (codexErrorSummary.hasError) {
932
956
  const limitInfo = detectUsageLimit(codexErrorSummary.message || lastMessage);
933
957
  const retryableError = classifyRetryableError(codexErrorSummary.message || lastMessage);
@@ -49,6 +49,8 @@ const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-s
49
49
  const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
50
50
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
51
51
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
52
+ const { safeReply } = await import('./telegram-safe-reply.lib.mjs');
53
+ const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
52
54
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
53
55
  const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
54
56
  const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
@@ -113,6 +115,7 @@ const config = yargs(hideBin(process.argv))
113
115
  alias: 'v',
114
116
  default: getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true',
115
117
  })
118
+ .option('autoStartScreenWatchMessage', { type: 'boolean', description: 'Experimental: auto-start separate /terminal_watch messages for public /solve sessions', alias: 'auto-start-screen-watch-message', default: getenv('TELEGRAM_AUTO_START_SCREEN_WATCH_MESSAGE', getenv('TELEGRAM_AUTO_WATCH_MESSAGE', 'false')) === 'true' })
116
119
  .option('isolation', { type: 'string', description: "Isolation backend (screen/tmux/docker). Defaults to 'screen' so Telegram-bot work sessions survive bot restarts; pass --isolation '' (or set TELEGRAM_ISOLATION='') to disable.", default: getenv('TELEGRAM_ISOLATION', 'screen') })
117
120
  .help('h')
118
121
  .alias('h', 'help')
@@ -130,6 +133,7 @@ if (config.configuration) {
130
133
 
131
134
  const BOT_TOKEN = config.token || getenv('TELEGRAM_BOT_TOKEN', '');
132
135
  const VERBOSE = config.verbose || getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true';
136
+ const AUTO_WATCH_MESSAGE = config.autoStartScreenWatchMessage === true;
133
137
  if (!BOT_TOKEN) {
134
138
  console.error('Error: TELEGRAM_BOT_TOKEN not set. Use --token or TELEGRAM_BOT_TOKEN env var.');
135
139
  process.exit(1);
@@ -532,25 +536,6 @@ async function validateGitHubUrl(args, options = {}) {
532
536
  return { valid: true, parsed, normalizedUrl: url };
533
537
  }
534
538
 
535
- // Issue #1460/#1497: safeReply - try Markdown first, fall back to plain text on parsing errors
536
- async function safeReply(ctx, text, options = {}) {
537
- try {
538
- return await ctx.reply(text, { parse_mode: 'Markdown', ...options });
539
- } catch (error) {
540
- const isParsingError = error.message && (error.message.includes("can't parse entities") || error.message.includes("Can't parse entities") || error.message.includes("can't find end of") || (error.message.includes('Bad Request') && error.message.includes('400')));
541
- if (!isParsingError) throw error;
542
- console.error(`[telegram-bot] safeReply: Markdown parsing failed: ${error.message}`);
543
- console.error(`[telegram-bot] safeReply: Failing message (${Buffer.byteLength(text, 'utf-8')} bytes): ${text}`);
544
- const plainText = text
545
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
546
- .replace(/\\_/g, '_')
547
- .replace(/\\\*/g, '*')
548
- .replace(/\*([^*]+)\*/g, '$1')
549
- .replace(/`([^`]+)`/g, '$1');
550
- return await ctx.reply(plainText, { ...options, parse_mode: undefined });
551
- }
552
- }
553
-
554
539
  async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null) {
555
540
  const { chat, message_id: msgId } = startingMessage;
556
541
  const safeEdit = async text => {
@@ -562,18 +547,24 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
562
547
  };
563
548
  const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
564
549
  const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
565
- let result, session;
550
+ let result, session, sessionInfo;
566
551
  if (iso) {
567
552
  session = iso.runner.generateSessionId();
568
553
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
569
554
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
570
- if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
555
+ if (result.success) {
556
+ sessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock, urlContext, requesterUserId };
557
+ trackSession(session, sessionInfo, VERBOSE);
558
+ }
571
559
  } else {
572
560
  result = await executeStartScreen(commandName, args);
573
561
  const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
574
562
  session = match ? match[1] : 'unknown';
575
563
  // Issue #1586: Non-isolation sessions auto-expire after 10 min — screen stays alive via `exec bash` so completion can't be detected reliably; this still blocks duplicate commands in the timeout window.
576
- if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
564
+ if (result.success && session !== 'unknown') {
565
+ sessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId };
566
+ trackSession(session, sessionInfo, VERBOSE);
567
+ }
577
568
  }
578
569
  if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
579
570
  if (result.success) {
@@ -584,6 +575,7 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
584
575
  infoBlock,
585
576
  })
586
577
  );
578
+ if (AUTO_WATCH_MESSAGE && commandName === 'solve' && sessionInfo?.isolationBackend) await startAutoTerminalWatchForSession({ bot, ctx, sessionId: session, sessionInfo, verbose: VERBOSE });
587
579
  } else await safeEdit(`❌ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\`\n\n${infoBlock}`);
588
580
  }
589
581
 
@@ -663,11 +655,12 @@ bot.command('help', async ctx => {
663
655
  message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
664
656
  message += '*/help* - Show this help message\n';
665
657
  message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
666
- message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n\n';
658
+ message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n';
659
+ message += '*/terminal\\_watch* - Live-update an isolation session log (owner only). Usage: `/terminal_watch <uuid>` or reply with `/terminal_watch`\n\n';
667
660
  message += '🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
668
661
  if (ISOLATION_BACKEND) message += `🔒 *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
669
662
  message += '\n';
670
- message += '⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /subscribe and /unsubscribe work in private and group chats.\n\n';
663
+ message += '⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /terminal\\_watch, /subscribe and /unsubscribe work in private and group chats.\n\n';
671
664
  message += '🔧 *Common Options:*\n';
672
665
  message += `• \`--model <model>\` or \`-m\` - ${buildModelOptionDescription()}\n`;
673
666
  message += '• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
@@ -1194,6 +1187,7 @@ const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
1194
1187
  registerTopCommand(bot, sharedCommandOpts);
1195
1188
  registerStartStopCommands(bot, sharedCommandOpts);
1196
1189
  await registerLogCommand(bot, sharedCommandOpts);
1190
+ await registerTerminalWatchCommand(bot, sharedCommandOpts);
1197
1191
 
1198
1192
  // Add message listener for verbose debugging
1199
1193
  if (VERBOSE) {
@@ -0,0 +1,19 @@
1
+ // Issue #1460/#1497: safeReply - try Markdown first, fall back to plain text on parsing errors.
2
+ export async function safeReply(ctx, text, options = {}) {
3
+ try {
4
+ return await ctx.reply(text, { parse_mode: 'Markdown', ...options });
5
+ } catch (error) {
6
+ const message = error?.message || '';
7
+ const isParsingError = message.includes("can't parse entities") || message.includes("Can't parse entities") || message.includes("can't find end of") || (message.includes('Bad Request') && message.includes('400'));
8
+ if (!isParsingError) throw error;
9
+ console.error(`[telegram-bot] safeReply: Markdown parsing failed: ${message}`);
10
+ console.error(`[telegram-bot] safeReply: Failing message (${Buffer.byteLength(text, 'utf-8')} bytes): ${text}`);
11
+ const plainText = text
12
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
13
+ .replace(/\\_/g, '_')
14
+ .replace(/\\\*/g, '*')
15
+ .replace(/\*([^*]+)\*/g, '$1')
16
+ .replace(/`([^`]+)`/g, '$1');
17
+ return await ctx.reply(plainText, { ...options, parse_mode: undefined });
18
+ }
19
+ }
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Telegram /terminal_watch command.
3
+ *
4
+ * Watches the text log reported by `$ --status <uuid>` and edits a separate
5
+ * Telegram message with the latest terminal-sized snapshot.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import { constants as fsConstants } from 'fs';
11
+ import { extractSessionIdFromText, decideLogDestination, resolveLogPath } from './telegram-log-command.lib.mjs';
12
+
13
+ const DEFAULT_WIDTH = 120;
14
+ const DEFAULT_HEIGHT = 25;
15
+ const DEFAULT_INTERVAL_MS = 2500;
16
+ const DEFAULT_MAX_CHARS = 3400;
17
+ const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
18
+ const GITHUB_URL_RE = /https:\/\/github\.com\/[^\s"'`<>]+/i;
19
+ const activeWatches = new Map();
20
+
21
+ function splitCommandArgs(text) {
22
+ const body = String(text || '')
23
+ .replace(/^\/terminal_watch(?:@\w+)?\b/i, '')
24
+ .trim();
25
+ return body.match(/"[^"]*"|'[^']*'|\S+/g)?.map(token => token.replace(/^(['"])(.*)\1$/, '$2')) || [];
26
+ }
27
+
28
+ function readOptionValue(tokens, index, inlineValue, optionName, errors) {
29
+ if (inlineValue !== null) return { value: inlineValue, nextIndex: index };
30
+ const next = tokens[index + 1];
31
+ if (!next || next.startsWith('--')) {
32
+ errors.push(`${optionName} requires a value`);
33
+ return { value: null, nextIndex: index };
34
+ }
35
+ return { value: next, nextIndex: index + 1 };
36
+ }
37
+
38
+ function parseIntegerOption(value, optionName, errors, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
39
+ const parsed = Number.parseInt(value, 10);
40
+ if (!Number.isFinite(parsed) || String(parsed) !== String(value).trim() || parsed < min || parsed > max) {
41
+ errors.push(`${optionName} must be an integer from ${min} to ${max}`);
42
+ return null;
43
+ }
44
+ return parsed;
45
+ }
46
+
47
+ export function parseTerminalWatchArgs(text) {
48
+ const tokens = splitCommandArgs(text);
49
+ const options = { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, intervalMs: DEFAULT_INTERVAL_MS, maxChars: DEFAULT_MAX_CHARS };
50
+ const errors = [];
51
+ let sessionId = extractSessionIdFromText(text);
52
+
53
+ for (let i = 0; i < tokens.length; i++) {
54
+ const token = tokens[i];
55
+ if (extractSessionIdFromText(token)) {
56
+ sessionId ||= extractSessionIdFromText(token);
57
+ continue;
58
+ }
59
+ if (!token.startsWith('--')) {
60
+ errors.push(`Unexpected argument: ${token}`);
61
+ continue;
62
+ }
63
+
64
+ const eq = token.indexOf('=');
65
+ const name = eq === -1 ? token : token.slice(0, eq);
66
+ const inlineValue = eq === -1 ? null : token.slice(eq + 1);
67
+ const read = () => {
68
+ const result = readOptionValue(tokens, i, inlineValue, name, errors);
69
+ i = result.nextIndex;
70
+ return result.value;
71
+ };
72
+
73
+ if (['--width', '--columns', '--cols', '--terminal-width'].includes(name)) {
74
+ const value = read();
75
+ if (value !== null) options.width = parseIntegerOption(value, name, errors, { min: 20, max: 240 }) || options.width;
76
+ } else if (['--height', '--lines', '--rows', '--terminal-height'].includes(name)) {
77
+ const value = read();
78
+ if (value !== null) options.height = parseIntegerOption(value, name, errors, { min: 5, max: 80 }) || options.height;
79
+ } else if (['--interval', '--interval-ms'].includes(name)) {
80
+ const value = read();
81
+ if (value !== null) options.intervalMs = parseIntegerOption(value, name, errors, { min: 1000, max: 60000 }) || options.intervalMs;
82
+ } else if (name === '--max-chars') {
83
+ const value = read();
84
+ if (value !== null) options.maxChars = parseIntegerOption(value, name, errors, { min: 500, max: 3800 }) || options.maxChars;
85
+ } else if (name === '--size') {
86
+ const value = read();
87
+ const match = value?.match(/^(\d+)x(\d+)$/i);
88
+ if (!match) errors.push('--size must use WIDTHxHEIGHT format, for example --size 120x25');
89
+ else {
90
+ options.width = parseIntegerOption(match[1], '--size width', errors, { min: 20, max: 240 }) || options.width;
91
+ options.height = parseIntegerOption(match[2], '--size height', errors, { min: 5, max: 80 }) || options.height;
92
+ }
93
+ } else {
94
+ errors.push(`Unknown option: ${name}`);
95
+ }
96
+ }
97
+
98
+ return { sessionId, options, errors };
99
+ }
100
+
101
+ export function tailTextForTerminal(text, { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, maxChars = DEFAULT_MAX_CHARS } = {}) {
102
+ const normalized = String(text || '')
103
+ .replace(/\r\n/g, '\n')
104
+ .replace(/\r/g, '\n');
105
+ const lines = normalized.split('\n');
106
+ const visibleLines = lines.slice(-height).map(line => {
107
+ const expanded = line.replace(/\t/g, ' ');
108
+ return expanded.length > width ? `...${expanded.slice(-(width - 3))}` : expanded;
109
+ });
110
+ let result = visibleLines.join('\n').trimEnd();
111
+ if (!result) return '(no log output yet)';
112
+ if (result.length > maxChars) {
113
+ result = result.slice(-maxChars);
114
+ const firstNewline = result.indexOf('\n');
115
+ if (firstNewline > 0) result = result.slice(firstNewline + 1);
116
+ result = `...[truncated]\n${result}`;
117
+ }
118
+ return result;
119
+ }
120
+
121
+ function sanitizeCodeBlock(text) {
122
+ return String(text || '').replace(/```/g, "'''");
123
+ }
124
+
125
+ export function formatTerminalWatchMessage({ sessionId, statusResult = null, logText = '', options = {}, updateCount = 0, completed = false, repoDescription = null }) {
126
+ const status = statusResult?.status || 'unknown';
127
+ const width = options.width || DEFAULT_WIDTH;
128
+ const height = options.height || DEFAULT_HEIGHT;
129
+ const snapshot = sanitizeCodeBlock(tailTextForTerminal(logText, options));
130
+ const title = completed ? '✅ Terminal watch complete' : '🔄 Live terminal watch';
131
+ const lines = [title, `Session: \`${sessionId}\``, `Status: \`${status}\``, `Terminal: \`${width}x${height}\``];
132
+ if (repoDescription) lines.push(`Repo: \`${repoDescription}\``);
133
+ if (!completed) lines.push(`Updates: ${updateCount}`);
134
+ lines.push('', '```', snapshot, '```');
135
+ return lines.join('\n');
136
+ }
137
+
138
+ async function fileExists(filePath) {
139
+ try {
140
+ await fs.access(filePath, fsConstants.R_OK);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ async function fileSize(filePath) {
148
+ try {
149
+ return (await fs.stat(filePath)).size;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ async function readLogFile(logPath) {
156
+ try {
157
+ return await fs.readFile(logPath, 'utf8');
158
+ } catch (error) {
159
+ if (error?.code === 'ENOENT') return '';
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ function extractGitHubUrlFromStatus(statusResult) {
165
+ const match = String(statusResult?.command || '').match(GITHUB_URL_RE);
166
+ return match ? match[0].replace(/[),.;]+$/, '') : null;
167
+ }
168
+
169
+ export async function resolveTerminalWatchRepository({ sessionInfo = null, statusResult = null, parseGitHubUrl, detectRepositoryVisibility }) {
170
+ const url = sessionInfo?.url || extractGitHubUrlFromStatus(statusResult);
171
+ if (!url || !parseGitHubUrl || !detectRepositoryVisibility) return { repoVisibility: null, repoDescription: null };
172
+ const parsed = parseGitHubUrl(url);
173
+ if (!parsed?.valid || !parsed.owner || !parsed.repo) return { repoVisibility: null, repoDescription: null };
174
+ try {
175
+ return {
176
+ repoVisibility: await detectRepositoryVisibility(parsed.owner, parsed.repo),
177
+ repoDescription: `${parsed.owner}/${parsed.repo}`,
178
+ };
179
+ } catch (error) {
180
+ console.error('[ERROR] /terminal_watch: detectRepositoryVisibility failed:', error);
181
+ return { repoVisibility: null, repoDescription: `${parsed.owner}/${parsed.repo}` };
182
+ }
183
+ }
184
+
185
+ async function sendLogDocument({ bot, chatId, logPath, sessionId, statusResult }) {
186
+ if (!(await fileExists(logPath))) return;
187
+ const size = await fileSize(logPath);
188
+ if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
189
+ await bot.telegram.sendMessage(chatId, `⚠️ Full log for \`${sessionId}\` is ${(size / (1024 * 1024)).toFixed(1)} MB, above Telegram's 50 MB upload limit.`, { parse_mode: 'Markdown' });
190
+ return;
191
+ }
192
+ await bot.telegram.sendDocument(chatId, { source: logPath, filename: path.basename(logPath) }, { caption: `📄 Full log for session \`${sessionId}\`${statusResult?.status ? `\nStatus: \`${statusResult.status}\`` : ''}`, parse_mode: 'Markdown' });
193
+ }
194
+
195
+ async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbose, attempts = 3) {
196
+ for (let attempt = 1; attempt <= attempts; attempt++) {
197
+ const statusResult = await querySessionStatus(sessionId, verbose);
198
+ if (statusResult?.exists || attempt === attempts) return statusResult;
199
+ await new Promise(resolve => setTimeout(resolve, 250));
200
+ }
201
+ return null;
202
+ }
203
+
204
+ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false, attachLogOnComplete = true }) {
205
+ const key = `${chatId}:${messageId}:${sessionId}`;
206
+ activeWatches.get(key)?.stop();
207
+
208
+ let stopped = false;
209
+ let lastMessage = '';
210
+ let updateCount = 0;
211
+ let timer = null;
212
+ const intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS;
213
+
214
+ const tick = async () => {
215
+ if (stopped) return;
216
+ try {
217
+ const statusResult = await querySessionStatus(sessionId, verbose);
218
+ const completed = !!statusResult?.status && isTerminalSessionStatus(statusResult.status);
219
+ const logText = await readLogFile(logPath);
220
+ const message = formatTerminalWatchMessage({ sessionId, statusResult, logText, options, updateCount: ++updateCount, completed, repoDescription });
221
+ if (message !== lastMessage) {
222
+ await bot.telegram.editMessageText(chatId, messageId, undefined, message, { parse_mode: 'Markdown' });
223
+ lastMessage = message;
224
+ }
225
+ if (completed) {
226
+ stopped = true;
227
+ activeWatches.delete(key);
228
+ if (attachLogOnComplete) await sendLogDocument({ bot, chatId, logPath, sessionId, statusResult });
229
+ return;
230
+ }
231
+ } catch (error) {
232
+ console.error(`[terminal-watch] Error while watching ${sessionId}:`, error);
233
+ }
234
+ if (!stopped) timer = setTimeout(tick, intervalMs);
235
+ };
236
+
237
+ const control = {
238
+ stop: () => {
239
+ stopped = true;
240
+ if (timer) clearTimeout(timer);
241
+ activeWatches.delete(key);
242
+ },
243
+ };
244
+ activeWatches.set(key, control);
245
+ timer = setTimeout(tick, 0);
246
+ return control;
247
+ }
248
+
249
+ function buildUsage() {
250
+ return 'Usage:\n• `/terminal_watch <UUID>`\n• Reply to a session message with `/terminal_watch`\n\nOptions: `--size 120x25`, `--width 120`, `--height 25`, `--interval-ms 2500`, `--max-chars 3400`';
251
+ }
252
+
253
+ async function createWatchMessage({ ctx, targetChatId, replyToMessageId, text }) {
254
+ if (targetChatId === ctx.chat.id) {
255
+ return await ctx.reply(text, { parse_mode: 'Markdown', reply_to_message_id: replyToMessageId });
256
+ }
257
+ return await ctx.telegram.sendMessage(targetChatId, text, replyToMessageId ? { parse_mode: 'Markdown', reply_to_message_id: replyToMessageId } : { parse_mode: 'Markdown' });
258
+ }
259
+
260
+ async function forwardOrCopyToDm(ctx, sourceMessage) {
261
+ const userId = ctx.from?.id;
262
+ if (!userId || !sourceMessage) return null;
263
+ try {
264
+ const forwarded = await ctx.telegram.forwardMessage(userId, ctx.chat.id, sourceMessage.message_id);
265
+ return forwarded?.message_id || null;
266
+ } catch (forwardError) {
267
+ try {
268
+ const copied = await ctx.telegram.copyMessage(userId, ctx.chat.id, sourceMessage.message_id);
269
+ return copied?.message_id || null;
270
+ } catch (copyError) {
271
+ console.error('[ERROR] /terminal_watch: forward/copyMessage to DM failed:', forwardError, copyError);
272
+ return null;
273
+ }
274
+ }
275
+ }
276
+
277
+ async function startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult, sessionInfo, decision, logPath, watchOptions, querySessionStatus, isTerminalSessionStatus, repoDescription, auto = false, verbose = false }) {
278
+ if (auto && decision.destination !== 'chat') {
279
+ verbose && console.log(`[VERBOSE] Auto terminal watch skipped for ${sessionId}: ${decision.reason}`);
280
+ return { started: false, reason: decision.reason };
281
+ }
282
+
283
+ const targetChatId = decision.destination === 'chat' ? ctx.chat.id : ctx.from?.id;
284
+ if (!targetChatId) return { started: false, reason: 'Missing target chat id' };
285
+
286
+ const initialLogText = await readLogFile(logPath);
287
+ const initialText = formatTerminalWatchMessage({ sessionId, statusResult, logText: initialLogText, options: watchOptions, repoDescription });
288
+ let replyToMessageId = ctx.message?.message_id || undefined;
289
+ if (decision.destination === 'dm' && ctx.chat.type !== 'private') {
290
+ replyToMessageId = await forwardOrCopyToDm(ctx, ctx.message?.reply_to_message || ctx.message);
291
+ }
292
+
293
+ const watchMessage = await createWatchMessage({ ctx, targetChatId, replyToMessageId, text: initialText });
294
+ watchTerminalLogSession({ bot, chatId: targetChatId, messageId: watchMessage.message_id, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options: watchOptions, repoDescription, verbose });
295
+
296
+ if (!auto && decision.destination === 'dm' && ctx.chat.type !== 'private') {
297
+ await ctx.reply(`📬 Started terminal watch for \`${sessionId}\` in your direct messages.`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
298
+ }
299
+ return { started: true, messageId: watchMessage.message_id, sessionInfo };
300
+ }
301
+
302
+ export async function startAutoTerminalWatchForSession({ bot, ctx, sessionId, sessionInfo, verbose = false, options = {} }) {
303
+ try {
304
+ const runner = await import('./isolation-runner.lib.mjs');
305
+ const { parseGitHubUrl, detectRepositoryVisibility } = await import('./github.lib.mjs');
306
+ const statusResult = await querySessionStatusWithRetry(runner.querySessionStatus, sessionId, verbose);
307
+ if (!statusResult?.exists) return { started: false, reason: 'Unknown session id' };
308
+ const { repoVisibility, repoDescription } = await resolveTerminalWatchRepository({ sessionInfo, statusResult, parseGitHubUrl, detectRepositoryVisibility });
309
+ const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType: ctx.chat?.type });
310
+ if (decision.destination !== 'chat') return { started: false, reason: decision.reason };
311
+ const logPath = resolveLogPath({ statusResult, isolationBackend: decision.isolationBackend });
312
+ if (!logPath) return { started: false, reason: 'Missing log path' };
313
+ return await startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult, sessionInfo, decision, logPath, watchOptions: { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, intervalMs: DEFAULT_INTERVAL_MS, maxChars: DEFAULT_MAX_CHARS, ...options }, querySessionStatus: runner.querySessionStatus, isTerminalSessionStatus: runner.isTerminalSessionStatus, repoDescription, auto: true, verbose });
314
+ } catch (error) {
315
+ console.error('[terminal-watch] Auto-start failed:', error);
316
+ return { started: false, reason: error.message || String(error) };
317
+ }
318
+ }
319
+
320
+ export async function registerTerminalWatchCommand(bot, options) {
321
+ const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
322
+ const runner = await import('./isolation-runner.lib.mjs');
323
+ const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
324
+ const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
325
+ const parseGitHubUrl = options.parseGitHubUrl || (await import('./github.lib.mjs')).parseGitHubUrl;
326
+
327
+ bot.command('terminal_watch', async ctx => {
328
+ VERBOSE && console.log('[VERBOSE] /terminal_watch command received');
329
+ if (isOldMessage && isOldMessage(ctx)) return;
330
+
331
+ const chat = ctx.chat;
332
+ const message = ctx.message;
333
+ if (!chat || !message) return;
334
+
335
+ const parsedArgs = parseTerminalWatchArgs(message.text || '');
336
+ if (parsedArgs.errors.length > 0) {
337
+ await ctx.reply(`❌ Invalid /terminal_watch options:\n${parsedArgs.errors.map(e => `• ${e}`).join('\n')}\n\n${buildUsage()}`, { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
338
+ return;
339
+ }
340
+
341
+ const sessionId = parsedArgs.sessionId || extractSessionIdFromText(message.reply_to_message?.text || message.reply_to_message?.caption || '');
342
+ if (!sessionId) {
343
+ await ctx.reply(`❌ /terminal_watch requires a session id.\n\n${buildUsage()}`, { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
344
+ return;
345
+ }
346
+
347
+ if (chat.type !== 'private') {
348
+ try {
349
+ const member = await ctx.telegram.getChatMember(chat.id, ctx.from.id);
350
+ if (!member || member.status !== 'creator') {
351
+ await ctx.reply('❌ /terminal_watch is only available to the chat owner.', { reply_to_message_id: message.message_id });
352
+ return;
353
+ }
354
+ } catch (error) {
355
+ console.error('[ERROR] /terminal_watch: getChatMember failed:', error);
356
+ await ctx.reply('❌ Failed to verify permissions for /terminal_watch.', { reply_to_message_id: message.message_id });
357
+ return;
358
+ }
359
+ }
360
+
361
+ if (isChatAuthorized && !isChatAuthorized(chat.id) && (!isTopicAuthorized || !isTopicAuthorized(ctx))) {
362
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chat.id}) is not authorized.`;
363
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
364
+ return;
365
+ }
366
+
367
+ let statusResult;
368
+ try {
369
+ statusResult = await runner.querySessionStatus(sessionId, VERBOSE);
370
+ } catch (error) {
371
+ console.error('[ERROR] /terminal_watch: querySessionStatus failed:', error);
372
+ await ctx.reply(`❌ Failed to query session status: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
373
+ return;
374
+ }
375
+
376
+ if (!statusResult?.exists) {
377
+ await ctx.reply(`❌ Session \`${sessionId}\` is not known to start-command.`, { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
378
+ return;
379
+ }
380
+
381
+ const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
382
+ const { repoVisibility, repoDescription } = await resolveTerminalWatchRepository({ sessionInfo, statusResult, parseGitHubUrl, detectRepositoryVisibility });
383
+ const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType: chat.type });
384
+ if (decision.destination === 'reject') {
385
+ await ctx.reply(`❌ ${decision.reason}`, { reply_to_message_id: message.message_id });
386
+ return;
387
+ }
388
+
389
+ const logPath = resolveLogPath({ statusResult, isolationBackend: decision.isolationBackend });
390
+ if (!logPath) {
391
+ await ctx.reply('❌ Could not determine the log file path for this session.', { reply_to_message_id: message.message_id });
392
+ return;
393
+ }
394
+
395
+ try {
396
+ await startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult, sessionInfo, decision, logPath, watchOptions: parsedArgs.options, querySessionStatus: runner.querySessionStatus, isTerminalSessionStatus: runner.isTerminalSessionStatus, repoDescription, verbose: VERBOSE });
397
+ } catch (error) {
398
+ console.error('[ERROR] /terminal_watch: failed to start watch:', error);
399
+ const friendly = error?.code === 403 || /chat not found|bot can't initiate conversation/i.test(error?.message || '') ? 'I could not send you a DM. Please open a private chat with me and send /start, then try again.' : `Failed to start terminal watch: ${error.message || String(error)}`;
400
+ await ctx.reply(`❌ ${friendly}`, { reply_to_message_id: message.message_id });
401
+ }
402
+ });
403
+ }
404
+
405
+ export const __INTERNAL_FOR_TESTS__ = {
406
+ DEFAULT_WIDTH,
407
+ DEFAULT_HEIGHT,
408
+ DEFAULT_INTERVAL_MS,
409
+ DEFAULT_MAX_CHARS,
410
+ TELEGRAM_DOCUMENT_MAX_BYTES,
411
+ GITHUB_URL_RE,
412
+ };