@link-assistant/hive-mind 1.59.2 → 1.59.4

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,136 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.59.4
4
+
5
+ ### Patch Changes
6
+
7
+ - b2e0d12: Fix `/terminal_watch` uploading the full session log file when the watch
8
+ completes — addresses issue
9
+ [#1720](https://github.com/link-assistant/hive-mind/issues/1720).
10
+
11
+ Before this fix, `/terminal_watch` finished by calling
12
+ `bot.telegram.sendDocument(chatId, ...)` to attach the `<uuid>.log` file. That
13
+ had two unwanted effects:
14
+ - It duplicated work that the dedicated `/log` command already does.
15
+ - The bare `bot.telegram.sendDocument(chatId, ...)` call did not carry
16
+ `message_thread_id`, so in forum-enabled supergroups the document landed in
17
+ the **General** topic instead of the topic where `/terminal_watch` was
18
+ invoked, and it was not threaded as a reply.
19
+
20
+ `/terminal_watch` now only updates the live "✅ Terminal watch complete"
21
+ message at the end of the session. To download the log, use
22
+ `/log <uuid>` — it correctly replies in the originating topic via
23
+ `ctx.replyWithDocument`, which Telegraf annotates with `message_thread_id`
24
+ automatically.
25
+
26
+ A new regression test (`tests/test-issue-1720-terminal-watch-no-log.mjs`)
27
+ guards both behaviours, and `tests/test-issue-467-terminal-watch.mjs` was
28
+ updated to assert that no document is uploaded by the watcher.
29
+
30
+ - 5c87a38: Fix `hive` to (a) stop forwarding `false` for solve options whose `type` is
31
+ `'string'` but whose `default` is `false`, and (b) exit non-zero when any
32
+ worker fails — issue #1718.
33
+
34
+ Previously, when a user ran `/hive` against several issues, every spawned
35
+ `solve` worker crashed with:
36
+
37
+ ```
38
+ Invalid --working-session-live-progress value: "false". Expected "comment" or "pr".
39
+ ```
40
+
41
+ …and `hive` itself still exited with code `0`, so the Telegram bot rendered a
42
+ green "Work session finished successfully" envelope even though zero PRs had
43
+ been created.
44
+
45
+ Two independent root causes:
46
+ 1. **Auto-forwarder leaked `false` as a string.** In
47
+ [`src/hive.mjs`](./src/hive.mjs), the auto-forward block read:
48
+
49
+ ```js
50
+ } else if ((def.type === 'string' || def.type === 'number') && value !== undefined) {
51
+ args.push(`--${optionName}`, String(value));
52
+ }
53
+ ```
54
+
55
+ For `working-session-live-progress`, `solve.config.lib.mjs` declares
56
+ `type: 'string', default: false`. yargs preserves the boolean `false`
57
+ verbatim, so hive forwarded `--working-session-live-progress false`,
58
+ which `solve` rejects. The fix adds `&& value !== false` to the
59
+ predicate. Other `type:'string'` options whose `default` is `false`
60
+ are now also protected by a single defense-in-depth check.
61
+
62
+ 2. **No non-zero exit on worker failures.** After `monitorWithSentry()`
63
+ resolved, hive returned without consulting `issueQueue.getStats()`. The
64
+ fix queries `finalStats = issueQueue.getStats()` and calls
65
+ `safeExit(1, …)` when `finalStats.failed > 0`, mirroring the exit
66
+ semantics solve already uses. Wrappers like `start-command`, the Telegram
67
+ bot, and CI now correctly observe the failure.
68
+
69
+ `--isolation screen` (R3 of the issue) was already wired through correctly;
70
+ no change required there. The verbose forwarder dump
71
+ (`📋 Command: ${solveCommand} ${args.join(' ')}`) — which is what allowed us
72
+ to diagnose this run in the first place — is preserved.
73
+
74
+ Tests: [`tests/test-issue-1718-hive-passthrough-false.mjs`](./tests/test-issue-1718-hive-passthrough-false.mjs)
75
+ locks the option shape, asserts both fixes are present in `src/hive.mjs`,
76
+ replays the forwarder logic on synthetic argv, and adds a defense-in-depth
77
+ sweep that no `type:'string'` / `default:false` option ever produces
78
+ `--<flag> false`.
79
+
80
+ Documentation: [`docs/case-studies/issue-1718/`](./docs/case-studies/issue-1718/README.md)
81
+ contains the timeline reconstructed from the user's `screen` log, the
82
+ distilled facts, the per-symptom root-cause analysis, the solution plan, and
83
+ notes confirming no upstream report (yargs / start-command) is required.
84
+
85
+ ## 1.59.3
86
+
87
+ ### Patch Changes
88
+
89
+ - b0bffdc: Fix `solve` to skip fork mode when the upstream repository is private and the
90
+ user has direct write access — even when the existing PR was created from a
91
+ fork (issue #1716).
92
+
93
+ Previously, when a PR was originally created from a fork (e.g. the upstream
94
+ repo was public and the user without write access used `--auto-fork`), but
95
+ the upstream is now private and the user has direct write access, `solve`
96
+ still tried to clone the fork. If the fork had been renamed, deleted, or was
97
+ otherwise inaccessible (which is common after a public→private flip), repo
98
+ setup failed with `Fork not accessible`.
99
+
100
+ The auto-fork path already handled this correctly (logging
101
+ _"Auto-fork: Write access detected to private repository, working directly on
102
+ repository"_ and leaving `forkOwner = null`). The bug was that **continue
103
+ mode** — both the auto-continue path and the direct PR-URL path — re-set
104
+ `forkOwner` from the existing PR's head repository unconditionally,
105
+ overriding the auto-fork bypass.
106
+
107
+ Fix: in [`src/solve.mjs`](./src/solve.mjs):
108
+ - Hoist `detectRepositoryVisibility(owner, repo)` out of the
109
+ `if (argv.autoCleanup === undefined)` block so `isRepoPublic` is
110
+ unconditionally available.
111
+ - Compute one bypass flag,
112
+ `skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess`.
113
+ - Gate both fork-from-PR-data branches behind it. When set, log
114
+ _"Issue #1716: Working directly on the private upstream repository"_ and
115
+ leave `forkOwner = null` so the regular non-fork code path runs.
116
+ - Gate the maintainer-modify auto-toggle on `forkOwner` being non-null so it
117
+ doesn't fire when the bypass triggered.
118
+
119
+ Explicit `--fork` still wins (the bypass requires `!argv.fork`), and users
120
+ with no write access on a private repo still hit the existing auto-fork
121
+ private-repo guard (the bypass requires `hasWriteAccess`).
122
+
123
+ Tests: [`tests/test-issue-1716-private-repo-skip-fork.mjs`](./tests/test-issue-1716-private-repo-skip-fork.mjs)
124
+ locks the flag declaration, the exact condition formula, both
125
+ fork-detection paths, and four scenario simulations
126
+ (private+writeAccess → bypass; public → no bypass; explicit `--fork` → no
127
+ bypass; no writeAccess → no bypass).
128
+
129
+ Documentation: [`docs/case-studies/issue-1716/`](./docs/case-studies/issue-1716/README.md)
130
+ contains the timeline reconstructed from the user's failure log, the
131
+ distilled facts, the per-symptom root-cause analysis, and the implementation
132
+ plan.
133
+
3
134
  ## 1.59.2
4
135
 
5
136
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.59.2",
3
+ "version": "1.59.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/hive.mjs CHANGED
@@ -798,8 +798,8 @@ if (isRunningDirectly) {
798
798
  for (const entry of value) {
799
799
  args.push(`--${optionName}`, String(entry));
800
800
  }
801
- } else if ((def.type === 'string' || def.type === 'number') && value !== undefined) {
802
- args.push(`--${optionName}`, String(value));
801
+ } else if ((def.type === 'string' || def.type === 'number') && value !== undefined && value !== false) {
802
+ args.push(`--${optionName}`, String(value)); // Issue #1718: skip false (some string options have default:false)
803
803
  }
804
804
  }
805
805
  // Log the actual command being executed so users can investigate/reproduce
@@ -1483,6 +1483,9 @@ if (isRunningDirectly) {
1483
1483
  await log(` 📁 Full log file: ${absoluteLogPath}`, { level: 'error' });
1484
1484
  await safeExit(1, 'Error occurred');
1485
1485
  }
1486
+
1487
+ const finalStats = issueQueue.getStats(); // Issue #1718: surface worker failures via exit code
1488
+ if (finalStats.failed > 0) await safeExit(1, `${finalStats.failed} task(s) failed (completed: ${finalStats.completed})`);
1486
1489
  } catch (fatalError) {
1487
1490
  // Handle fatal errors during initialization or execution
1488
1491
  console.error('\n❌ Fatal error occurred during hive initialization or execution');
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Fork-detection helpers for solve.mjs
5
+ *
6
+ * Extracted from solve.mjs to keep the file under the 1500-line CI limit.
7
+ * - handleAutoForkOption: implements the --auto-fork detection branch.
8
+ * - handleMaintainerForkAccess: handles the
9
+ * --allow-to-push-to-contributors-pull-requests-as-maintainer follow-up
10
+ * that runs after a fork PR has been detected.
11
+ *
12
+ * Tests for Issue #1716 grep solve.mjs textually, so the *call sites* there
13
+ * still hold the canonical condition checks; only the bodies live here.
14
+ */
15
+
16
+ if (typeof globalThis.use === 'undefined') {
17
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
18
+ }
19
+ const use = globalThis.use;
20
+ const { $ } = await use('command-stream');
21
+
22
+ const lib = await import('./lib.mjs');
23
+ const { log, ghCmdRetry } = lib;
24
+ const githubLib = await import('./github.lib.mjs');
25
+
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.
30
+ *
31
+ * Mutates argv.fork in place when fork mode is enabled.
32
+ *
33
+ * @param {object} params
34
+ * @param {string} params.owner
35
+ * @param {string} params.repo
36
+ * @param {object} params.argv - CLI arguments (mutated: argv.fork may be set)
37
+ * @param {(code: number, reason?: string) => Promise<void>} params.safeExit
38
+ */
39
+ export async function handleAutoForkOption({ owner, repo, argv, safeExit }) {
40
+ if (!argv.autoFork || argv.fork) return;
41
+
42
+ const { detectRepositoryVisibility } = githubLib;
43
+ await log('🔍 Checking repository access for auto-fork...');
44
+ const permResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
45
+
46
+ if (permResult.code === 0) {
47
+ const permissions = JSON.parse(permResult.stdout.toString().trim());
48
+ const hasWriteAccess = permissions.push === true || permissions.admin === true || permissions.maintain === true;
49
+
50
+ if (!hasWriteAccess) {
51
+ const { isPublic } = await detectRepositoryVisibility(owner, repo);
52
+
53
+ 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');
67
+ return;
68
+ }
69
+
70
+ await log('✅ Auto-fork: No write access detected, enabling fork mode');
71
+ argv.fork = true;
72
+ } else {
73
+ const { isPublic } = await detectRepositoryVisibility(owner, repo);
74
+ await log(`✅ Auto-fork: Write access detected to ${isPublic ? 'public' : 'private'} repository, working directly on repository`);
75
+ }
76
+ } else {
77
+ const { isPublic } = await detectRepositoryVisibility(owner, repo);
78
+
79
+ if (!isPublic) {
80
+ await log('');
81
+ await log('❌ --auto-fork failed: Could not verify permissions for private repository', { level: 'error' });
82
+ await log('');
83
+ await log(' 🔍 What happened:', { level: 'error' });
84
+ await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
85
+ await log(' Could not check your permissions to this repository', { level: 'error' });
86
+ await log('');
87
+ await log(' 💡 Solutions:', { level: 'error' });
88
+ await log(' • Check your GitHub CLI authentication: gh auth status', { level: 'error' });
89
+ await log(" • Request collaborator access if you don't have it yet", { level: 'error' });
90
+ await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
91
+ await log('');
92
+ await safeExit(1, 'Auto-fork failed - cannot verify private repository permissions');
93
+ return;
94
+ }
95
+
96
+ await log('⚠️ Auto-fork: Could not check permissions, enabling fork mode for public repository');
97
+ argv.fork = true;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * After a fork PR is detected, optionally check whether the maintainer can
103
+ * push directly to the contributor's fork. If not, request access.
104
+ *
105
+ * @param {object} params
106
+ * @param {string} params.owner
107
+ * @param {string} params.repo
108
+ * @param {string|number} params.prNumber
109
+ */
110
+ export async function handleMaintainerForkAccess({ owner, repo, prNumber }) {
111
+ const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
112
+ const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
113
+
114
+ if (canModify) {
115
+ await log('✅ Maintainer can push to fork: Enabled by contributor');
116
+ await log(" Will push changes directly to contributor's fork instead of creating own fork");
117
+ return;
118
+ }
119
+
120
+ await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', { level: 'warning' });
121
+ await log(' Posting comment to request access...', { level: 'warning' });
122
+ await requestMaintainerAccess(owner, repo, prNumber);
123
+ await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
124
+ }
125
+
126
+ export default { handleAutoForkOption, handleMaintainerForkAccess };
package/src/solve.mjs CHANGED
@@ -58,6 +58,7 @@ const { postTrackedComment, USAGE_LIMIT_REACHED_MARKER } = await import('./tool-
58
58
  const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions } = await import('./solve.preparation.lib.mjs');
59
59
  const { validateAndExitOnInvalidModel } = await import('./models/index.mjs');
60
60
  const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
61
+ const { handleAutoForkOption, handleMaintainerForkAccess } = await import('./solve.fork-detection.lib.mjs');
61
62
  // Initialize log file early (before argument parsing) to capture all output
62
63
  const logFile = await initializeLogFile(null);
63
64
  // Log version and raw command IMMEDIATELY after log file initialization
@@ -209,73 +210,7 @@ if (argv.autoAcceptInvite) {
209
210
  await autoAcceptInviteForRepo(owner, repo, log, argv.verbose);
210
211
  }
211
212
  // Handle --auto-fork option: automatically fork public repositories without write access
212
- if (argv.autoFork && !argv.fork) {
213
- const { detectRepositoryVisibility } = githubLib;
214
- // Check if we have write access first (issue #1536: retry on transient network errors)
215
- await log('🔍 Checking repository access for auto-fork...');
216
- const permResult = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
217
-
218
- if (permResult.code === 0) {
219
- const permissions = JSON.parse(permResult.stdout.toString().trim());
220
- const hasWriteAccess = permissions.push === true || permissions.admin === true || permissions.maintain === true;
221
-
222
- if (!hasWriteAccess) {
223
- // No write access - check if repository is public before enabling fork mode
224
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
225
-
226
- if (!isPublic) {
227
- // Private repository without write access - cannot fork
228
- await log('');
229
- await log("❌ --auto-fork failed: Repository is private and you don't have write access", { level: 'error' });
230
- await log('');
231
- await log(' 🔍 What happened:', { level: 'error' });
232
- await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
233
- await log(" You don't have write access to this repository", { level: 'error' });
234
- await log(' --auto-fork cannot create a fork of a private repository you cannot access', {
235
- level: 'error',
236
- });
237
- await log('');
238
- await log(' 💡 Solution:', { level: 'error' });
239
- await log(' • Request collaborator access from the repository owner', { level: 'error' });
240
- await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
241
- await log('');
242
- await safeExit(1, 'Auto-fork failed - private repository without access');
243
- }
244
-
245
- // Public repository without write access - automatically enable fork mode
246
- await log('✅ Auto-fork: No write access detected, enabling fork mode');
247
- argv.fork = true;
248
- } else {
249
- // Has write access - work directly on the repo (works for both public and private repos)
250
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
251
- await log(`✅ Auto-fork: Write access detected to ${isPublic ? 'public' : 'private'} repository, working directly on repository`);
252
- }
253
- } else {
254
- // Could not check permissions - assume no access and try to fork if public
255
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
256
-
257
- if (!isPublic) {
258
- // Cannot determine permissions for private repo - fail safely
259
- await log('');
260
- await log('❌ --auto-fork failed: Could not verify permissions for private repository', { level: 'error' });
261
- await log('');
262
- await log(' 🔍 What happened:', { level: 'error' });
263
- await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
264
- await log(' Could not check your permissions to this repository', { level: 'error' });
265
- await log('');
266
- await log(' 💡 Solutions:', { level: 'error' });
267
- await log(' • Check your GitHub CLI authentication: gh auth status', { level: 'error' });
268
- await log(" • Request collaborator access if you don't have it yet", { level: 'error' });
269
- await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
270
- await log('');
271
- await safeExit(1, 'Auto-fork failed - cannot verify private repository permissions');
272
- }
273
-
274
- // Public repository but couldn't check permissions - assume no access and fork
275
- await log('⚠️ Auto-fork: Could not check permissions, enabling fork mode for public repository');
276
- argv.fork = true;
277
- }
278
- }
213
+ await handleAutoForkOption({ owner, repo, argv, safeExit });
279
214
  // Permission check BEFORE entity validation (#1552): avoids false 404 on private repos without access
280
215
  const { checkRepositoryWritePermission } = githubLib;
281
216
  const hasWriteAccess = await checkRepositoryWritePermission(owner, repo, {
@@ -296,19 +231,26 @@ if (!entityCheck.valid) {
296
231
  await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
297
232
  }
298
233
 
299
- // Detect repository visibility and set auto-cleanup default if not explicitly set
234
+ // Detect repository visibility once and reuse for downstream decisions
235
+ // (auto-cleanup default + Issue #1716 private-repo fork bypass)
236
+ const { detectRepositoryVisibility } = githubLib;
237
+ const { isPublic: isRepoPublic } = await detectRepositoryVisibility(owner, repo);
300
238
  if (argv.autoCleanup === undefined) {
301
- const { detectRepositoryVisibility } = githubLib;
302
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
303
239
  // For public repos: keep temp directories (default false)
304
240
  // For private repos: clean up temp directories (default true)
305
- argv.autoCleanup = !isPublic;
241
+ argv.autoCleanup = !isRepoPublic;
306
242
  if (argv.verbose) {
307
- await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isPublic ? 'public' : 'private'})`, {
243
+ await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isRepoPublic ? 'public' : 'private'})`, {
308
244
  verbose: true,
309
245
  });
310
246
  }
311
247
  }
248
+ // Issue #1716: When the upstream repository is private and the user has direct
249
+ // write access, fork-based workflows should be skipped — even if the existing
250
+ // PR was originally created from a fork. Forks of private repositories often
251
+ // become inaccessible (renamed, deleted, parent re-private'd) and there's no
252
+ // reason to use them when we can push branches and PRs to the upstream repo.
253
+ const skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess;
312
254
  // Determine mode and get issue details
313
255
  let issueNumber;
314
256
  let prNumber;
@@ -345,32 +287,26 @@ if (autoContinueResult.isContinueMode) {
345
287
  await log(` Merge status: ${mergeStateStatus || 'UNKNOWN'}`, { verbose: true });
346
288
  }
347
289
  if (prCheckData.headRepositoryOwner && prCheckData.headRepositoryOwner.login !== owner) {
348
- forkOwner = prCheckData.headRepositoryOwner.login;
349
- // Get actual fork repository name (may be prefixed) and store for use in setupRepository
350
- forkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
351
- await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
352
- if (argv.verbose) {
353
- await log(` Fork owner: ${forkOwner}`, { verbose: true });
354
- await log(' Will clone fork repository for continue mode', { verbose: true });
290
+ const detectedForkOwner = prCheckData.headRepositoryOwner.login;
291
+ const detectedForkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
292
+ // Issue #1716: Skip fork mode for private upstream repos with write access.
293
+ if (skipForkForPrivateUpstream) {
294
+ await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
295
+ await log(' Working directly on the private upstream repository (Issue #1716).');
296
+ } else {
297
+ forkOwner = detectedForkOwner;
298
+ // Get actual fork repository name (may be prefixed) and store for use in setupRepository
299
+ forkRepoName = detectedForkRepoName;
300
+ await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
301
+ if (argv.verbose) {
302
+ await log(` Fork owner: ${forkOwner}`, { verbose: true });
303
+ await log(' Will clone fork repository for continue mode', { verbose: true });
304
+ }
355
305
  }
356
306
 
357
307
  // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
358
- if (argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
359
- const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
360
- const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
361
-
362
- if (canModify) {
363
- await log('✅ Maintainer can push to fork: Enabled by contributor');
364
- await log(" Will push changes directly to contributor's fork instead of creating own fork");
365
- // Don't disable fork mode, but we'll use the contributor's fork
366
- } else {
367
- await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', {
368
- level: 'warning',
369
- });
370
- await log(' Posting comment to request access...', { level: 'warning' });
371
- await requestMaintainerAccess(owner, repo, prNumber);
372
- await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
373
- }
308
+ if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
309
+ await handleMaintainerForkAccess({ owner, repo, prNumber });
374
310
  }
375
311
  }
376
312
  }
