flowviant 0.74.0 → 0.74.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/authproxy.mjs +7 -0
- package/bin/lib/runtimes.mjs +45 -14
- package/bin/lib/work.mjs +23 -13
- package/package.json +3 -3
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -421,6 +421,13 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
421
421
|
// WS upgrade (HMR). The browser resends the Basic-auth header on same-origin
|
|
422
422
|
// upgrades, so we gate it too, then pipe the two sockets together.
|
|
423
423
|
server.on('upgrade', (req, socket, head) => {
|
|
424
|
+
// OWN THE SOCKET FROM THE FIRST LINE. Between this handler firing and the
|
|
425
|
+
// origin's 101, the client socket had no 'error' listener — a browser
|
|
426
|
+
// that RSTs mid-handshake (tab closed during HMR reconnect) emitted
|
|
427
|
+
// 'error' with nobody listening, and an uncaughtException took the WHOLE
|
|
428
|
+
// daemon down (2026-08 audit HIGH, reproduced on Node 24; closed
|
|
429
|
+
// 2026-09-02). The post-101 block still swaps in the cross-destroy pair.
|
|
430
|
+
socket.on('error', () => socket.destroy());
|
|
424
431
|
// Nothing under the reserved prefix is ever piped to the origin.
|
|
425
432
|
if (safePathname(req.url).startsWith('/__fv/')) {
|
|
426
433
|
socket.destroy();
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
-
*
|
|
125
|
-
*
|
|
143
|
+
* SCRUB BEFORE CAP — the 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
|
-
|
|
129
|
-
|
|
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:
|
|
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
|
|
142
|
-
...newS
|
|
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:
|
|
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:
|
|
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:
|
|
184
|
+
return { t: 'glob', q: clean(input.pattern, 200) };
|
|
154
185
|
case 'Bash':
|
|
155
|
-
return { t: 'bash', c:
|
|
186
|
+
return { t: 'bash', c: clean(input.command, 200) };
|
|
156
187
|
case 'Task':
|
|
157
|
-
return { t: 'task', q:
|
|
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:
|
|
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
|
|
2408
|
-
*
|
|
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
|
-
|
|
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.
|
|
2447
|
-
|
|
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.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.74.2",
|
|
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
|
+
}
|