flowviant 0.48.1 → 0.48.2

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/bin/lib/env.mjs CHANGED
@@ -232,7 +232,7 @@ export async function loadCachedEnv(projectId) {
232
232
  * verification in materializeInto, which refuses to write a secret that git can
233
233
  * still see.
234
234
  */
235
- function excludeInWorktree(wt, relPaths) {
235
+ export function excludeInWorktree(wt, relPaths) {
236
236
  try {
237
237
  let gitdir;
238
238
  try {
package/bin/lib/fleet.mjs CHANGED
@@ -1215,6 +1215,7 @@ export async function runFleetDaemon() {
1215
1215
  retireWorkSessions,
1216
1216
  reportWorktrees,
1217
1217
  shutdownWork,
1218
+ workBusy,
1218
1219
  } = createWorkManager({
1219
1220
  repoRoot,
1220
1221
  baseDir,
@@ -1804,9 +1805,14 @@ export async function runFleetDaemon() {
1804
1805
  if (roster.daemon) {
1805
1806
  // "No worker mid-task" must include the wiki runner: updating mid-sweep
1806
1807
  // re-execs the daemon, orphans the wiki Claude, and the fresh process
1807
- // starts a second sweep racing it on the same vault.
1808
+ // starts a second sweep racing it on the same vault. And it must include
1809
+ // SESSION work (workBusy — turns, ships, undelivered settle reports):
1810
+ // dispatch workers' children say nothing about the Workbench tabs, and a
1811
+ // re-exec mid-turn SIGTERMs the tab's CLI and settles a partial answer.
1808
1812
  const safeToUpdate =
1809
- !wikiBusy && [...workers.values()].every((w) => w.state.child == null);
1813
+ !wikiBusy &&
1814
+ !workBusy() &&
1815
+ [...workers.values()].every((w) => w.state.child == null);
1810
1816
  const updating = handleVersionSignal({
1811
1817
  latest: roster.daemon.latest,
1812
1818
  min: roster.daemon.min,
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import {
16
+ existsSync,
16
17
  readdirSync,
17
18
  readFileSync,
18
19
  realpathSync,
@@ -23,6 +24,7 @@ import {
23
24
  } from 'node:fs';
24
25
  import { homedir } from 'node:os';
25
26
  import { join } from 'node:path';
27
+ import { execFileSync } from 'node:child_process';
26
28
 
27
29
  const REPORT_CAP = 30;
28
30
  // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
@@ -35,11 +37,14 @@ const ENDED_CAP = 5;
35
37
 
36
38
  /**
37
39
  * The conversation's own title, off the transcript's `ai-title` records
38
- * (the LAST one wins — titles get rewritten as a session evolves), falling
39
- * back to the first real user message. Those records sit anywhere in the
40
- * file (measured: line 81 to line 4457), so this reads the WHOLE transcript
41
- * behind an mtime cache, because the scan runs every minute and a title
42
- * only changes when the file does: steady state is a stat, not a read.
40
+ * (the LAST one wins — titles get rewritten as a session evolves), and ONLY
41
+ * those records: this wire is presence METADATA, and the first user message
42
+ * the fallback this used to relay is transcript CONTENT wearing a
43
+ * title's clothes. An untitled row is honest presence; a quoted message is
44
+ * a leak. Those records sit anywhere in the file (measured: line 81 to line
45
+ * 4457), so this reads the WHOLE transcript — behind an mtime cache, because
46
+ * the scan runs every minute and a title only changes when the file does:
47
+ * steady state is a stat, not a read.
43
48
  */
44
49
  const titleCache = new Map(); // file → { mtimeMs, title }
45
50
  function transcriptTitle(file, mtimeMs) {
@@ -50,31 +55,15 @@ function transcriptTitle(file, mtimeMs) {
50
55
  const stat = statSync(file);
51
56
  // A transcript past this is not worth a read per minute of drift.
52
57
  if (stat.size <= 64 * 1024 * 1024) {
53
- let firstUser = null;
54
58
  for (const line of readFileSync(file, 'utf8').split('\n')) {
55
- if (line.includes('"type":"ai-title"')) {
56
- try {
57
- const t = JSON.parse(line)?.aiTitle;
58
- if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
59
- } catch {
60
- /* torn line */
61
- }
62
- } else if (!firstUser && !title && line.includes('"type":"user"') && !line.includes('"isMeta":true')) {
63
- try {
64
- const content = JSON.parse(line)?.message?.content;
65
- const text =
66
- typeof content === 'string'
67
- ? content
68
- : Array.isArray(content)
69
- ? (content.find((b) => typeof b?.text === 'string')?.text ?? '')
70
- : '';
71
- if (text.trim() && !text.startsWith('<')) firstUser = text.trim();
72
- } catch {
73
- /* torn line */
74
- }
59
+ if (!line.includes('"type":"ai-title"')) continue;
60
+ try {
61
+ const t = JSON.parse(line)?.aiTitle;
62
+ if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
63
+ } catch {
64
+ /* torn line */
75
65
  }
76
66
  }
77
- if (!title && firstUser) title = firstUser;
78
67
  if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
79
68
  }
80
69
  } catch {
@@ -104,7 +93,32 @@ function pidAlive(pid, procStart) {
104
93
  try {
105
94
  stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
106
95
  } catch {
107
- return false; // no /proc entry the process is gone
96
+ // No /proc ENTRY means the process is gone — but only where /proc exists
97
+ // at all. On macOS there is no /proc, so this read fails for EVERY pid,
98
+ // every session scans as dead, and live sessions become adoptable — the
99
+ // exact two-drivers-on-one-conversation outcome liveness exists to
100
+ // prevent. Probe by name instead: `ps -o comm=` on the pid, alive only if
101
+ // the surviving process still LOOKS like a coding-CLI process. A bare
102
+ // signal-0 probe was tried first and reads a RECYCLED pid as alive
103
+ // forever — a crashed session whose pid a launchd service inherited would
104
+ // report live for weeks, refuse adoption, and suppress its own ended row.
105
+ // The name check keeps the conservative direction (a transient race still
106
+ // errs live) without the permanent false-live.
107
+ if (!existsSync('/proc')) {
108
+ try {
109
+ const comm = execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], {
110
+ encoding: 'utf8',
111
+ stdio: ['ignore', 'pipe', 'ignore'],
112
+ timeout: 5_000,
113
+ })
114
+ .trim()
115
+ .toLowerCase();
116
+ return /\b(claude|node|bun|codex|agy)\b|\/(claude|node|bun|codex|agy)$/.test(comm);
117
+ } catch {
118
+ return false; // ps errored or the pid is gone — the process is dead
119
+ }
120
+ }
121
+ return false; // /proc is real and has no entry — the process is gone
108
122
  }
109
123
  if (procStart == null) return true; // nothing recorded to compare against
110
124
  const close = stat.lastIndexOf(')');
@@ -389,8 +403,28 @@ function agyLastWriteMs(id) {
389
403
  }
390
404
 
391
405
  /** Any agy process on the machine right now? /proc comm scan — cheap at the
392
- * 60s cadence, and the only liveness signal agy leaves (locks are inert). */
406
+ * 60s cadence, and the only liveness signal agy leaves (locks are inert).
407
+ * Tri-state on purpose: true (an agy process exists), false (scanned /proc
408
+ * and found none), null (no /proc to scan — macOS — so the question is
409
+ * UNANSWERABLE here, which is a different fact from "no", and the adoption
410
+ * path below treats it differently: unknowable refuses, absent permits). */
393
411
  function agyProcessAlive() {
412
+ if (!existsSync('/proc')) {
413
+ // No /proc (macOS): ask pgrep the same question. A MEASURED "none" here
414
+ // matters — answering null instead made every agy conversation on such a
415
+ // machine read live forever, which both invented a state ("live" when the
416
+ // truth was "unmeasured") and permanently killed agy adoption there.
417
+ try {
418
+ execFileSync('pgrep', ['-x', 'agy'], {
419
+ stdio: ['ignore', 'ignore', 'ignore'],
420
+ timeout: 5_000,
421
+ });
422
+ return true; // exit 0 — at least one agy process
423
+ } catch (e) {
424
+ if (e?.status === 1) return false; // pgrep ran and found none
425
+ return null; // pgrep itself unavailable/errored — genuinely unknowable
426
+ }
427
+ }
394
428
  try {
395
429
  for (const name of readdirSync('/proc')) {
396
430
  if (!/^\d+$/.test(name)) continue;
@@ -401,7 +435,7 @@ function agyProcessAlive() {
401
435
  }
402
436
  }
403
437
  } catch {
404
- /* no /proc call nothing live rather than everything */
438
+ return null; // /proc exists but won't read still unknowable
405
439
  }
406
440
  return false;
407
441
  }
@@ -418,7 +452,15 @@ function agyProcessAlive() {
418
452
  const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
419
453
  export function isAgyConversationLive(id) {
420
454
  try {
421
- if (!agyProcessAlive()) return false;
455
+ const up = agyProcessAlive();
456
+ // Unknowable is NOT "ended". Without /proc the process half of the
457
+ // composite cannot be measured, and calling that "no process" would make
458
+ // every agy conversation on such a machine adoptable — the move-adoption
459
+ // then puts a second driver on a store someone may still be writing.
460
+ // "Live" here means "refuse adoption", and refusing what we cannot verify
461
+ // is the same conservatism as the composite itself.
462
+ if (up === null) return true;
463
+ if (!up) return false;
422
464
  const t = agyLastWriteMs(id);
423
465
  return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
424
466
  } catch {
@@ -466,7 +508,11 @@ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
466
508
  out.push({
467
509
  id,
468
510
  cwd: real,
469
- live: processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
511
+ // Mirrors isAgyConversationLive exactly, unknowable (null) included:
512
+ // the report must never offer as adoptable a conversation the adopt
513
+ // path will then refuse — an offer wired to a refusal is the dead
514
+ // control this product keeps deleting.
515
+ live: processUp === null ? true : processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
470
516
  lastActiveAt: new Date(lastMs).toISOString(),
471
517
  runtime: 'antigravity',
472
518
  });
@@ -493,6 +493,14 @@ rules:
493
493
  10. BE PROPORTIONAL. A one-line typo fix inside the card you already hold is
494
494
  that card's work, not a new card. When in doubt, fewer cards.
495
495
 
496
+ THERE IS NO LATER. Your turn ends when you stop writing, and nothing of yours
497
+ runs after that — so never promise to report back, keep watching, follow up, or
498
+ tell them the result "as soon as it finishes". If something you started is
499
+ still running, either wait for it inside this turn and report what happened, or
500
+ end by saying plainly that it is unfinished, what is still running, and how they
501
+ can check. A promise you cannot keep reads as a hang: they sit waiting for a
502
+ message that will never come.
503
+
496
504
  POSTURE: terminal, not ticket. Don't ask permission to look at things. Don't
497
505
  narrate ceremony. Ground claims in files you opened. When they ask a question,
498
506
  answer it; when they ask for work, do it; when you spot something broken along
@@ -544,6 +552,14 @@ MECHANICS OF THIS TAB:
544
552
  as well: a client that doesn't render the fence shows it as plain text, so
545
553
  the reply has to read as a question with its options either way.
546
554
 
555
+ THERE IS NO LATER. Your turn ends when you stop writing, and nothing of yours
556
+ runs after that — so never promise to report back, keep watching, or tell them
557
+ the result "as soon as it finishes". If something you started is still running,
558
+ either wait for it inside this turn and report what happened, or end by saying
559
+ plainly that it is unfinished, what is still running, and how they can check. A
560
+ promise you cannot keep reads as a hang: they sit waiting for a message that
561
+ will never come.
562
+
547
563
  POSTURE: terminal, not ticket. Don't ask permission to look at things. Ground
548
564
  claims in files you opened. When they ask a question, answer it; when they ask
549
565
  for work, do it.
@@ -87,6 +87,17 @@ export function runUpdateCommand() {
87
87
 
88
88
  // Nag at most once per target version, so a poll every ~10s doesn't spam.
89
89
  let naggedFor = null;
90
+ /**
91
+ * When the last install ATTEMPT failed, so a failure can be retried instead of
92
+ * being final. It used to set `naggedFor` and stop: one unwritable global
93
+ * prefix, one npm blip, or one offline moment and the daemon stayed on its old
94
+ * version until the server announced a DIFFERENT one — which on a box nobody
95
+ * logs into means features silently missing for days (measured: a machine sat
96
+ * on 0.47.0 through two releases). A readout in the app now surfaces it too,
97
+ * but the machine should also just try again.
98
+ */
99
+ let lastInstallFailAt = 0;
100
+ const INSTALL_RETRY_MS = 15 * 60_000;
90
101
 
91
102
  /**
92
103
  * React to the server's {latest, min} signal from a roster poll.
@@ -110,6 +121,14 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
110
121
  }
111
122
  return false;
112
123
  }
124
+ // A failed install is retried on a cooldown, not abandoned: the common
125
+ // causes (an unwritable global prefix mid-fix, a registry blip, a network
126
+ // that came back) are all transient, and the poll runs every ~10s so
127
+ // without this a single failure is effectively permanent. Checked BEFORE
128
+ // the npm-view probe below, which is a SYNCHRONOUS network call — during
129
+ // backoff every ~10s poll would otherwise block the whole event loop on a
130
+ // question whose answer we already decided not to act on.
131
+ if (Date.now() - lastInstallFailAt < INSTALL_RETRY_MS) return false;
113
132
  // Loop guard: the server can announce a version before it's published. npm is
114
133
  // the source of truth — only install if npm ACTUALLY has something newer than
115
134
  // us, else `npm i -g @latest` reinstalls our own version and we'd re-exec
@@ -119,6 +138,9 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
119
138
  published = execFileSync('npm', ['view', 'flowviant', 'version'], {
120
139
  encoding: 'utf8',
121
140
  stdio: ['ignore', 'pipe', 'ignore'],
141
+ // Synchronous, so it holds the event loop hostage for however long it
142
+ // runs — a hung registry must not become a hung daemon.
143
+ timeout: 10_000,
122
144
  }).trim();
123
145
  } catch {
124
146
  /* offline / npm hiccup — treat as "can't confirm", skip this poll */
@@ -137,8 +159,10 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
137
159
  reexec(teardown);
138
160
  return true;
139
161
  } catch (e) {
140
- warn(`self-update failed (${e?.message ?? e}) — update manually: npm i -g flowviant@latest`);
141
- naggedFor = target; // don't retry-spam a failing install every poll
162
+ lastInstallFailAt = Date.now();
163
+ warn(
164
+ `self-update failed (${e?.message ?? e}) — retrying in 15m; to fix it now: npm i -g flowviant@latest`
165
+ );
142
166
  return false;
143
167
  }
144
168
  }
package/bin/lib/work.mjs CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  SYSTEM_WORK_PLAIN,
41
41
  WORK_TURN_KICKOFF_PLAIN,
42
42
  } from './prompts.mjs';
43
- import { materializeInto, scrub as envScrub } from './env.mjs';
43
+ import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
44
44
  import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
45
45
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
46
46
  import { worktreeDiff } from './worktreeDiff.mjs';
@@ -91,6 +91,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
91
91
  const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
92
92
  const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
93
93
  const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
94
+ const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
94
95
  const workAnswering = new Set(); // turn ids currently queued/running here
95
96
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
96
97
  const MAX_WORK_TRIES = 3;
@@ -132,9 +133,23 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
132
133
  */
133
134
  const pendingWorkReports = new Map(); // turnId -> work-turn-done body
134
135
  const pendingShipReports = new Map(); // sessionId -> ship-done body
135
- /** POST a settle body. 'ok' | 'terminal' (the server will never accept this
136
- * body — 403 not this fleet's session, 404 unknown turn, 409 ship already
137
- * settledso retrying is spam, not delivery) | 'retry'. */
136
+ /** POST a settle body. Four outcomes, and the split between the last two is
137
+ * load-bearing:
138
+ * - 'ok' delivered.
139
+ * - 'terminal' — the EXPLICIT per-endpoint statuses under which the server
140
+ * will never re-offer the job (403 not this fleet's session, 404 unknown
141
+ * turn, 409 ship already settled). Only these may drop the report AND
142
+ * the attempts counter: they are the statuses where forgetting is safe
143
+ * because the job is gone server-side too.
144
+ * - 'reject' — any OTHER 4xx (a 400 from deploy skew, an edge/WAF rule):
145
+ * the server refused this BODY, but the job row may still be pending and
146
+ * riding every poll. The report must stay QUEUED — it is the skip-guard
147
+ * that stops the turn being re-run with all its side effects — but
148
+ * re-POSTing a body the server just refused every poll is spam, so
149
+ * delivery backs off. Treating this as terminal once re-ran whole
150
+ * non-idempotent CLI turns in a loop; treating it as plain retry
151
+ * hammered a refused body forever.
152
+ * - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
138
153
  const postSettle = async (url, body, terminalStatuses) => {
139
154
  try {
140
155
  const res = await fetch(url, {
@@ -149,26 +164,41 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
149
164
  });
150
165
  if (res.ok) return 'ok';
151
166
  if (terminalStatuses.includes(res.status)) return 'terminal';
167
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
168
+ return 'reject';
152
169
  return 'retry';
153
170
  } catch {
154
171
  return 'retry';
155
172
  }
156
173
  };
174
+ /** How long a REJECTED report sits out before re-offering its body — long
175
+ * enough that a deploy-skew 400 costs a handful of POSTs a day, short
176
+ * enough that a server fix picks the report up the same morning. */
177
+ const REJECT_RETRY_MS = 10 * 60 * 1000;
178
+ const reportBackoff = new Map(); // turnId|sessionId -> earliest next attempt
157
179
  const settleWorkTurn = async (turnId, payload) => {
158
180
  const body = { turnId, ...payload };
159
181
  const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
160
- if (r === 'retry') pendingWorkReports.set(turnId, body);
161
- else {
182
+ if (r === 'retry' || r === 'reject') {
183
+ pendingWorkReports.set(turnId, body);
184
+ if (r === 'reject') reportBackoff.set(turnId, Date.now() + REJECT_RETRY_MS);
185
+ } else {
162
186
  pendingWorkReports.delete(turnId);
163
187
  workAttempts.delete(turnId);
188
+ reportBackoff.delete(turnId);
164
189
  }
165
190
  return r;
166
191
  };
167
192
  const settleShip = async (sessionId, payload) => {
168
193
  const body = { sessionId, ...payload };
169
194
  const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
170
- if (r === 'retry') pendingShipReports.set(sessionId, body);
171
- else pendingShipReports.delete(sessionId);
195
+ if (r === 'retry' || r === 'reject') {
196
+ pendingShipReports.set(sessionId, body);
197
+ if (r === 'reject') reportBackoff.set(sessionId, Date.now() + REJECT_RETRY_MS);
198
+ } else {
199
+ pendingShipReports.delete(sessionId);
200
+ reportBackoff.delete(sessionId);
201
+ }
172
202
  return r;
173
203
  };
174
204
  /**
@@ -187,7 +217,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
187
217
  */
188
218
  const ACTIVITY_MIN_MS = 1_500;
189
219
  const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
190
- const makeNarrator = (sessionId) => {
220
+ /** `turnId` scopes the narration to the turn that produced it: a POST
221
+ * already on the wire when the turn settles must not re-stamp a "working…"
222
+ * line over the finished reply — the server drops narration for a turn
223
+ * that is no longer pending. (A session-level pending count can't tell the
224
+ * settled turn's stale line from the queued NEXT turn's fresh one.) */
225
+ const makeNarrator = (sessionId, turnId) => {
191
226
  const recent = [];
192
227
  let lastSent = 0;
193
228
  let dirty = false;
@@ -209,7 +244,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
209
244
  'Content-Type': 'application/json',
210
245
  },
211
246
  signal: AbortSignal.timeout(10_000),
212
- body: JSON.stringify({ sessionId, lines }),
247
+ body: JSON.stringify({ sessionId, turnId, lines }),
213
248
  });
214
249
  } catch {
215
250
  /* narration is decoration — a dropped line is not an incident */
@@ -228,7 +263,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
228
263
  };
229
264
  return {
230
265
  line(label) {
231
- const s = String(label ?? '')
266
+ // Scrub, like every string that leaves this machine: a narration line
267
+ // is the CLI's own stdout — a command echoing an env var, a read of a
268
+ // config file — and it rides the same uplink the final answer does.
269
+ const s = envScrub(String(label ?? ''))
232
270
  .replace(/\s+/g, ' ')
233
271
  .trim()
234
272
  .slice(0, 200);
@@ -328,6 +366,78 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
328
366
  })();
329
367
  };
330
368
 
369
+ /**
370
+ * FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
371
+ *
372
+ * A screenshot in a chat bubble is useless to an agent; a path is not. So the
373
+ * turn's attachments are downloaded into `.flowviant/uploads/` inside the
374
+ * session's own worktree and the prompt is handed the relative paths.
375
+ *
376
+ * `.flowviant/` rather than the repo proper, and gitignored-or-not it is
377
+ * never committed by us: these are the human's inputs to a conversation, not
378
+ * project files. The name is re-sanitized HERE even though the server already
379
+ * did it — this string becomes a path on someone's machine, and one place
380
+ * doing that check is one deploy away from being zero places.
381
+ */
382
+ const UPLOAD_DIR = '.flowviant/uploads';
383
+ const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
384
+ const safeUploadName = (raw) => {
385
+ const base = String(raw ?? '')
386
+ .split(/[\\/]/)
387
+ .pop()
388
+ .replace(/[^A-Za-z0-9._-]/g, '_')
389
+ .replace(/^[.-]+/, '')
390
+ .slice(0, 80);
391
+ return base || 'attachment';
392
+ };
393
+ /** @returns relative paths written, in the order the human attached them. */
394
+ const fetchAttachments = async (wt, attachments) => {
395
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
396
+ const dir = join(wt, UPLOAD_DIR);
397
+ try {
398
+ mkdirSync(dir, { recursive: true });
399
+ } catch {
400
+ return [];
401
+ }
402
+ // "Never committed by us" has to be true for GIT, not just for this code:
403
+ // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
404
+ // every ship, exempts the tree from closed-tab retirement forever, and
405
+ // shows the human's own uploads in the rail as session changes. Same
406
+ // mechanism as the materialized env files — the exclude file git actually
407
+ // reads (env.mjs), which already skips lines it has written before, so
408
+ // calling it per fetch is idempotent.
409
+ excludeInWorktree(wt, ['.flowviant/']);
410
+ const written = [];
411
+ for (const a of attachments.slice(0, 8)) {
412
+ if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
413
+ if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
414
+ try {
415
+ const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
416
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
417
+ signal: AbortSignal.timeout(60_000),
418
+ });
419
+ if (!res.ok) continue;
420
+ const buf = Buffer.from(await res.arrayBuffer());
421
+ if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
422
+ // Collisions are real (two screenshots both named Screenshot.png), and
423
+ // silently overwriting one with the other loses a file the human sent.
424
+ let name = safeUploadName(a.name);
425
+ if (existsSync(join(dir, name))) {
426
+ const dot = name.lastIndexOf('.');
427
+ const stem = dot > 0 ? name.slice(0, dot) : name;
428
+ const ext = dot > 0 ? name.slice(dot) : '';
429
+ name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
430
+ }
431
+ writeFileSync(join(dir, name), buf);
432
+ written.push(`${UPLOAD_DIR}/${name}`);
433
+ } catch {
434
+ /* one file failing must not fail the turn — the prompt lists what
435
+ actually arrived, so the agent never chases a path that isn't there */
436
+ }
437
+ }
438
+ return written;
439
+ };
440
+
331
441
  let flushingReports = false;
332
442
  const flushWorkReports = async () => {
333
443
  if (flushingReports) return;
@@ -335,15 +445,26 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
335
445
  flushingReports = true;
336
446
  try {
337
447
  for (const [id, body] of [...pendingWorkReports]) {
448
+ // A rejected body sits out its backoff; the queued entry itself stays
449
+ // — it is the skip-guard against re-running a turn whose side effects
450
+ // already happened.
451
+ if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
338
452
  const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
339
- if (r !== 'retry') {
453
+ if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
454
+ else if (r !== 'retry') {
340
455
  pendingWorkReports.delete(id);
341
456
  workAttempts.delete(id);
457
+ reportBackoff.delete(id);
342
458
  }
343
459
  }
344
460
  for (const [id, body] of [...pendingShipReports]) {
461
+ if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
345
462
  const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
346
- if (r !== 'retry') pendingShipReports.delete(id);
463
+ if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
464
+ else if (r !== 'retry') {
465
+ pendingShipReports.delete(id);
466
+ reportBackoff.delete(id);
467
+ }
347
468
  }
348
469
  } finally {
349
470
  flushingReports = false;
@@ -1088,9 +1209,20 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1088
1209
  let out;
1089
1210
  let seenThreadId = null; // codex's conversation id, off thread.started
1090
1211
  const spawned = []; // this turn's children, for the teardown registry
1091
- const narrator = makeNarrator(job.sessionId);
1212
+ const narrator = makeNarrator(job.sessionId, job.id);
1092
1213
  try {
1093
- const message = [job.body, adoptNote, carryNote].filter(Boolean).join('\n\n');
1214
+ // Files first, then the message that references them: the agent
1215
+ // must be able to open what it is being told about. Only the ones
1216
+ // that actually landed are named.
1217
+ const files = await fetchAttachments(dir.wt, job.attachments);
1218
+ const filesNote = files.length
1219
+ ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
1220
+ .map((f) => `- ${f}`)
1221
+ .join('\n')}`
1222
+ : '';
1223
+ const message = [job.body, filesNote, adoptNote, carryNote]
1224
+ .filter(Boolean)
1225
+ .join('\n\n');
1094
1226
  const turnArgs = {
1095
1227
  // A plain tab has no tools to name and no session id to pass —
1096
1228
  // its kickoff asks for one complete report instead of a stream.
@@ -1599,6 +1731,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1599
1731
  }
1600
1732
  };
1601
1733
 
1734
+ /**
1735
+ * Is ANY session work in flight — the answer safeToUpdate needs. A self-
1736
+ * update re-execs the process: a mid-turn CLI would be SIGTERM'd and its
1737
+ * half-finished answer settled as the tab's reply, and a queued-but-
1738
+ * undelivered settle report would die in memory — after which the skip-
1739
+ * guard's protection is gone and the re-exec'd daemon re-runs a turn whose
1740
+ * side effects (edits, commits, cards) already happened. `workChains` holds
1741
+ * an entry for every queued-or-running turn and ship (entries self-delete
1742
+ * when a chain drains); the other collections are belt over braces for the
1743
+ * windows around it.
1744
+ */
1745
+ const workBusy = () =>
1746
+ workChains.size > 0 ||
1747
+ shipping.size > 0 ||
1748
+ workChildren.size > 0 ||
1749
+ workAnswering.size > 0 ||
1750
+ pendingWorkReports.size > 0 ||
1751
+ pendingShipReports.size > 0;
1752
+
1602
1753
  return {
1603
1754
  flushWorkReports,
1604
1755
  processWorkTurns,
@@ -1606,5 +1757,6 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1606
1757
  retireWorkSessions,
1607
1758
  reportWorktrees,
1608
1759
  shutdownWork,
1760
+ workBusy,
1609
1761
  };
1610
1762
  }
@@ -71,6 +71,10 @@ export function worktreeDiff(wt, baseRef) {
71
71
  let deletions = 0;
72
72
  const push = (path, added, deleted, binary = false) => {
73
73
  if (!path) return;
74
+ // Clamped to the server's zod cap (file path ≤ 300 — see the caps at the
75
+ // return, below): one over-cap string would 400 the whole report batch,
76
+ // and a readout must degrade to a shorter label, never to silence.
77
+ path = path.slice(0, 300);
74
78
  files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
75
79
  additions += added;
76
80
  deletions += deleted;
@@ -143,7 +147,14 @@ export function worktreeDiff(wt, baseRef) {
143
147
  for (const line of raw.split('\n')) {
144
148
  if (!line.trim()) continue;
145
149
  const [sha, subject, author] = line.split('\x1f');
146
- if (sha) baseCommits.push({ sha, subject: subject ?? '', author: author ?? '' });
150
+ // Same clamp-to-the-server's-caps rule as the file paths: a subject or
151
+ // author name is whatever a person typed, and git puts no bound on it.
152
+ if (sha)
153
+ baseCommits.push({
154
+ sha,
155
+ subject: (subject ?? '').slice(0, 200),
156
+ author: (author ?? '').slice(0, 80),
157
+ });
147
158
  }
148
159
  }
149
160
  } catch {
@@ -161,12 +172,16 @@ export function worktreeDiff(wt, baseRef) {
161
172
  files.sort(
162
173
  (x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
163
174
  );
175
+ // Every string here is clamped to the server's own zod caps (branch ≤ 200,
176
+ // baseLabel ≤ 120, subject ≤ 200, author ≤ 80, file path ≤ 300): the report
177
+ // rides in a BATCH, so a single over-cap string — a generated branch name, a
178
+ // pathological commit subject — would 400 every session's readout at once.
164
179
  return {
165
- branch,
180
+ branch: branch.slice(0, 200),
166
181
  path: wt,
167
182
  ahead,
168
183
  behind,
169
- baseLabel: String(baseRef).replace(/^origin\//, ''),
184
+ baseLabel: String(baseRef).replace(/^origin\//, '').slice(0, 120),
170
185
  baseCommits,
171
186
  dirty,
172
187
  additions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.48.1",
3
+ "version": "0.48.2",
4
4
  "description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {