farming-code 2.2.28 → 2.2.30

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.
Files changed (83) hide show
  1. package/README.md +27 -90
  2. package/README.zh_cn.md +17 -90
  3. package/THIRD_PARTY_NOTICES.md +25 -6
  4. package/backend/acp/packaged-claude-acp.js +13 -0
  5. package/backend/acp/packaged-codex-acp.js +2 -2
  6. package/backend/acp-runtime.js +38 -17
  7. package/backend/acp-session-state.js +78 -88
  8. package/backend/acp-transcript.js +174 -9
  9. package/backend/agent-manager.js +27 -2
  10. package/backend/agent-session-history.js +45 -12
  11. package/backend/codex-session-history.js +1 -1
  12. package/backend/codex-transcript.js +0 -202
  13. package/backend/config-manager.js +44 -16
  14. package/backend/data/LICENSE.agent-browser +201 -0
  15. package/backend/data/runtime-dependency-manifest.json +199 -0
  16. package/backend/data/runtime-dependency-sources.json +5 -0
  17. package/backend/executable-discovery.js +7 -0
  18. package/backend/farming-agent-bootstrap.zh_cn.md +4 -2
  19. package/backend/farming-app-cli.js +161 -65
  20. package/backend/farming-cli.js +7 -59
  21. package/backend/index-html.js +31 -0
  22. package/backend/main-agent-skills.js +7 -28
  23. package/backend/npm-update-helper.js +43 -6
  24. package/backend/provider-adapters.js +10 -0
  25. package/backend/runtime-dependency-manager.js +759 -0
  26. package/backend/runtime-executable-invocation.js +20 -0
  27. package/backend/server-process-identity.js +47 -0
  28. package/backend/server.js +145 -204
  29. package/backend/slash-command-discovery.js +138 -1
  30. package/backend/storage-layout.js +30 -0
  31. package/backend/update-service.js +60 -914
  32. package/config/farming.deploy.env.example +1 -1
  33. package/config/farming.install.env.example +5 -1
  34. package/dist/acp/LICENSE.claude-agent-acp +191 -0
  35. package/dist/acp/LICENSE.claude-agent-sdk +1 -0
  36. package/dist/acp/claude-agent-acp-0.59.0.mjs +41606 -0
  37. package/dist/acp/{codex-acp-1.1.4.js → codex-acp-1.1.4.mjs} +387 -7
  38. package/dist/assets/{App-CM7Cy-l4.css → App-BKNUILuL.css} +1 -1
  39. package/dist/assets/App-Z1N7T0eM.js +695 -0
  40. package/dist/assets/{FileEditorMarkdownPreview-DYOMm-b1.js → FileEditorMarkdownPreview-Cs9cLv8a.js} +1 -1
  41. package/dist/assets/FileEditorPane-CNtuGjQ3.js +2 -0
  42. package/dist/assets/IconGlyphs-BClgag6E.js +1 -0
  43. package/dist/assets/ProjectFilesSection-K3GHuQ4U.js +12 -0
  44. package/dist/assets/ReviewPage-BRuxFaH5.js +3 -0
  45. package/dist/assets/code-dark-kLsQLvpf.css +1 -0
  46. package/dist/assets/{index-BzQO3pUr.js → index-D-zetSol.js} +2 -2
  47. package/dist/assets/main-DUTz-Rd2.css +1 -0
  48. package/dist/assets/{workspace-editor-monaco-tXA-udGx.js → workspace-editor-monaco-CJNwbpCy.js} +1 -1
  49. package/dist/assets/workspace-editor-monaco-CO9w8YV0.js +1 -0
  50. package/dist/assets/workspace-view-state-BjKPYp0E.js +7 -0
  51. package/dist/index.html +33 -3
  52. package/extensions/browser/backend/agent-browser-runtime.js +1041 -0
  53. package/extensions/browser/backend/browser-mcp-server.js +352 -0
  54. package/extensions/browser/backend/browser-resource-manager.js +921 -114
  55. package/extensions/browser/backend/browser-resource-store.js +47 -6
  56. package/extensions/browser/backend/browser-router.js +15 -2
  57. package/extensions/browser/backend/executable-discovery.js +164 -17
  58. package/extensions/browser/backend/managed-chromium-installer.js +593 -0
  59. package/extensions/browser/bin/farming-browser +384 -30
  60. package/extensions/browser/frontend/BrowserSidebarPortals.tsx +17 -19
  61. package/extensions/browser/frontend/BrowserViewer.tsx +217 -46
  62. package/extensions/browser/frontend/browser.css +68 -17
  63. package/extensions/browser/frontend/types.ts +13 -0
  64. package/extensions/browser/frontend/useBrowserResources.ts +18 -3
  65. package/index.html +32 -2
  66. package/package.json +10 -6
  67. package/shared/backend-connection-status.d.ts +12 -0
  68. package/shared/backend-connection-status.js +35 -4
  69. package/backend/agent-activity.js +0 -35
  70. package/backend/agent-memory-report.js +0 -583
  71. package/backend/bundle-update-helper.js +0 -113
  72. package/backend/codex-rollout-follower.js +0 -326
  73. package/dist/assets/App-CPG4WcjO.js +0 -658
  74. package/dist/assets/FileEditorPane-DXXq82aM.js +0 -2
  75. package/dist/assets/IconGlyphs-DrdPjYXr.js +0 -1
  76. package/dist/assets/ProjectFilesSection-CP66XVPl.js +0 -12
  77. package/dist/assets/ReviewPage-w21MEPD6.js +0 -3
  78. package/dist/assets/code-dark-BDjwj7Um.css +0 -1
  79. package/dist/assets/main-BlVUXfxa.css +0 -1
  80. package/dist/assets/workspace-editor-monaco-CFEfFNcA.js +0 -1
  81. package/dist/assets/workspace-view-state-BS_V8JVs.js +0 -7
  82. package/extensions/browser/backend/cdp-browser-runtime.js +0 -648
  83. package/extensions/browser/backend/cdp-client.js +0 -167
