@link-assistant/hive-mind 1.74.0 → 1.74.1

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,29 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 59eee9a: feat(interactive-mode): display images the AI reads/writes inline in PR comments (#1843)
8
+
9
+ When `--interactive-mode` posts Claude/Codex tool activity as PR comments, any
10
+ images the AI reads or produces (the `Read` tool on a screenshot, Playwright
11
+ captures, MCP image results) were previously serialized as multi-kilobyte
12
+ base64 blobs inside the "Raw JSON" section — unreadable and pushing comments
13
+ toward GitHub's size limit.
14
+
15
+ Those base64 payloads are now uploaded to hidden custom Git refs
16
+ (`refs/hive-mind-media/pr-...`) via the Git Data API and embedded inline in the
17
+ comment as commit-SHA `![](…?raw=true)` blob URLs, so reviewers see the actual
18
+ image (GitHub's Camo proxy renders `?raw=true` blob URLs inline for both public
19
+ and private repos, whereas `data:` URIs are stripped by the comment sanitizer).
20
+ Uploads are content-hashed (SHA-256) for dedup, and the base64 is redacted from
21
+ the Raw JSON section with a `<image data: N base64 chars>` placeholder.
22
+
23
+ Enabled by default; use `--no-interactive-image-upload` to opt out, in which
24
+ case each image degrades to a compact metadata note instead of being embedded.
25
+ All comment bodies continue to pass through the token sanitizer (#1745).
26
+
3
27
  ## 1.74.0
4
28
 
5
29
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.74.0",
3
+ "version": "1.74.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -687,6 +687,8 @@ export const executeClaudeCommand = async params => {
687
687
  // so the comment-posting path can honor them; flags default to false.
688
688
  skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
689
689
  skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
690
+ // Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
691
+ imageUploadEnabled: argv['interactive-image-upload'] !== false,
690
692
  });
691
693
  } else if (argv.interactiveMode) {
692
694
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
package/src/codex.lib.mjs CHANGED
@@ -805,6 +805,8 @@ export const executeCodexCommand = async params => {
805
805
  // comment-posting path can honor them. All default to false.
806
806
  skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
807
807
  skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
808
+ // Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
809
+ imageUploadEnabled: argv['interactive-image-upload'] !== false,
808
810
  });
