flowviant 0.55.0 → 0.55.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.
- package/bin/cli.mjs +6 -1
- package/bin/lib/env.mjs +133 -1
- package/bin/lib/fleet.mjs +43 -1
- package/bin/lib/runtimes.mjs +20 -3
- package/bin/lib/update.mjs +11 -1
- package/bin/lib/work.mjs +65 -2
- package/package.json +2 -2
package/bin/cli.mjs
CHANGED
|
@@ -233,7 +233,12 @@ if (process.argv[2] === 'env') {
|
|
|
233
233
|
// that name every stored project and every way out. The one thing this block
|
|
234
234
|
// must never do is serve a project the resolution did not name — "it said
|
|
235
235
|
// skadooble in my calendar repo" is the confusion this exists to end.
|
|
236
|
-
|
|
236
|
+
// A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
|
|
237
|
+
// auto-updated daemon's child sees two TTYs; without this it would stop on the
|
|
238
|
+
// binding confirm below and the machine would stay dark until somebody typed a
|
|
239
|
+
// key. Same reasoning as the headless case, and the same answer.
|
|
240
|
+
const interactive =
|
|
241
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.FLOWVIANT_REEXEC !== '1';
|
|
237
242
|
const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
238
243
|
|
|
239
244
|
/** Re-exec a plain `flowviant` after an inline login — the login command's own
|
package/bin/lib/env.mjs
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
mkdirSync,
|
|
35
35
|
existsSync,
|
|
36
36
|
appendFileSync,
|
|
37
|
+
chmodSync,
|
|
38
|
+
lstatSync,
|
|
37
39
|
rmSync,
|
|
38
40
|
} from 'node:fs';
|
|
39
41
|
import { execFileSync } from 'node:child_process';
|
|
@@ -123,11 +125,52 @@ export function myPubB64() {
|
|
|
123
125
|
return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
/** Query params the roster poll carries: identity
|
|
128
|
+
/** Query params the roster poll carries: identity, materialized version, and
|
|
129
|
+
* the target files we REFUSED to write.
|
|
130
|
+
*
|
|
131
|
+
* `envv` alone was a half-truth and the surface built on it said the wrong
|
|
132
|
+
* thing out loud: it is set the moment the bundle DECRYPTS, independent of
|
|
133
|
+
* whether a single byte reached a worktree, so a project whose `.env` is
|
|
134
|
+
* tracked in git got the green "on the current env" chip while every session
|
|
135
|
+
* ran on whatever stale placeholder git had checked out. The daemon knew —
|
|
136
|
+
* it warned, to a console nobody reads. `envskip` is that warning routed
|
|
137
|
+
* somewhere a human is actually looking.
|
|
138
|
+
*
|
|
139
|
+
* A daemon→server REPORT, so it needs no version floor: an older daemon
|
|
140
|
+
* simply sends no `envskip` key, which reads as "nothing to report" — and
|
|
141
|
+
* that is honest, because an older daemon genuinely is not measuring it.
|
|
142
|
+
* Bounded hard: a query string is not a log. */
|
|
127
143
|
export async function envQueryParams() {
|
|
128
144
|
await ensureKeypair();
|
|
129
145
|
const params = { envpub: myPubB64() };
|
|
130
146
|
if (bundleVersion >= 0) params.envv = String(bundleVersion);
|
|
147
|
+
// THE EMPTY STRING IS A REPORT, and it is the only thing that can ever CLEAR
|
|
148
|
+
// the surface's warning. Gating this on truthiness (which is what it did
|
|
149
|
+
// first) meant a person who followed the on-screen remedy exactly — gitignore
|
|
150
|
+
// the file, restart — sent no `envskip` at all, the server left the column
|
|
151
|
+
// alone by design, and the amber line stayed up forever telling them to fix
|
|
152
|
+
// something already fixed. Absence must keep meaning IGNORANCE, so the gate
|
|
153
|
+
// is "has a pass actually run", never "is there something to say".
|
|
154
|
+
// (fleet.mjs's query loop had to stop filtering on truthiness too — one
|
|
155
|
+
// check here is useless while a second one downstream drops the same value.)
|
|
156
|
+
if (everMaterialized) {
|
|
157
|
+
const files = [...new Set([...skippedByWorktree.values()].flat())].sort();
|
|
158
|
+
// Each path is percent-encoded BEFORE the join, because `isSafeEnvTargetFile`
|
|
159
|
+
// permits a comma in a filename and the server splits on one — unencoded,
|
|
160
|
+
// `a,b.env` would arrive as two files that do not exist. Truncation is by
|
|
161
|
+
// WHOLE ELEMENTS against a byte budget; a mid-path cut names a file nobody
|
|
162
|
+
// has, which is worse than naming fewer.
|
|
163
|
+
const parts = [];
|
|
164
|
+
let budget = 400;
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
if (parts.length >= 10) break;
|
|
167
|
+
const enc = encodeURIComponent(f);
|
|
168
|
+
if (enc.length + 1 > budget) break;
|
|
169
|
+
parts.push(enc);
|
|
170
|
+
budget -= enc.length + 1;
|
|
171
|
+
}
|
|
172
|
+
params.envskip = parts.join(',');
|
|
173
|
+
}
|
|
131
174
|
return params;
|
|
132
175
|
}
|
|
133
176
|
|
|
@@ -310,6 +353,37 @@ function isIgnoredInGit(wt, relPath) {
|
|
|
310
353
|
|
|
311
354
|
// Per-worktree: the target files we last materialized THIS SESSION.
|
|
312
355
|
const lastFilesByWorktree = new Map();
|
|
356
|
+
|
|
357
|
+
/** Target files refused for a GIT reason, PER WORKTREE — reported to the
|
|
358
|
+
* server on the next poll. Per-worktree because the refusal is: both
|
|
359
|
+
* predicates (`isTrackedInGit`, `isIgnoredInGit`) run with `cwd: wt`, so
|
|
360
|
+
* ".env is refused" is a fact about ONE tree. A process-global set mixed two
|
|
361
|
+
* trees' answers together and, worse, could only ever grow.
|
|
362
|
+
*
|
|
363
|
+
* Names only — a path is not a secret, and the whole point is that a human
|
|
364
|
+
* can act on it ("gitignore apps/api/.dev.vars"). Only the two GIT causes go
|
|
365
|
+
* in here: they have a remedy the reader can carry out, and the surface names
|
|
366
|
+
* that remedy. A transient write failure is a warn, not a standing claim. */
|
|
367
|
+
const skippedByWorktree = new Map();
|
|
368
|
+
|
|
369
|
+
/** Worktrees whose most recent pass wrote everything it was asked to.
|
|
370
|
+
* `hasMaterialized` is built on THIS rather than on "a pass ran", so a pass
|
|
371
|
+
* that refused something RETRIES on the next turn — which is what lets
|
|
372
|
+
* `echo .env >> .gitignore` actually take effect without waiting for an
|
|
373
|
+
* unrelated bundle change. A pass with nothing to write counts as clean. */
|
|
374
|
+
const cleanWorktrees = new Set();
|
|
375
|
+
|
|
376
|
+
/** True once any materialization pass has completed. Distinguishes "we refused
|
|
377
|
+
* nothing" from "we have not looked", which is the whole contract of the
|
|
378
|
+
* `envskip` report — see envQueryParams. */
|
|
379
|
+
let everMaterialized = false;
|
|
380
|
+
|
|
381
|
+
/** Has this process completed a CLEAN materialization pass for this worktree?
|
|
382
|
+
* The creation-only rule (work.mjs) needs a second condition or a directory
|
|
383
|
+
* that existed before the bundle did is never revisited. */
|
|
384
|
+
export function hasMaterialized(wt) {
|
|
385
|
+
return cleanWorktrees.has(wt);
|
|
386
|
+
}
|
|
313
387
|
// Project-global union of every target file we've ever materialized — PERSISTED
|
|
314
388
|
// in the cache and seeded on load, so a file whose key was deleted while the
|
|
315
389
|
// daemon was down still gets its stale plaintext copy cleaned on the next
|
|
@@ -366,6 +440,15 @@ export function appSecretsFor(env) {
|
|
|
366
440
|
* app secrets go to the provider at deploy, deploy creds are injected only. */
|
|
367
441
|
export function materializeInto(wt) {
|
|
368
442
|
if (!wt || !existsSync(wt)) return;
|
|
443
|
+
// NEVER SYNCED IS NOT "NO SECRETS", and conflating them cost a whole session.
|
|
444
|
+
// `values` is empty both before the first bundle lands and for a project that
|
|
445
|
+
// genuinely has none; `bundleVersion < 0` is the one that means IGNORANCE.
|
|
446
|
+
// Writing nothing here and RECORDING it as materialized let a worktree
|
|
447
|
+
// created on the first poll after a restart — before handleRosterEnv had
|
|
448
|
+
// warmed the cache — sit secret-less for its entire life, because nothing
|
|
449
|
+
// re-materializes a directory that is neither fresh nor covered by a bundle
|
|
450
|
+
// CHANGE. Returning without recording is what makes the next turn retry.
|
|
451
|
+
if (bundleVersion < 0) return;
|
|
369
452
|
const byFile = new Map();
|
|
370
453
|
for (const v of values) {
|
|
371
454
|
if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
|
|
@@ -382,9 +465,16 @@ export function materializeInto(wt) {
|
|
|
382
465
|
excludeInWorktree(wt, [...byFile.keys()]);
|
|
383
466
|
|
|
384
467
|
const written = [];
|
|
468
|
+
/** Refused for a GIT reason this pass — reported, and remediable. */
|
|
469
|
+
const refusedForGit = [];
|
|
470
|
+
/** Anything that did not get written, git reasons and write failures alike.
|
|
471
|
+
* Blocks the clean mark so the next turn tries again. */
|
|
472
|
+
let anyProblem = false;
|
|
385
473
|
for (const [file, list] of byFile) {
|
|
386
474
|
if (isTrackedInGit(wt, file)) {
|
|
387
475
|
warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
|
|
476
|
+
refusedForGit.push(file);
|
|
477
|
+
anyProblem = true;
|
|
388
478
|
continue;
|
|
389
479
|
}
|
|
390
480
|
// The load-bearing check. A materialized secret sits in a worktree whose
|
|
@@ -392,10 +482,27 @@ export function materializeInto(wt) {
|
|
|
392
482
|
// "git cannot see this file" is a precondition for writing it, not a nicety.
|
|
393
483
|
if (!isIgnoredInGit(wt, file)) {
|
|
394
484
|
warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
|
|
485
|
+
refusedForGit.push(file);
|
|
486
|
+
anyProblem = true;
|
|
395
487
|
continue;
|
|
396
488
|
}
|
|
397
489
|
try {
|
|
398
490
|
const abs = join(wt, file);
|
|
491
|
+
// A SYMLINK AT THE TARGET IS NOT A TARGET. `writeFileSync` follows one,
|
|
492
|
+
// so a link committed into the repo (or dropped by an agent) at the
|
|
493
|
+
// materialization path would write the project's decrypted secrets
|
|
494
|
+
// wherever it points — outside the worktree, and outside everything the
|
|
495
|
+
// check-ignore gate can reason about. `lstat`, not `stat`, and refuse.
|
|
496
|
+
// Cheap, and the whole exposure is one call away otherwise.
|
|
497
|
+
try {
|
|
498
|
+
if (lstatSync(abs).isSymbolicLink()) {
|
|
499
|
+
warn(`env: "${file}" is a symlink — refusing to write secrets through it.`);
|
|
500
|
+
anyProblem = true;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
/* does not exist yet — the ordinary case */
|
|
505
|
+
}
|
|
399
506
|
mkdirSync(dirname(abs), { recursive: true });
|
|
400
507
|
const body = renderEnvFile(list);
|
|
401
508
|
// Skip an identical rewrite — otherwise every bundle bump touches the
|
|
@@ -407,9 +514,22 @@ export function materializeInto(wt) {
|
|
|
407
514
|
/* new file */
|
|
408
515
|
}
|
|
409
516
|
if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
|
|
517
|
+
// `mode` on writeFileSync applies at CREATION only — an overwrite of a
|
|
518
|
+
// file that already existed keeps whatever mode it had, so a 0644 stub
|
|
519
|
+
// committed by a teammate (or left by an older daemon) would hold
|
|
520
|
+
// plaintext secrets world-readable on a shared box. chmod every time.
|
|
521
|
+
try {
|
|
522
|
+
chmodSync(abs, 0o600);
|
|
523
|
+
} catch {
|
|
524
|
+
/* best-effort: a filesystem without modes is not a reason to refuse */
|
|
525
|
+
}
|
|
410
526
|
written.push(file);
|
|
411
527
|
} catch (e) {
|
|
528
|
+
// NOT reported as a refusal: the surface's line names a git cause and a
|
|
529
|
+
// git remedy, and a full disk is neither. It still blocks the clean mark,
|
|
530
|
+
// so the next turn retries.
|
|
412
531
|
warn(`env: could not write ${file} into worktree: ${e.message}`);
|
|
532
|
+
anyProblem = true;
|
|
413
533
|
}
|
|
414
534
|
}
|
|
415
535
|
|
|
@@ -423,6 +543,18 @@ export function materializeInto(wt) {
|
|
|
423
543
|
}
|
|
424
544
|
for (const f of written) knownTargetFiles.add(f);
|
|
425
545
|
lastFilesByWorktree.set(wt, written);
|
|
546
|
+
|
|
547
|
+
// THE PASS'S VERDICT, recorded whole and REPLACING the previous one — this is
|
|
548
|
+
// what lets a refusal clear. `refusedForGit` is recomputed from scratch every
|
|
549
|
+
// pass, so a file that gets gitignored simply is not in the next one, and the
|
|
550
|
+
// union reported on the poll shrinks. `anyProblem` (which also covers a write
|
|
551
|
+
// failure) is what decides whether this worktree gets retried on the next
|
|
552
|
+
// turn; a clean pass is remembered so we stop touching a live directory.
|
|
553
|
+
if (refusedForGit.length > 0) skippedByWorktree.set(wt, refusedForGit);
|
|
554
|
+
else skippedByWorktree.delete(wt);
|
|
555
|
+
if (anyProblem) cleanWorktrees.delete(wt);
|
|
556
|
+
else cleanWorktrees.add(wt);
|
|
557
|
+
everMaterialized = true;
|
|
426
558
|
}
|
|
427
559
|
|
|
428
560
|
/**
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -69,6 +69,7 @@ import { ensureVault, syncVault } from './vault.mjs';
|
|
|
69
69
|
import {
|
|
70
70
|
envQueryParams,
|
|
71
71
|
handleRosterEnv,
|
|
72
|
+
loadCachedEnv,
|
|
72
73
|
materializeInto,
|
|
73
74
|
myPubB64,
|
|
74
75
|
scrub as envScrub,
|
|
@@ -150,9 +151,17 @@ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds =
|
|
|
150
151
|
/* best-effort — the poll must never fail on a readout */
|
|
151
152
|
}
|
|
152
153
|
// Env-sync identity + materialized version (the Settings "env vN" chip).
|
|
154
|
+
//
|
|
155
|
+
// `!= null`, NOT truthiness. `envskip` uses the EMPTY STRING as a real
|
|
156
|
+
// report — "measured, refused nothing" — and it is the only value that can
|
|
157
|
+
// clear the surface's warning. A `if (v)` here silently dropped it, so
|
|
158
|
+
// fixing the filter in envQueryParams alone would have changed nothing.
|
|
159
|
+
// This is the same trap the skills relay eight lines up documents and
|
|
160
|
+
// sidesteps by calling `url.searchParams.set` directly; the general fix is
|
|
161
|
+
// better than a second special case.
|
|
153
162
|
try {
|
|
154
163
|
for (const [k, v] of Object.entries(await envQueryParams())) {
|
|
155
|
-
if (v) url.searchParams.set(k, v);
|
|
164
|
+
if (v != null) url.searchParams.set(k, v);
|
|
156
165
|
}
|
|
157
166
|
} catch {
|
|
158
167
|
/* env identity is best-effort — the poll must never fail on it */
|
|
@@ -395,6 +404,39 @@ export async function runFleetDaemon() {
|
|
|
395
404
|
warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
|
|
396
405
|
|
|
397
406
|
await preflight({ needGit: true });
|
|
407
|
+
|
|
408
|
+
// WARM THE ENV CACHE BEFORE THE FIRST POLL, not on the first roster tick.
|
|
409
|
+
// `handleRosterEnv` loads it, and `handleRosterEnv` runs AFTER
|
|
410
|
+
// `processWorkTurns` in the reconcile below — so on the first poll after a
|
|
411
|
+
// restart a brand-new session worktree was materialized against an EMPTY
|
|
412
|
+
// bundle, and then never revisited (creation-only, and `needSync` is false
|
|
413
|
+
// when the cache holds the version the server is already on). The turn ran
|
|
414
|
+
// with no secrets and nothing said so.
|
|
415
|
+
//
|
|
416
|
+
// This is only possible since 0.55.0: the credential store knows which
|
|
417
|
+
// project this checkout is, so the cache — which is keyed by projectId — can
|
|
418
|
+
// be found before the server has named anything. A `--fleet`/env token names
|
|
419
|
+
// no project until the roster does, so it keeps the old lazy path.
|
|
420
|
+
// Best-effort throughout: a cache miss is the ordinary first-run state.
|
|
421
|
+
//
|
|
422
|
+
// GATED ON THE STORE ACTUALLY BEING THE SOURCE. `--fleet` / `FLOWVIANT_FLEET`
|
|
423
|
+
// OVERRIDE the stored credential (config.mjs), but `CREDENTIAL` is resolved
|
|
424
|
+
// from the store regardless — so reading its projectId here would decrypt and
|
|
425
|
+
// materialize project A's cached secrets while this daemon is serving project
|
|
426
|
+
// B's token. That is the wrong project's plaintext in a worktree, which is
|
|
427
|
+
// the exact failure the repo binding exists to prevent, arriving by a
|
|
428
|
+
// different door. An external token names no project until the roster does,
|
|
429
|
+
// so it keeps the lazy path and loses nothing but one poll.
|
|
430
|
+
const externalToken =
|
|
431
|
+
process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
|
|
432
|
+
if (!externalToken && CREDENTIAL?.entry?.projectId) {
|
|
433
|
+
try {
|
|
434
|
+
await loadCachedEnv(CREDENTIAL.entry.projectId);
|
|
435
|
+
} catch {
|
|
436
|
+
/* no cache, no keypair yet, unreadable home — the roster tick retries */
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
398
440
|
// Kill any preview dev-server/tunnel groups a previously-crashed daemon left
|
|
399
441
|
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
400
442
|
reapOrphanPreviews((m) => info(m));
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -79,7 +79,16 @@ export function humanizeClaudeTool(name, input = {}, cwd = '') {
|
|
|
79
79
|
case 'LS':
|
|
80
80
|
return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
|
|
81
81
|
case 'Bash':
|
|
82
|
-
|
|
82
|
+
// `command` rides beside the display label, VERBATIM (capped): the label
|
|
83
|
+
// is a 60-char readout for a console and the rail, and truncation is
|
|
84
|
+
// fine there — but the admin's command audit exists to answer "what
|
|
85
|
+
// actually ran on our box", and an ellipsis is exactly where the part
|
|
86
|
+
// that matters would hide.
|
|
87
|
+
return {
|
|
88
|
+
kind: 'bash',
|
|
89
|
+
command: String(input.command ?? '').slice(0, 2000),
|
|
90
|
+
label: `$ ${oneLine(input.command, 60)}`,
|
|
91
|
+
};
|
|
83
92
|
default:
|
|
84
93
|
return null; // other tools: silent
|
|
85
94
|
}
|
|
@@ -103,7 +112,11 @@ function humanizeCodexItem(item = {}, cwd = '') {
|
|
|
103
112
|
case 'reasoning':
|
|
104
113
|
return { kind: 'think', label: oneLine(item.text) || 'thinking…' };
|
|
105
114
|
case 'command_execution':
|
|
106
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
kind: 'bash',
|
|
117
|
+
command: String(item.command ?? '').slice(0, 2000),
|
|
118
|
+
label: `$ ${oneLine(item.command, 60)}`,
|
|
119
|
+
};
|
|
107
120
|
case 'file_change': {
|
|
108
121
|
// `changes` is a list of touched paths; the daemon counts distinct files,
|
|
109
122
|
// so emit one activity per path rather than one for the batch.
|
|
@@ -207,7 +220,11 @@ function humanizeAgyTool(name, p = {}, cwd = '') {
|
|
|
207
220
|
case 'list_dir':
|
|
208
221
|
return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
|
|
209
222
|
case 'run_command':
|
|
210
|
-
return {
|
|
223
|
+
return {
|
|
224
|
+
kind: 'bash',
|
|
225
|
+
command: String(p.CommandLine ?? '').slice(0, 2000),
|
|
226
|
+
label: `$ ${oneLine(p.CommandLine, 60)}`,
|
|
227
|
+
};
|
|
211
228
|
case 'call_mcp_tool':
|
|
212
229
|
return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
|
|
213
230
|
default:
|
package/bin/lib/update.mjs
CHANGED
|
@@ -59,7 +59,17 @@ function reexec(teardown) {
|
|
|
59
59
|
}
|
|
60
60
|
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
61
61
|
stdio: 'inherit',
|
|
62
|
-
|
|
62
|
+
// MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
|
|
63
|
+
// stdio is inherited, so the child sees two TTYs and believes a human is
|
|
64
|
+
// watching — and 0.55.0 asks a one-time binding question on exactly that
|
|
65
|
+
// signal. An auto-update that lands while nobody is looking would then sit
|
|
66
|
+
// on `Serve this repo as X? [Y/n]` with the machine dark until someone
|
|
67
|
+
// walks past. The rule credentials.mjs already states for systemd applies
|
|
68
|
+
// verbatim here: a RESTART must not hang on a prompt. Skipping the confirm
|
|
69
|
+
// is not a widening — the daemon serves exactly the credential it was
|
|
70
|
+
// already serving one second ago, and the question gets asked the next
|
|
71
|
+
// time a human starts it by hand.
|
|
72
|
+
env: { ...process.env, FLOWVIANT_REEXEC: '1' },
|
|
63
73
|
});
|
|
64
74
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
65
75
|
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
SYSTEM_WORK_PLAIN,
|
|
49
49
|
WORK_TURN_KICKOFF_PLAIN,
|
|
50
50
|
} from './prompts.mjs';
|
|
51
|
-
import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
51
|
+
import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
52
52
|
import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
|
|
53
53
|
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
54
54
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
@@ -102,6 +102,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
102
102
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
103
103
|
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
104
104
|
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
105
|
+
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
105
106
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
106
107
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
107
108
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
@@ -899,6 +900,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
899
900
|
} catch {
|
|
900
901
|
/* best-effort */
|
|
901
902
|
}
|
|
903
|
+
} else if (!hasMaterialized(wt)) {
|
|
904
|
+
// CREATION-ONLY NEEDED A SECOND CONDITION. The rule above is right about
|
|
905
|
+
// a LIVE directory — its env belongs to the session and re-writing it
|
|
906
|
+
// mid-flight is not ours to do — but "created" and "ever given a bundle"
|
|
907
|
+
// are different events, and the gap between them is a whole daemon
|
|
908
|
+
// restart: `handleRosterEnv` (which warms the encrypted cache) runs
|
|
909
|
+
// AFTER `processWorkTurns` on the same poll, so a worktree made on the
|
|
910
|
+
// first turn after a restart was materialized against an EMPTY bundle
|
|
911
|
+
// and, being neither fresh nor covered by a bundle CHANGE, never
|
|
912
|
+
// revisited. `materializeInto` now declines to record a pass it made in
|
|
913
|
+
// ignorance (bundleVersion < 0), so this branch is what retries it —
|
|
914
|
+
// once, on the next turn, and never again after it succeeds. Idempotent
|
|
915
|
+
// by construction: identical bodies are not rewritten, so nothing
|
|
916
|
+
// hot-restarts a dev server the driver is watching.
|
|
917
|
+
try {
|
|
918
|
+
materializeInto(wt);
|
|
919
|
+
} catch {
|
|
920
|
+
/* best-effort */
|
|
921
|
+
}
|
|
902
922
|
}
|
|
903
923
|
return { wt, fresh };
|
|
904
924
|
};
|
|
@@ -1584,6 +1604,45 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1584
1604
|
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
1585
1605
|
const spawned = []; // this turn's children, for the teardown registry
|
|
1586
1606
|
const narrator = makeNarrator(job.sessionId, job.id);
|
|
1607
|
+
|
|
1608
|
+
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
1609
|
+
// to the server verbatim so an admin can read what actually ran on
|
|
1610
|
+
// this box. Same events the narrator renders and forgets; this is
|
|
1611
|
+
// the durable copy, and it carries ONLY commands — no prose, no
|
|
1612
|
+
// thinking, no file reads (the session stays private; what executed
|
|
1613
|
+
// on the shared machine is the machine's own fact to relay).
|
|
1614
|
+
// Flushed mid-turn every 25 so a long turn is not one giant loss on
|
|
1615
|
+
// a kill, and again at settle. Best-effort: a failed post drops the
|
|
1616
|
+
// batch rather than blocking the turn — the surface says it is the
|
|
1617
|
+
// machine's report, not a syscall trace.
|
|
1618
|
+
const auditBatch = [];
|
|
1619
|
+
const flushAudit = () => {
|
|
1620
|
+
if (auditBatch.length === 0) return;
|
|
1621
|
+
const commands = auditBatch.splice(0, auditBatch.length);
|
|
1622
|
+
void fetch(SESSION_COMMANDS_URL, {
|
|
1623
|
+
method: 'POST',
|
|
1624
|
+
headers: {
|
|
1625
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1626
|
+
'User-Agent': USER_AGENT,
|
|
1627
|
+
'Content-Type': 'application/json',
|
|
1628
|
+
},
|
|
1629
|
+
signal: AbortSignal.timeout(30_000),
|
|
1630
|
+
body: JSON.stringify({
|
|
1631
|
+
sessionId: job.sessionId,
|
|
1632
|
+
turnId: job.id,
|
|
1633
|
+
runtime: rt.id,
|
|
1634
|
+
cwd: dir.wt,
|
|
1635
|
+
commands,
|
|
1636
|
+
}),
|
|
1637
|
+
}).catch(() => {
|
|
1638
|
+
/* best-effort — the audit records what reached it */
|
|
1639
|
+
});
|
|
1640
|
+
};
|
|
1641
|
+
const auditCommand = (a) => {
|
|
1642
|
+
if (a?.kind !== 'bash' || !a.command) return;
|
|
1643
|
+
auditBatch.push({ command: a.command, at: new Date().toISOString() });
|
|
1644
|
+
if (auditBatch.length >= 25) flushAudit();
|
|
1645
|
+
};
|
|
1587
1646
|
try {
|
|
1588
1647
|
// Files first, then the message that references them: the agent
|
|
1589
1648
|
// must be able to open what it is being told about. Only the ones
|
|
@@ -1627,7 +1686,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1627
1686
|
// posted once. Every line goes to the narrator above, throttled.
|
|
1628
1687
|
streamJson: true,
|
|
1629
1688
|
answerFromResult: true,
|
|
1630
|
-
onActivity: (a) =>
|
|
1689
|
+
onActivity: (a) => {
|
|
1690
|
+
narrator.line(a?.label);
|
|
1691
|
+
auditCommand(a);
|
|
1692
|
+
},
|
|
1631
1693
|
// What this CLI says it can be asked for by name. Harvested off
|
|
1632
1694
|
// the init event the stream already carries — no probe, no scan,
|
|
1633
1695
|
// no extra spawn — and reported on the next roster poll so the
|
|
@@ -1683,6 +1745,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1683
1745
|
// is cleared server-side at settle — clearing it here would race
|
|
1684
1746
|
// the settle and blank the tab a beat before the reply lands.
|
|
1685
1747
|
narrator.stop();
|
|
1748
|
+
flushAudit();
|
|
1686
1749
|
for (const ch of spawned) workChildren.delete(ch);
|
|
1687
1750
|
if (lockPath) {
|
|
1688
1751
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.55.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.55.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"
|