@link-assistant/hive-mind 1.56.18 → 1.56.19

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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.56.19
4
+
5
+ ### Patch Changes
6
+
7
+ - 0da8eba: Add a `/log` Telegram command that lets a chat owner pull the on-disk log of a `$` isolation session (`screen`, `tmux`, `docker`). The command accepts `/log <UUID>` directly or `/log` as a reply to any session message that contains a session UUID, validates the id with `$ --status`, derives the log path from start-command's `logPath` field, and uploads the file as a reply to the user. Logs from public GitHub repositories are uploaded to the same chat; logs from private (or unknown-visibility) repositories are sent via direct message after forwarding the originating session message into the DM, so private logs never leak into public chats. Access is restricted to the chat owner (Telegram `creator` status), matching the existing `/start`, `/stop`, and `/top` policy.
8
+
3
9
  ## 1.56.18
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.56.18",
3
+ "version": "1.56.19",
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-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-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",
@@ -41,17 +41,18 @@ export function generateSessionId() {
41
41
  * Keep the parser tolerant so completion monitoring survives either format.
42
42
  *
43
43
  * @param {string} output - Raw stdout from `$ --status`
44
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}}
44
+ * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, raw: string}}
45
45
  */
46
46
  export function parseSessionStatusOutput(output) {
47
47
  const raw = (output || '').trim();
48
48
  if (!raw) {
49
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
49
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
50
50
  }
51
51
 
52
52
  try {
53
53
  const parsed = JSON.parse(raw);
54
54
  const data = Array.isArray(parsed) ? parsed[0] : parsed;
55
+ const isolationFromOptions = typeof data?.options?.isolation === 'string' ? data.options.isolation.toLowerCase() : null;
55
56
  return {
56
57
  exists: true,
57
58
  uuid: data?.uuid || null,
@@ -60,6 +61,10 @@ export function parseSessionStatusOutput(output) {
60
61
  startTime: data?.startTime || null,
61
62
  endTime: data?.endTime || null,
62
63
  currentTime: data?.currentTime || null,
64
+ logPath: data?.logPath || null,
65
+ command: data?.command || null,
66
+ isolation: typeof data?.isolation === 'string' ? data.isolation.toLowerCase() : isolationFromOptions,
67
+ workingDirectory: data?.workingDirectory || null,
63
68
  raw,
64
69
  };
65
70
  } catch {
@@ -87,6 +92,10 @@ export function parseSessionStatusOutput(output) {
87
92
  startTime: readField('startTime'),
88
93
  endTime: readField('endTime'),
89
94
  currentTime: readField('currentTime'),
95
+ logPath: readField('logPath'),
96
+ command: readField('command'),
97
+ isolation: readField('isolation')?.toLowerCase() || null,
98
+ workingDirectory: readField('workingDirectory'),
90
99
  raw,
91
100
  };
92
101
  }
@@ -213,7 +222,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
213
222
  if (verbose) {
214
223
  console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
215
224
  }
216
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
225
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
217
226
  }
218
227
 
219
228
  try {
@@ -230,7 +239,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
230
239
  if (verbose) {
231
240
  console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
232
241
  }
233
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
242
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
234
243
  }
235
244
  }
236
245
 
@@ -89,6 +89,21 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Look up the in-memory record for a session id (UUID for isolation sessions
94
+ * or the screen session name for non-isolation sessions). Returns null when no
95
+ * record exists — for example, after a process restart or for sessions that
96
+ * were never tracked through the Telegram bot. Used by `/log` to discover the
97
+ * originating chat id and the GitHub URL associated with a session.
98
+ *
99
+ * @param {string} sessionName
100
+ * @returns {Object|null}
101
+ */
102
+ export function getTrackedSessionInfo(sessionName) {
103
+ if (!sessionName) return null;
104
+ return activeSessions.get(sessionName) || null;
105
+ }
106
+
92
107
  /**
93
108
  * Get the number of active sessions being tracked
94
109
  * @param {boolean} verbose - Whether to log verbose output
@@ -662,9 +662,9 @@ bot.command('help', async ctx => {
662
662
  message += "Merges all PRs with 'ready' label sequentially.\n";
663
663
  message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
664
664
  message += '*/help* - Show this help message\n';