@@ -425,32 +361,26 @@ if (isPrUrl) {
425
361
  prState = prData.state;
426
362
  // Check if this is a fork PR
427
363
  if (prData.headRepositoryOwner && prData.headRepositoryOwner.login !== owner) {
428
- forkOwner = prData.headRepositoryOwner.login;
429
- // Get actual fork repository name and store for use in setupRepository
430
- forkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
431
- await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
432
- if (argv.verbose) {
433
- await log(` Fork owner: ${forkOwner}`, { verbose: true });
434
- await log(' Will clone fork repository for continue mode', { verbose: true });
364
+ const detectedForkOwner = prData.headRepositoryOwner.login;
365
+ const detectedForkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
366
+ // Issue #1716: Skip fork mode for private upstream repos with write access.
367
+ if (skipForkForPrivateUpstream) {
368
+ await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
369
+ await log(' Working directly on the private upstream repository (Issue #1716).');
370
+ } else {
371
+ forkOwner = detectedForkOwner;
372
+ // Get actual fork repository name and store for use in setupRepository
373
+ forkRepoName = detectedForkRepoName;
374
+ await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
375
+ if (argv.verbose) {
376
+ await log(` Fork owner: ${forkOwner}`, { verbose: true });
377
+ await log(' Will clone fork repository for continue mode', { verbose: true });
378
+ }
435
379
  }
436
380
 
437
381
  // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
438
- if (argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
439
- const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
440
- const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
441
-
442
- if (canModify) {
443
- await log('✅ Maintainer can push to fork: Enabled by contributor');
444
- await log(" Will push changes directly to contributor's fork instead of creating own fork");
445
- // Don't disable fork mode, but we'll use the contributor's fork
446
- } else {
447
- await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', {
448
- level: 'warning',
449
- });
450
- await log(' Posting comment to request access...', { level: 'warning' });
451
- await requestMaintainerAccess(owner, repo, prNumber);
452
- await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
453
- }
382
+ if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
383
+ await handleMaintainerForkAccess({ owner, repo, prNumber });
454
384
  }
455
385
  }
