@link-assistant/hive-mind 1.56.18 → 1.57.0

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.
@@ -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
+ };