@link-assistant/hive-mind 1.69.13 → 1.69.15

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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.69.15
4
+
5
+ ### Patch Changes
6
+
7
+ - fbda6de: Fix `--auto-fork` failing on private repositories with read-only access when forking is allowed. `handleAutoForkOption` now probes the `allow_forking` repository attribute before bailing out: when it is `true`, fork mode is enabled (the same behaviour already used for public repos without write access); when it is explicitly `false`, the fatal exit explains that direct branch mode needs push/write access, fork mode is disabled, and the maintainer must either grant Write access or enable private forking; when it cannot be determined, we fall through with a verbose warning so `gh repo fork` can produce a precise downstream error. Resolves #1795.
8
+
9
+ ## 1.69.14
10
+
11
+ ### Patch Changes
12
+
13
+ - 32af9e1: Show subscription end date in `/limits` for Claude and Codex when the underlying providers expose it. Claude trials display the trial end date from the OAuth profile; Codex displays the renewal date decoded from the ChatGPT JWT (`chatgpt_subscription_active_until`). Lines are only rendered when real data is available.
14
+
3
15
  ## 1.69.13
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.69.13",
3
+ "version": "1.69.15",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -74,6 +74,11 @@ const ENGLISH_LIMITS = {
74
74
  seven_day_sonnet_only: '7d Sonnet only',
75
75
  session: 'session',
76
76
  start: 'Start',
77
+ subscription_ends: 'Subscription ends {{time}}',
78
+ subscription_ends_in: 'Subscription ends in {{duration}} ({{time}})',
79
+ subscription_status: 'Subscription: {{status}}',
80
+ trial_ends: 'Trial ends {{time}}',
81
+ trial_ends_in: 'Trial ends in {{duration}} ({{time}})',
77
82
  unavailable: 'unavailable',
78
83
  unlimited: 'unlimited',
79
84
  used: 'used',
@@ -110,6 +115,20 @@ export function formatLimitResetsAt(resetTime, options = {}) {
110
115
  return lt('resets_at', { time: resetTime }, options);
111
116
  }
112
117
 
118
+ export function formatSubscriptionEnds(duration, resetTime, options = {}) {
119
+ if (duration) return lt('subscription_ends_in', { duration, time: resetTime }, options);
120
+ return lt('subscription_ends', { time: resetTime }, options);
121
+ }
122
+
123
+ export function formatTrialEnds(duration, resetTime, options = {}) {
124
+ if (duration) return lt('trial_ends_in', { duration, time: resetTime }, options);
125
+ return lt('trial_ends', { time: resetTime }, options);
126
+ }
127
+
128
+ export function formatSubscriptionStatus(status, options = {}) {
129
+ return lt('subscription_status', { status }, options);
130
+ }
131
+
113
132
  export function formatLocalizedResetTime(isoDate, includeTimezone = true, options = {}) {
114
133
  if (typeof includeTimezone === 'object') return formatLocalizedResetTime(isoDate, true, includeTimezone);
115
134
  const locale = resolveLimitLocale(options);
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Subscription metadata helpers for the /limits command.
3
+ *
4
+ * Surfaces the subscription end / trial end / status fields the underlying
5
+ * providers expose:
6
+ * - Claude: GET https://api.anthropic.com/api/oauth/profile
7
+ * - Codex: decoded id_token from ~/.codex/auth.json
8
+ *
9
+ * See `docs/case-studies/issue-1793/`.
10
+ */
11
+
12
+ import { CACHE_TTL, DEFAULT_CODEX_AUTH_PATH, DEFAULT_CREDENTIALS_PATH, decodeJwtPayload, getLimitCache, readCodexAuth, readCredentials } from './limits.lib.mjs';
13
+ import { formatLocalizedRelativeTime, formatLocalizedResetTime, formatSubscriptionEnds, formatSubscriptionStatus, formatTrialEnds, resolveLimitLocale } from './limits-i18n.lib.mjs';
14
+
15
+ const PROFILE_API_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile';
16
+
17
+ /**
18
+ * Render the localized "Subscription ends …" / "Trial ends …" / "Subscription: …"
19
+ * line for a tool. Returns '' when no displayable data is present.
20
+ */
21
+ export function formatSubscriptionLines(subscription, options = {}) {
22
+ if (!subscription) return '';
23
+ const locale = resolveLimitLocale(options);
24
+ const buildLine = (iso, formatter) => {
25
+ const resetTime = formatLocalizedResetTime(iso, true, { locale });
26
+ return resetTime ? formatter(formatLocalizedRelativeTime(iso, { locale }), resetTime, { locale }) : null;
27
+ };
28
+ let line = null;
29
+ if (subscription.endsAt) line = buildLine(subscription.endsAt, formatSubscriptionEnds);
30
+ else if (subscription.trialEndsAt) line = buildLine(subscription.trialEndsAt, formatTrialEnds);
31
+ else if (subscription.status) line = formatSubscriptionStatus(subscription.status, { locale });
32
+ return line ? `${line}\n` : '';
33
+ }
34
+
35
+ /**
36
+ * Get Claude subscription metadata.
37
+ *
38
+ * Reads `~/.claude/.credentials.json` and queries Anthropic's
39
+ * `/api/oauth/profile` endpoint to surface what is currently published:
40
+ * - subscription_status ("active" / "inactive")
41
+ * - subscription_created_at (ISO timestamp)
42
+ * - claude_code_trial_ends_at (ISO timestamp; trials only)
43
+ * - subscriptionType from local creds (informational plan label)
44
+ *
45
+ * Anthropic does NOT currently expose a `subscription_ends_at` field for
46
+ * paid plans, so we never fabricate one. The renderer only emits a line
47
+ * when a value is available.
48
+ *
49
+ * @param {Object} opts
50
+ * @param {boolean} opts.verbose - Verbose logging
51
+ * @param {string} opts.credentialsPath - Override Claude credentials path
52
+ * @returns {Object} `{ success, subscription? , error? }`
53
+ */
54
+ export async function getClaudeSubscriptionInfo({ verbose = false, credentialsPath = DEFAULT_CREDENTIALS_PATH } = {}) {
55
+ try {
56
+ const credentials = await readCredentials(credentialsPath, verbose);
57
+ if (!credentials) {
58
+ return {
59
+ success: false,
60
+ error: 'Could not read Claude credentials. Make sure Claude is properly installed and authenticated.',
61
+ };
62
+ }
63
+
64
+ const accessToken = credentials?.claudeAiOauth?.accessToken;
65
+ const planType = credentials?.claudeAiOauth?.subscriptionType || null;
66
+
67
+ if (!accessToken) {
68
+ return {
69
+ success: false,
70
+ error: 'No access token found in Claude credentials.',
71
+ };
72
+ }
73
+
74
+ const requestHeaders = {
75
+ Accept: 'application/json',
76
+ 'Content-Type': 'application/json',
77
+ 'User-Agent': 'claude-code/2.0.55',
78
+ Authorization: `Bearer ${accessToken}`,
79
+ 'anthropic-beta': 'oauth-2025-04-20',
80
+ };
81
+
82
+ if (verbose) {
83
+ console.log(`[VERBOSE] /limits subscription: GET ${PROFILE_API_ENDPOINT}`);
84
+ }
85
+
86
+ const response = await fetch(PROFILE_API_ENDPOINT, { method: 'GET', headers: requestHeaders });
87
+ if (!response.ok) {
88
+ if (verbose) {
89
+ console.error(`[VERBOSE] /limits subscription HTTP ${response.status} ${response.statusText}`);
90
+ }
91
+ return {
92
+ success: false,
93
+ error: `Failed to fetch Claude profile: ${response.status} ${response.statusText}`,
94
+ };
95
+ }
96
+
97
+ const data = await response.json();
98
+ if (verbose) {
99
+ console.log('[VERBOSE] /limits subscription body:', JSON.stringify(data, null, 2));
100
+ }
101
+
102
+ const organization = data?.organization || {};
103
+ return {
104
+ success: true,
105
+ subscription: {
106
+ planType,
107
+ status: organization.subscription_status || null,
108
+ createdAt: organization.subscription_created_at || null,
109
+ trialEndsAt: organization.claude_code_trial_ends_at || null,
110
+ endsAt: organization.subscription_ends_at || null,
111
+ },
112
+ };
113
+ } catch (error) {
114
+ if (verbose) console.error('[VERBOSE] /limits subscription error:', error);
115
+ return { success: false, error: `Failed to get Claude subscription info: ${error.message}` };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get Codex subscription metadata.
121
+ *
122
+ * Decodes the OIDC `id_token` persisted in `~/.codex/auth.json`. The token's
123
+ * `https://api.openai.com/auth` claim carries the active ChatGPT subscription
124
+ * window — including `chatgpt_subscription_active_until`, which is exactly
125
+ * the renewal/end date the issue asks for. No HTTP call is needed.
126
+ *
127
+ * @param {Object} opts
128
+ * @param {boolean} opts.verbose
129
+ * @param {string} opts.authPath
130
+ * @returns {Object} `{ success, subscription? , error? }`
131
+ */
132
+ export async function getCodexSubscriptionInfo({ verbose = false, authPath = DEFAULT_CODEX_AUTH_PATH } = {}) {
133
+ try {
134
+ const auth = await readCodexAuth(authPath, verbose);
135
+ if (!auth) {
136
+ return {
137
+ success: false,
138
+ error: 'Could not read Codex authentication.',
139
+ };
140
+ }
141
+
142
+ if (auth.auth_mode && auth.auth_mode !== 'chatgpt') {
143
+ return {
144
+ success: false,
145
+ error: 'Codex subscription info is only available for ChatGPT-authenticated Codex.',
146
+ };
147
+ }
148
+
149
+ const idToken = auth?.tokens?.id_token || null;
150
+ const accessToken = auth?.tokens?.access_token || null;
151
+ const payload = decodeJwtPayload(idToken) || decodeJwtPayload(accessToken);
152
+ const claims = payload?.['https://api.openai.com/auth'] || null;
153
+
154
+ if (!claims) {
155
+ return {
156
+ success: false,
157
+ error: 'Could not decode Codex subscription claims from id_token.',
158
+ };
159
+ }
160
+
161
+ return {
162
+ success: true,
163
+ subscription: {
164
+ planType: claims.chatgpt_plan_type || null,
165
+ activeStart: claims.chatgpt_subscription_active_start || null,
166
+ endsAt: claims.chatgpt_subscription_active_until || null,
167
+ lastChecked: claims.chatgpt_subscription_last_checked || null,
168
+ },
169
+ };
170
+ } catch (error) {
171
+ if (verbose) console.error('[VERBOSE] /limits Codex subscription error:', error);
172
+ return { success: false, error: `Failed to get Codex subscription info: ${error.message}` };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Cached Claude subscription metadata. Uses the same 20-minute
178
+ * TTL as `/limits` so we don't add traffic to the Anthropic OAuth API.
179
+ */
180
+ export async function getCachedClaudeSubscription(verbose = false) {
181
+ const cache = getLimitCache();
182
+ const cached = cache.get('claude-subscription', CACHE_TTL.USAGE_API);
183
+ if (cached) {
184
+ if (verbose) console.log('[VERBOSE] /limits-cache: Using cached Claude subscription');
185
+ return cached;
186
+ }
187
+ const result = await getClaudeSubscriptionInfo({ verbose });
188
+ if (result.success) cache.set('claude-subscription', result, CACHE_TTL.USAGE_API);
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Cached Codex subscription metadata. The JWT decode is local, but we still
194
+ * cache for parity with the rest of the /limits pipeline.
195
+ */
196
+ export async function getCachedCodexSubscription(verbose = false) {
197
+ const cache = getLimitCache();
198
+ const cached = cache.get('codex-subscription', CACHE_TTL.USAGE_API);
199
+ if (cached) {
200
+ if (verbose) console.log('[VERBOSE] /limits-cache: Using cached Codex subscription');
201
+ return cached;
202
+ }
203
+ const result = await getCodexSubscriptionInfo({ verbose });
204
+ if (result.success) cache.set('codex-subscription', result, CACHE_TTL.USAGE_API);
205
+ return result;
206
+ }
@@ -14,6 +14,8 @@ import utc from 'dayjs/plugin/utc.js';
14
14
 
15
15
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
16
  import { formatLimitResetsAt, formatLimitResetsIn, formatLocalizedCurrentTime, formatLocalizedRelativeTime, formatLocalizedResetTime, localizeCompactDuration, lt, resolveLimitLocale } from './limits-i18n.lib.mjs';
17
+ import { formatSubscriptionLines, getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo } from './limits-subscription.lib.mjs';
18
+ export { getCachedClaudeSubscription, getCachedCodexSubscription, getClaudeSubscriptionInfo, getCodexSubscriptionInfo };
17
19
  // Initialize dayjs plugins
18
20
  dayjs.extend(utc);
19
21
 
@@ -31,8 +33,8 @@ const execAsync = promisify(exec);
31
33
  /**
32
34
  * Default path to Claude credentials file
33
35
  */
34
- const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
35
- const DEFAULT_CODEX_AUTH_PATH = join(homedir(), '.codex', 'auth.json');
36
+ export const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
37
+ export const DEFAULT_CODEX_AUTH_PATH = join(homedir(), '.codex', 'auth.json');
36
38
  const DEFAULT_CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
37
39
 
38
40
  /**
@@ -41,7 +43,7 @@ const DEFAULT_CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
41
43
  const USAGE_API_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
42
44
  const CODEX_USAGE_API_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api';
43
45
 
44
- function decodeJwtPayload(token) {
46
+ export function decodeJwtPayload(token) {
45
47
  if (!token || typeof token !== 'string') return null;
46
48
 
47
49
  try {
@@ -73,7 +75,7 @@ function mapCodexWindow(window) {
73
75
  };
74
76
  }
75
77
 
76
- async function readCodexAuth(authPath = DEFAULT_CODEX_AUTH_PATH, verbose = false) {
78
+ export async function readCodexAuth(authPath = DEFAULT_CODEX_AUTH_PATH, verbose = false) {
77
79
  try {
78
80
  const content = await readFile(authPath, 'utf-8');
79
81
  const auth = JSON.parse(content);
@@ -121,7 +123,7 @@ async function getCodexUsageBaseUrl(configPath = DEFAULT_CODEX_CONFIG_PATH, verb
121
123
  * @param {boolean} verbose - Whether to log verbose output
122
124
  * @returns {Object|null} Credentials object or null if not found
123
125
  */
124
- async function readCredentials(credentialsPath = DEFAULT_CREDENTIALS_PATH, verbose = false) {
126
+ export async function readCredentials(credentialsPath = DEFAULT_CREDENTIALS_PATH, verbose = false) {
125
127
  try {
126
128
  const content = await readFile(credentialsPath, 'utf-8');
127
129
  const credentials = JSON.parse(content);
@@ -1000,6 +1002,7 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1000
1002
  extraSections = [];
1001
1003
  }
1002
1004
  const locale = resolveLimitLocale(options);
1005
+ const subscription = options?.subscription || null;
1003
1006
  const sections = [];
1004
1007
 
1005
1008
  sections.push(`${lt('current_time', {}, { locale })}: ${formatCurrentTime({ locale })}\n`);
@@ -1157,6 +1160,9 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
1157
1160
  sonnetSection += `${lt('na', {}, { locale })}\n`;
1158
1161
  }
1159
1162
  sections.push(sonnetSection);
1163
+
1164
+ const subscriptionLines = formatSubscriptionLines(subscription, { locale });
1165
+ if (subscriptionLines) sections.push(subscriptionLines);
1160
1166
  }
1161
1167
 
1162
1168
  // Append any caller-provided extra sections (e.g. queue status) inside the code block
@@ -1187,6 +1193,7 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
1187
1193
  const additionalRateLimits = codexLimits?.additionalRateLimits || [];
1188
1194
  const credits = codexLimits?.credits || null;
1189
1195
  const planType = codexLimits?.planType || null;
1196
+ const subscription = options?.subscription || null;
1190
1197
 
1191
1198
  let section = `${lt('codex_limits', {}, { locale })}\n`;
1192
1199
  if (planType) {
@@ -1249,6 +1256,9 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
1249
1256
  section += `\n${lt('codex_credits', {}, { locale })}\n${creditSummary}\n`;
1250
1257
  }
1251
1258
 
1259
+ const subscriptionLines = formatSubscriptionLines(subscription, { locale });
1260
+ if (subscriptionLines) section += subscriptionLines;
1261
+
1252
1262
  return section;
1253
1263
  }
1254
1264
 
@@ -1435,14 +1445,16 @@ export async function getCachedDiskInfo(verbose = false) {
1435
1445
  }
1436
1446
 
1437
1447
  export async function getAllCachedLimits(verbose = false) {
1438
- const [claude, codex, github, memory, cpu, disk] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose)]);
1439
- return { claude, codex, github, memory, cpu, disk };
1448
+ const [claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription] = await Promise.all([getCachedClaudeLimits(verbose), getCachedCodexLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose), getCachedClaudeSubscription(verbose), getCachedCodexSubscription(verbose)]);
1449
+ return { claude, codex, github, memory, cpu, disk, claudeSubscription, codexSubscription };
1440
1450
  }
1441
1451
 
1442
1452
  export default {
1443
1453
  // Raw functions (no caching)
1444
1454
  getClaudeUsageLimits,
1445
1455
  getCodexUsageLimits,
1456
+ getClaudeSubscriptionInfo,
1457
+ getCodexSubscriptionInfo,
1446
1458
  getCpuLoadInfo,
1447
1459
  getMemoryInfo,
1448
1460
  getDiskSpaceInfo,
@@ -1461,6 +1473,8 @@ export default {
1461
1473
  // Cached functions
1462
1474
  getCachedClaudeLimits,
1463
1475
  getCachedCodexLimits,
1476
+ getCachedClaudeSubscription,
1477
+ getCachedCodexSubscription,
1464
1478
  getCachedGitHubLimits,
1465
1479
  getCachedMemoryInfo,
1466
1480
  getCachedCpuInfo,
@@ -135,6 +135,11 @@ en
135
135
  limits.seven_day_sonnet_only "7d Sonnet only"
136
136
  limits.session "session"
137
137
  limits.start "Start"
138
+ limits.subscription_ends "Subscription ends {{time}}"
139
+ limits.subscription_ends_in "Subscription ends in {{duration}} ({{time}})"
140
+ limits.subscription_status "Subscription: {{status}}"
141
+ limits.trial_ends "Trial ends {{time}}"
142
+ limits.trial_ends_in "Trial ends in {{duration}} ({{time}})"
138
143
  limits.unavailable "unavailable"
139
144
  limits.unlimited "unlimited"
140
145
  limits.used "used"
@@ -135,6 +135,11 @@ hi
135
135
  limits.seven_day_sonnet_only "7 दिन केवल Sonnet"
136
136
  limits.session "सत्र"
137
137
  limits.start "शुरुआत"
138
+ limits.subscription_ends "सदस्यता समाप्त होगी {{time}}"
139
+ limits.subscription_ends_in "सदस्यता {{duration}} में समाप्त होगी ({{time}})"
140
+ limits.subscription_status "सदस्यता: {{status}}"
141
+ limits.trial_ends "ट्रायल समाप्त होगा {{time}}"
142
+ limits.trial_ends_in "ट्रायल {{duration}} में समाप्त होगा ({{time}})"
138
143
  limits.unavailable "अनुपलब्ध"
139
144
  limits.unlimited "असीमित"
140
145
  limits.used "उपयोग"
@@ -135,6 +135,11 @@ ru
135
135
  limits.seven_day_sonnet_only "7 дней, только Sonnet"
136
136
  limits.session "сеанс"
137
137
  limits.start "Начало"
138
+ limits.subscription_ends "Подписка заканчивается {{time}}"
139
+ limits.subscription_ends_in "Подписка заканчивается через {{duration}} ({{time}})"
140
+ limits.subscription_status "Подписка: {{status}}"
141
+ limits.trial_ends "Пробный период заканчивается {{time}}"
142
+ limits.trial_ends_in "Пробный период заканчивается через {{duration}} ({{time}})"
138
143
  limits.unavailable "недоступно"
139
144
  limits.unlimited "без ограничений"
140
145
  limits.used "использовано"
@@ -135,6 +135,11 @@ zh
135
135
  limits.seven_day_sonnet_only "7 天仅 Sonnet"
136
136
  limits.session "会话"
137
137
  limits.start "开始"
138
+ limits.subscription_ends "订阅结束于 {{time}}"
139
+ limits.subscription_ends_in "订阅将在 {{duration}} 后结束 ({{time}})"
140
+ limits.subscription_status "订阅: {{status}}"
141
+ limits.trial_ends "试用结束于 {{time}}"
142
+ limits.trial_ends_in "试用将在 {{duration}} 后结束 ({{time}})"
138
143
  limits.unavailable "不可用"
139
144
  limits.unlimited "无限"
140
145
  limits.used "已用"
@@ -24,9 +24,41 @@ const { log, ghCmdRetry } = lib;
24
24
  const githubLib = await import('./github.lib.mjs');
25
25
 
26
26
  /**
27
- * Handle the --auto-fork option: when the user lacks write access to a public
28
- * repository, automatically enable fork mode; when the repository is private,
29
- * fail with an actionable error.
27
+ * Probe whether the repository allows forking via the GitHub API
28
+ * (`allow_forking` is true for repos that can be forked by users with read
29
+ * access including private repositories). Returns `null` when the
30
+ * attribute could not be determined so callers can decide a safe default.
31
+ *
32
+ * Kept here (instead of github.lib.mjs) because it's only used by the
33
+ * auto-fork branch and the existing `detectRepositoryVisibility` already
34
+ * makes a separate API call; this avoids reshuffling shared helpers.
35
+ *
36
+ * Issue #1795: a private repository with read-only access can still be
37
+ * forked when `allow_forking` is true, so failing early was overly
38
+ * conservative.
39
+ */
40
+ async function detectAllowForking(owner, repo) {
41
+ const result = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .allow_forking`, { label: `allow_forking ${owner}/${repo}` });
42
+ if (result.code !== 0) return null;
43
+ const raw = result.stdout.toString().trim();
44
+ if (raw === 'true') return true;
45
+ if (raw === 'false') return false;
46
+ return null;
47
+ }
48
+
49
+ function describeRepoPermissionLevel(permissions) {
50
+ if (permissions.admin === true) return 'Admin';
51
+ if (permissions.maintain === true) return 'Maintain';
52
+ if (permissions.push === true) return 'Write';
53
+ if (permissions.triage === true) return 'Triage';
54
+ if (permissions.pull === true) return 'Read';
55
+ return 'No confirmed repository access';
56
+ }
57
+
58
+ /**
59
+ * Handle the --auto-fork option: when the user lacks write access, attempt
60
+ * to enable fork mode (including for private repositories where
61
+ * `allow_forking` is true). Only fail when forking is also unavailable.
30
62
  *
31
63
  * Mutates argv.fork in place when fork mode is enabled.
32
64
  *
@@ -51,19 +83,52 @@ export async function handleAutoForkOption({ owner, repo, argv, safeExit }) {
51
83
  const { isPublic } = await detectRepositoryVisibility(owner, repo);
52
84
 
53
85
  if (!isPublic) {
54
- await log('');
55
- await log("❌ --auto-fork failed: Repository is private and you don't have write access", { level: 'error' });
56
- await log('');
57
- await log(' 🔍 What happened:', { level: 'error' });
58
- await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
59
- await log(" You don't have write access to this repository", { level: 'error' });
60
- await log(' --auto-fork cannot create a fork of a private repository you cannot access', { level: 'error' });
61
- await log('');
62
- await log(' 💡 Solution:', { level: 'error' });
63
- await log(' Request collaborator access from the repository owner', { level: 'error' });
64
- await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
65
- await log('');
66
- await safeExit(1, 'Auto-fork failed - private repository without access');
86
+ // Issue #1795: read access to a private repo is enough to fork it
87
+ // when the upstream allows forking. Probe `allow_forking` before
88
+ // bailing out so users with limited (read-only) access can still
89
+ // proceed via their own fork.
90
+ const allowForking = await detectAllowForking(owner, repo);
91
+ if (allowForking === false) {
92
+ const permissionLevel = describeRepoPermissionLevel(permissions);
93
+
94
+ await log('');
95
+ await log("❌ --auto-fork failed: Repository is private, you don't have write access, and forking is disabled", { level: 'error' });
96
+ await log('');
97
+ await log(' 🔍 What happened:', { level: 'error' });
98
+ await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
99
+ await log(` Your detected GitHub repository access level is ${permissionLevel}`, { level: 'error' });
100
+ await log(` API permissions: ${JSON.stringify(permissions)}`, { level: 'error' });
101
+ await log(' Direct branch mode requires push/write access, but permissions.push is false', { level: 'error' });
102
+ await log(" Fork mode is also unavailable because the repository owner disabled private forking ('allow_forking' is false)", {
103
+ level: 'error',
104
+ });
105
+ await log('');
106
+ await log(' 💡 Solution:', { level: 'error' });
107
+ await log(' • To let Hive Mind work directly in this repository:', { level: 'error' });
108
+ await log(` Ask an owner/admin to open https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
109
+ await log(' Then add this GitHub account or its team with the Write role (Maintain/Admin also works)', { level: 'error' });
110
+ await log(' • To let Hive Mind work through a fork instead:', { level: 'error' });
111
+ await log(` Ask an owner/admin to open https://github.com/${owner}/${repo}/settings`, { level: 'error' });
112
+ await log(' Then enable Settings -> General -> Features -> Allow forking', { level: 'error' });
113
+ await log(' For organization-owned private repositories, the organization must also allow private repository forks', {
114
+ level: 'error',
115
+ });
116
+ await log('');
117
+ await safeExit(1, 'Auto-fork failed - private repository without access and forking is disabled');
118
+ return;
119
+ }
120
+
121
+ if (allowForking === true) {
122
+ await log('✅ Auto-fork: Read-only access to private repository, enabling fork mode (allow_forking=true)');
123
+ } else {
124
+ await log("✅ Auto-fork: Read-only access to private repository, attempting fork mode (allow_forking couldn't be confirmed)");
125
+ await log(" ⚠️ Could not determine 'allow_forking' for the private repository; letting gh repo fork report the exact result", {
126
+ verbose: true,
127
+ level: 'warning',
128
+ });
129
+ }
130
+
131
+ argv.fork = true;
67
132
  return;
68
133
  }
69
134
 
@@ -566,8 +566,10 @@ bot.command('limits', async ctx => {
566
566
  const codexError = limits.codex.success ? null : limits.codex.error;
567
567
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
568
568
  const queueStatus = await solveQueue.formatStatus({ locale: userLocale });
569
- const codexSection = formatCodexLimitsSection(limits.codex.success ? limits.codex : null, codexError, { locale: userLocale });
570
- const message = t('telegram.usage_limits_title', {}, { locale: userLocale }) + '\n\n' + formatUsageMessage(limits.claude.success ? limits.claude.usage : null, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null, claudeError, [codexSection, queueStatus], { locale: userLocale });
569
+ const claudeSubscription = limits.claudeSubscription?.success ? limits.claudeSubscription.subscription : null;
570
+ const codexSubscription = limits.codexSubscription?.success ? limits.codexSubscription.subscription : null;
571
+ const codexSection = formatCodexLimitsSection(limits.codex.success ? limits.codex : null, codexError, { locale: userLocale, subscription: codexSubscription });
572
+ const message = t('telegram.usage_limits_title', {}, { locale: userLocale }) + '\n\n' + formatUsageMessage(limits.claude.success ? limits.claude.usage : null, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null, claudeError, [codexSection, queueStatus], { locale: userLocale, subscription: claudeSubscription });
571
573
  await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown', fallbackLocale: userLocale, verbose: VERBOSE });
572
574
  });
573
575
  bot.command('version', async ctx => {