809
811
  } else if (argv.interactiveMode) {
810
812
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive Mode Image Rendering (Issue #1843)
4
+ *
5
+ * Bridges the raw image uploader (interactive-image-upload.lib.mjs) and the
6
+ * Markdown formatter (interactive-mode.shared.lib.mjs) so interactive-mode.lib.mjs
7
+ * can turn the base64 images Claude/Codex read or wrote into an inline
8
+ * `### 🖼️ Images` section with a single call. Kept in its own module so the main
9
+ * interactive-mode handler stays under the 1500-line file limit (issue #1730).
10
+ *
11
+ * @module interactive-image-render.lib.mjs
12
+ * @experimental
13
+ */
14
+
15
+ import { formatImageEmbeds } from './interactive-mode.shared.lib.mjs';
16
+ import { createImageUploader, extractImagePayload, isImageNode } from './interactive-image-upload.lib.mjs';
17
+
18
+ // Re-exported so the main handler can import its image helpers from one place.
19
+ export { extractImagePayload, isImageNode };
20
+
21
+ /**
22
+ * Collect normalized image payloads from tool-result-like objects, de-duplicated
23
+ * by base64 content. Scans arrays directly, an array `content` (Claude/MCP), and
24
+ * the node itself (Claude Read `tool_use_result`). Enriches a missing
25
+ * `originalSize` from a later sibling payload with the same bytes.
26
+ *
27
+ * @param {...*} sources - candidate nodes/containers to scan
28
+ * @returns {Array<{ base64: string, mediaType?: string, originalSize?: number }>}
29
+ */
30
+ export const collectImagePayloads = (...sources) => {
31
+ const payloads = [];
32
+ const seen = new Set();
33
+ const add = node => {
34
+ const payload = extractImagePayload(node);
35
+ if (!payload) return;
36
+ const key = payload.base64.slice(0, 64) + ':' + payload.base64.length;
37
+ if (seen.has(key)) {
38
+ // Enrich an already-collected payload with size metadata if we now have it.
39
+ if (payload.originalSize) {
40
+ const existing = payloads.find(p => p.base64.slice(0, 64) + ':' + p.base64.length === key);
41
+ if (existing && !existing.originalSize) existing.originalSize = payload.originalSize;
42
+ }
43
+ return;
44
+ }
45
+ seen.add(key);
46
+ payloads.push(payload);
47
+ };
48
+ const scan = source => {
49
+ if (!source) return;
50
+ if (Array.isArray(source)) {
51
+ source.forEach(add);
52
+ } else if (typeof source === 'object') {
53
+ if (Array.isArray(source.content)) source.content.forEach(add);
54
+ add(source);
55
+ }
56
+ };
57
+ sources.forEach(scan);
58
+ return payloads;
59
+ };
60
+
61
+ /**
62
+ * Create an image renderer bound to a PR. Wraps an image uploader (built here, or
63
+ * injected via `options.uploader` for tests) and produces the Markdown image
64
+ * section for a set of tool-result sources. Upload failures / disabled uploads
65
+ * degrade to a metadata note inside `formatImageEmbeds` rather than dumping base64.
66
+ *
67
+ * @param {Object} [options]
68
+ * @param {Object} [options.uploader] - injected uploader (tests); otherwise built from the remaining options
69
+ * @param {Object} [options.state] - handler state, used to label images by tool name
70
+ * @param {Function} [options.log] - async logging function
71
+ * @param {boolean} [options.verbose=false]
72
+ * @param {string} [options.owner]
73
+ * @param {string} [options.repo]
74
+ * @param {number|string} [options.prNumber]
75
+ * @param {string} [options.mediaRef]
76
+ * @param {string} [options.refNamespace]
77
+ * @param {Function} [options.execFile]
78
+ * @param {boolean} [options.enabled=true]
79
+ * @returns {{ uploader: Object, collect: Function, render: Function, toolLabel: Function, section: Function }}
80
+ */
81
+ export const createImageRenderer = (options = {}) => {
82
+ const { uploader: injectedUploader, state, log = async () => {}, verbose = false, owner, repo, prNumber, mediaRef, refNamespace, execFile, enabled = true } = options;
83
+
84
+ const uploader = injectedUploader !== undefined ? injectedUploader : createImageUploader({ owner, repo, prNumber, mediaRef, refNamespace, log, verbose, execFile, enabled });
85
+
86
+ /**
87
+ * Upload normalized payloads and render the `### 🖼️ Images` Markdown section.
88
+ * Always returns a string (empty when there are no images).
89
+ * @param {Array<{ base64: string, mediaType?: string, originalSize?: number }>} payloads
90
+ * @param {string} [label] - human label prefix for captions / commit messages
91
+ * @returns {Promise<string>}
92
+ */
93
+ const render = async (payloads, label = 'image') => {
94
+ if (!Array.isArray(payloads) || payloads.length === 0) return '';
95
+ const rendered = [];
96
+ for (let i = 0; i < payloads.length; i++) {
97
+ const payload = payloads[i];
98
+ const name = payloads.length > 1 ? `${label} ${i + 1}` : label;
99
+ let url = null;
100
+ try {
101
+ if (uploader && typeof uploader.uploadImage === 'function') {
102
+ url = await uploader.uploadImage({ base64: payload.base64, mediaType: payload.mediaType, name });
103
+ }
104
+ } catch (err) {
105
+ if (verbose) await log(`⚠️ Interactive mode: image upload threw: ${err.message}`, { verbose: true });
106
+ url = null;
107
+ }
108
+ rendered.push({ url, mediaType: payload.mediaType, originalSize: payload.originalSize, name });
109
+ }
110
+ return formatImageEmbeds(rendered);
111
+ };
112
+
113
+ /**
114
+ * A human label for image captions, derived from the tool name in the
115
+ * pending-call / registry maps (e.g. "Read image"). Falls back to "image".
116
+ * @param {string} toolUseId
117
+ * @returns {string}
118
+ */
119
+ const toolLabel = toolUseId => {
120
+ const name = state?.pendingToolCalls?.get(toolUseId)?.toolName || state?.toolUseRegistry?.get(toolUseId)?.toolName;
121
+ return name ? `${name} image` : 'image';
122
+ };
123
+
124
+ /**
125
+ * Convenience: collect images from `sources` and render them in one call.
126
+ * @param {Array<*>} sources - array of candidate nodes/containers
127
+ * @param {string} [label]
128
+ * @returns {Promise<string>}
129
+ */
130
+ const section = async (sources, label) => render(collectImagePayloads(...(Array.isArray(sources) ? sources : [sources])), label);
131
+
132
+ return { uploader, collect: collectImagePayloads, render, toolLabel, section };
133
+ };
134
+
135
+ export default {
136
+ collectImagePayloads,
137
+ createImageRenderer,
138
+ extractImagePayload,
139
+ isImageNode,
140
+ };
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive Mode Image Upload (Issue #1843)
4
+ *
5
+ * When Claude/Codex read or write images during an --interactive-mode session,
6
+ * interactive mode should show those images inline in the PR comments it posts.
7
+ *
8
+ * GitHub strips `data:` URIs from comment Markdown (its sanitizer only allows
9
+ * http/https/mailto/relative schemes), so embedding base64 inline does NOT work.
10
+ * The web "user-attachments" uploader is cookie-gated and rejects Personal Access
11
+ * Tokens (HTTP 422), so it cannot be driven headlessly either.
12
+ *
13
+ * The token-viable approach used here: store image blobs in a hidden custom Git
14
+ * ref (`refs/hive-mind-media/...`) via the Git Data API and embed a commit-SHA
15
+ * `?raw=true` blob URL. The custom ref keeps commits reachable without creating
16
+ * a branch or tag, while GitHub's Camo proxy renders the URL inline for authorized
17
+ * viewers in public and private repos.
18
+ *
19
+ * See docs/case-studies/issue-1843/ for the full analysis and sources.
20
+ *
21
+ * @module interactive-image-upload.lib.mjs
22
+ * @experimental
23
+ */
24
+
25
+ import { createHash } from 'node:crypto';
26
+ import { execFileAsync } from './interactive-mode.shared.lib.mjs';
27
+
28
+ /**
29
+ * Hidden custom-ref namespace used to keep interactive-mode image commits alive
30
+ * without adding a branch or tag to the repository UI. Each PR gets its own ref:
31
+ * `refs/hive-mind-media/pr-<number>`.
32
+ */
33
+ export const DEFAULT_MEDIA_REF_NAMESPACE = 'refs/hive-mind-media';
34
+
35
+ const README_CONTENT = `# hive-mind interactive media
36
+
37
+ This custom Git ref stores images that hive-mind's \`--interactive-mode\` read or
38
+ wrote during a session, so they can be embedded inline in PR comments.
39
+
40
+ It is created and updated automatically under \`refs/hive-mind-media/...\`. It is
41
+ not a branch or tag and is not meant to be checked out or merged. Files are
42
+ organized as \`media/pr-<number>/<sha256-prefix>.<ext>\` and de-duplicated by
43
+ content hash. See \`docs/case-studies/issue-1843/\` for details.
44
+ `;
45
+
46
+ const EXT_BY_MEDIA_TYPE = {
47
+ 'image/png': 'png',
48
+ 'image/jpeg': 'jpg',
49
+ 'image/jpg': 'jpg',
50
+ 'image/gif': 'gif',
51
+ 'image/webp': 'webp',
52
+ 'image/svg+xml': 'svg',
53
+ 'image/bmp': 'bmp',
54
+ 'image/avif': 'avif',
55
+ 'image/tiff': 'tiff',
56
+ };
57
+
58
+ /**
59
+ * Map an image MIME type to a file extension. Defaults to `png`.
60
+ * @param {string} mediaType
61
+ * @returns {string}
62
+ */
63
+ export const extensionForMediaType = mediaType => {
64
+ if (typeof mediaType !== 'string') return 'png';
65
+ const key = mediaType.toLowerCase().split(';')[0].trim();
66
+ return EXT_BY_MEDIA_TYPE[key] || 'png';
67
+ };
68
+
69
+ const sanitizeRefSegment = value => {
70
+ const segment = String(value || 'misc')
71
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
72
+ .replace(/^-+|-+$/g, '');
73
+ return segment || 'misc';
74
+ };
75
+
76
+ const normalizeRefNamespace = namespace => {
77
+ const raw = String(namespace || DEFAULT_MEDIA_REF_NAMESPACE)
78
+ .replace(/\/+$/g, '')
79
+ .replace(/^\/+/g, '');
80
+ return raw.startsWith('refs/') ? raw : `refs/${raw}`;
81
+ };
82
+
83
+ /**
84
+ * Build the hidden custom Git ref used for one PR's media.
85
+ * @param {Object} [options]
86
+ * @param {number|string} [options.prNumber]
87
+ * @param {string} [options.namespace]
88
+ * @returns {string}
89
+ */
90
+ export const buildMediaRef = ({ prNumber, namespace = DEFAULT_MEDIA_REF_NAMESPACE } = {}) => {
91
+ const prSeg = prNumber ? `pr-${sanitizeRefSegment(prNumber)}` : 'misc';
92
+ return `${normalizeRefNamespace(namespace)}/${prSeg}`;
93
+ };
94
+
95
+ const refPathForApi = fullRef =>
96
+ String(fullRef)
97
+ .replace(/^refs\//, '')
98
+ .split('/')
99
+ .map(seg => encodeURIComponent(seg))
100
+ .join('/');
101
+
102
+ /**
103
+ * Normalize an image payload from any of the shapes Claude/Codex emit into
104
+ * `{ base64, mediaType }`, or return null if the node is not an image.
105
+ *
106
+ * Verified shapes (see docs/case-studies/issue-1843/external/research-notes.md):
107
+ * - Claude tool_result content: { type:'image', source:{ type:'base64', data, media_type } }
108
+ * - MCP image content: { type:'image', data, mimeType }
109
+ * - Claude Read tool_use_result: { type:'image', file:{ base64, type, originalSize } }
110
+ *
111
+ * @param {*} node
112
+ * @returns {{ base64: string, mediaType: string, originalSize?: number } | null}
113
+ */
114
+ export const extractImagePayload = node => {
115
+ if (!node || typeof node !== 'object') return null;
116
+
117
+ // Claude tool_result image block: { type:'image', source:{ data, media_type } }
118
+ if (node.type === 'image' && node.source && typeof node.source === 'object' && typeof node.source.data === 'string' && node.source.data.length > 0) {
119
+ return { base64: node.source.data, mediaType: node.source.media_type || node.source.mimeType || 'image/png' };
120
+ }
121
+
122
+ // MCP image content: { type:'image', data, mimeType }
123
+ if (node.type === 'image' && typeof node.data === 'string' && node.data.length > 0) {
124
+ return { base64: node.data, mediaType: node.mimeType || node.media_type || 'image/png' };
125
+ }
126
+
127
+ // Claude Read tool_use_result: { type:'image', file:{ base64, type, originalSize } }
128
+ if (node.file && typeof node.file === 'object' && typeof node.file.base64 === 'string' && node.file.base64.length > 0) {
129
+ return { base64: node.file.base64, mediaType: node.file.type || node.file.media_type || 'image/png', originalSize: node.file.originalSize };
130
+ }
131
+
132
+ return null;
133
+ };
134
+
135
+ /**
136
+ * True when `extractImagePayload` would return a payload for this node.
137
+ * @param {*} node
138
+ * @returns {boolean}
139
+ */
140
+ export const isImageNode = node => extractImagePayload(node) !== null;
141
+
142
+ /**
143
+ * Build a Markdown-embeddable raw blob URL for a file committed at a reachable
144
+ * commit SHA. The custom media ref keeps that commit reachable without exposing a
145
+ * branch/tag, and the `?raw=true` URL renders inline in GitHub comments.
146
+ *
147
+ * @param {string} owner
148
+ * @param {string} repo
149
+ * @param {string} commitSha
150
+ * @param {string} path
151
+ * @returns {string}
152
+ */
153
+ export const buildRawBlobUrl = (owner, repo, commitSha, path) => {
154
+ const safePath = String(path)
155
+ .split('/')
156
+ .map(seg => encodeURIComponent(seg))
157
+ .join('/');
158
+ return `https://github.com/${owner}/${repo}/blob/${encodeURIComponent(commitSha)}/${safePath}?raw=true`;
159
+ };
160
+
161
+ /**
162
+ * Create an image uploader bound to a repository/PR.
163
+ *
164
+ * @param {Object} options
165
+ * @param {string} options.owner - Repository owner
166
+ * @param {string} options.repo - Repository name
167
+ * @param {number|string} [options.prNumber] - PR number (used to namespace paths/ref)
168
+ * @param {string} [options.mediaRef] - Full custom media ref (defaults to refs/hive-mind-media/pr-<n>)
169
+ * @param {string} [options.refNamespace] - Custom media ref namespace
170
+ * @param {Function} [options.log] - async logging function
171
+ * @param {boolean} [options.verbose=false]
172
+ * @param {Function} [options.execFile] - injected gh runner (for testing)
173
+ * @param {boolean} [options.enabled=true] - master switch
174
+ * @returns {{ uploadImage: Function, ensureMediaRef: Function, enabled: boolean, mediaRef: string, _state: Object }}
175
+ */
176
+ export const createImageUploader = (options = {}) => {
177
+ const { owner, repo, prNumber, mediaRef = buildMediaRef({ prNumber, namespace: options.refNamespace }), log = async () => {}, verbose = false, execFile: execFileFn, enabled = true } = options;
178
+
179
+ const runGhApi = execFileFn || execFileAsync;
180
+ const mediaRefPath = refPathForApi(mediaRef);
181
+
182
+ // Memoized ref-readiness promise and a per-session content-hash -> URL cache.
183
+ let mediaRefReady = null;
184
+ let currentCommitSha = null;
185
+ let currentTreeSha = null;
186
+ let currentTreePaths = new Set();
187
+ const uploadedByHash = new Map();
188
+
189
+ /**
190
+ * Invoke `gh api` and parse the JSON response. For non-GET methods a JSON body
191
+ * is supplied on stdin via `--input -` (matches interactive-mode's postComment).
192
+ * @private
193
+ */
194
+ const ghJson = async (apiPath, { method = 'GET', body } = {}) => {
195
+ const args = ['api', apiPath];
196
+ if (method && method !== 'GET') args.push('-X', method);
197
+ const execOpts = { maxBuffer: 16 * 1024 * 1024 };
198
+ if (body !== undefined) {
199
+ args.push('--input', '-');
200
+ execOpts.input = JSON.stringify(body);
201
+ }
202
+ const { stdout } = await runGhApi('gh', args, execOpts);
203
+ if (!stdout) return null;
204
+ try {
205
+ return JSON.parse(stdout);
206
+ } catch {
207
+ return null;
208
+ }
209
+ };
210
+
211
+ const rememberTreePaths = async treeSha => {
212
+ try {
213
+ const tree = await ghJson(`repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`);
214
+ currentTreePaths = new Set((Array.isArray(tree?.tree) ? tree.tree : []).filter(entry => entry?.type === 'blob' && typeof entry.path === 'string').map(entry => entry.path));
215
+ } catch {
216
+ currentTreePaths = new Set();
217
+ }
218
+ };
219
+
220
+ const rememberCommit = async sha => {
221
+ if (!sha) return false;
222
+ const commit = await ghJson(`repos/${owner}/${repo}/git/commits/${sha}`);
223
+ if (!commit?.sha || !commit?.tree?.sha) return false;
224
+ currentCommitSha = commit.sha;
225
+ currentTreeSha = commit.tree.sha;
226
+ await rememberTreePaths(currentTreeSha);
227
+ return true;
228
+ };
229
+
230
+ const fetchMediaRef = async () => {
231
+ try {
232
+ return await ghJson(`repos/${owner}/${repo}/git/ref/${mediaRefPath}`);
233
+ } catch {
234
+ return null;
235
+ }
236
+ };
237
+
238
+ const refreshMediaRef = async () => {
239
+ const ref = await fetchMediaRef();
240
+ return !!(ref?.object?.sha && (await rememberCommit(ref.object.sha)));
241
+ };
242
+
243
+ /**
244
+ * Ensure the custom media ref exists. Creates it once via the Git Data API
245
+ * (blob -> tree -> parentless commit -> ref). Memoized; resolves to a boolean.
246
+ * @returns {Promise<boolean>}
247
+ */
248
+ const ensureMediaRef = async () => {
249
+ if (mediaRefReady) return mediaRefReady;
250
+ mediaRefReady = (async () => {
251
+ if (await refreshMediaRef()) return true;
252
+ try {
253
+ const readmeB64 = Buffer.from(README_CONTENT, 'utf8').toString('base64');
254
+ const blob = await ghJson(`repos/${owner}/${repo}/git/blobs`, {
255
+ method: 'POST',
256
+ body: { content: readmeB64, encoding: 'base64' },
257
+ });
258
+ const tree = await ghJson(`repos/${owner}/${repo}/git/trees`, {
259
+ method: 'POST',
260
+ body: { tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: blob.sha }] },
261
+ });
262
+ const commit = await ghJson(`repos/${owner}/${repo}/git/commits`, {
263
+ method: 'POST',
264
+ body: { message: 'chore: initialize hive-mind interactive media ref', tree: tree.sha, parents: [] },
265
+ });
266
+ await ghJson(`repos/${owner}/${repo}/git/refs`, {
267
+ method: 'POST',
268
+ body: { ref: mediaRef, sha: commit.sha },
269
+ });
270
+ currentCommitSha = commit.sha;
271
+ currentTreeSha = tree.sha;
272
+ currentTreePaths = new Set(['README.md']);
273
+ if (verbose) await log(`🖼️ Interactive mode: created media ref '${mediaRef}'`, { verbose: true });
274
+ return true;
275
+ } catch (err) {
276
+ // A concurrent run may have created it between our check and create.
277
+ if (await refreshMediaRef()) return true;
278
+ await log(`⚠️ Interactive mode: could not prepare media ref '${mediaRef}': ${err.message}`);
279
+ return false;
280
+ }
281
+ })();
282
+ return mediaRefReady;
283
+ };
284
+
285
+ const pathExistsAtCurrentCommit = async path => {
286
+ return !!(currentCommitSha && currentTreePaths.has(path));
287
+ };
288
+
289
+ const existingUrl = async path => {
290
+ if (await pathExistsAtCurrentCommit(path)) {
291
+ return buildRawBlobUrl(owner, repo, currentCommitSha, path);
292
+ }
293
+ return null;
294
+ };
295
+
296
+ /**
297
+ * Upload one image and return a Markdown-embeddable URL, or null on failure /
298
+ * when disabled. De-duplicates by SHA-256 of the base64 content.
299
+ *
300
+ * @param {Object} image
301
+ * @param {string} image.base64 - base64-encoded image bytes
302
+ * @param {string} [image.mediaType] - MIME type (e.g. image/png)
303
+ * @param {string} [image.name] - human label for the commit message
304
+ * @returns {Promise<string|null>}
305
+ */
306
+ const uploadImage = async ({ base64, mediaType, name } = {}) => {
307
+ if (!enabled) return null;
308
+ if (!owner || !repo) return null;
309
+ if (typeof base64 !== 'string' || base64.length === 0) return null;
310
+
311
+ const hash = createHash('sha256').update(base64).digest('hex');
312
+ if (uploadedByHash.has(hash)) return uploadedByHash.get(hash);
313
+
314
+ const ready = await ensureMediaRef();
315
+ if (!ready || !currentCommitSha || !currentTreeSha) return null;
316
+
317
+ const ext = extensionForMediaType(mediaType);
318
+ const short = hash.slice(0, 16);
319
+ const prSeg = prNumber ? `pr-${sanitizeRefSegment(prNumber)}` : 'misc';
320
+ const path = `media/${prSeg}/${short}.${ext}`;
321
+
322
+ const alreadyUploaded = await existingUrl(path);
323
+ if (alreadyUploaded) {
324
+ uploadedByHash.set(hash, alreadyUploaded);
325
+ return alreadyUploaded;
326
+ }
327
+
328
+ let lastError = null;
329
+ for (let attempt = 0; attempt < 2; attempt++) {
330
+ try {
331
+ const parentSha = currentCommitSha;
332
+ const blob = await ghJson(`repos/${owner}/${repo}/git/blobs`, {
333
+ method: 'POST',
334
+ body: { content: base64, encoding: 'base64' },
335
+ });
336
+ const tree = await ghJson(`repos/${owner}/${repo}/git/trees`, {
337
+ method: 'POST',
338
+ body: {
339
+ base_tree: currentTreeSha,
340
+ tree: [{ path, mode: '100644', type: 'blob', sha: blob.sha }],
341
+ },
342
+ });
343
+ const commit = await ghJson(`repos/${owner}/${repo}/git/commits`, {
344
+ method: 'POST',
345
+ body: {
346
+ message: `chore: add interactive media ${name || short}`,
347
+ tree: tree.sha,
348
+ parents: [parentSha],
349
+ },
350
+ });
351
+ await ghJson(`repos/${owner}/${repo}/git/refs/${mediaRefPath}`, {
352
+ method: 'PATCH',
353
+ body: { sha: commit.sha, force: false },
354
+ });
355
+
356
+ currentCommitSha = commit.sha;
357
+ currentTreeSha = tree.sha;
358
+ currentTreePaths.add(path);
359
+ const url = buildRawBlobUrl(owner, repo, commit.sha, path);
360
+ uploadedByHash.set(hash, url);
361
+ if (verbose) await log(`🖼️ Interactive mode: uploaded image -> ${url}`, { verbose: true });
362
+ return url;
363
+ } catch (err) {
364
+ lastError = err;
365
+ // Most likely a concurrent non-fast-forward update. Refresh once and retry.
366
+ if (attempt === 0 && (await refreshMediaRef())) {
367
+ const existingAfterRefresh = await existingUrl(path);
368
+ if (existingAfterRefresh) {
369
+ uploadedByHash.set(hash, existingAfterRefresh);
370
+ return existingAfterRefresh;
371
+ }
372
+ continue;
373
+ }
374
+ }
375
+ }
376
+
377
+ const existingAfterFailure = await existingUrl(path);
378
+ if (existingAfterFailure) {
379
+ uploadedByHash.set(hash, existingAfterFailure);
380
+ return existingAfterFailure;
381
+ }
382
+
383
+ await log(`⚠️ Interactive mode: image upload failed (${path}): ${lastError?.message || 'unknown error'}`);
384
+ return null;
385
+ };
386
+
387
+ return {
388
+ uploadImage,
389
+ ensureMediaRef,
390
+ enabled,
391
+ mediaRef,
392
+ _state: {
393
+ uploadedByHash,
394
+ get currentCommitSha() {
395
+ return currentCommitSha;
396
+ },
397
+ get currentTreeSha() {
398
+ return currentTreeSha;
399
+ },
400
+ get currentTreePaths() {
401
+ return currentTreePaths;
402
+ },
403
+ },
404
+ };
405
+ };
406
+
407
+ export default {
408
+ DEFAULT_MEDIA_REF_NAMESPACE,
409
+ buildMediaRef,
410
+ extensionForMediaType,
411
+ extractImagePayload,
412
+ isImageNode,
413
+ buildRawBlobUrl,
414
+ createImageUploader,
415
+ };
@@ -32,7 +32,9 @@
32
32
  * @experimental
33
33
  */
34
34
 
35
- import { CONFIG, createCollapsible, createRawJsonSection, escapeMarkdown, execFileAsync, formatCost, formatDuration, getToolIcon, safeJsonStringify, sanitizeUnicode, truncateMiddle } from './interactive-mode.shared.lib.mjs';
35
+ import { CONFIG, createCollapsible, createRawJsonSection, createRedactedRawJsonSection, escapeMarkdown, execFileAsync, formatCost, formatDuration, getToolIcon, redactImageData, safeJsonStringify, sanitizeUnicode, truncateMiddle } from './interactive-mode.shared.lib.mjs';
36
+ // Issue #1843: turn base64 image tool-results into inline PR-comment images.
37
+ import { createImageRenderer, extractImagePayload, isImageNode } from './interactive-image-render.lib.mjs';
36
38
  // Issue #1625: track interactive-mode comment IDs so they're excluded from
37
39
  // the "did the AI post anything?" check in checkForAiCreatedComments().
38
40
  // Use the session-started marker as the single source of truth for the
@@ -75,6 +77,11 @@ export const createInteractiveHandler = options => {
75
77
  // Pre-existing user content carve-out (issue body / non-bot comments /
76
78
  // pre-existing code). When provided, sanitizer leaves these tokens untouched.
77
79
  excludeTokens = [],
80
+ // Issue #1843: when true (default), base64 tool-result images are embedded
81
+ // inline; when false they degrade to a metadata note. See createImageRenderer.
82
+ imageUploadEnabled = true,
83
+ mediaRef,
84
+ imageUploader: injectedImageUploader,
78
85
  } = options;
79
86
  // Use injected execFile for testability, or the real one by default
80
87
  const runGhApi = execFileFn || execFileAsync;
@@ -111,6 +118,8 @@ export const createInteractiveHandler = options => {
111
118
  editsFailed: 0,
112
119
  };
113
120
 
121
+ const imageRenderer = createImageRenderer({ owner, repo, prNumber, mediaRef, log, verbose, execFile: execFileFn, enabled: imageUploadEnabled, uploader: injectedImageUploader, state }); // Issue #1843
122
+
114
123
  /**
115
124
  * Sanitize a comment body and warn the chat owner when a known-local token
116
125
  * was about to be published. Issue #1745. The returned string is what we
@@ -632,6 +641,8 @@ ${createRawJsonSection(data)}`;
632
641
  .map(c => {
633
642
  if (typeof c === 'string') return c;
634
643
  if (c.type === 'text') return c.text || '';
644
+ // Issue #1843: image bytes render in the section below, not the fence.
645
+ if (isImageNode(c)) return `_[image: ${extractImagePayload(c).mediaType}]_`;
635
646
  return safeJsonStringify(c);
636
647
  })
637
648
  .join('\n');
@@ -644,6 +655,10 @@ ${createRawJsonSection(data)}`;
644
655
  keepEnd: 25,
645
656
  });
646
657
 
658
+ // Issue #1843: render images this result read/produced (used by both paths).
659
+ const imagesSection = await imageRenderer.section([toolResult.content, data.tool_use_result], imageRenderer.toolLabel(toolUseId));
660
+ const imagesBlock = imagesSection ? `${imagesSection}\n\n` : '';
661
+
647
662
  // Check if we have a pending tool call to merge with
648
663
  const pendingCall = state.pendingToolCalls.get(toolUseId);
649
664
 
@@ -703,9 +718,9 @@ ${inputDisplay}
703
718
 
704
719
  ${createCollapsible(`📤 Output (${statusIcon} ${statusText.toLowerCase()})`, '```\n' + escapeMarkdown(truncatedContent) + '\n```', true)}
705
720
 
706
- ---
721
+ ${imagesBlock}---
707
722
 
708
- ${createRawJsonSection([toolData, data])}`;
723
+ ${createRedactedRawJsonSection([toolData, data])}`;
709
724
 
710
725
  // Edit the existing comment
711
726
  const editSuccess = await editComment(commentId, mergedComment);
@@ -742,9 +757,9 @@ ${createRawJsonSection([toolData, data])}`;
742
757
 
743
758
  ${createCollapsible(`📤 Output (${statusIcon} ${statusText.toLowerCase()})`, '```\n' + escapeMarkdown(truncatedContent) + '\n```', true)}
744
759
 
745
- ---
760
+ ${imagesBlock}---
746
761
 
747
- ${createRawJsonSection(data)}`;
762
+ ${createRedactedRawJsonSection(data)}`;
748
763
 
749
764
  await postComment(comment);
750
765
 
@@ -1153,17 +1168,20 @@ ${createRawJsonSection(data)}`;
1153
1168
  const item = data.item || {};
1154
1169
  const summary = [`**Server:** \`${item.server || 'unknown'}\``, `**Tool:** \`${item.tool || 'unknown'}\``, `**Status:** \`${item.status || 'unknown'}\``].join('\n');
1155
1170
  const details = item.arguments != null ? createCollapsible('📥 Arguments', '```json\n' + safeJsonStringify(item.arguments, 2) + '\n```', true) : '';
1156
- const resultSection = item.result != null ? '\n\n' + createCollapsible('📤 Result', '```json\n' + safeJsonStringify(item.result, 2) + '\n```', false) : '';
1171
+ // Issue #1843: render MCP-result images inline; base64 is redacted from JSON below.
1172
+ const imagesSection = await imageRenderer.section([item.result], `${item.tool || 'mcp'} image`);
1173
+ const imagesBlock = imagesSection ? '\n\n' + imagesSection : '';
1174
+ const resultSection = item.result != null ? '\n\n' + createCollapsible('📤 Result', '```json\n' + safeJsonStringify(redactImageData(item.result), 2) + '\n```', false) : '';
1157
1175
  const errorSection = item.error != null ? '\n\n' + createCollapsible('❌ Error', '```json\n' + safeJsonStringify(item.error, 2) + '\n```', true) : '';
1158
1176
 
1159
1177
  await postComment(`## 🔌 Codex MCP tool call
1160
1178
 
1161
1179
  ${summary}
1162
- ${details}${resultSection}${errorSection}
1180
+ ${details}${resultSection}${imagesBlock}${errorSection}
1163
1181
 
1164
1182
  ---
1165
1183
 
1166
- ${createRawJsonSection(data)}`);
1184
+ ${createRedactedRawJsonSection(data)}`);
1167
1185
  };
1168
1186
 
1169
1187
  const handleCodexWebSearch = async data => {
@@ -1388,6 +1406,7 @@ ${createRawJsonSection(data)}`;
1388
1406
  processEvent,
1389
1407
  flush,
1390
1408
  getState,
1409
+ imageUploader: imageRenderer.uploader, // Issue #1843: exposed for callers/tests.
1391
1410
  // Expose individual handlers for testing
1392
1411
  _handlers: {
1393
1412
  handleSystemInit,
@@ -96,6 +96,103 @@ export const createRawJsonSection = data => {
96
96
  return createCollapsible('📄 Raw JSON', '```json\n' + jsonContent + '\n```');
97
97
  };
98
98
 
99
+ /**
100
+ * Issue #1843: Deep-clone an event object, replacing base64 image payloads with
101
+ * a short `<image data: N base64 chars>` placeholder. Image base64 (Read tool,
102
+ * Playwright screenshots, MCP image results) can be many kilobytes on a single
103
+ * JSON line, which `truncateMiddle` cannot shrink — so without this the "Raw
104
+ * JSON" sections would bloat every image-bearing comment toward the API limit.
105
+ *
106
+ * Redaction is targeted at the three known image carriers and leaves all other
107
+ * fields intact for debugging:
108
+ * - `{ type:'image', source:{ data } }` → source.data
109
+ * - `{ type:'image', data }` → data (MCP shape)
110
+ * - `{ file:{ base64 } }` → file.base64 (Read tool_use_result)
111
+ *
112
+ * @param {*} data
113
+ * @returns {*} a redacted clone (primitives returned as-is)
114
+ */
115
+ export const redactImageData = data => {
116
+ const seen = new WeakSet();
117
+ const placeholder = len => `<image data: ${len} base64 chars>`;
118
+ const walk = node => {
119
+ if (Array.isArray(node)) return node.map(walk);
120
+ if (node && typeof node === 'object') {
121
+ if (seen.has(node)) return '[Circular]';
122
+ seen.add(node);
123
+ const out = {};
124
+ for (const [k, v] of Object.entries(node)) out[k] = walk(v);
125
+ if (out.type === 'image' && out.source && typeof out.source === 'object' && typeof out.source.data === 'string') {
126
+ out.source = { ...out.source, data: placeholder(out.source.data.length) };
127
+ }
128
+ if (out.type === 'image' && typeof out.data === 'string' && out.data.length > 64) {
129
+ out.data = placeholder(out.data.length);
130
+ }
131
+ if (out.file && typeof out.file === 'object' && typeof out.file.base64 === 'string') {
132
+ out.file = { ...out.file, base64: placeholder(out.file.base64.length) };
133
+ }
134
+ return out;
135
+ }
136
+ return node;
137
+ };
138
+ return walk(data);
139
+ };
140
+
141
+ /**
142
+ * Issue #1843: Like createRawJsonSection, but strips base64 image data first.
143
+ * @param {*} data
144
+ * @returns {string}
145
+ */
146
+ export const createRedactedRawJsonSection = data => createRawJsonSection(redactImageData(data));
147
+
148
+ /**
149
+ * Format a byte count as a short human-readable string (e.g. "7.2 MB").
150
+ * @param {number} bytes
151
+ * @returns {string}
152
+ */
153
+ export const formatBytes = bytes => {
154
+ if (typeof bytes !== 'number' || !isFinite(bytes) || bytes < 0) return '';
155
+ if (bytes < 1024) return `${bytes} B`;
156
+ const units = ['KB', 'MB', 'GB'];
157
+ let value = bytes / 1024;
158
+ let i = 0;
159
+ while (value >= 1024 && i < units.length - 1) {
160
+ value /= 1024;
161
+ i++;
162
+ }
163
+ return `${value.toFixed(value >= 10 || Number.isInteger(value) ? 0 : 1)} ${units[i]}`;
164
+ };
165
+
166
+ /**
167
+ * Sanitize Markdown image alt text so it can't break the `![alt](url)` syntax.
168
+ * @param {string} text
169
+ * @returns {string}
170
+ */
171
+ const escapeAltText = text => (!text || typeof text !== 'string' ? 'image' : text.replace(/[[\]\n\r]/g, ' ').trim() || 'image');
172
+
173
+ /**
174
+ * Issue #1843: Render an "🖼️ Images" Markdown section for images surfaced in a
175
+ * tool result. Each entry that has a `url` is embedded inline with `![](url)`;
176
+ * entries without a `url` (upload disabled or failed) degrade to a compact
177
+ * metadata note instead of dumping base64.
178
+ *
179
+ * @param {Array<{ url?: string, mediaType?: string, originalSize?: number, name?: string }>} images
180
+ * @returns {string} Markdown (empty string when there are no images)
181
+ */
182
+ export const formatImageEmbeds = images => {
183
+ if (!Array.isArray(images) || images.length === 0) return '';
184
+ const blocks = images.map((img, i) => {
185
+ const label = img.name || `image ${i + 1}`;
186
+ const meta = [img.mediaType, formatBytes(img.originalSize)].filter(Boolean).join(', ');
187
+ const caption = meta ? `${label} (${meta})` : label;
188
+ if (img.url) {
189
+ return `**${escapeMarkdown(caption)}**\n\n![${escapeAltText(label)}](${img.url})`;
190
+ }
191
+ return `**${escapeMarkdown(caption)}** — _image upload unavailable; not shown inline_`;
192
+ });
193
+ return `### 🖼️ Images\n\n${blocks.join('\n\n')}`;
194
+ };
195
+
99
196
  export const formatDuration = ms => {
100
197
  if (!ms || ms < 0) return 'unknown';
101
198
 
@@ -405,6 +405,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
405
405
  description: '[EXPERIMENTAL] Post tool output as PR comments in real-time. Supported for --tool claude and --tool codex.',
406
406
  default: false,
407
407
  },
408
+ // Issue #1843: render images that Claude/Codex read/write inline in the PR
409
+ // comments interactive mode posts. Images are committed to hidden custom Git
410
+ // refs and embedded via commit-SHA ?raw=true blob URLs (GitHub strips data: URIs).
411
+ // Disable with --no-interactive-image-upload to fall back to a metadata note.
412
+ 'interactive-image-upload': {
413
+ type: 'boolean',
414
+ description: '[EXPERIMENTAL] When --interactive-mode is on, upload images read/written by the AI to hidden custom Git refs (refs/hive-mind-media/...) and embed them inline in PR comments. Enabled by default; use --no-interactive-image-upload to disable.',
415
+ default: true,
416
+ },
408
417
  // Issue #817: Bidirectional interactive options
409
418
  'accept-incomming-comments-as-input': {
410
419
  type: 'boolean',