flowviant 0.74.0 → 0.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -107,6 +107,25 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
107
107
  }
108
108
  }
109
109
 
110
+ /** Count '\n' without materializing a split — a Write's content can be a
111
+ * multi-MB generated file, and `split('\n')` re-allocates all of it as line
112
+ * strings on the synchronous stream path the narrator and wake socket share.
113
+ * Numbers cannot leak, so counting runs on the RAW string. */
114
+ export function countLines(s) {
115
+ const str = String(s ?? '');
116
+ if (!str) return 0;
117
+ let n = 1;
118
+ let i = -1;
119
+ while ((i = str.indexOf('\n', i + 1)) !== -1) n++;
120
+ return n;
121
+ }
122
+
123
+ /** How much raw string the scrubber sees before any cap is applied. A secret
124
+ * can only surface in the first ~capped chars of a field; giving the scrub a
125
+ * window this much larger means a secret would have to be longer than the
126
+ * window minus the cap to straddle out of it — no real credential is. */
127
+ const SCRUB_WINDOW = 8_192;
128
+
110
129
  /**
111
130
  * Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
112
131
  * relay's durable form, where `humanizeClaudeTool` above is its one-line live
@@ -121,45 +140,57 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
121
140
  * dl: a few "-/+" prefixed preview lines of an Edit
122
141
  * items: the plan's todos, x = text, s = done|active|open
123
142
  *
124
- * The CALLER scrubs (work.mjs envScrub) this stays a pure shape function so
125
- * it is testable without a vault.
143
+ * SCRUB BEFORE CAPthe order is load-bearing (review, 2026-09-01). The
144
+ * caller passes its `envScrub`; every string is scrubbed over a bounded
145
+ * window FIRST and capped LAST, because the reverse order had two failures:
146
+ * a secret straddling the cap boundary was cut into a prefix the
147
+ * exact-substring scrub could no longer match (a partial credential on the
148
+ * wire), and a scrub REPLACEMENT that grew a string past the cap tripped the
149
+ * server's field limits. `scrub` defaults to identity so this stays testable
150
+ * without a vault.
126
151
  */