@@ -1,21 +1,9 @@
1
- const fsp = require('fs/promises');
2
- const path = require('path');
3
- const { fileURLToPath } = require('url');
4
-
5
1
  const MAX_ACP_UPDATES = 2_000;
6
2
  const MAX_ACP_UPDATE_LOG_VALUE_CHARS = 32 * 1024;
7
- const MAX_CODEX_HISTORY_IMAGES_PER_MESSAGE = 6;
8
- const MAX_CODEX_HISTORY_IMAGE_BYTES = 5 * 1024 * 1024;
9
- const CODEX_HISTORY_IMAGE_MIME_BY_EXT = Object.freeze({
10
- '.gif': 'image/gif',
11
- '.jpeg': 'image/jpeg',
12
- '.jpg': 'image/jpeg',
13
- '.png': 'image/png',
14
- '.svg': 'image/svg+xml',
15
- '.webp': 'image/webp',
16
- });
3
+ const MAX_CODEX_SUBAGENTS = 128;
4
+ const MAX_CODEX_SUBAGENT_ID_CHARS = 160;
5
+ const MAX_CODEX_SUBAGENT_NAME_CHARS = 120;
17
6
  const {
18
- codexHistoryImageTargets,
19
7
  visibleUserMessageText,
20
8
  } = require('./codex-transcript');
