@yeaft/webchat-agent 0.1.720 → 0.1.722

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.720",
3
+ "version": "0.1.722",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,224 @@
1
+ /**
2
+ * attachments.js — Unify (group/feature) attachment handling.
3
+ *
4
+ * Mirrors the Chat-mode pipeline implemented in `agent/workbench/transfer.js`,
5
+ * adapted to the Unify architecture:
6
+ *
7
+ * - Chat mode has a per-conversation `state.workDir` and a single
8
+ * in-flight Claude SDK query — `transfer.js` enqueues a constructed
9
+ * user message into that query's `inputStream`.
10
+ * - Unify mode has many VPs taking turns inside a group, no per-VP
11
+ * workDir, and the Engine accepts the user message via `query()`
12
+ * args. So we (a) save attachments to a shared per-group folder
13
+ * under the agent's CWD (so file-tools using `ctx.cwd` can read
14
+ * them with relative paths) and (b) hand back the persisted-form
15
+ * metadata AND a `promptParts` content array (image blocks +
16
+ * synthesized [Uploaded files] suffix) for the LLM call.
17
+ *
18
+ * Inputs (`files`) come from the server-side resolver in
19
+ * `client-conversation.js` / `client-crew.js`: each entry is
20
+ * `{ name, mimeType, data: <base64>, isImage }` — the `pendingFiles`
21
+ * `fileId` was already consumed by the server before `forwardToAgent`.
22
+ *
23
+ * Output (single bundle, all named for the role each piece plays in
24
+ * the LLM call):
25
+ * - `promptAttachments`: persisted metadata `{ name, path, mimeType,
26
+ * isImage }` suitable for the group jsonl-log (NO base64 — must
27
+ * stay small).
28
+ * - `promptSuffix`: text to append to the user's prompt so the model
29
+ * sees the file list in the same form Chat mode uses.
30
+ * - `promptParts`: an array of `{ type:'image', source:{ data, mediaType } }`
31
+ * blocks for images, ready to be combined with a text block for
32
+ * `engine.query({ promptParts })`. Empty when no images are present.
33
+ * - `failed`: list of `{ name, error }` for entries that could not
34
+ * be persisted (disk full, bad base64, ...). The caller surfaces
35
+ * this so the UI can tell the user *which* file blew up rather
36
+ * than swallowing it in a console.warn.
37
+ */
38
+
39
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
40
+ import { basename, extname, join } from 'node:path';
41
+ import { randomBytes } from 'node:crypto';
42
+
43
+ // Same dir name Chat mode uses, so ".gitignore" rules and tool-side
44
+ // expectations stay identical.
45
+ const TEMP_UPLOAD_DIR = '.claude-tmp-attachments';
46
+
47
+ // Caps. Any ingestion path without caps is a denial-of-service waiting
48
+ // to be discovered. Cheap insurance.
49
+ // - MAX_FILES_PER_TURN: matches the UI's per-message attachment cap.
50
+ // - MAX_TOTAL_BYTES: 50 MiB across all files in one turn.
51
+ export const MAX_FILES_PER_TURN = 16;
52
+ export const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
53
+
54
+ /**
55
+ * Sanitize a user-supplied filename's basename for use as an on-disk
56
+ * path component. We KEEP Unicode (CJK, emoji, accented letters) —
57
+ * the disk path is opaque, the UI uses `promptAttachments[].name` for
58
+ * display, and tool consumers (file-read, bash) handle UTF-8 paths
59
+ * fine on Linux/macOS. We only strip what is structurally dangerous:
60
+ * - path separators (`/`, `\`)
61
+ * - NUL bytes
62
+ * - leading dots (so a user can't write `.bashrc` into the temp dir)
63
+ * - leading `-` (so the path can't be mistaken for a CLI flag)
64
+ * - control characters
65
+ */
66
+ function sanitizeBaseName(base) {
67
+ let s = String(base ?? '')
68
+ .replace(/[\/\\\0]/g, '_')
69
+ // Strip C0 controls (\u0000–\u001F) and DEL (\u007F).
70
+ .replace(/[\u0000-\u001f\u007f]/g, '_')
71
+ // Trim runs of leading dots/dashes that would create dotfiles or
72
+ // CLI-flag-looking paths.
73
+ .replace(/^[.\-]+/, '');
74
+ if (!s) s = 'file';
75
+ // Hard cap on length — most filesystems are fine with 255 bytes per
76
+ // name, and the random suffix + extension still need to fit.
77
+ if (s.length > 80) s = s.slice(0, 80);
78
+ return s;
79
+ }
80
+
81
+ /**
82
+ * Persist resolved files to disk and build the LLM-side payload pieces.
83
+ *
84
+ * @param {Array<{name:string, mimeType:string, data:string, isImage?:boolean}>} files
85
+ * Resolved files from server (pendingFiles → base64).
86
+ * @param {Object} [opts]
87
+ * @param {string} [opts.subdir] Sub-folder under TEMP_UPLOAD_DIR
88
+ * (e.g. groupId). Lets multiple groups co-exist without clobbering.
89
+ * @param {string} [opts.cwd] Override base dir; defaults to process.cwd()
90
+ * which is what unify tools (file-read, bash, ...) resolve relative
91
+ * paths against.
92
+ * @returns {{
93
+ * promptAttachments: Array<{name:string, path:string, mimeType:string, isImage:boolean}>,
94
+ * promptSuffix: string,
95
+ * promptParts: Array<{type:'image', source:{type:'base64', mediaType:string, data:string}}>,
96
+ * failed: Array<{name:string, error:string}>
97
+ * }}
98
+ */
99
+ export function persistUnifyAttachments(files, opts = {}) {
100
+ const cwd = opts.cwd || process.cwd();
101
+ // Subdir is OURS — it must remain ASCII-safe because we generate it
102
+ // from groupId. Keep the existing strict policy here; this is NOT
103
+ // user-visible.
104
+ const subdir = opts.subdir ? String(opts.subdir).replace(/[^a-zA-Z0-9._-]/g, '_') : '';
105
+ const uploadDir = subdir
106
+ ? join(cwd, TEMP_UPLOAD_DIR, subdir)
107
+ : join(cwd, TEMP_UPLOAD_DIR);
108
+
109
+ if (!Array.isArray(files) || files.length === 0) {
110
+ return { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
111
+ }
112
+
113
+ // Enforce per-turn file count cap. Excess entries are surfaced as
114
+ // failures so the UI can tell the user what got dropped.
115
+ const accepted = files.slice(0, MAX_FILES_PER_TURN);
116
+ const rejectedByCount = files.slice(MAX_FILES_PER_TURN);
117
+
118
+ if (!existsSync(uploadDir)) {
119
+ mkdirSync(uploadDir, { recursive: true });
120
+ }
121
+
122
+ const promptAttachments = [];
123
+ const promptParts = [];
124
+ const failed = rejectedByCount.map((f) => ({
125
+ name: f?.name || '<unknown>',
126
+ error: `too many files (cap=${MAX_FILES_PER_TURN})`,
127
+ }));
128
+
129
+ let totalBytes = 0;
130
+
131
+ for (const file of accepted) {
132
+ if (!file || !file.name || !file.data) {
133
+ // Silently skip null/empty entries — these are caller bugs, not
134
+ // user errors, and the existing test suite asserts they don't
135
+ // appear in `failed`. (Backwards compatible.)
136
+ continue;
137
+ }
138
+ try {
139
+ const ext = extname(file.name);
140
+ const base = basename(file.name, ext);
141
+ const safeBase = sanitizeBaseName(base);
142
+ // Identity comes from random bytes — a clock is not an identity.
143
+ // 4 bytes (2^32) is plenty for a 16-file cap.
144
+ const suffix = randomBytes(4).toString('hex');
145
+ const uniqueName = `${safeBase}_${suffix}${ext || ''}`;
146
+ const absPath = join(uploadDir, uniqueName);
147
+ const relPath = subdir
148
+ ? join(TEMP_UPLOAD_DIR, subdir, uniqueName)
149
+ : join(TEMP_UPLOAD_DIR, uniqueName);
150
+
151
+ const buffer = Buffer.from(file.data, 'base64');
152
+
153
+ // Total-bytes cap. Check BEFORE write so we don't half-fill the
154
+ // disk and then bail.
155
+ if (totalBytes + buffer.length > MAX_TOTAL_BYTES) {
156
+ failed.push({
157
+ name: file.name,
158
+ error: `total upload exceeds ${MAX_TOTAL_BYTES} bytes`,
159
+ });
160
+ continue;
161
+ }
162
+ totalBytes += buffer.length;
163
+
164
+ writeFileSync(absPath, buffer);
165
+
166
+ const isImage = !!file.isImage || (file.mimeType || '').startsWith('image/');
167
+ promptAttachments.push({
168
+ name: file.name,
169
+ path: relPath,
170
+ mimeType: file.mimeType || 'application/octet-stream',
171
+ isImage,
172
+ });
173
+
174
+ if (isImage) {
175
+ promptParts.push({
176
+ type: 'image',
177
+ source: {
178
+ type: 'base64',
179
+ // Both adapters accept either `mediaType` or `media_type`
180
+ // (see openai-responses.js:#translateUserContent and the
181
+ // Anthropic upstream contract). Use the camelCase form to
182
+ // match `openai-responses.js` exactly.
183
+ mediaType: file.mimeType || 'image/png',
184
+ data: file.data,
185
+ },
186
+ });
187
+ }
188
+ } catch (err) {
189
+ failed.push({
190
+ name: file?.name || '<unknown>',
191
+ error: err?.message || String(err),
192
+ });
193
+ }
194
+ }
195
+
196
+ let promptSuffix = '';
197
+ if (promptAttachments.length > 0) {
198
+ const lines = promptAttachments.map((f) =>
199
+ `- ${f.path} (${f.isImage ? 'image' : f.mimeType})`
200
+ );
201
+ promptSuffix = `\n\n[Uploaded files]\n${lines.join('\n')}`;
202
+ }
203
+
204
+ return { promptAttachments, promptSuffix, promptParts, failed };
205
+ }
206
+
207
+ /**
208
+ * Strip base64 data from a resolved-files array so it can be safely
209
+ * persisted (e.g. into a group jsonl-log or memory entry). Keeps name,
210
+ * mimeType, isImage, and the on-disk path returned by
211
+ * `persistUnifyAttachments`.
212
+ *
213
+ * @param {Array<{name:string, path:string, mimeType:string, isImage:boolean}>} promptAttachments
214
+ * @returns {Array<{name:string, path:string, mimeType:string, isImage:boolean}>}
215
+ */
216
+ export function attachmentsForPersistence(promptAttachments) {
217
+ if (!Array.isArray(promptAttachments)) return [];
218
+ return promptAttachments.map((f) => ({
219
+ name: f.name,
220
+ path: f.path,
221
+ mimeType: f.mimeType,
222
+ isImage: !!f.isImage,
223
+ }));
224
+ }
package/unify/engine.js CHANGED
@@ -991,9 +991,19 @@ export class Engine {
991
991
  * @param {string} [params.scenario='chat'] - task-327b: scenario tag
992
992
  * forwarded to the effort decision tree. See effort.js
993
993
  * SCENARIO_EFFORT. Unknown values fall through to 'high'.
994
+ * @param {Array<{type:string, source?:object, text?:string}>} [params.promptParts] -
995
+ * PR #721: optional content-array form of the user message used
996
+ * when attachments are present. Each entry is either an
997
+ * `{type:'image', source:{type:'base64', mediaType, data}}` block
998
+ * (one per uploaded image) or a `{type:'text', text}` block (the
999
+ * text prompt body, including any [Uploaded files] suffix). When
1000
+ * supplied and non-empty, the LLM call uses this array as the
1001
+ * user-message content; the string `prompt` is then only used for
1002
+ * logging / history. When omitted the engine falls back to the
1003
+ * string-prompt shape (no regression for existing callers).
994
1004
  * @yields {EngineEvent}
995
1005
  */
996
- async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
1006
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
997
1007
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
998
1008
  yield {
999
1009
  type: 'error',
@@ -1002,6 +1012,14 @@ export class Engine {
1002
1012
  };
1003
1013
  return;
1004
1014
  }
1015
+ // promptParts (optional): a content-array form of the user message
1016
+ // (e.g. [{type:'image',source:{...}}, {type:'text',text:'@vp-x ...'}]).
1017
+ // When supplied, it REPLACES the trailing `{role:'user',content:prompt}`
1018
+ // entry built into conversationMessages — the string `prompt` is still
1019
+ // used for memory recall, system prompt rendering, and turn previews
1020
+ // because those layers all need plain text. Adapter side already
1021
+ // accepts content arrays for user messages (anthropic.js:72,
1022
+ // openai-responses.js:#translateUserContent).
1005
1023
 
1006
1024
  // task-327b: `/max` / `/high` / `/medium` / `/low` prefix override.
1007
1025
  // Explicit caller-supplied userEffort wins over the prefix.
@@ -1044,7 +1062,7 @@ export class Engine {
1044
1062
  const runSignal = abortCtrl.signal;
1045
1063
 
1046
1064
  try {
1047
- yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
1065
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
1048
1066
  } finally {
1049
1067
  if (signal) {
1050
1068
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1062,7 +1080,7 @@ export class Engine {
1062
1080
  * in a try/finally without indenting the whole loop.
1063
1081
  * @private
1064
1082
  */
1065
- async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
1083
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
1066
1084
 
1067
1085
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1068
1086
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -1189,11 +1207,17 @@ export class Engine {
1189
1207
  ]
1190
1208
  : [];
1191
1209
 
1192
- // Build conversation: optional compact head + existing messages + new user message
1210
+ // Build conversation: optional compact head + existing messages + new user message.
1211
+ // If `promptParts` was supplied (image/file attachments), use the array form
1212
+ // so the adapter sees image content blocks alongside the text. Otherwise the
1213
+ // legacy string form keeps prompt-cache behavior identical.
1214
+ const finalUserContent = (Array.isArray(promptParts) && promptParts.length > 0)
1215
+ ? promptParts
1216
+ : prompt;
1193
1217
  const conversationMessages = [
1194
1218
  ...compactMessages,
1195
1219
  ...messages,
1196
- { role: 'user', content: prompt },
1220
+ { role: 'user', content: finalUserContent },
1197
1221
  ];
1198
1222
 
1199
1223
  // PR-L: T2 carry-forward. If a previous query()'s end-of-turn
@@ -68,8 +68,37 @@ export function createCoordinator(group, options = {}) {
68
68
  const mentions = parseMentions(input.text);
69
69
 
70
70
  // Persist first — audit log / replay works even if dispatch has bugs.
71
+ //
72
+ // Convention: any field on `input` that starts with `_` is treated
73
+ // as ephemeral and is forwarded to the envelope (so per-turn driver
74
+ // payloads — image base64 blocks, prompt suffixes — reach the LLM
75
+ // call) but is NEVER passed to appendMessage. The jsonl-log must
76
+ // stay lean: base64 in audit history would blow up replay.
77
+ //
78
+ // The split is enforced structurally — see the assertion below the
79
+ // partition loop. Don't loosen it. If a new ephemeral key is added,
80
+ // it gets the `_` prefix at its source and inherits the protection
81
+ // for free; no allowlist to maintain.
82
+ const persistInput = {};
83
+ const ephemeral = {};
84
+ for (const [k, v] of Object.entries(input)) {
85
+ if (typeof k === 'string' && k.startsWith('_')) {
86
+ ephemeral[k] = v;
87
+ } else {
88
+ persistInput[k] = v;
89
+ }
90
+ }
91
+ // Structural guarantee: nothing with a `_` prefix may reach the
92
+ // jsonl-log via `persistInput`. If this ever throws, the `_` rule
93
+ // got bypassed — fix the caller, not this assertion.
94
+ {
95
+ const leaked = Object.keys(persistInput).filter((k) => typeof k === 'string' && k.startsWith('_'));
96
+ if (leaked.length > 0) {
97
+ throw new Error(`coordinator.ingest: ephemeral fields leaked into persisted record: ${leaked.join(', ')}`);
98
+ }
99
+ }
71
100
  const stored = group.appendMessage({
72
- ...input,
101
+ ...persistInput,
73
102
  mentions,
74
103
  role: input.role || (fromUser ? 'user' : 'assistant'),
75
104
  });
@@ -96,7 +125,7 @@ export function createCoordinator(group, options = {}) {
96
125
  }
97
126
 
98
127
  if (selection.reason === 'broadcast') {
99
- const envelope = makeEnvelope(stored, meta, 'broadcast');
128
+ const envelope = makeEnvelope(stored, meta, 'broadcast', ephemeral);
100
129
  for (const vpId of selection.dispatched) deliver(vpId, envelope);
101
130
  return {
102
131
  message: stored,
@@ -110,7 +139,7 @@ export function createCoordinator(group, options = {}) {
110
139
 
111
140
  if (selection.reason === 'mention') {
112
141
  for (const vpId of selection.dispatched) {
113
- deliver(vpId, makeEnvelope(stored, meta, 'mention'));
142
+ deliver(vpId, makeEnvelope(stored, meta, 'mention', ephemeral));
114
143
  }
115
144
  return {
116
145
  message: stored,
@@ -121,7 +150,7 @@ export function createCoordinator(group, options = {}) {
121
150
  }
122
151
 
123
152
  if (selection.reason === 'fallback' && selection.fallback) {
124
- deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback'));
153
+ deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback', ephemeral));
125
154
  return {
126
155
  message: stored,
127
156
  dispatched: selection.dispatched,
@@ -146,12 +175,16 @@ export function createCoordinator(group, options = {}) {
146
175
  };
147
176
  }
148
177
 
149
- function makeEnvelope(msg, meta, trigger) {
178
+ function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
150
179
  return {
151
180
  groupId: meta.id,
152
181
  taskId: msg.taskId || null,
153
182
  msg,
154
183
  trigger, // 'broadcast' | 'mention' | 'fallback'
184
+ // Ephemeral fields (any `_`-prefixed key on coord.ingest input).
185
+ // Used to ferry per-turn payloads (e.g. image base64 blocks) that
186
+ // must reach the driver but must NOT be persisted to the group log.
187
+ ...ephemeral,
155
188
  };
156
189
  }
157
190
 
@@ -69,11 +69,23 @@ export function openGroup(groupsRoot, groupId) {
69
69
  /**
70
70
  * Append a message to the group log. Assigns an id if absent.
71
71
  * Returns the stored record (with id + ts).
72
+ *
73
+ * Structural invariant: NO field on `record` may start with `_`.
74
+ * The `_` prefix is reserved for ephemeral per-turn payloads (image
75
+ * base64, prompt suffixes) that must reach the driver but must
76
+ * never hit the persisted jsonl-log. If this throws, the caller
77
+ * forgot to partition ephemeral fields off — fix the caller.
72
78
  */
73
79
  appendMessage(record) {
74
80
  if (!record || typeof record !== 'object') {
75
81
  throw new Error('appendMessage: record required');
76
82
  }
83
+ {
84
+ const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
85
+ if (leaked.length > 0) {
86
+ throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
87
+ }
88
+ }
77
89
  const stored = {
78
90
  id: record.id || nextMsgId(),
79
91
  ts: record.ts || new Date().toISOString(),
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { LLMAdapter } from './adapter.js';
19
19
  import { getThinkingCapability, normalizeEffort } from '../models.js';
20
+ import { pairSanitize } from '../pair-sanitize.js';
20
21
 
21
22
  /**
22
23
  * task-327a: feature-flag accessor. Read lazily so tests can flip.
@@ -58,6 +59,66 @@ export function filterEffortForModel(params) {
58
59
  return { ...params, effort: norm };
59
60
  }
60
61
 
62
+ /**
63
+ * task-715: last-line-of-defense pair sanitize at the wire.
64
+ *
65
+ * `pairSanitize` already runs in two upstream paths
66
+ * (`conversation/persist.js#loadRecentByGroup` and
67
+ * `history-compact.js#compactHistory`), but the engine's main loop
68
+ * mutates `conversationMessages` AFTER those — appending tool results
69
+ * mid-loop, archiving bulky tool results into stubs, and (in failure
70
+ * paths) potentially leaving an assistant `tool_use` whose matching
71
+ * `role:'tool'` was dropped or never produced. Anthropic's Messages
72
+ * API rejects either shape with HTTP 400 ("Each tool_use block must
73
+ * have a corresponding tool_result block in the next message").
74
+ *
75
+ * The router is the SINGLE choke point through which every
76
+ * adapter.stream() / adapter.call() flows. Sanitizing here means no
77
+ * caller can accidentally bypass the guard — including the per-VP
78
+ * group-mode path that surfaced the bug.
79
+ *
80
+ * Implementation: call `pairSanitize` exactly once and compare the
81
+ * result to the input. If nothing changed (length matches AND each
82
+ * assistant kept its toolCalls count), return the original `params`
83
+ * reference so the happy path is one O(n) walk + one comparison
84
+ * walk, no allocation downstream. If something WAS dropped, return
85
+ * `{ ...params, messages: cleaned }` and log a diagnostic so a
86
+ * recurrence stays traceable in agent logs.
87
+ *
88
+ * @param {object} params
89
+ * @returns {object}
90
+ */
91
+ export function sanitizeMessagesForWire(params) {
92
+ if (!params || !Array.isArray(params.messages)) return params;
93
+ const cleaned = pairSanitize(params.messages);
94
+ if (sliceUnchanged(params.messages, cleaned)) return params;
95
+ console.warn(
96
+ `[router] dropped tool_use/tool_result orphans before wire send: ` +
97
+ `${params.messages.length} → ${cleaned.length} messages`
98
+ );
99
+ return { ...params, messages: cleaned };
100
+ }
101
+
102
+ /**
103
+ * Cheap structural equality between the original messages array and
104
+ * the post-sanitize result. Same length AND every assistant kept its
105
+ * toolCalls count = no orphans were dropped. We do NOT compare full
106
+ * deep equality — `pairSanitize` only ever shrinks, never reorders or
107
+ * mutates payloads.
108
+ */
109
+ function sliceUnchanged(original, cleaned) {
110
+ if (original.length !== cleaned.length) return false;
111
+ for (let i = 0; i < original.length; i += 1) {
112
+ const a = original[i];
113
+ const b = cleaned[i];
114
+ if (!a || !b) continue;
115
+ const aCalls = Array.isArray(a.toolCalls) ? a.toolCalls.length : 0;
116
+ const bCalls = Array.isArray(b.toolCalls) ? b.toolCalls.length : 0;
117
+ if (aCalls !== bCalls) return false;
118
+ }
119
+ return true;
120
+ }
121
+
61
122
  /**
62
123
  * AdapterRouter — Implements LLMAdapter, routes by model → provider.
63
124
  */
@@ -178,8 +239,9 @@ export class AdapterRouter extends LLMAdapter {
178
239
  */
179
240
  async *stream(params) {
180
241
  const filtered = filterEffortForModel(params);
181
- const adapter = await this.#resolveAdapter(filtered.model);
182
- yield* adapter.stream(filtered);
242
+ const sanitized = sanitizeMessagesForWire(filtered);
243
+ const adapter = await this.#resolveAdapter(sanitized.model);
244
+ yield* adapter.stream(sanitized);
183
245
  }
184
246
 
185
247
  /**
@@ -190,8 +252,9 @@ export class AdapterRouter extends LLMAdapter {
190
252
  */
191
253
  async call(params) {
192
254
  const filtered = filterEffortForModel(params);
193
- const adapter = await this.#resolveAdapter(filtered.model);
194
- return adapter.call(filtered);
255
+ const sanitized = sanitizeMessagesForWire(filtered);
256
+ const adapter = await this.#resolveAdapter(sanitized.model);
257
+ return adapter.call(sanitized);
195
258
  }
196
259
 
197
260
  /**
@@ -53,6 +53,7 @@ import {
53
53
  } from './history-compact.js';
54
54
  import { createFeatureArc } from './feature-arc.js';
55
55
  import { getFeatureStore } from './tools/feature-tools.js';
56
+ import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
56
57
 
57
58
  /** @type {import('./session.js').Session | null} */
58
59
  let session = null;
@@ -384,10 +385,24 @@ function ensureDriverRunning(groupId, vpId) {
384
385
  // the legacy fan-out path so the model sees the same surface form
385
386
  // it always has.
386
387
  const text = envelope?.msg?.text || '';
387
- const prompt = `@vp-${vpId} ${text}`;
388
+ // Attachments ride on the envelope's ephemeral fields (set by
389
+ // handleUnifyGroupChat → coord.ingest). When images are present
390
+ // we append the file list to the text prompt (same surface as
391
+ // Chat mode) AND build a content-array form so the LLM sees the
392
+ // image bytes alongside the text. Pure file-only uploads only
393
+ // need the suffix; engine.query falls back to string mode.
394
+ const inboundSuffix = envelope?._promptSuffix || '';
395
+ const inboundParts = Array.isArray(envelope?._promptParts)
396
+ ? envelope._promptParts
397
+ : [];
398
+ const prompt = `@vp-${vpId} ${text}${inboundSuffix}`;
399
+ const promptParts = inboundParts.length > 0
400
+ ? [...inboundParts, { type: 'text', text: prompt }]
401
+ : null;
388
402
  try {
389
403
  await runVpTurn({
390
404
  prompt,
405
+ promptParts,
391
406
  groupId,
392
407
  vpId,
393
408
  turnId,
@@ -1170,7 +1185,13 @@ function handleEngineEvent(event, hctx) {
1170
1185
  export async function handleUnifyGroupChat(msg) {
1171
1186
  if (!msg || typeof msg !== 'object') return;
1172
1187
  const { text } = msg;
1173
- if (!text?.trim()) return;
1188
+ // PR #721: image-only send is allowed — text may be empty when the
1189
+ // user attached files only. The frontend synthesizes a placeholder
1190
+ // string in `sendUnifyGroupChat`, so by the time we get here `text`
1191
+ // should always be non-empty; but defend anyway in case an API
1192
+ // caller sends a bare attachment payload.
1193
+ const hasFiles = Array.isArray(msg.files) && msg.files.length > 0;
1194
+ if (!text?.trim() && !hasFiles) return;
1174
1195
  const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
1175
1196
  const groupId = (typeof msg.groupId === 'string' && msg.groupId.trim())
1176
1197
  ? msg.groupId.trim()
@@ -1328,6 +1349,38 @@ export async function handleUnifyGroupChat(msg) {
1328
1349
  console.warn('[Unify] unify_group_chat: selective abort pre-pass failed', err?.message || err);
1329
1350
  }
1330
1351
 
1352
+ // ── Attachments (images + files) ───────────────────────────────
1353
+ // Server has already resolved fileId → { name, mimeType, data:base64,
1354
+ // isImage } via the same path crew uses (client-conversation.js relay
1355
+ // for `unify_*`). We persist files to disk under the agent's CWD so
1356
+ // file-tools (file-read / bash) can pick them up with relative paths,
1357
+ // and we build per-image content blocks for the LLM call. The
1358
+ // resolved metadata WITHOUT base64 rides on coord.ingest meta so it
1359
+ // shows up in the persisted group log and on the envelope every VP
1360
+ // driver receives.
1361
+ const inboundFiles = Array.isArray(msg.files) ? msg.files : [];
1362
+ let attachmentBundle = { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
1363
+ if (inboundFiles.length > 0) {
1364
+ try {
1365
+ attachmentBundle = persistUnifyAttachments(inboundFiles, { subdir: groupId });
1366
+ } catch (err) {
1367
+ console.warn('[Unify] unify_group_chat: attachment persist failed', err?.message || err);
1368
+ }
1369
+ }
1370
+ // Surface partial / total upload failures to the user. We don't abort
1371
+ // the turn — the LLM can still answer the text-only portion — but the
1372
+ // user must know which files didn't make it.
1373
+ if (Array.isArray(attachmentBundle.failed) && attachmentBundle.failed.length > 0) {
1374
+ const detail = attachmentBundle.failed
1375
+ .map((f) => ` - ${f.name}: ${f.error}`)
1376
+ .join('\n');
1377
+ sendUnifyOutput({
1378
+ type: 'assistant',
1379
+ message: { content: [{ type: 'text', text: `⚠️ ${attachmentBundle.failed.length} file(s) could not be attached:\n${detail}` }] },
1380
+ }, { groupId });
1381
+ }
1382
+ const persistedAttachments = attachmentsForPersistence(attachmentBundle.promptAttachments);
1383
+
1331
1384
  // Ingest user text. The coordinator persists, applies mention/fanout
1332
1385
  // rules, and calls deliver() (== enqueueForVp) for each chosen VP —
1333
1386
  // which both (a) emits vp_typing_start and (b) ensures a driver runs.
@@ -1337,7 +1390,16 @@ export async function handleUnifyGroupChat(msg) {
1337
1390
  from: 'user',
1338
1391
  role: 'user',
1339
1392
  text,
1340
- meta: { mentions },
1393
+ meta: {
1394
+ mentions,
1395
+ // Persisted form (no base64) — safe for jsonl-log.
1396
+ attachments: persistedAttachments,
1397
+ },
1398
+ // Live form — adapters need the base64 image blocks; runVpTurn
1399
+ // reads `_promptParts` off the envelope rather than going
1400
+ // back to disk on every fan-out target. NOT persisted.
1401
+ _promptParts: attachmentBundle.promptParts,
1402
+ _promptSuffix: attachmentBundle.promptSuffix,
1341
1403
  });
1342
1404
  } catch (err) {
1343
1405
  console.warn('[Unify] unify_group_chat: coord.ingest failed', err?.message || err);
@@ -1581,7 +1643,7 @@ async function ensureSessionLoaded() {
1581
1643
  *
1582
1644
  * @param {{ prompt: string, groupId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
1583
1645
  */
1584
- async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
1646
+ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
1585
1647
  if (!prompt?.trim()) return;
1586
1648
 
1587
1649
  const envelope = { groupId, vpId, turnId };
@@ -1727,6 +1789,7 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1727
1789
  });
1728
1790
  for await (const event of vpEngine.query({
1729
1791
  prompt,
1792
+ promptParts,
1730
1793
  messages: trimmedMessages,
1731
1794
  signal: vpAbort.signal,
1732
1795
  ...queryOpts,