@link-assistant/hive-mind 1.50.8 → 1.50.10

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/src/lib.mjs CHANGED
@@ -188,16 +188,107 @@ export const setupVerboseLogInterceptor = () => {
188
188
  */
189
189
  let stdioInterceptorInstalled = false;
190
190
  let _writingFromLog = false; // Guard flag to prevent double-logging from log()
191
+ let stdoutBroken = false;
192
+ let stderrBroken = false;
193
+ let brokenPipeDiagnosticsWritten = false;
194
+
195
+ const isBrokenPipeError = error => {
196
+ return error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED';
197
+ };
198
+
199
+ const invokeWriteCallback = (callback, error = null) => {
200
+ if (typeof callback === 'function') {
201
+ callback(error);
202
+ }
203
+ };
204
+
205
+ const appendInternalDiagnostic = async message => {
206
+ if (!logFile) return;
207
+ const prefix = `[${new Date().toISOString()}] [INTERNAL]`;
208
+ await fs.appendFile(logFile, `${prefix} ${message}\n`).catch(() => {
209
+ // Silent fail to avoid recursive logging errors
210
+ });
211
+ };
212
+
213
+ const formatStreamDiagnostic = stream => {
214
+ return JSON.stringify({
215
+ isTTY: Boolean(stream?.isTTY),
216
+ destroyed: Boolean(stream?.destroyed),
217
+ writable: stream?.writable,
218
+ writableEnded: Boolean(stream?.writableEnded),
219
+ writableFinished: Boolean(stream?.writableFinished),
220
+ errored: stream?.errored?.code || stream?.errored?.message || null,
221
+ fd: typeof stream?.fd === 'number' ? stream.fd : null,
222
+ });
223
+ };
224
+
225
+ const normalizeWriteCallback = (encoding, callback) => {
226
+ return typeof encoding === 'function' ? encoding : callback;
227
+ };
228
+
229
+ const safeTerminalWrite = ({ originalWrite, chunk, encoding, callback, streamName }) => {
230
+ const isStdout = streamName === 'stdout';
231
+ const normalizedCallback = normalizeWriteCallback(encoding, callback);
232
+ if ((isStdout && stdoutBroken) || (!isStdout && stderrBroken)) {
233
+ invokeWriteCallback(normalizedCallback);
234
+ return false;
235
+ }
236
+
237
+ try {
238
+ return originalWrite(chunk, encoding, callback);
239
+ } catch (error) {
240
+ if (!isBrokenPipeError(error)) {
241
+ throw error;
242
+ }
243
+
244
+ if (isStdout) {
245
+ stdoutBroken = true;
246
+ } else {
247
+ stderrBroken = true;
248
+ }
249
+
250
+ invokeWriteCallback(normalizedCallback, error);
251
+ return false;
252
+ }
253
+ };
254
+
255
+ const installBrokenPipeGuard = (stream, streamName) => {
256
+ stream.on('error', error => {
257
+ if (isBrokenPipeError(error)) {
258
+ if (streamName === 'stdout') {
259
+ stdoutBroken = true;
260
+ } else {
261
+ stderrBroken = true;
262
+ }
263
+ if (!brokenPipeDiagnosticsWritten) {
264
+ brokenPipeDiagnosticsWritten = true;
265
+ void appendInternalDiagnostic(`Detected broken ${streamName} stream (${error.code || 'unknown'}). Stream state=${formatStreamDiagnostic(stream)}. Further terminal writes will be skipped when possible.`);
266
+ }
267
+ return;
268
+ }
269
+
270
+ throw error;
271
+ });
272
+ };
273
+
191
274
  export const setupStdioLogInterceptor = () => {
192
275
  if (stdioInterceptorInstalled) return;
193
276
  stdioInterceptorInstalled = true;
194
277
 
195
278
  const originalStdoutWrite = process.stdout.write.bind(process.stdout);
196
279
  const originalStderrWrite = process.stderr.write.bind(process.stderr);
280
+ installBrokenPipeGuard(process.stdout, 'stdout');
281
+ installBrokenPipeGuard(process.stderr, 'stderr');
197
282
 
198
283
  process.stdout.write = (chunk, encoding, callback) => {
199
- // Always write to terminal first
200
- const result = originalStdoutWrite(chunk, encoding, callback);
284
+ // Always write to terminal first, unless the output pipe is already broken.
285
+ const result = safeTerminalWrite({
286
+ originalWrite: originalStdoutWrite,
287
+ chunk,
288
+ encoding,
289
+ callback,
290
+ streamName: 'stdout',
291
+ });
201
292
 
202
293
  // Also append to log file if set, but skip if this write originated from log()
203
294
  if (logFile && !_writingFromLog) {
@@ -214,8 +305,14 @@ export const setupStdioLogInterceptor = () => {
214
305
  };
215
306
 
216
307
  process.stderr.write = (chunk, encoding, callback) => {
217
- // Always write to terminal first
218
- const result = originalStderrWrite(chunk, encoding, callback);
308
+ // Always write to terminal first, unless the output pipe is already broken.
309
+ const result = safeTerminalWrite({
310
+ originalWrite: originalStderrWrite,
311
+ chunk,
312
+ encoding,
313
+ callback,
314
+ streamName: 'stderr',
315
+ });
219
316
 
220
317
  // Also append to log file if set, but skip if this write originated from log()
221
318
  if (logFile && !_writingFromLog) {
@@ -30,11 +30,87 @@ const execAsync = promisify(exec);
30
30
  * Default path to Claude credentials file
31
31
  */
32
32
  const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
33
+ const DEFAULT_CODEX_AUTH_PATH = join(homedir(), '.codex', 'auth.json');
34
+ const DEFAULT_CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
33
35
 
34
36
  /**
35
37
  * Anthropic OAuth usage API endpoint
36
38
  */
37
39
  const USAGE_API_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
40
+ const CODEX_USAGE_API_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api';
41
+
42
+ function decodeJwtPayload(token) {
43
+ if (!token || typeof token !== 'string') return null;
44
+
45
+ try {
46
+ const payload = token.split('.')[1];
47
+ if (!payload) return null;
48
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
49
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
50
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function unixSecondsToIsoDate(seconds) {
57
+ if (seconds === null || seconds === undefined) return null;
58
+ const numeric = Number(seconds);
59
+ if (!Number.isFinite(numeric) || numeric <= 0) return null;
60
+ return new Date(numeric * 1000).toISOString();
61
+ }
62
+
63
+ function mapCodexWindow(window) {
64
+ const resetsAt = unixSecondsToIsoDate(window?.reset_at);
65
+ return {
66
+ percentage: window?.used_percent ?? null,
67
+ resetTime: formatResetTime(resetsAt),
68
+ resetsAt,
69
+ windowSeconds: window?.limit_window_seconds ?? null,
70
+ resetAfterSeconds: window?.reset_after_seconds ?? null,
71
+ };
72
+ }
73
+
74
+ async function readCodexAuth(authPath = DEFAULT_CODEX_AUTH_PATH, verbose = false) {
75
+ try {
76
+ const content = await readFile(authPath, 'utf-8');
77
+ const auth = JSON.parse(content);
78
+
79
+ if (verbose) {
80
+ console.log('[VERBOSE] /limits Codex auth loaded from:', authPath);
81
+ }
82
+
83
+ return auth;
84
+ } catch (error) {
85
+ if (verbose) {
86
+ console.error('[VERBOSE] /limits failed to read Codex auth:', error.message);
87
+ }
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function getCodexUsageBaseUrl(configPath = DEFAULT_CODEX_CONFIG_PATH, verbose = false) {
93
+ try {
94
+ const content = await readFile(configPath, 'utf-8');
95
+ const match = content.match(/^\s*chatgpt_base_url\s*=\s*["']([^"']+)["']/m);
96
+ if (!match?.[1]) return CODEX_USAGE_API_DEFAULT_BASE_URL;
97
+
98
+ const baseUrl = match[1].trim().replace(/\/+$/, '');
99
+ const normalized = baseUrl.endsWith('/backend-api') ? baseUrl : `${baseUrl}/backend-api`;
100
+
101
+ if (verbose) {
102
+ console.log('[VERBOSE] /limits Codex base URL loaded from config:', normalized);
103
+ }
104
+
105
+ return normalized;
106
+ } catch (error) {
107
+ if (verbose) {
108
+ console.log('[VERBOSE] /limits using default Codex base URL:', CODEX_USAGE_API_DEFAULT_BASE_URL);
109
+ console.log('[VERBOSE] /limits failed to read Codex config:', error.message);
110
+ }
111
+ return CODEX_USAGE_API_DEFAULT_BASE_URL;
112
+ }
113
+ }
38
114
 
39
115
  /**
40
116
  * Read Claude credentials from the credentials file
@@ -702,6 +778,162 @@ export async function getClaudeUsageLimits(verbose = false, credentialsPath = DE
702
778
  }
703
779
  }
704
780
 
781
+ /**
782
+ * Get Codex usage limits through the ChatGPT-authenticated usage endpoint.
783
+ * Mirrors the supported upstream Codex account/rate-limits path.
784
+ *
785
+ * Returns usage data for:
786
+ * - Current session (5-hour) usage percentage and reset time
787
+ * - Current week usage percentage and reset date
788
+ * - Additional metered Codex limits when available
789
+ *
790
+ * @param {boolean} verbose - Whether to log verbose output
791
+ * @param {string} authPath - Optional path to Codex auth.json
792
+ * @param {string|null} baseUrl - Optional backend base URL override
793
+ * @returns {Object} Object with success boolean, and either usage data or error message
794
+ */
795
+ export async function getCodexUsageLimits(verbose = false, authPath = DEFAULT_CODEX_AUTH_PATH, baseUrl = null) {
796
+ try {
797
+ const auth = await readCodexAuth(authPath, verbose);
798
+
799
+ if (!auth) {
800
+ return {
801
+ success: false,
802
+ error: 'Could not read Codex authentication. Make sure Codex is properly installed and authenticated.',
803
+ };
804
+ }
805
+
806
+ if (auth.auth_mode && auth.auth_mode !== 'chatgpt') {
807
+ return {
808
+ success: false,
809
+ error: 'Codex rate limits require ChatGPT authentication. API key auth does not expose account usage windows.',
810
+ };
811
+ }
812
+
813
+ const accessToken = auth?.tokens?.access_token;
814
+ if (!accessToken) {
815
+ return {
816
+ success: false,
817
+ error: 'No Codex access token found. Please authenticate Codex with your ChatGPT account.',
818
+ };
819
+ }
820
+
821
+ const resolvedBaseUrl = (baseUrl || (await getCodexUsageBaseUrl(undefined, verbose))).replace(/\/+$/, '');
822
+ const usageEndpoint = `${resolvedBaseUrl}/wham/usage`;
823
+ const tokenPayload = decodeJwtPayload(accessToken);
824
+ const requestHeaders = {
825
+ Accept: 'application/json',
826
+ Authorization: `Bearer ${accessToken}`,
827
+ 'User-Agent': 'hive-mind-codex-limits/1.0',
828
+ };
829
+
830
+ if (verbose) {
831
+ console.log('[VERBOSE] /limits fetching Codex usage from API...');
832
+ console.log(`[VERBOSE] /limits Codex API request: GET ${usageEndpoint}`);
833
+ console.log('[VERBOSE] /limits Codex auth mode:', auth.auth_mode || 'unknown');
834
+ console.log('[VERBOSE] /limits Codex account id:', auth?.tokens?.account_id || tokenPayload?.['https://api.openai.com/auth']?.chatgpt_account_id || 'unknown');
835
+ console.log('[VERBOSE] /limits Codex plan type:', tokenPayload?.['https://api.openai.com/auth']?.chatgpt_plan_type || 'unknown');
836
+ console.log(
837
+ '[VERBOSE] /limits Codex API request headers:',
838
+ JSON.stringify(
839
+ {
840
+ Accept: requestHeaders.Accept,
841
+ Authorization: `Bearer ...${accessToken.slice(-8)}`,
842
+ 'User-Agent': requestHeaders['User-Agent'],
843
+ },
844
+ null,
845
+ 2
846
+ )
847
+ );
848
+ }
849
+
850
+ const response = await fetch(usageEndpoint, {
851
+ method: 'GET',
852
+ headers: requestHeaders,
853
+ });
854
+
855
+ if (verbose) {
856
+ console.log(`[VERBOSE] /limits Codex API HTTP status: ${response.status} ${response.statusText}`);
857
+ const responseHeaders = {};
858
+ response.headers.forEach((value, key) => {
859
+ responseHeaders[key] = value;
860
+ });
861
+ console.log('[VERBOSE] /limits Codex API response headers:', JSON.stringify(responseHeaders, null, 2));
862
+ }
863
+
864
+ if (!response.ok) {
865
+ const errorText = await response.text();
866
+ if (verbose) {
867
+ console.error('[VERBOSE] /limits Codex API error body:', errorText);
868
+ }
869
+
870
+ if (response.status === 401) {
871
+ return {
872
+ success: false,
873
+ error: 'Codex authentication expired. Please re-authenticate Codex with your ChatGPT account.',
874
+ };
875
+ }
876
+
877
+ if (response.status === 429) {
878
+ const retryAfter = response.headers.get('retry-after');
879
+ return {
880
+ success: false,
881
+ error: `Codex usage API access has reached rate limit.${formatRetryAfterMessage(retryAfter)}`,
882
+ };
883
+ }
884
+
885
+ return {
886
+ success: false,
887
+ error: `Failed to fetch Codex usage from API: ${response.status} ${response.statusText}`,
888
+ };
889
+ }
890
+
891
+ const data = await response.json();
892
+
893
+ if (verbose) {
894
+ console.log('[VERBOSE] /limits Codex API response body:', JSON.stringify(data, null, 2));
895
+ }
896
+
897
+ const usage = {
898
+ currentSession: mapCodexWindow(data?.rate_limit?.primary_window),
899
+ allModels: mapCodexWindow(data?.rate_limit?.secondary_window),
900
+ sonnetOnly: {
901
+ percentage: null,
902
+ resetTime: null,
903
+ resetsAt: null,
904
+ },
905
+ };
906
+
907
+ const additionalRateLimits = Array.isArray(data?.additional_rate_limits)
908
+ ? data.additional_rate_limits.map(limit => ({
909
+ limitId: limit?.metered_feature || null,
910
+ limitName: limit?.limit_name || limit?.metered_feature || 'additional',
911
+ currentSession: mapCodexWindow(limit?.rate_limit?.primary_window),
912
+ allModels: mapCodexWindow(limit?.rate_limit?.secondary_window),
913
+ allowed: limit?.rate_limit?.allowed ?? null,
914
+ limitReached: limit?.rate_limit?.limit_reached ?? null,
915
+ }))
916
+ : [];
917
+
918
+ return {
919
+ success: true,
920
+ usage,
921
+ planType: data?.plan_type || tokenPayload?.['https://api.openai.com/auth']?.chatgpt_plan_type || null,
922
+ credits: data?.credits || null,
923
+ additionalRateLimits,
924
+ raw: data,
925
+ };
926
+ } catch (error) {
927
+ if (verbose) {
928
+ console.error('[VERBOSE] /limits Codex error:', error);
929
+ }
930
+ return {
931
+ success: false,
932
+ error: `Failed to get Codex usage limits: ${error.message}`,
933
+ };
934
+ }
935
+ }
936
+
705
937
  /**
706
938
  * Generate a text-based progress bar for usage percentage
707
939
  * @param {number} percentage - Usage percentage (0-100)
@@ -953,6 +1185,85 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
953
1185
  return '```\n' + sections.join('\n') + '```';
954
1186
  }
955
1187
 
1188
+ /**
1189
+ * Format Codex usage data into a section suitable for appending to /limits output.
1190
+ *
1191
+ * @param {Object|null} codexLimits - Result object from getCodexUsageLimits, or null
1192
+ * @param {string|null} codexError - Optional error message
1193
+ * @returns {string} Formatted section text
1194
+ */
1195
+ export function formatCodexLimitsSection(codexLimits, codexError = null) {
1196
+ if (codexError) {
1197
+ return `Codex limits\n${codexError}\n`;
1198
+ }
1199
+
1200
+ const usage = codexLimits?.usage || null;
1201
+ const additionalRateLimits = codexLimits?.additionalRateLimits || [];
1202
+ const credits = codexLimits?.credits || null;
1203
+ const planType = codexLimits?.planType || null;
1204
+
1205
+ let section = 'Codex limits\n';
1206
+ if (planType) {
1207
+ section += `Plan: ${planType}\n`;
1208
+ }
1209
+
1210
+ let sessionSection = 'Codex 5 hour session\n';
1211
+ if (usage?.currentSession?.percentage !== null) {
1212
+ const timePassed = calculateTimePassedPercentage(usage.currentSession.resetsAt, 5);
1213
+ if (timePassed !== null) {
1214
+ sessionSection += `${getProgressBar(timePassed)} ${timePassed}% passed\n`;
1215
+ }
1216
+ const pct = Math.floor(usage.currentSession.percentage);
1217
+ const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION);
1218
+ const suffix = pct >= DISPLAY_THRESHOLDS.CODEX_5_HOUR_SESSION ? ' ⚠️' : ' used';
1219
+ sessionSection += `${bar} ${pct}%${suffix}\n`;
1220
+ if (usage.currentSession.resetTime) {
1221
+ const relativeTime = formatRelativeTime(usage.currentSession.resetsAt);
1222
+ sessionSection += relativeTime ? `Resets in ${relativeTime} (${usage.currentSession.resetTime})\n` : `Resets ${usage.currentSession.resetTime}\n`;
1223
+ }
1224
+ } else {
1225
+ sessionSection += 'N/A\n';
1226
+ }
1227
+
1228
+ let weeklySection = 'Current week (all models)\n';
1229
+ if (usage?.allModels?.percentage !== null) {
1230
+ const timePassed = calculateTimePassedPercentage(usage.allModels.resetsAt, 168);
1231
+ if (timePassed !== null) {
1232
+ weeklySection += `${getProgressBar(timePassed)} ${timePassed}% passed\n`;
1233
+ }
1234
+ const pct = Math.floor(usage.allModels.percentage);
1235
+ const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CODEX_WEEKLY);
1236
+ const suffix = pct >= DISPLAY_THRESHOLDS.CODEX_WEEKLY ? ' ⚠️' : ' used';
1237
+ weeklySection += `${bar} ${pct}%${suffix}\n`;
1238
+ if (usage.allModels.resetTime) {
1239
+ const relativeTime = formatRelativeTime(usage.allModels.resetsAt);
1240
+ weeklySection += relativeTime ? `Resets in ${relativeTime} (${usage.allModels.resetTime})\n` : `Resets ${usage.allModels.resetTime}\n`;
1241
+ }
1242
+ } else {
1243
+ weeklySection += 'N/A\n';
1244
+ }
1245
+
1246
+ section += `${sessionSection}\n${weeklySection}`;
1247
+
1248
+ if (additionalRateLimits.length > 0) {
1249
+ section += '\nAdditional Codex limits\n';
1250
+ for (const limit of additionalRateLimits) {
1251
+ const sessionPct = limit.currentSession?.percentage;
1252
+ const weeklyPct = limit.allModels?.percentage;
1253
+ const sessionText = sessionPct === null ? 'session N/A' : `session ${Math.floor(sessionPct)}%`;
1254
+ const weeklyText = weeklyPct === null ? 'week N/A' : `week ${Math.floor(weeklyPct)}%`;
1255
+ section += `${limit.limitName}: ${sessionText}, ${weeklyText}\n`;
1256
+ }
1257
+ }
1258
+
1259
+ if (credits) {
1260
+ const creditSummary = credits.unlimited ? 'unlimited' : `${credits.balance ?? '0'} balance`;
1261
+ section += `\nCodex credits\n${creditSummary}\n`;
1262
+ }
1263
+
1264
+ return section;
1265
+ }
1266
+
956
1267
  // ============================================================================
957
1268
  // Caching Layer
958
1269
  // ============================================================================
@@ -1064,6 +1375,29 @@ export async function getCachedClaudeLimits(verbose = false) {
1064
1375
  return result;
1065
1376
  }
1066
1377
 
1378
+ export async function getCachedCodexLimits(verbose = false) {
1379
+ const cache = getLimitCache();
1380
+ const cached = cache.get('codex', CACHE_TTL.USAGE_API);
1381
+ if (cached) {
1382
+ if (verbose) console.log('[VERBOSE] /limits-cache: Using cached Codex limits (TTL: ' + Math.round(CACHE_TTL.USAGE_API / 60000) + ' minutes)');
1383
+ return cached;
1384
+ }
1385
+ const cachedError = cache.get('codex-rate-limited', CACHE_TTL.USAGE_API);
1386
+ if (cachedError) {
1387
+ if (verbose) console.log('[VERBOSE] /limits-cache: Using cached Codex rate-limit error');
1388
+ return cachedError;
1389
+ }
1390
+ if (verbose) console.log('[VERBOSE] /limits-cache: Cache miss for Codex limits, fetching from API...');
1391
+ const result = await getCodexUsageLimits(verbose);
1392
+ if (result.success) {
1393
+ cache.set('codex', result, CACHE_TTL.USAGE_API);
1394
+ } else if (result.error && result.error.includes('rate limit')) {
1395
+ cache.set('codex-rate-limited', result, CACHE_TTL.USAGE_API);
1396
+ if (verbose) console.log('[VERBOSE] /limits-cache: Cached Codex rate-limit error for ' + Math.round(CACHE_TTL.USAGE_API / 60000) + ' minutes');
1397
+ }
1398
+ return result;
1399
+ }
1400
+
1067
1401
  export async function getCachedGitHubLimits(verbose = false) {
1068
1402
  const cache = getLimitCache();
1069
1403
  const cached = cache.get('github', CACHE_TTL.API);
@@ -1113,13 +1447,14 @@ export async function getCachedDiskInfo(verbose = false) {
1113
1447
  }
1114
1448
 
1115
1449
  export async function getAllCachedLimits(verbose = false) {
1116
- const [claude, github, memory, cpu, disk] = await Promise.all([getCachedClaudeLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose)]);
1117
- return { claude, github, memory, cpu, disk };
1450
+ const [claude, codex, github, memory, cpu, disk] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose)]);
1451
+ return { claude, codex, github, memory, cpu, disk };
1118
1452
  }
1119
1453
 
1120
1454
  export default {
1121
1455
  // Raw functions (no caching)
1122
1456
  getClaudeUsageLimits,
1457
+ getCodexUsageLimits,
1123
1458
  getCpuLoadInfo,
1124
1459
  getMemoryInfo,
1125
1460
  getDiskSpaceInfo,
@@ -1127,6 +1462,7 @@ export default {
1127
1462
  getProgressBar,
1128
1463
  calculateTimePassedPercentage,
1129
1464
  formatUsageMessage,
1465
+ formatCodexLimitsSection,
1130
1466
  formatRetryAfterMessage,
1131
1467
  // Threshold constants for progress bar visualization
1132
1468
  DISPLAY_THRESHOLDS,
@@ -1136,6 +1472,7 @@ export default {
1136
1472
  resetLimitCache,
1137
1473
  // Cached functions
1138
1474
  getCachedClaudeLimits,
1475
+ getCachedCodexLimits,
1139
1476
  getCachedGitHubLimits,
1140
1477
  getCachedMemoryInfo,
1141
1478
  getCachedCpuInfo,
@@ -11,6 +11,7 @@ const use = globalThis.use;
11
11
 
12
12
  // Use command-stream for consistent $ behavior across runtimes
13
13
  const { $ } = await use('command-stream');
14
+ const $silent = $({ mirror: false, capture: true });
14
15
 
15
16
  // Import shared library functions
16
17
  const lib = await import('./lib.mjs');
@@ -20,6 +21,12 @@ const { log } = lib;
20
21
  const sentryLib = await import('./sentry.lib.mjs');
21
22
  const { reportError } = sentryLib;
22
23
 
24
+ const summarizeCommandOutput = value => {
25
+ const text = value?.toString()?.trim() || '';
26
+ if (!text) return '';
27
+ return text.length > 500 ? `${text.slice(0, 500)}... [truncated ${text.length - 500} chars]` : text;
28
+ };
29
+
23
30
  /**
24
31
  * Upload a log file using gh-upload-log command
25
32
  * @param {Object} options - Upload options
@@ -92,12 +99,18 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
92
99
  // For gist: get raw URL from gist API
93
100
  const gistId = result.url.split('/').pop();
94
101
  try {
95
- const gistDetailsResult = await $`gh api gists/${gistId} --jq '{owner: .owner.login, files: .files, history: .history}'`;
102
+ if (verbose) {
103
+ await log(` 🔍 Fetching gist metadata for raw URL resolution (gistId=${gistId})`, { verbose: true });
104
+ }
105
+ const gistDetailsResult = await $silent`gh api gists/${gistId} --jq '{owner: .owner.login, history: .history, fileNames: (.files | keys)}'`;
106
+ if (verbose) {
107
+ await log(` 📥 Gist metadata fetch completed (code=${gistDetailsResult.code ?? 'unknown'})`, { verbose: true });
108
+ }
96
109
  if (gistDetailsResult.code === 0) {
97
110
  const gistDetails = JSON.parse(gistDetailsResult.stdout.toString());
98
111
  const gistOwner = gistDetails.owner;
99
112
  const commitSha = gistDetails.history?.[0]?.version;
100
- const fileNames = gistDetails.files ? Object.keys(gistDetails.files) : [];
113
+ const fileNames = Array.isArray(gistDetails.fileNames) ? gistDetails.fileNames : [];
101
114
  const fileName = fileNames.length > 0 ? fileNames[0] : 'log.txt';
102
115
 
103
116
  if (commitSha) {
@@ -105,6 +118,18 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
105
118
  } else {
106
119
  result.rawUrl = `https://gist.githubusercontent.com/${gistOwner}/${gistId}/raw/${fileName}`;
107
120
  }
121
+ if (verbose) {
122
+ await log(` 🧩 Gist metadata resolved owner=${gistOwner}, commitSha=${commitSha || 'latest'}, fileName=${fileName}`, { verbose: true });
123
+ }
124
+ } else if (verbose) {
125
+ const stderrSummary = summarizeCommandOutput(gistDetailsResult.stderr);
126
+ const stdoutSummary = summarizeCommandOutput(gistDetailsResult.stdout);
127
+ if (stderrSummary) {
128
+ await log(` ⚠️ Gist metadata stderr: ${stderrSummary}`, { verbose: true });
129
+ }
130
+ if (stdoutSummary) {
131
+ await log(` ⚠️ Gist metadata stdout: ${stdoutSummary}`, { verbose: true });
132
+ }
108
133
  }
109
134
  } catch (apiError) {
110
135
  if (verbose) {
@@ -121,7 +146,13 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
121
146
  try {
122
147
  const repoUrl = result.url;
123
148
  const repoPath = repoUrl.replace('https://github.com/', '');
124
- const contentsResult = await $`gh api repos/${repoPath}/contents --jq '.[].name'`;
149
+ if (verbose) {
150
+ await log(` 🔍 Fetching repository contents for raw URL resolution (repoPath=${repoPath})`, { verbose: true });
151
+ }
152
+ const contentsResult = await $silent`gh api repos/${repoPath}/contents --jq '.[].name'`;
153
+ if (verbose) {
154
+ await log(` 📥 Repository contents fetch completed (code=${contentsResult.code ?? 'unknown'})`, { verbose: true });
155
+ }
125
156
  if (contentsResult.code === 0) {
126
157
  const files = contentsResult.stdout
127
158
  .toString()
@@ -131,6 +162,18 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
131
162
  if (files.length > 0) {
132
163
  const fileName = files[0];
133
164
  result.rawUrl = `${repoUrl}/raw/main/${fileName}`;
165
+ if (verbose) {
166
+ await log(` 🧩 Repository contents resolved fileName=${fileName}`, { verbose: true });
167
+ }
168
+ }
169
+ } else if (verbose) {
170
+ const stderrSummary = summarizeCommandOutput(contentsResult.stderr);
171
+ const stdoutSummary = summarizeCommandOutput(contentsResult.stdout);
172
+ if (stderrSummary) {
173
+ await log(` ⚠️ Repository contents stderr: ${stderrSummary}`, { verbose: true });
174
+ }
175
+ if (stdoutSummary) {
176
+ await log(` ⚠️ Repository contents stdout: ${stdoutSummary}`, { verbose: true });
134
177
  }
135
178
  }
136
179
  } catch (apiError) {