665
- message += '*/stop* - Stop accepting new tasks (owner only)\n';
666
- message += '*/start* - Resume accepting tasks (owner only)\n\n';
667
- message += '🔔 *Session Notifications:* The bot monitors sessions and notifies when they complete. Use /subscribe to also get DM forwards (in-memory, resets on restart).\n';
665
+ 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';
667
+ message += '🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
668
668
  if (ISOLATION_BACKEND) message += `🔒 *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
669
669
  message += '\n';
670
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';
@@ -763,7 +763,6 @@ bot.command('version', async ctx => {
763
763
  await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, '🤖 *Version Information*\n\n' + formatVersionMessage(result.versions), { parse_mode: 'Markdown' });
764
764
  });
765
765
 
766
- // Register external command modules (keeps telegram-bot.mjs under line limit)
767
766
  const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
768
767
  const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
769
768
  registerAcceptInvitesCommand(bot, sharedCommandOpts);
@@ -1191,8 +1190,10 @@ bot.command(/^hive$/i, handleHiveCommand);
1191
1190
 
1192
1191
  const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
1193
1192
  const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
1193
+ const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
1194
1194
  registerTopCommand(bot, sharedCommandOpts);
1195
1195
  registerStartStopCommands(bot, sharedCommandOpts);
1196
+ await registerLogCommand(bot, sharedCommandOpts);
1196
1197
 
1197
1198
  // Add message listener for verbose debugging
1198
1199
  if (VERBOSE) {
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Telegram /log command implementation
3
+ *
4
+ * Lets a chat owner pull the log of an isolation session that was launched
5
+ * through the `$` (start-command) CLI. The session is identified by its UUID,
6
+ * either passed as `/log <UUID>` or extracted from a message that the
7
+ * `/log` command is replying to.
8
+ *
9
+ * Privacy guarantees:
10
+ * - Only the chat creator (`status === 'creator'`) may invoke `/log`.
11
+ * - Logs from public GitHub repositories may be uploaded into the chat where
12
+ * `/log` was issued.
13
+ * - Logs from private GitHub repositories — and logs whose repository
14
+ * visibility we cannot determine — are sent to the user via direct message
15
+ * only, after forwarding the original message that contained the session id.
16
+ * - Currently only sessions launched with one of the `$` isolation backends
17
+ * (`screen`, `tmux`, `docker`) are supported. Direct (non-isolation) sessions
18
+ * are rejected with a clear message.
19
+ *
20
+ * @see https://github.com/link-assistant/hive-mind/issues/1686
21
+ */
22
+
23
+ import path from 'path';
24
+ import fs from 'fs/promises';
25
+ import { constants as fsConstants } from 'fs';
26
+
27
+ const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
28
+ const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
29
+ // Telegram bots may upload documents up to 50 MB via sendDocument.
30
+ // https://core.telegram.org/bots/api#senddocument
31
+ const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
32
+
33
+ /**
34
+ * Extract the first RFC 4122 v4-shaped UUID found in `text`.
35
+ *
36
+ * @param {string|null|undefined} text
37
+ * @returns {string|null}
38
+ */
39
+ export function extractSessionIdFromText(text) {
40
+ if (!text || typeof text !== 'string') return null;
41
+ const match = text.match(UUID_RE);
42
+ return match ? match[1].toLowerCase() : null;
43
+ }
44
+
45
+ /**
46
+ * Decide where the log for a session should be delivered.
47
+ *
48
+ * Inputs:
49
+ * - `statusResult`: parsed result of `$ --status <uuid>` (see
50
+ * `parseSessionStatusOutput` in `isolation-runner.lib.mjs`).
51
+ * - `sessionInfo`: in-memory record from the Telegram session monitor, or null.
52
+ * - `repoVisibility`: result of `detectRepositoryVisibility(owner, repo)`, or
53
+ * null when the repo could not be identified.
54
+ * - `chatType`: Telegram chat type where `/log` was invoked
55
+ * (`'private'` | `'group'` | `'supergroup'` | `'channel'`).
56
+ *
57
+ * Output: `{ destination, reason, isolationBackend }` where `destination` is
58
+ * one of `'chat'` (deliver in the same chat), `'dm'` (deliver in DM),
59
+ * `'reject'` (don't deliver). `reason` is a short, user-facing string.
60
+ *
61
+ * @returns {{destination: 'chat'|'dm'|'reject', reason: string, isolationBackend: string|null}}
62
+ */
63
+ export function decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType }) {
64
+ if (!statusResult || !statusResult.exists) {
65
+ return { destination: 'reject', reason: 'Unknown session id (start-command does not know about it).', isolationBackend: null };
66
+ }
67
+
68
+ // Determine isolation backend. Prefer the in-memory record (which knows what
69
+ // we asked `$` to use), fall back to whatever `$ --status` reports.
70
+ const isolationBackend = (sessionInfo?.isolationBackend || statusResult.isolation || '').toLowerCase() || null;
71
+ if (!isolationBackend || !ISOLATION_BACKENDS.has(isolationBackend)) {
72
+ return {
73
+ destination: 'reject',
74
+ reason: 'This command currently supports only sessions launched with `$` isolation (screen / tmux / docker).',
75
+ isolationBackend: isolationBackend || null,
76
+ };
77
+ }
78
+
79
+ // Privacy decision — fail closed when in doubt.
80
+ const isPublic = repoVisibility?.isPublic === true;
81
+ const visibilityKnown = !!repoVisibility && repoVisibility.visibility !== null;
82
+
83
+ if (isPublic && visibilityKnown) {
84
+ if (chatType === 'private') {
85
+ // /log was invoked in DM. Deliver in DM regardless of repo visibility.
86
+ return { destination: 'dm', reason: 'Public repository, delivering in DM (command was sent in a private chat).', isolationBackend };
87
+ }
88
+ return { destination: 'chat', reason: 'Public repository, delivering in chat.', isolationBackend };
89
+ }
90
+
91
+ // Private OR unknown visibility — never leak in a public chat.
92
+ return {
93
+ destination: 'dm',
94
+ reason: visibilityKnown ? 'Private repository — delivering via direct message.' : 'Repository visibility could not be determined — delivering via direct message (fail-closed).',
95
+ isolationBackend,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Resolve the on-disk log path for a session.
101
+ *
102
+ * Prefers the `logPath` field reported by `$ --status` (always correct when
103
+ * supported). Falls back to start-command's documented layout if the field is
104
+ * missing.
105
+ *
106
+ * @returns {string|null}
107
+ */
108
+ export function resolveLogPath({ statusResult, isolationBackend }) {
109
+ if (statusResult?.logPath) return statusResult.logPath;
110
+ const uuid = statusResult?.uuid;
111
+ if (!uuid) return null;
112
+ if (isolationBackend && ISOLATION_BACKENDS.has(isolationBackend)) {
113
+ return path.join('/tmp/start-command/logs/isolation', isolationBackend, `${uuid}.log`);
114
+ }
115
+ return path.join('/tmp/start-command/logs/direct', `${uuid}.log`);
116
+ }
117
+
118
+ async function fileExists(filePath) {
119
+ try {
120
+ await fs.access(filePath, fsConstants.R_OK);
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ async function fileSize(filePath) {
128
+ try {
129
+ const stat = await fs.stat(filePath);
130
+ return stat.size;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Registers the /log command handler with the bot.
138
+ *
139
+ * Dependencies (`querySessionStatus`, `getTrackedSessionInfo`,
140
+ * `detectRepositoryVisibility`, `parseGitHubUrl`) are lazy-loaded from the
141
+ * existing libraries by default; tests pass mocked versions through `options`.
142
+ *
143
+ * @param {Object} bot - Telegraf bot instance
144
+ * @param {Object} options
145
+ * @param {boolean} [options.VERBOSE]
146
+ * @param {Function} options.isOldMessage
147
+ * @param {Function} options.isChatAuthorized
148
+ * @param {Function} [options.isTopicAuthorized]
149
+ * @param {Function} [options.buildAuthErrorMessage]
150
+ * @param {Function} [options.querySessionStatus] - Override for tests
151
+ * @param {Function} [options.getTrackedSessionInfo] - Override for tests
152
+ * @param {Function} [options.detectRepositoryVisibility] - Override for tests
153
+ * @param {Function} [options.parseGitHubUrl] - Override for tests
154
+ */
155
+ export async function registerLogCommand(bot, options) {
156
+ const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
157
+ const querySessionStatus = options.querySessionStatus || (await import('./isolation-runner.lib.mjs')).querySessionStatus;
158
+ const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
159
+ const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
160
+ const parseGitHubUrl = options.parseGitHubUrl || (await import('./github.lib.mjs')).parseGitHubUrl;
161
+
162
+ bot.command('log', async ctx => {
163
+ VERBOSE && console.log('[VERBOSE] /log command received');
164
+
165
+ if (isOldMessage && isOldMessage(ctx)) {
166
+ VERBOSE && console.log('[VERBOSE] /log ignored: old message');
167
+ return;
168
+ }
169
+
170
+ const chat = ctx.chat;
171
+ const message = ctx.message;
172
+ if (!chat || !message) return;
173
+
174
+ const chatType = chat.type;
175
+ const chatId = chat.id;
176
+
177
+ // Extract the session id. Priority: explicit argument, then reply text.
178
+ const directSessionId = extractSessionIdFromText(message.text || '');
179
+ const repliedTo = message.reply_to_message;
180
+ const replySessionId = repliedTo ? extractSessionIdFromText(repliedTo.text || repliedTo.caption || '') : null;
181
+ const sessionId = directSessionId || replySessionId;
182
+
183
+ if (!sessionId) {
184
+ await ctx.reply('❌ /log requires a session id.\n\nUsage:\n• `/log <UUID>` — fetch a specific session log\n• Reply to a session message with `/log` — fetch the session referenced in that message', {
185
+ parse_mode: 'Markdown',
186
+ reply_to_message_id: message.message_id,
187
+ });
188
+ return;
189
+ }
190
+
191
+ // Authorization. /log is only available to chat owners. In private chats
192
+ // there is no "creator" status — the user is implicitly the owner of their
193
+ // own DM, so we allow it. We still apply the optional allowlist used by
194
+ // other commands so a private bot deployment can lock /log to known users.
195
+ if (chatType === 'private') {
196
+ // No further auth required beyond the optional whitelist applied below.
197
+ } else {
198
+ try {
199
+ const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
200
+ if (!member || member.status !== 'creator') {
201
+ VERBOSE && console.log('[VERBOSE] /log rejected: not chat owner');
202
+ await ctx.reply('❌ /log is only available to the chat owner.', { reply_to_message_id: message.message_id });
203
+ return;
204
+ }
205
+ } catch (error) {
206
+ console.error('[ERROR] /log: getChatMember failed:', error);
207
+ await ctx.reply('❌ Failed to verify permissions for /log.', { reply_to_message_id: message.message_id });
208
+ return;
209
+ }
210
+ }
211
+
212
+ if (isChatAuthorized && !isChatAuthorized(chatId)) {
213
+ // Topic-aware fallback (used elsewhere in this repo for forum topics).
214
+ if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
215
+ VERBOSE && console.log('[VERBOSE] /log rejected: chat not authorized');
216
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized.`;
217
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
218
+ return;
219
+ }
220
+ }
221
+
222
+ // 1. Validate the session id with $ --status.
223
+ let statusResult;
224
+ try {
225
+ statusResult = await querySessionStatus(sessionId, VERBOSE);
226
+ } catch (error) {
227
+ console.error('[ERROR] /log: querySessionStatus failed:', error);
228
+ await ctx.reply(`❌ Failed to query session status: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
229
+ return;
230
+ }
231
+
232
+ if (!statusResult || !statusResult.exists) {
233
+ await ctx.reply(`❌ Session \`${sessionId}\` is not known to start-command.\n\nUse the session id from a \`📊 Session: <uuid>\` line in one of the bot's status messages.`, {
234
+ parse_mode: 'Markdown',
235
+ reply_to_message_id: message.message_id,
236
+ });
237
+ return;
238
+ }
239
+
240
+ // 2. Look up tracked metadata (for repo URL and original chat).
241
+ const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
242
+
243
+ // 3. Decide repo visibility — prefer the URL we tracked at launch time.
244
+ let repoVisibility = null;
245
+ let repoUrlDescription = null;
246
+ const trackedUrl = sessionInfo?.url || null;
247
+ if (trackedUrl) {
248
+ const parsed = parseGitHubUrl ? parseGitHubUrl(trackedUrl) : null;
249
+ if (parsed && parsed.valid && parsed.owner && parsed.repo) {
250
+ repoUrlDescription = `${parsed.owner}/${parsed.repo}`;
251
+ try {
252
+ repoVisibility = await detectRepositoryVisibility(parsed.owner, parsed.repo);
253
+ } catch (error) {
254
+ console.error('[ERROR] /log: detectRepositoryVisibility failed:', error);
255
+ repoVisibility = null;
256
+ }
257
+ }
258
+ }
259
+
260
+ // 4. Decide the destination.
261
+ const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType });
262
+ if (decision.destination === 'reject') {
263
+ await ctx.reply(`❌ ${decision.reason}`, { reply_to_message_id: message.message_id });
264
+ return;
265
+ }
266
+
267
+ // 5. Resolve and validate the on-disk log file.
268
+ const logPath = resolveLogPath({ statusResult, isolationBackend: decision.isolationBackend });
269
+ if (!logPath) {
270
+ await ctx.reply('❌ Could not determine the log file path for this session.', { reply_to_message_id: message.message_id });
271
+ return;
272
+ }
273
+ if (!(await fileExists(logPath))) {
274
+ await ctx.reply(`❌ Log file does not exist on disk:\n\`${logPath}\`\n\nThe session may have been cleaned up by the host or the isolation backend.`, {
275
+ parse_mode: 'Markdown',
276
+ reply_to_message_id: message.message_id,
277
+ });
278
+ return;
279
+ }
280
+ const size = await fileSize(logPath);
281
+ if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
282
+ await ctx.reply(`❌ Log file is ${(size / (1024 * 1024)).toFixed(1)} MB which exceeds Telegram's 50 MB document upload limit.\n\nFile path on host: \`${logPath}\``, {
283
+ parse_mode: 'Markdown',
284
+ reply_to_message_id: message.message_id,
285
+ });
286
+ return;
287
+ }
288
+
289
+ const filename = path.basename(logPath);
290
+ const captionLines = [`📁 Log for session \`${sessionId}\``];
291
+ if (decision.isolationBackend) captionLines.push(`🔒 Isolation: \`${decision.isolationBackend}\``);
292
+ if (statusResult.status) captionLines.push(`Status: \`${statusResult.status}\``);
293
+ if (repoUrlDescription) captionLines.push(`Repo: \`${repoUrlDescription}\``);
294
+ captionLines.push(`Privacy: ${decision.reason}`);
295
+ const caption = captionLines.join('\n');
296
+
297
+ if (decision.destination === 'chat') {
298
+ // Public repository → reply with the document directly in the chat.
299
+ try {
300
+ await ctx.replyWithDocument({ source: logPath, filename }, { reply_to_message_id: message.message_id, caption, parse_mode: 'Markdown' });
301
+ } catch (error) {
302
+ console.error('[ERROR] /log: replyWithDocument failed:', error);
303
+ await ctx.reply(`❌ Failed to upload log: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
304
+ }
305
+ return;
306
+ }
307
+
308
+ // DM flow: forward the originating message into DM (so the audit chain
309
+ // is preserved), then reply to that forwarded message with the log file.
310
+ const userId = ctx.from?.id;
311
+ if (!userId) {
312
+ await ctx.reply('❌ Cannot deliver the log via DM: missing user id.', { reply_to_message_id: message.message_id });
313
+ return;
314
+ }
315
+
316
+ let forwardedMessageId = null;
317
+ try {
318
+ // Forward the message that contains the session id (the reply target if
319
+ // any, otherwise the /log message itself).
320
+ const forwardSource = repliedTo || message;
321
+ const forwardedFromChatId = forwardSource === repliedTo ? chatId : chatId;
322
+ const forwardedSourceMessageId = forwardSource.message_id;
323
+ try {
324
+ const forwarded = await ctx.telegram.forwardMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
325
+ forwardedMessageId = forwarded?.message_id || null;
326
+ } catch (forwardError) {
327
+ // forwardMessage can fail if the user has not opened a DM with the bot
328
+ // yet, or the source chat blocks forwards. Fall back to copyMessage,
329
+ // which works without a forward header.
330
+ try {
331
+ const copied = await ctx.telegram.copyMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
332
+ forwardedMessageId = copied?.message_id || null;
333
+ } catch (copyError) {
334
+ console.error('[ERROR] /log: forward/copyMessage to DM failed:', forwardError, copyError);
335
+ // Fall through — we can still try sendDocument without a reply ref.
336
+ }
337
+ }
338
+ } catch (error) {
339
+ console.error('[ERROR] /log: DM forwarding step failed:', error);
340
+ }
341
+
342
+ try {
343
+ const replyOpts = forwardedMessageId ? { reply_to_message_id: forwardedMessageId, caption, parse_mode: 'Markdown' } : { caption, parse_mode: 'Markdown' };
344
+ await ctx.telegram.sendDocument(userId, { source: logPath, filename }, replyOpts);
345
+ } catch (error) {
346
+ console.error('[ERROR] /log: sendDocument to DM failed:', error);
347
+ // Tell the user, in their original chat, that DM delivery failed
348
+ // (commonly because they have not started a chat with the bot).
349
+ 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 send the log via DM: ${error.message || String(error)}`;
350
+ await ctx.reply(`❌ ${friendly}`, { reply_to_message_id: message.message_id });
351
+ return;
352
+ }
353
+
354
+ // Acknowledge in the original chat (only if it wasn't already a DM).
355
+ if (chatType !== 'private') {
356
+ try {
357
+ await ctx.reply(`📬 Sent the log for \`${sessionId}\` to your direct messages (private repository).`, {
358
+ parse_mode: 'Markdown',
359
+ reply_to_message_id: message.message_id,
360
+ });
361
+ } catch (error) {
362
+ console.error('[ERROR] /log: failed to acknowledge in chat:', error);
363
+ }
364
+ }
365
+ });
366
+ }
367
+
368
+ export const __INTERNAL_FOR_TESTS__ = {
369
+ UUID_RE,
370
+ TELEGRAM_DOCUMENT_MAX_BYTES,
371
+ ISOLATION_BACKENDS,
372
+ };