21
9
  const {
@@ -27,6 +15,54 @@ function clone(value) {
27
15
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
28
16
  }
29
17
 
18
+ function boundedSubagentId(value) {
19
+ return String(value || '').trim().slice(0, MAX_CODEX_SUBAGENT_ID_CHARS);
20
+ }
21
+
22
+ function normalizeCodexSubagentStatus(value) {
23
+ const status = String(value || '');
24
+ return [
25
+ 'pendingInit',
26
+ 'running',
27
+ 'completed',
28
+ 'interrupted',
29
+ 'errored',
30
+ 'shutdown',
31
+ 'notFound',
32
+ ].includes(status) ? status : '';
33
+ }
34
+
35
+ function normalizeCodexSubagentUpdate(raw, sessionId) {
36
+ if (!raw || raw.version !== 1 || !Array.isArray(raw.agents)) return null;
37
+ const rootThreadId = boundedSubagentId(raw.rootThreadId);
38
+ if (!rootThreadId || rootThreadId !== sessionId) return null;
39
+ const agents = [];
40
+ const seen = new Set();
41
+ for (const candidate of raw.agents.slice(0, MAX_CODEX_SUBAGENTS)) {
42
+ const threadId = boundedSubagentId(candidate?.threadId);
43
+ const status = normalizeCodexSubagentStatus(candidate?.status);
44
+ if (!threadId || threadId === rootThreadId || !status || seen.has(threadId)) continue;
45
+ seen.add(threadId);
46
+ const parentThreadId = boundedSubagentId(candidate?.parentThreadId);
47
+ const name = String(candidate?.name || '').trim().slice(0, MAX_CODEX_SUBAGENT_NAME_CHARS);
48
+ agents.push({
49
+ threadId,
50
+ parentThreadId: parentThreadId && parentThreadId !== threadId ? parentThreadId : null,
51
+ ...(name ? { name } : {}),
52
+ status,
53
+ });
54
+ }
55
+ return {
56
+ version: 1,
57
+ rootThreadId,
58
+ revision: Number.isFinite(Number(raw.revision))
59
+ ? Math.max(0, Math.floor(Number(raw.revision)))
60
+ : 0,
61
+ kind: raw.kind === 'delta' ? 'delta' : 'snapshot',
62
+ agents,
63
+ };
64
+ }
65
+
30
66
  function compactUpdateForLog(update) {
31
67
  let serialized = '';
32
68
  try {
@@ -62,40 +98,6 @@ function contentText(content) {
62
98
  .join('');
63
99
  }
64
100
 
65
- function localPathFromHistoryImageTarget(value) {
66
- const target = String(value || '').trim();
67
- if (!target || target.includes('\0') || /^data:/i.test(target)) return '';
68
- if (/^file:\/\//i.test(target)) {
69
- try {
70
- return fileURLToPath(target);
71
- } catch {
72
- return '';
73
- }
74
- }
75
- return path.isAbsolute(target) ? target : '';
76
- }
77
-
78
- function imageBlockFromDataUrl(value) {
79
- const match = String(value || '').match(/^data:(image\/(?:gif|jpe?g|png|svg\+xml|webp));base64,([a-z0-9+/=]+)$/i);
80
- if (!match) return null;
81
- const data = match[2];
82
- if (!data || Math.ceil(data.length * 3 / 4) > MAX_CODEX_HISTORY_IMAGE_BYTES) return null;
83
- return { type: 'image', mimeType: match[1].toLowerCase(), data };
84
- }
85
-
86
- async function imageBlockFromLocalPath(filePath) {
87
- const ext = path.extname(filePath).toLowerCase();
88
- const mimeType = CODEX_HISTORY_IMAGE_MIME_BY_EXT[ext];
89
- if (!mimeType) return null;
90
- try {
91
- const stat = await fsp.stat(filePath);
92
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_CODEX_HISTORY_IMAGE_BYTES) return null;
93
- return { type: 'image', mimeType, data: (await fsp.readFile(filePath)).toString('base64') };
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
101
  function isContextCompactionText(content) {
100
102
  const text = contentText(content).trim();
101
103
  return /^\*?Context compacted(?: to fit the model's context window)?\.?\*?$/i.test(text)
@@ -157,6 +159,7 @@ class AcpSessionState {
157
159
  this.configOptions = [];
158
160
  this.title = '';
159
161
  this.updatedAt = '';
162
+ this.codexSubagents = null;
160
163
  this.truncated = false;
161
164
  this.sequence = 0;
162
165
  this.revision = Number.isFinite(Number(options.revisionBase))
@@ -202,6 +205,7 @@ class AcpSessionState {
202
205
  state.configOptions = clone(checkpoint.configOptions || []);
203
206
  state.title = String(checkpoint.title || '');
204
207
  state.updatedAt = String(checkpoint.updatedAt || '');
208
+ state.codexSubagents = clone(checkpoint.codexSubagents ?? null);
205
209
  state.truncated = checkpoint.truncated === true;
206
210
  return state;
207
211
  }
@@ -282,44 +286,6 @@ class AcpSessionState {
282
286
  // History replay uses the same reducer as live updates; nothing to close.
283
287
  }
284
288
 
285
- hasCodexHistoryImageReferences() {
286
- return this.provider === 'codex' && this.entries.some(entry => (
287
- entry?.type === 'message'
288
- && entry.role === 'user'
289
- && codexHistoryImageTargets(contentText(entry.content)).length > 0
290
- ));
291
- }
292
-
293
- async hydrateCodexHistoryAttachments(options = {}) {
294
- if (this.provider !== 'codex') return 0;
295
- const imageDataByPath = options.imageDataByPath instanceof Map ? options.imageDataByPath : new Map();
296
- let hydrated = 0;
297
- for (const entry of this.entries) {
298
- if (entry?.type !== 'message' || entry.role !== 'user') continue;
299
- const existingImages = (entry.content || []).filter(content => content?.type === 'image');
300
- const remaining = MAX_CODEX_HISTORY_IMAGES_PER_MESSAGE - existingImages.length;
301
- if (remaining <= 0) continue;
302
- const targets = codexHistoryImageTargets(contentText(entry.content));
303
- const seenPaths = new Set();
304
- const blocks = [];
305
- for (const target of targets) {
306
- const filePath = localPathFromHistoryImageTarget(target);
307
- if (!filePath || seenPaths.has(filePath)) continue;
308
- seenPaths.add(filePath);
309
- const fallback = imageDataByPath.get(filePath);
310
- const block = await imageBlockFromLocalPath(filePath) || imageBlockFromDataUrl(fallback);
311
- if (!block) continue;
312
- blocks.push(block);
313
- if (blocks.length >= remaining) break;
314
- }
315
- if (blocks.length === 0) continue;
316
- entry.content.push(...blocks);
317
- this.touchEntry(entry);
318
- hydrated += blocks.length;
319
- }
320
- return hydrated;
321
- }
322
-
323
289
  apply(notification) {
324
290
  if (!notification || notification.sessionId !== this.sessionId) return false;
325
291
  const update = notification.update;
@@ -360,10 +326,31 @@ class AcpSessionState {
360
326
  } else if (kind === 'session_info_update') {
361
327
  if (Object.prototype.hasOwnProperty.call(update, 'title')) this.title = String(update.title || '');
362
328
  if (Object.prototype.hasOwnProperty.call(update, 'updatedAt')) this.updatedAt = String(update.updatedAt || '');
329
+ this.applyCodexSubagentUpdate(update?._meta?.codex?.subagents);
363
330
  }
364
331
  return true;
365
332
  }
366
333
 
334
+ applyCodexSubagentUpdate(raw) {
335
+ const update = normalizeCodexSubagentUpdate(raw, this.sessionId);
336
+ if (!update) return false;
337
+ const agentsById = update.kind === 'delta' && this.codexSubagents?.rootThreadId === update.rootThreadId
338
+ ? new Map(this.codexSubagents.agents.map(agent => [agent.threadId, agent]))
339
+ : new Map();
340
+ for (const agent of update.agents) agentsById.set(agent.threadId, agent);
341
+ this.codexSubagents = {
342
+ version: 1,
343
+ rootThreadId: update.rootThreadId,
344
+ revision: update.revision,
345
+ agents: [...agentsById.values()].slice(0, MAX_CODEX_SUBAGENTS),
346
+ };
347
+ // Session metadata changes are visible transcript state even when no
348
+ // message/tool entry changed. Advance the reducer revision so the existing
349
+ // revision-driven transcript read reconciles this snapshot without polling.
350
+ this.revision += 1;
351
+ return true;
352
+ }
353
+
367
354
  applyMessageChunk(update, kind) {
368
355
  const role = kind === 'user_message_chunk' ? 'user' : 'assistant';
369
356
  const type = kind === 'agent_thought_chunk' ? 'thought' : 'message';
@@ -523,6 +510,7 @@ class AcpSessionState {
523
510
  revision: this.revision,
524
511
  delta,
525
512
  hasMoreBefore: startIndex > 0,
513
+ codexSubagents: clone(this.codexSubagents),
526
514
  };
527
515
  }
528
516
 
@@ -564,7 +552,7 @@ class AcpSessionState {
564
552
  if (
565
553
  entry.type === 'message'
566
554
  && entry.role === 'user'
567
- && ((entry.content || []).some(content => content?.type === 'image') || codexHistoryImageTargets(contentText(entry.content)).length > 0)
555
+ && (entry.content || []).some(content => content?.type === 'image')
568
556
  ) {
569
557
  renderedAttachmentKinds.push('image');
570
558
  }
@@ -608,6 +596,7 @@ class AcpSessionState {
608
596
  availableCommands: clone(this.availableCommands),
609
597
  currentModeId: this.currentModeId,
610
598
  configOptions: clone(this.configOptions),
599
+ codexSubagents: clone(this.codexSubagents),
611
600
  ...clone(extra),
612
601
  };
613
602
  if (options.includeUpdates === true) snapshot.updates = clone(this.updates);
@@ -632,6 +621,7 @@ class AcpSessionState {
632
621
  configOptions: clone(this.configOptions),
633
622
  title: this.title,
634
623
  updatedAt: this.updatedAt,
624
+ codexSubagents: clone(this.codexSubagents),
635
625
  truncated: this.truncated,
636
626
  };
637
627
  }
@@ -1,12 +1,31 @@
1
+ const crypto = require('crypto');
1
2
  const { createTwoFilesPatch, diffLines } = require('diff');
2
3
 
3
4
  const MAX_RENDERED_DIFF_CHARS = 64 * 1024;
4
5
  const MAX_INLINE_TOOL_DETAIL_CHARS = 4 * 1024;
6
+ const MAX_TRANSCRIPT_INLINE_TOOL_DETAIL_CHARS = 64 * 1024;
7
+ const MAX_TRANSCRIPT_MEDIA_BYTES = 25 * 1024 * 1024;
8
+ const MAX_TRANSCRIPT_MEDIA_BASE64_CHARS = Math.ceil(MAX_TRANSCRIPT_MEDIA_BYTES / 3) * 4;
5
9
  const MAX_EMBEDDED_RESOURCE_TEXT_CHARS = 4 * 1024;
6
10
  const MAX_COLLABORATION_AGENTS = 16;
7
11
  const MAX_COLLABORATION_ID_CHARS = 160;
8
12
  const MAX_COLLABORATION_PATH_CHARS = 512;
9
13
  const MAX_COLLABORATION_MESSAGE_CHARS = 160;
14
+ const TRANSCRIPT_MEDIA_MIME_TYPES = new Set([
15
+ 'audio/aac',
16
+ 'audio/flac',
17
+ 'audio/mp4',
18
+ 'audio/mpeg',
19
+ 'audio/ogg',
20
+ 'audio/wav',
21
+ 'audio/wave',
22
+ 'audio/webm',
23
+ 'audio/x-wav',
24
+ 'image/gif',
25
+ 'image/jpeg',
26
+ 'image/png',
27
+ 'image/webp',
28
+ ]);
10
29
 
11
30
  function diffBlocks(content) {
12
31
  return (Array.isArray(content) ? content : [])
@@ -184,7 +203,92 @@ function transcriptResource(resourceValue) {
184
203
  };
185
204
  }
186
205
 
187
- function transcriptMediaBlocks(entry) {
206
+ function transcriptMediaValue(block) {
207
+ if (!block || typeof block !== 'object') return null;
208
+ if (['image', 'audio'].includes(block.type)) return block;
209
+ if (
210
+ block.type === 'content'
211
+ && block.content
212
+ && typeof block.content === 'object'
213
+ && ['image', 'audio'].includes(block.content.type)
214
+ ) {
215
+ return block.content;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ function validatedTranscriptMedia(block) {
221
+ const type = String(block?.type || '');
222
+ const data = typeof block?.data === 'string' ? block.data : '';
223
+ const mimeType = String(block?.mimeType || '').trim().toLowerCase();
224
+ if (
225
+ !['image', 'audio'].includes(type)
226
+ || !data
227
+ || data.length > MAX_TRANSCRIPT_MEDIA_BASE64_CHARS
228
+ || data.length % 4 !== 0
229
+ || !TRANSCRIPT_MEDIA_MIME_TYPES.has(mimeType)
230
+ || !/^[a-z0-9+/]*={0,2}$/i.test(data)
231
+ ) return null;
232
+ const paddingChars = data.endsWith('==') ? 2 : (data.endsWith('=') ? 1 : 0);
233
+ const decodedBytes = (data.length / 4) * 3 - paddingChars;
234
+ if (decodedBytes <= 0 || decodedBytes > MAX_TRANSCRIPT_MEDIA_BYTES) return null;
235
+ return { data, decodedBytes, mimeType, type };
236
+ }
237
+
238
+ function transcriptMediaId(block) {
239
+ const media = validatedTranscriptMedia(block);
240
+ if (!media) return '';
241
+ return crypto.createHash('sha256')
242
+ .update(media.type)
243
+ .update('\0')
244
+ .update(media.mimeType)
245
+ .update('\0')
246
+ .update(media.data)
247
+ .digest('hex');
248
+ }
249
+
250
+ function decodeAcpTranscriptMedia(block) {
251
+ const media = validatedTranscriptMedia(block);
252
+ if (!media) return null;
253
+ const content = Buffer.from(media.data, 'base64');
254
+ if (content.length !== media.decodedBytes) return null;
255
+ return {
256
+ content,
257
+ mimeType: media.mimeType,
258
+ };
259
+ }
260
+
261
+ function compactTranscriptMedia(block, entry, options = {}) {
262
+ const mediaPathPrefix = String(options.mediaPathPrefix || '').replace(/\/+$/, '');
263
+ if (!mediaPathPrefix) return JSON.parse(JSON.stringify(block));
264
+ const hasInlineData = typeof block?.data === 'string' && block.data.length > 0;
265
+ if (!hasInlineData) return JSON.parse(JSON.stringify(block));
266
+ const mediaId = transcriptMediaId(block);
267
+ const projected = { ...block };
268
+ delete projected.data;
269
+ if (!mediaId) return projected;
270
+ projected.url = `${mediaPathPrefix}/${encodeURIComponent(String(entry?.id || ''))}/${mediaId}`;
271
+ return projected;
272
+ }
273
+
274
+ function acpTranscriptMedia(entry, requestedMediaId) {
275
+ const mediaId = String(requestedMediaId || '').trim().toLowerCase();
276
+ if (!/^[a-f0-9]{64}$/.test(mediaId)) return null;
277
+ const direct = Array.isArray(entry?.content) ? entry.content : [];
278
+ const output = entry?.rawOutput && typeof entry.rawOutput === 'object' ? entry.rawOutput : {};
279
+ const result = output?.result && typeof output.result === 'object' ? output.result : {};
280
+ const raw = Array.isArray(result.content)
281
+ ? result.content
282
+ : (Array.isArray(output.content) ? output.content : []);
283
+ for (const block of [...direct, ...raw]) {
284
+ const media = transcriptMediaValue(block);
285
+ if (!media) continue;
286
+ if (transcriptMediaId(media) === mediaId) return media;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ function transcriptMediaBlocks(entry, options = {}) {
188
292
  const direct = Array.isArray(entry?.content) ? entry.content : [];
189
293
  const output = entry?.rawOutput && typeof entry.rawOutput === 'object' ? entry.rawOutput : {};
190
294
  const result = output?.result && typeof output.result === 'object' ? output.result : {};
@@ -194,7 +298,10 @@ function transcriptMediaBlocks(entry) {
194
298
  const blocks = [...direct, ...raw].flatMap(block => {
195
299
  if (!block || typeof block !== 'object') return [];
196
300
  if (block.type === 'terminal') return [{ type: 'terminal', terminalId: String(block.terminalId || '') }];
197
- if (['image', 'audio', 'resource_link'].includes(block.type)) return [JSON.parse(JSON.stringify(block))];
301
+ if (['image', 'audio'].includes(block.type)) {
302
+ return [compactTranscriptMedia(block, entry, options)];
303
+ }
304
+ if (block.type === 'resource_link') return [JSON.parse(JSON.stringify(block))];
198
305
  if (block.type === 'resource') {
199
306
  return [{
200
307
  type: 'resource',
@@ -203,7 +310,11 @@ function transcriptMediaBlocks(entry) {
203
310
  }
204
311
  if (block.type !== 'content' || !block.content || typeof block.content !== 'object') return [];
205
312
  const content = block.content;
206
- if (['image', 'audio', 'resource_link'].includes(content.type)) {
313
+ if (['image', 'audio'].includes(content.type)) {
314
+ const projected = compactTranscriptMedia(content, entry, options);
315
+ return [{ type: 'content', content: projected }];
316
+ }
317
+ if (content.type === 'resource_link') {
207
318
  return [{ type: 'content', content: JSON.parse(JSON.stringify(content)) }];
208
319
  }
209
320
  if (content.type === 'resource') {
@@ -226,6 +337,20 @@ function transcriptMediaBlocks(entry) {
226
337
  });
227
338
  }
228
339
 
340
+ function transcriptEntryWithCompactMedia(entry, options = {}) {
341
+ const mediaPathPrefix = String(options.mediaPathPrefix || '').trim();
342
+ if (!mediaPathPrefix || !Array.isArray(entry?.content)) return entry;
343
+ const content = entry.content.map(block => {
344
+ const media = transcriptMediaValue(block);
345
+ if (!media) return block;
346
+ const projected = compactTranscriptMedia(media, entry, options);
347
+ return block?.type === 'content'
348
+ ? { ...block, content: projected }
349
+ : projected;
350
+ });
351
+ return { ...entry, content };
352
+ }
353
+
229
354
  function generatedMediaTool(entry) {
230
355
  const title = String(entry?.title || '').trim().toLowerCase();
231
356
  const id = String(entry?.id || '').trim().toLowerCase();
@@ -296,9 +421,13 @@ function transcriptCodexToolMeta(entry) {
296
421
  return Object.keys(compact).length > 0 ? compact : null;
297
422
  }
298
423
 
299
- function acpTranscriptToolEntry(entry) {
424
+ function acpTranscriptToolEntry(entry, options = {}) {
300
425
  if (!entry || entry.type !== 'tool') return entry;
301
426
  const detail = detailForTool(entry);
427
+ const requestedInlineDetailChars = Number(options.maxInlineDetailChars);
428
+ const inlineDetailChars = Number.isFinite(requestedInlineDetailChars)
429
+ ? Math.max(0, Math.min(MAX_INLINE_TOOL_DETAIL_CHARS, Math.floor(requestedInlineDetailChars)))
430
+ : MAX_INLINE_TOOL_DETAIL_CHARS;
302
431
  const changes = patchChanges(entry.content).map(change => ({
303
432
  path: change.path,
304
433
  kind: change.kind,
@@ -313,7 +442,11 @@ function acpTranscriptToolEntry(entry) {
313
442
  .join('\n');
314
443
  const meta = {};
315
444
  if (entry?._meta?.subagent_session_info) {
316
- meta.subagent_session_info = JSON.parse(JSON.stringify(entry._meta.subagent_session_info));
445
+ const sessionId = boundedCollaborationString(
446
+ entry._meta.subagent_session_info.session_id,
447
+ MAX_COLLABORATION_ID_CHARS
448
+ );
449
+ if (sessionId) meta.subagent_session_info = { session_id: sessionId };
317
450
  }
318
451
  if (entry?._meta?.farming_patch_decisions) {
319
452
  meta.farming_patch_decisions = JSON.parse(JSON.stringify(entry._meta.farming_patch_decisions));
@@ -326,12 +459,12 @@ function acpTranscriptToolEntry(entry) {
326
459
  title: String(entry.title || ''),
327
460
  kind: String(entry.kind || 'other'),
328
461
  status: String(entry.status || ''),
329
- content: transcriptMediaBlocks(entry),
462
+ content: transcriptMediaBlocks(entry, options),
330
463
  ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
331
- transcriptDetail: detail.length <= MAX_INLINE_TOOL_DETAIL_CHARS
464
+ transcriptDetail: detail.length <= inlineDetailChars
332
465
  ? detail
333
- : `${detail.slice(0, MAX_INLINE_TOOL_DETAIL_CHARS)}\n\n[Open to load full detail]`,
334
- transcriptDetailTruncated: detail.length > MAX_INLINE_TOOL_DETAIL_CHARS,
466
+ : `${detail.slice(0, inlineDetailChars)}${inlineDetailChars > 0 ? '\n\n' : ''}[Open to load full detail]`,
467
+ transcriptDetailTruncated: detail.length > inlineDetailChars,
335
468
  transcriptPatchSummary: patchSummary,
336
469
  transcriptChanges: changes,
337
470
  generatedMedia: generatedMediaTool(entry),
@@ -339,9 +472,41 @@ function acpTranscriptToolEntry(entry) {
339
472
  };
340
473
  }
341
474
 
475
+ function acpTranscriptEntries(entries, options = {}) {
476
+ const requestedBudget = Number(options.maxInlineToolDetailChars);
477
+ let remainingInlineDetailChars = Number.isFinite(requestedBudget)
478
+ ? Math.max(0, Math.floor(requestedBudget))
479
+ : MAX_TRANSCRIPT_INLINE_TOOL_DETAIL_CHARS;
480
+ const source = Array.isArray(entries) ? entries : [];
481
+ const projected = new Array(source.length);
482
+
483
+ // The transcript page owns only a bounded summary. Exact tool and subagent
484
+ // detail remains backend-owned and is loaded on explicit expansion.
485
+ for (let index = source.length - 1; index >= 0; index -= 1) {
486
+ const entry = source[index];
487
+ if (entry?.type !== 'tool') {
488
+ projected[index] = transcriptEntryWithCompactMedia(entry, options);
489
+ continue;
490
+ }
491
+ const projectedEntry = acpTranscriptToolEntry(entry, {
492
+ maxInlineDetailChars: remainingInlineDetailChars,
493
+ mediaPathPrefix: options.mediaPathPrefix,
494
+ });
495
+ remainingInlineDetailChars = Math.max(
496
+ 0,
497
+ remainingInlineDetailChars - String(projectedEntry.transcriptDetail || '').length
498
+ );
499
+ projected[index] = projectedEntry;
500
+ }
501
+ return projected;
502
+ }
503
+
342
504
  module.exports = {
505
+ acpTranscriptEntries,
506
+ acpTranscriptMedia,
343
507
  acpToolChanges: entry => patchChanges(entry?.content, { includeDiff: true }),
344
508
  acpToolDetail: detailForTool,
345
509
  acpToolReviewChanges: entry => patchReviewChanges(entry?.content),
346
510
  acpTranscriptToolEntry,
511
+ decodeAcpTranscriptMedia,
347
512
  };
@@ -50,7 +50,13 @@ const {
50
50
  stopPersistedAcpProcessGroup,
51
51
  } = require('./acp-runtime');
52
52
  const { chatRuntimeForProvider, isChatMode } = require('./chat-runtime');
53
- const { acpToolChanges, acpToolDetail, acpToolReviewChanges, acpTranscriptToolEntry } = require('./acp-transcript');
53
+ const {
54
+ acpTranscriptEntries,
55
+ acpTranscriptMedia,
56
+ acpToolChanges,
57
+ acpToolDetail,
58
+ acpToolReviewChanges,
59
+ } = require('./acp-transcript');
54
60
  const {
55
61
  applyCodexTerminalProfile,
56
62
  codexTerminalProfileFromOutput,
@@ -5085,10 +5091,21 @@ class AgentManager extends EventEmitter {
5085
5091
  const transcript = this.acpRuntime.getTranscriptSession(agentId, options);
5086
5092
  return {
5087
5093
  ...transcript,
5088
- entries: transcript.entries.map(entry => entry?.type === 'tool' ? acpTranscriptToolEntry(entry) : entry),
5094
+ entries: acpTranscriptEntries(transcript.entries, {
5095
+ mediaPathPrefix: options.mediaPathPrefix,
5096
+ }),
5089
5097
  };
5090
5098
  }
5091
5099
 
5100
+ getAcpTranscriptMedia(agentId, entryId, mediaId) {
5101
+ this.requireLiveAcpAgent(agentId);
5102
+ const entry = this.acpRuntime.getTranscriptEntry(agentId, entryId);
5103
+ if (!entry) throw new Error('ACP transcript entry not found');
5104
+ const media = acpTranscriptMedia(entry, mediaId);
5105
+ if (!media) throw new Error('ACP transcript media not found');
5106
+ return media;
5107
+ }
5108
+
5092
5109
  getAcpToolDetail(agentId, toolCallId) {
5093
5110
  this.requireLiveAcpAgent(agentId);
5094
5111
  const entry = this.acpRuntime.getToolEntry(agentId, toolCallId);
@@ -5661,6 +5678,14 @@ class AgentManager extends EventEmitter {
5661
5678
  if (admission) {
5662
5679
  transitionLifecycleOperation(staged, admission.operation.id, 'succeeded');
5663
5680
  }
5681
+ if (!admission && !structuralUpdateChanged && !readUpdateChanged) {
5682
+ return {
5683
+ agentId,
5684
+ ...updates,
5685
+ changed: false,
5686
+ requiresState: false,
5687
+ };
5688
+ }
5664
5689
  try {
5665
5690
  this.ensurePersistentAgentSession(staged);
5666
5691
  } catch (error) {