127
- export function toolEventOf(name, input = {}, cwd = '') {
128
- const rel = (p) => shortPath(p, cwd).slice(0, 300);
129
- const lines = (s) => (s ? String(s).split('\n').length : 0);
152
+ export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
153
+ // Window scrub cap. The window bounds what a multi-MB input costs; the
154
+ // cap is applied AFTER the scrub so a replacement cannot overflow it.
155
+ const clean = (v, cap) => scrub(String(v ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
156
+ const rel = (p) => clean(shortPath(p, cwd), 300);
157
+ // The first k lines of a side, from a scrubbed bounded prefix — never a
158
+ // full split of the raw string.
159
+ const firstLines = (s, k) =>
160
+ scrub(String(s ?? '').slice(0, SCRUB_WINDOW)).split('\n', k).slice(0, k);
130
161
  switch (name) {
131
162
  case 'Read':
132
163
  return { t: 'read', p: rel(input.file_path) };
133
164
  case 'Write':
134
- return { t: 'write', p: rel(input.file_path), a: lines(input.content) };
165
+ return { t: 'write', p: rel(input.file_path), a: countLines(input.content) };
135
166
  case 'Edit': {
136
167
  const oldS = String(input.old_string ?? '');
137
168
  const newS = String(input.new_string ?? '');
138
169
  // A MINI-DIFF, not the diff: the first lines of each side, enough to
139
170
  // recognise the change at a glance. The real diff lives in git.
140
171
  const dl = [
141
- ...oldS.split('\n').slice(0, 2).map((l) => `- ${l}`),
142
- ...newS.split('\n').slice(0, 3).map((l) => `+ ${l}`),
172
+ ...firstLines(oldS, 2).map((l) => `- ${l}`),
173
+ ...firstLines(newS, 3).map((l) => `+ ${l}`),
143
174
  ].map((l) => l.slice(0, 160));
144
- return { t: 'edit', p: rel(input.file_path), a: lines(newS), d: lines(oldS), dl };
175
+ return { t: 'edit', p: rel(input.file_path), a: countLines(newS), d: countLines(oldS), dl };
145
176
  }
146
177
  case 'Grep':
147
178
  return {
148
179
  t: 'grep',
149
- q: String(input.pattern ?? '').slice(0, 200),
180
+ q: clean(input.pattern, 200),
150
181
  ...(input.path ? { p: rel(input.path) } : {}),
151
182
  };
152
183
  case 'Glob':
153
- return { t: 'glob', q: String(input.pattern ?? '').slice(0, 200) };
184
+ return { t: 'glob', q: clean(input.pattern, 200) };
154
185
  case 'Bash':
155
- return { t: 'bash', c: String(input.command ?? '').slice(0, 200) };
186
+ return { t: 'bash', c: clean(input.command, 200) };
156
187
  case 'Task':
157
- return { t: 'task', q: String(input.description ?? '').slice(0, 200) };
188
+ return { t: 'task', q: clean(input.description, 200) };
158
189
  case 'TodoWrite': {
159
190
  const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
160
191
  const items = todos
161
192
  .map((td) => ({
162
- x: String(td?.content ?? '').slice(0, 120),
193
+ x: clean(td?.content, 120),
163
194
  s: td?.status === 'completed' ? 'done' : td?.status === 'in_progress' ? 'active' : 'open',
164
195
  }))
165
196
  .filter((i) => i.x);
package/bin/lib/work.mjs CHANGED
@@ -2404,21 +2404,19 @@ export function createWorkManager({
2404
2404
  * · the PLAN is a single event — a new TodoWrite replaces the old
2405
2405
  * plan at the current position, so the log shows the latest plan
2406
2406
  * where it last changed rather than five stale copies;
2407
- * · capped at the newest 60, with the shed counted (`dropped`) —
2408
- * scrollback semantics, the same trade the transcript itself
2409
- * makes.
2407
+ * · capped at the newest 60 NON-PLAN rows, with the shed counted
2408
+ * call-for-call (`dropped += n`) scrollback semantics, the
2409
+ * same trade the transcript itself makes; the plan is exempt,
2410
+ * because it is current state rather than scrollback.
2410
2411
  */
2411
2412
  const toolLog = { ev: [], dropped: 0 };
2412
- const scrubEv = (e) => {
2413
- for (const k of ['p', 'q', 'c']) if (typeof e[k] === 'string') e[k] = envScrub(e[k]);
2414
- if (Array.isArray(e.dl)) e.dl = e.dl.map((l) => envScrub(l));
2415
- if (Array.isArray(e.items)) for (const i of e.items) i.x = envScrub(i.x);
2416
- return e;
2417
- };
2418
2413
  const pushToolEvent = (name, input) => {
2419
- const e = toolEventOf(name, input, dir.wt);
2414
+ // envScrub rides INTO the builder, which scrubs over a bounded
2415
+ // window BEFORE its caps — scrubbing after the cut both leaked a
2416
+ // boundary-straddling secret's prefix and grew a capped field
2417
+ // past the server's limits (review, 2026-09-01).
2418
+ const e = toolEventOf(name, input, dir.wt, envScrub);
2420
2419
  if (!e) return;
2421
- scrubEv(e);
2422
2420
  if (e.t === 'plan') {
2423
2421
  const i = toolLog.ev.findIndex((x) => x.t === 'plan');
2424
2422
  if (i >= 0) toolLog.ev.splice(i, 1);
@@ -2442,9 +2440,15 @@ export function createWorkManager({
2442
2440
  toolLog.ev.push(e);
2443
2441
  }
2444
2442
  }
2443
+ // The cap evicts the oldest NON-plan row: the plan is current
2444
+ // state, not scrollback — the one card the fold keeps out — and a
2445
+ // shed collapsed row counts its repeats, so "N steps" never
2446
+ // understates what the cut removed.
2445
2447
  while (toolLog.ev.length > 60) {
2446
- toolLog.ev.shift();
2447
- toolLog.dropped++;
2448
+ const i = toolLog.ev.findIndex((x) => x.t !== 'plan');
2449
+ if (i < 0) break; // only the plan left; it stays
2450
+ const [shed] = toolLog.ev.splice(i, 1);
2451
+ toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
2448
2452
  }
2449
2453
  };
2450
2454
  const narrator = makeNarrator(job.sessionId, job.id, () =>
@@ -2672,6 +2676,9 @@ export function createWorkManager({
2672
2676
  await settleWorkTurn(job.id, {
2673
2677
  ok: false,
2674
2678
  answer: "Couldn't resume the terminal session — it may have been removed.",
2679
+ // Whatever it DID before coming back empty is exactly the
2680
+ // question a failed turn's log answers.
2681
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
2675
2682
  });
2676
2683
  warn('adopt turn produced no output — settled as failed');
2677
2684
  return;
@@ -2719,6 +2726,9 @@ export function createWorkManager({
2719
2726
  // routinely quotes command output, and command output can quote a
2720
2727
  // synced secret.
2721
2728
  answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
2729
+ // "What did it do before it failed" is exactly the question a
2730
+ // crashed turn's log answers — same spread as the main settle.
2731
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
2722
2732
  });
2723
2733
  warn(`session turn failed: ${e?.message ?? e}`);
2724
2734
  } finally {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.74.0",
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.",
3
+ "version": "0.74.1",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
7
7
  "flowviant": "bin/cli.mjs"
@@ -40,4 +40,4 @@
40
40
  "bugs": {
41
41
  "url": "https://github.com/flowviant/cli/issues"
42
42
  }
43
- }
43
+ }