flowviant 0.111.0 → 0.113.0

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.
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { appendFileSync, readFileSync } from 'node:fs';
40
40
  import { CARD_THREAD_RENDER_MAX } from './cardThread.mjs';
41
+ import { CARD_FILES_RENDER_MAX } from './agentFiles.mjs';
41
42
 
42
43
  /**
43
44
  * How many card specs one agent may accumulate.
@@ -56,8 +57,11 @@ export const MAX_STASHED_CARDS = 40;
56
57
  * the card's discussion, so the bound grows by the most a turn's discussion
57
58
  * can render to (`CARD_THREAD_RENDER_MAX`). The discussion is printed LAST,
58
59
  * and a stash that cut it would hand the pre-review a thread missing its
59
- * newest words — the ones the agent read most recently. */
60
- export const MAX_SPEC_CHARS = 8_000 + CARD_THREAD_RENDER_MAX;
60
+ * newest words — the ones the agent read most recently. Since 0.112.0 the
61
+ * files on that discussion follow it (`CARD_FILES_RENDER_MAX`), and a cut
62
+ * there would hand the pre-review a card missing the screenshot its agent
63
+ * was shown. */
64
+ export const MAX_SPEC_CHARS = 8_000 + CARD_THREAD_RENDER_MAX + CARD_FILES_RENDER_MAX;
61
65
 
62
66
  /**
63
67
  * Write down the spec this turn is about to hand the agent.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A TURN'S FILES, AS THE WIRE HANDS THEM OVER (0.112.0) — the owner,
3
+ * 2026-09-28: "im unable to paste pictures or add files for the conversations
4
+ * in working or stuck or review".
5
+ *
6
+ * The server hands an agent turn its files on `agentTurnJobs[].attachments`:
7
+ * `{ id, name, size, from }`, where `from` is `message` (attached to THIS
8
+ * turn's words — an answer, a send-back) or `card` (attached to notes on the
9
+ * card the turn is about). Absent is none; the server sends the key only to a
10
+ * daemon at `DAEMON_AGENT_FILES_MIN` and parks the turn below it, which is why
11
+ * this release reads it.
12
+ *
13
+ * THE WIRE IS NOT TRUSTED, the card thread's rule (cardThread.mjs): the server
14
+ * capped each origin (`AGENT_FILES_PER_MESSAGE_MAX`, `CARD_FILES_PER_TURN_MAX`
15
+ * in the app's fieldCaps.ts) and this module applies the SAME numbers again —
16
+ * copies the release gate holds to the server's (`scripts/check-app-parity.mjs`,
17
+ * rule 10), because a tighter copy would silently drop a file the person
18
+ * watched go out. An entry with no id, or from an origin this daemon cannot
19
+ * place in a prompt, is dropped alone; one file named twice (an answer's
20
+ * screenshot that is also on the card's thread) is kept once, where it came
21
+ * first — the person's own message.
22
+ *
23
+ * Pure data and pure functions, like cardThread.mjs: prompts imports it.
24
+ */
25
+
26
+ /** The server's `AGENT_FILES_PER_MESSAGE_MAX` — one message's files. */
27
+ export const AGENT_FILES_PER_MESSAGE_MAX = 4;
28
+ /** The server's `CARD_FILES_PER_TURN_MAX` — the card's newest files, per turn. */
29
+ export const CARD_FILES_PER_TURN_MAX = 8;
30
+
31
+ /**
32
+ * THE LONGEST LINE ONE FILE ADDS TO A PROMPT. A landed file is
33
+ * `.flowviant/uploads/<name>` with the name at most `SAFE_NAME_MAX` (80) plus
34
+ * a collision's `-<id6>`; a missed one is its safe name. The renderer cuts at
35
+ * this regardless, so the bound below holds by construction, not by trust.
36
+ */
37
+ export const AGENT_FILE_LINE_MAX = 160;
38
+
39
+ /**
40
+ * THE MOST THE CARD'S FILES CAN ADD TO A CARD SPEC, rendered — so the spec
41
+ * stash (agentCards.mjs `MAX_SPEC_CHARS`) holds the note whole beside the
42
+ * discussion, and the pre-review is handed the files the agent was. A header
43
+ * line and its blank line, then one line per file. `agentFiles.test.mjs`
44
+ * renders the worst case against it.
45
+ */
46
+ export const CARD_FILES_RENDER_MAX = 200 + CARD_FILES_PER_TURN_MAX * (AGENT_FILE_LINE_MAX + 1);
47
+
48
+ /**
49
+ * The files off the wire, message files first then the card's, each origin at
50
+ * its cap, as `{ id, name, size, from }`. Never throws; anything that is not a
51
+ * list is none.
52
+ */
53
+ export function readAgentFiles(raw) {
54
+ if (!Array.isArray(raw)) return [];
55
+ const seen = new Set();
56
+ const pick = (from, max) => {
57
+ const out = [];
58
+ for (const a of raw) {
59
+ if (out.length >= max) break;
60
+ if (!a || typeof a !== 'object' || a.from !== from) continue;
61
+ if (typeof a.id !== 'string' || !a.id || seen.has(a.id)) continue;
62
+ seen.add(a.id);
63
+ out.push({ id: a.id, name: typeof a.name === 'string' ? a.name : '', size: a.size, from });
64
+ }
65
+ return out;
66
+ };
67
+ return [...pick('message', AGENT_FILES_PER_MESSAGE_MAX), ...pick('card', CARD_FILES_PER_TURN_MAX)];
68
+ }
@@ -23,6 +23,7 @@ import { AGENT_TASK_TYPES, agentTaskTypeFor, agentTaskTypeOf } from './agentTask
23
23
  import { ARTIFACT_CONTRACT_TAIL, SYSTEM_AGENT_DECK, SYSTEM_AGENT_MODEL } from './artifactContracts.mjs';