456
386
  await log(`📝 PR branch: ${prBranch}`);
@@ -6,15 +6,12 @@
6
6
  */
7
7
 
8
8
  import fs from 'fs/promises';
9
- import path from 'path';
10
- import { constants as fsConstants } from 'fs';
11
9
  import { extractSessionIdFromText, decideLogDestination, resolveLogPath } from './telegram-log-command.lib.mjs';
12
10
 
13
11
  const DEFAULT_WIDTH = 120;
14
12
  const DEFAULT_HEIGHT = 25;
15
13
  const DEFAULT_INTERVAL_MS = 2500;
16
14
  const DEFAULT_MAX_CHARS = 3400;
17
- const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
18
15
  const GITHUB_URL_RE = /https:\/\/github\.com\/[^\s"'`<>]+/i;
19
16
  const activeWatches = new Map();
20
17
 
@@ -135,23 +132,6 @@ export function formatTerminalWatchMessage({ sessionId, statusResult = null, log
135
132
  return lines.join('\n');
136
133
  }
137
134
 
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
135
  async function readLogFile(logPath) {
156
136
  try {
157
137
  return await fs.readFile(logPath, 'utf8');
@@ -182,16 +162,6 @@ export async function resolveTerminalWatchRepository({ sessionInfo = null, statu
182
162
  }
183
163
  }
184
164
 
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
165
  async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbose, attempts = 3) {
196
166
  for (let attempt = 1; attempt <= attempts; attempt++) {
197
167
  const statusResult = await querySessionStatus(sessionId, verbose);
@@ -201,7 +171,9 @@ async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbos
201
171
  return null;
202
172
  }
203
173
 
204
- export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false, attachLogOnComplete = true }) {
174
+ // Note: /terminal_watch never uploads the full session log itself (issue #1720).
175
+ // Use /log <uuid> if you want the log file delivered as a document.
176
+ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false }) {
205
177
  const key = `${chatId}:${messageId}:${sessionId}`;
206
178
  activeWatches.get(key)?.stop();
207
179
 
@@ -225,7 +197,6 @@ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, log
225
197
  if (completed) {
226
198
  stopped = true;
227
199
  activeWatches.delete(key);
228
- if (attachLogOnComplete) await sendLogDocument({ bot, chatId, logPath, sessionId, statusResult });
229
200
  return;
230
201
  }
231
202
  } catch (error) {
@@ -409,6 +380,5 @@ export const __INTERNAL_FOR_TESTS__ = {
409
380
  DEFAULT_HEIGHT,
410
381
  DEFAULT_INTERVAL_MS,
411
382
  DEFAULT_MAX_CHARS,
412
- TELEGRAM_DOCUMENT_MAX_BYTES,
413
383
  GITHUB_URL_RE,
414
384
  };