24
24
  import { isLibraryReference } from './knowledgeLibrary.mjs';
25
25
  import { readCardThread } from './cardThread.mjs';
26
+ import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
26
27
 
27
28
  // Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
28
29
  // knowledge VAULT — a plain directory of markdown files with [[wikilinks]]
@@ -1093,18 +1094,19 @@ export const AGENT_HANDS_BACK = Object.freeze({
1093
1094
  * files cards in this project, and this turn is about to edit code. The
1094
1095
  * instruction is ours and sits outside the fence. The card's DISCUSSION
1095
1096
  * (`cardThread`, 0.106.0) is printed inside the same fence, as part of the
1096
- * spec (`AGENT_TASK_SPEC`), so the spec stashed for the pre-review carries it.
1097
+ * spec (`AGENT_TASK_SPEC`), so the spec stashed for the pre-review carries it
1098
+ * — and the files on that discussion (`cardFiles`, 0.112.0) with it.
1097
1099
  *
1098
1100
  * The queue POSITION is stated because it changes behaviour: an agent that
1099
1101
  * thinks it is finishing tidies up, writes summaries and stops; one that knows
1100
1102
  * three more cards are coming leaves the ground ready for them.
1101
1103
  */
1102
- export const AGENT_TASK_KICKOFF = ({ agentName, task, cardThread, position, total }) =>
1104
+ export const AGENT_TASK_KICKOFF = ({ agentName, task, cardThread, cardFiles, position, total }) =>
1103
1105
  agentTaskKindOf(task?.taskKind) === 'code'
1104
1106
  ? `You are the agent "${safeName(agentName)}", working card ${position} of ${total} ` +
1105
1107
  `on this branch. Everything you commit here is reviewed and merged TOGETHER with ` +
1106
1108
  `the other cards in this run.\n\n` +
1107
- `${fence('THE CARD', taskBlock(task, cardThread))}\n\n` +
1109
+ `${fence('THE CARD', taskBlock(task, cardThread, cardFiles))}\n\n` +
1108
1110
  `When you commit, put this trailer on the LAST line of each commit message so ` +
1109
1111
  `the card can find its own commits:\n` +
1110
1112
  `Flowviant-Task: ${task?.id ?? ''}\n\n` +
@@ -1116,7 +1118,7 @@ export const AGENT_TASK_KICKOFF = ({ agentName, task, cardThread, position, tota
1116
1118
  `You are the agent "${safeName(agentName)}", working card ${position} of ${total} ` +
1117
1119
  `in this run. This card is ${AGENT_HANDS_BACK[agentTaskKindOf(task?.taskKind)]}, ` +
1118
1120
  `written under .flowviant/artifacts/, and changes nothing in the repository.\n\n` +
1119
- `${fence('THE CARD', taskBlock(task, cardThread))}\n\n` +
1121
+ `${fence('THE CARD', taskBlock(task, cardThread, cardFiles))}\n\n` +
1120
1122
  `Do it, and end with the JSON object.`;
1121
1123
 
1122
1124
  /**
@@ -1144,7 +1146,7 @@ const safeName = (n) =>
1144
1146
  * renderings of a card that can drift — and the drift would be invisible,
1145
1147
  * because nobody reads both prompts side by side.
1146
1148
  */
1147
- export const AGENT_TASK_SPEC = (task, cardThread) =>
1149
+ export const AGENT_TASK_SPEC = (task, cardThread, cardFiles) =>
1148
1150
  `id: ${task?.id ?? ''}\n` +
1149
1151
  `title: ${task?.title ?? ''}\n` +
1150
1152
  // THE KIND, when it is news (0.97.0). A code card's spec stays byte-for-byte
@@ -1174,8 +1176,12 @@ export const AGENT_TASK_SPEC = (task, cardThread) =>
1174
1176
  // LAST, after everything the card asks. Printed only when it names THIS
1175
1177
  // card and holds something, so every spec without a discussion — every
1176
1178
  // stash and every prompt before 0.106.0 — is byte-for-byte what it was.
1179
+ // …and THE FILES ON IT (0.112.0) right after it, under the same rule: the
1180
+ // server sends a card's files with that card's thread, so a thread naming
1181
+ // another card names nothing here, and no files is no line.
1177
1182
  (cardThread && task?.id && String(cardThread.taskId ?? '') === String(task.id)
1178
- ? prefixed('\n', cardThreadText(readCardThread(cardThread, 'turn')))
1183
+ ? prefixed('\n', cardThreadText(readCardThread(cardThread, 'turn'))) +
1184
+ prefixed('\n', cardFilesText(cardFiles))
1179
1185
  : '');
1180
1186
 
1181
1187
  /** A card's type as the spec prints it, or ''. */
@@ -1211,6 +1217,40 @@ const cardThreadText = (thread, indent = '') => {
1211
1217
  /** `head` before `text`, or '' when there is no text. */
1212
1218
  const prefixed = (head, text) => (text ? `${head}${text}` : '');
1213
1219
 
1220
+ /**
1221
+ * A TURN'S FILES AS TEXT (0.112.0) — the Terminal's note, in the Terminal's
1222
+ * shape (workSessionTurns.mjs): a bracketed header saying they are already on
1223
+ * disk, then one relative path per line, so "see the screenshot" is a file
1224
+ * the agent can open. A file that did not arrive is named and given no path
1225
+ * (workAttachments.mjs `fetchAgentFiles` says why it is named at all). ''
1226
+ * when there is nothing, which is what keeps every prompt without files
1227
+ * byte-for-byte what it was. Each origin is cut at the server's cap again
1228
+ * and each line at `AGENT_FILE_LINE_MAX`, so what this adds is bounded here.
1229
+ */
1230
+ const filesText = (head, files, max) => {
1231
+ const lines = (Array.isArray(files) ? files : [])
1232
+ .slice(0, max)
1233
+ .map((f) =>
1234
+ typeof f?.path === 'string' && f.path
1235
+ ? oneLine(`- ${f.path}`, AGENT_FILE_LINE_MAX)
1236
+ : typeof f?.missed === 'string' && f.missed
1237
+ ? oneLine(`- could not be fetched: ${f.missed}`, AGENT_FILE_LINE_MAX)
1238
+ : ''
1239
+ )
1240
+ .filter(Boolean);
1241
+ return lines.length ? `[${head}]\n${lines.join('\n')}` : '';
1242
+ };
1243
+ /** The files a person attached to THIS message — the Terminal's header, with
1244
+ * "person" for "human" as everywhere else an agent is addressed. */
1245
+ const messageFilesText = (files) =>
1246
+ filesText('FILES THE PERSON ATTACHED TO THIS MESSAGE — already on disk in this worktree', files, AGENT_FILES_PER_MESSAGE_MAX);
1247
+ /** The files on the card's notes, printed with the card's discussion; ends in
1248
+ * a newline like every other section of a spec. */
1249
+ const cardFilesText = (files) => {
1250
+ const text = filesText("FILES ON THIS CARD'S DISCUSSION — already on disk in this worktree", files, CARD_FILES_PER_TURN_MAX);
1251
+ return text ? `${text}\n` : '';
1252
+ };
1253
+
1214
1254
  /** A reference as the server sends it: a library path and its title. Anything
1215
1255
  * else is not printed — this text is fenced as card content, but a path that
1216
1256
  * is not a path is not worth handing an agent.
@@ -1240,24 +1280,48 @@ const taskBlock = AGENT_TASK_SPEC;
1240
1280
  * fence of its own, naming the card (a message often carries no `task` to name
1241
1281
  * it by). Never inside THE CARD YOU ARE ON: that block is the spec, as it
1242
1282
  * always was. Absent, nothing is printed, and the prompt is what it was.
1283
+ *
1284
+ * THE TURN'S FILES (0.112.0), each where its origin says: `messageFiles` (the
1285
+ * person attached them to these words) inside WHAT THEY SAID, `cardFiles` (on
1286
+ * the card's notes) at the foot of THE CARD'S DISCUSSION. None, and the prompt
1287
+ * is byte-for-byte what it was.
1243
1288
  */
1244
- export const AGENT_HUMAN_KICKOFF = ({ agentName, message, askedByName, task, cardThread, position, total }) =>
1289
+ export const AGENT_HUMAN_KICKOFF = ({ agentName, message, askedByName, task, cardThread, messageFiles, cardFiles, position, total }) =>
1245
1290
  `You are the agent "${safeName(agentName)}"` +
1246
1291
  (task ? `, working card ${position} of ${total} on this branch` : '') +
1247
1292
  `.\n\n` +
1248
1293
  `${fence('WHO IS TALKING', askedByName || 'a member of this project')}\n\n` +
1249
- `${fence('WHAT THEY SAID', message)}\n\n` +
1294
+ `${fence('WHAT THEY SAID', withMessageFiles(message, messageFiles))}\n\n` +
1250
1295
  (task ? `${fence('THE CARD YOU ARE ON', taskBlock(task))}\n\n` : '') +
1251
- cardDiscussionBlock(cardThread) +
1296
+ cardDiscussionBlock(cardThread, cardFiles) +
1252
1297
  `Carry on, and end with the JSON object as usual.`;
1253
1298
 
1299
+ /**
1300
+ * THE PERSON'S FILES GO WITH THEIR WORDS (0.112.0), after them and inside
1301
+ * WHAT THEY SAID — the Terminal's placement exactly (its note joins the
1302
+ * message before the kickoff fences it). "Like this" and the path to "this"
1303
+ * are one message, read together; a sibling block after the fence would make
1304
+ * the agent join them back up. Inside the fence costs nothing: the lines are
1305
+ * facts about their message, never an instruction of ours, and a person who
1306
+ * types a forged "[FILES …]" line names a path they could already have typed.
1307
+ * No files, and the message is handed through untouched.
1308
+ */
1309
+ const withMessageFiles = (message, files) => {
1310
+ const note = messageFilesText(files);
1311
+ return note ? [message, note].filter(Boolean).join('\n\n') : message;
1312
+ };
1313
+
1254
1314
  /** A human turn's card discussion, fenced, naming the card — '' when there is
1255
- * none to print. */
1256
- const cardDiscussionBlock = (cardThread) => {
1315
+ * none to print. The card's files (0.112.0) close it, after the discussion
1316
+ * they were attached in; a card with files and no readable thread still gets
1317
+ * the block, since the files alone are worth naming — but only where the
1318
+ * thread names a card to hang them on. */
1319
+ const cardDiscussionBlock = (cardThread, cardFiles) => {
1257
1320
  const text = cardThreadText(readCardThread(cardThread, 'turn'));
1258
- if (!text) return '';
1321
+ const files = cardThread?.taskId ? cardFilesText(cardFiles) : '';
1322
+ if (!text && !files) return '';
1259
1323
  const head = `card: ${oneLine(cardThread.taskId, 64)}\ntitle: ${oneLine(cardThread.title, 300)}\n\n`;
1260
- return `${fence("THE CARD'S DISCUSSION", `${head}${text}`)}\n\n`;
1324
+ return `${fence("THE CARD'S DISCUSSION", `${head}${text}${prefixed(text ? '\n' : '', files)}`)}\n\n`;
1261
1325
  };
1262
1326
 
1263
1327
  /**
package/bin/lib/work.mjs CHANGED
@@ -36,7 +36,7 @@
36
36
  * workReportQueue.mjs every settle, held and retried until delivered
37
37
  * workSessionTokens.mjs the per-session work credential
38
38
  * workNarration.mjs the tab's live line and the deferral sentence
39
- * workAttachments.mjs files the human attached, brought to disk
39
+ * workAttachments.mjs files the human attached, brought to disk (tabs and agents)
40
40
  * workSessionRuntime.mjs which CLI a tab speaks, which conversation resumes
41
41
  * workAdoptCarry.mjs adoption's dirty carry
42
42
  * workSessionTurns.mjs a tab's turn, start to settle
@@ -229,7 +229,7 @@ export function createWorkManager({
229
229
  });
230
230
  const { workTokens, mintWorkToken } = createWorkSessionTokens({ getLeaseTtl });
231
231
  const { makeNarrator, sayTurnDeferred, lastDeferSaid } = createWorkNarration();
232
- const { fetchAttachments } = createWorkAttachments();
232
+ const { fetchAttachments, fetchAgentFiles } = createWorkAttachments();
233
233
  const { sessionRuntime, agyRegistryLookup } = createWorkSessionRuntime({ sessionMetaPath });
234
234
  const { carryDirtyState } = createAdoptCarry({ sessionMetaPath });
235
235
  const { processWorkTurns, workAnswering } = createWorkSessionTurns({
@@ -359,6 +359,7 @@ export function createWorkManager({
359
359
  workChildren,
360
360
  agentPublished,
361
361
  agentRemoteAt,
362
+ fetchAgentFiles,
362
363
  });
363
364
 
364
365
  const { processAgentMergeJobs, agentMerges } = createWorkAgentMerges({
@@ -56,6 +56,7 @@ export function createAgentTurnExecution({
56
56
  postAgentTrace,
57
57
  postAgentActivity,
58
58
  postAgentParked,
59
+ fetchAgentFiles,
59
60
  }) {
60
61
  /** How long the final flush may hold the settle. Bounded because the settle
61
62
  * is the turn's contract and the trace is a readout: a wedged uplink costs
@@ -266,6 +267,23 @@ export function createAgentTurnExecution({
266
267
  ? Boolean(codexResumeId)
267
268
  : rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
268
269
 
270
+ /**
271
+ * THE TURN'S FILES, ON DISK BEFORE THE PROMPT NAMES THEM (0.112.0) — the
272
+ * Terminal's order: the agent must be able to open what it is told about.
273
+ * Into this worktree's `.flowviant/uploads/`, which the common exclude
274
+ * already hides from git, so a screenshot is never committed by the
275
+ * agent's `git add -A`. After the kind's refusals (a turn refused runs
276
+ * nothing and needs no files) and before the stash, which names the
277
+ * card's files beside its discussion so the pre-review — a fresh reader
278
+ * standing in this same worktree — is pointed at what the agent was.
279
+ * No key is no call: a turn without files touches nothing here, and its
280
+ * prompt is byte-for-byte what it was.
281
+ */
282
+ const files =
283
+ Array.isArray(job.attachments) && job.attachments.length > 0
284
+ ? await fetchAgentFiles(wt, job.attachments)
285
+ : { message: [], card: [] };
286
+
269
287
  /**
270
288
  * WRITE THE CARD DOWN BEFORE HANDING IT OVER — the material the AI
271
289
  * pre-review reads at review entry (see agentCards.mjs).
@@ -284,7 +302,8 @@ export function createAgentTurnExecution({
284
302
  * ONE SPEC BUILDER (`AGENT_TASK_SPEC`) shared with the kickoff below, so
285
303
  * what the reviewer reads is byte-identical to what the agent read — the
286
304
  * card's discussion included (0.106.0): both are handed the same
287
- * `job.cardThread`, and the builder prints it inside the spec.
305
+ * `job.cardThread`, and the builder prints it inside the spec — and the
306
+ * card's files after it (0.112.0), fetched just above.
288
307
  * Failure is swallowed inside `stashCard`: a note about a turn may never
289
308
  * cost the turn.
290
309
  */
@@ -292,7 +311,7 @@ export function createAgentTurnExecution({
292
311
  stashCard(
293
312
  sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
294
313
  job.task.id,
295
- AGENT_TASK_SPEC(job.task, job.cardThread)
314
+ AGENT_TASK_SPEC(job.task, job.cardThread, files.card)
296
315
  );
297
316
  }
298
317
 
@@ -375,8 +394,10 @@ export function createAgentTurnExecution({
375
394
  agentName: job.agentName,
376
395
  task: job.task,
377
396
  // The card's discussion (0.106.0), printed inside THE CARD by
378
- // the same builder the stash above wrote down.
397
+ // the same builder the stash above wrote down — and the files
398
+ // on it (0.112.0), after it, the same way.
379
399
  cardThread: job.cardThread,
400
+ cardFiles: files.card,
380
401
  position: job.position ?? 1,
381
402
  total: job.total ?? 1,
382
403
  })
@@ -388,6 +409,10 @@ export function createAgentTurnExecution({
388
409
  // The discussion on the one card this message is about, in a
389
410
  // fence of its own after the person's words (0.106.0).
390
411
  cardThread: job.cardThread,
412
+ // The files (0.112.0): the person's inside their words, the
413
+ // card's at the foot of its discussion.
414
+ messageFiles: files.message,
415
+ cardFiles: files.card,
391
416
  position: job.position ?? 1,
392
417
  total: job.total ?? 1,
393
418
  }),
@@ -46,6 +46,8 @@ export function createWorkAgentTurns({
46
46
  workChildren,
47
47
  agentPublished,
48
48
  agentRemoteAt,
49
+ /** The turn's files, fetched into its worktree (workAttachments.mjs, 0.112.0). */
50
+ fetchAgentFiles,
49
51
  }) {
50
52
  const {
51
53
  postAgentTurn,
@@ -108,6 +110,7 @@ export function createWorkAgentTurns({
108
110
  postAgentTrace,
109
111
  postAgentActivity,
110
112
  postAgentParked,
113
+ fetchAgentFiles,
111
114
  });
112
115
 
113
116
  /** `releaseSlot` hands back the admission reservation the caller took on this
@@ -3,19 +3,42 @@
3
3
  *
4
4
  * Split out of work.mjs (2026-09-26, SOLID F037). One trust boundary — a
5
5
  * server-sent name becoming a path on somebody's machine — with its caps, its
6
- * collision rule and its git exclude, reached only from the session lane.
6
+ * collision rule and its git exclude, reached from the session lane
7
+ * (`fetchAttachments`) and, since 0.112.0, the agent lane (`fetchAgentFiles`:
8
+ * the files on an answer, a send-back or the card's notes). ONE download loop
9
+ * serves both, so the two lanes cannot disagree about what a safe name is.
7
10
  * The name rule itself is safeFileName.mjs's.
8
11
  */
9
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
10
13
  import { join } from 'node:path';
11
14
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
12
15
  import { excludeInWorktree } from './git.mjs';
13
16
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
14
17
  import { safeFileName } from './safeFileName.mjs';
15
18
  import { fleetEndpoint } from './fleetWire.mjs';
19
+ import { readAgentFiles } from './agentFiles.mjs';
20
+
21
+ /**
22
+ * ONE FILE'S CEILING, for both lanes (0.113.0, the owner 2026-09-29: "20 MB
23
+ * for every file"). The server's `ATTACHMENT_MAX_BYTES` (the app's
24
+ * fieldCaps.ts), held equal by the release gate (`scripts/check-app-parity.mjs`,
25
+ * rule 11): a tighter copy here skips a file the person watched go out — which
26
+ * is what 0.112.0's 10 MB did to anything bigger, and why the server floors a
27
+ * file over 10 MB at this release (`DAEMON_LARGE_FILES_MIN`).
28
+ */
29
+ export const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
16
30
 
17
31
  export function createWorkAttachments() {
18
- const ATTACHMENT_URL = fleetEndpoint('attachment', FLEET_URL);
32
+ /**
33
+ * WHERE THE BYTES COME FROM, by lane: the Terminal's `/fleet/attachment/:id`
34
+ * (a session's uploads) and the agent lane's `/fleet/agent-file/:id` (a
35
+ * card conversation's, 0.112.0). Same shape — the machine credential, the
36
+ * bytes streamed, 404/410 when gone — and a different store behind each.
37
+ */
38
+ const FILE_URL = {
39
+ attachment: fleetEndpoint('attachment', FLEET_URL),
40
+ 'agent-file': fleetEndpoint('agent-file', FLEET_URL),
41
+ };
19
42
  /**
20
43
  * FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
21
44
  *
@@ -30,21 +53,38 @@ export function createWorkAttachments() {
30
53
  * doing that check is one deploy away from being zero places.
31
54
  */
32
55
  const UPLOAD_DIR = '.flowviant/uploads';
33
- const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
34
56
  // `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
35
57
  // home): THE EXTENSION SURVIVES A CUT, and it is idempotent over its own
36
58
  // output, so re-applying it to what the server already sanitized is a no-op
37
59
  // and the two sides never disagree about the cut. An empty name is
38
60
  // `attachment`, the server's own fallback.
39
61
  const safeUploadName = (raw) => safeFileName(raw, 'attachment');
40
- /** @returns relative paths written, in the order the human attached them. */
41
- const fetchAttachments = async (wt, attachments) => {
42
- if (!Array.isArray(attachments) || attachments.length === 0) return [];
62
+ /** The file at `path` holds exactly `buf` — read only when the sizes agree. */
63
+ const holds = (path, buf) => {
64
+ try {
65
+ return statSync(path).size === buf.byteLength && readFileSync(path).equals(buf);
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
70
+ /**
71
+ * THE ONE DOWNLOAD LOOP. Each entry's relative path, or null where it did
72
+ * not land — aligned with `list`, so a caller can say which file is missing.
73
+ * `endpoint` names the route (`FILE_URL`). `sameBytesKept` is the agent
74
+ * lane's: a turn about a card is handed the card's files EVERY turn, into
75
+ * one long-lived worktree, and without it the second turn's copy of
76
+ * `mock.png` collided with the first's and was renamed `mock-<id6>.png` — one
77
+ * file under two names, the prompt naming a different one each turn. A name
78
+ * already holding these exact bytes IS this file. The Terminal's loop never
79
+ * asks it: a tab's message carries its files once, and its answer stays
80
+ * what it was.
81
+ */
82
+ const landFiles = async (wt, list, endpoint, sameBytesKept) => {
43
83
  const dir = join(wt, UPLOAD_DIR);
44
84
  try {
45
85
  mkdirSync(dir, { recursive: true });
46
86
  } catch {
47
- return [];
87
+ return list.map(() => null);
48
88
  }
49
89
  // "Never committed by us" has to be true for GIT, not just for this code:
50
90
  // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
@@ -58,12 +98,13 @@ export function createWorkAttachments() {
58
98
  // whole-directory line also hid a repo's own new `.flowviant/check.json`
59
99
  // from its agent's `git add -A`.
60
100
  excludeInWorktree(wt, FLOWVIANT_OWN_PATHS);
61
- const written = [];
62
- for (const a of attachments.slice(0, 8)) {
101
+ const landed = [];
102
+ for (const a of list) {
103
+ landed.push(null);
63
104
  if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
64
105
  if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
65
106
  try {
66
- const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
107
+ const res = await fetch(`${FILE_URL[endpoint]}/${a.id}`, {
67
108
  headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
68
109
  signal: AbortSignal.timeout(60_000),
69
110
  });
@@ -73,21 +114,54 @@ export function createWorkAttachments() {
73
114
  // Collisions are real (two screenshots both named Screenshot.png), and
74
115
  // silently overwriting one with the other loses a file the human sent.
75
116
  let name = safeUploadName(a.name);
76
- if (existsSync(join(dir, name))) {
117
+ let already = sameBytesKept && holds(join(dir, name), buf);
118
+ if (!already && existsSync(join(dir, name))) {
77
119
  const dot = name.lastIndexOf('.');
78
120
  const stem = dot > 0 ? name.slice(0, dot) : name;
79
121
  const ext = dot > 0 ? name.slice(dot) : '';
80
122
  name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
123
+ already = sameBytesKept && holds(join(dir, name), buf);
81
124
  }
82
- writeFileSync(join(dir, name), buf);
83
- written.push(`${UPLOAD_DIR}/${name}`);
125
+ if (!already) writeFileSync(join(dir, name), buf);
126
+ landed[landed.length - 1] = `${UPLOAD_DIR}/${name}`;
84
127
  } catch {
85
128
  /* one file failing must not fail the turn — the prompt lists what
86
129
  actually arrived, so the agent never chases a path that isn't there */
87
130
  }
88
131
  }
89
- return written;
132
+ return landed;
133
+ };
134
+
135
+ /** @returns relative paths written, in the order the human attached them. */
136
+ const fetchAttachments = async (wt, attachments) => {
137
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
138
+ return (await landFiles(wt, attachments.slice(0, 8), 'attachment', false)).filter(Boolean);
139
+ };
140
+
141
+ /**
142
+ * AN AGENT TURN'S FILES (0.112.0), read off the wire (agentFiles.mjs: each
143
+ * origin at the server's cap, message files first) and fetched from
144
+ * `/fleet/agent-file/:id` into the agent's own worktree.
145
+ *
146
+ * Split by where the prompt prints them, and each entry says whether it
147
+ * LANDED: `{ path }` for a file on disk, `{ missed: name }` for one that did
148
+ * not arrive. Unlike the Terminal, which lists only what arrived, a miss is
149
+ * NAMED: a tab's person is watching the reply and re-attaches; a person who
150
+ * answered a parked agent "like this" with a screenshot is not, and an agent
151
+ * told nothing reads "like this" with no referent and guesses. Told the file
152
+ * could not be fetched, it can say so in its answer. Never a path for a
153
+ * miss, so it still never chases one.
154
+ *
155
+ * @returns {Promise<{ message: Array<{path: string}|{missed: string}>, card: Array<{path: string}|{missed: string}> }>}
156
+ */
157
+ const fetchAgentFiles = async (wt, attachments) => {
158
+ const out = { message: [], card: [] };
159
+ const list = readAgentFiles(attachments);
160
+ if (list.length === 0) return out;
161
+ const landed = await landFiles(wt, list, 'agent-file', true);
162
+ list.forEach((a, i) => out[a.from].push(landed[i] ? { path: landed[i] } : { missed: safeUploadName(a.name) }));
163
+ return out;
90
164
  };
91
165
 
92
- return { fetchAttachments };
166
+ return { fetchAttachments, fetchAgentFiles };
93
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.111.0",
3
+ "version": "0.113.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {