flowviant 0.78.1 → 0.80.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.
- package/README.md +5 -1
- package/bin/cli.mjs +2 -2
- package/bin/lib/config.mjs +22 -1
- package/bin/lib/fleet.mjs +1 -1
- package/bin/lib/vault.mjs +1 -1
- package/bin/lib/work.mjs +167 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,11 @@ Launch with `@latest` so each start pulls the newest published version — a bar
|
|
|
47
47
|
|
|
48
48
|
Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
|
|
49
49
|
|
|
50
|
-
Nothing starts
|
|
50
|
+
Nothing starts a session except you opening a tab and typing in it.
|
|
51
|
+
|
|
52
|
+
## Agents
|
|
53
|
+
|
|
54
|
+
Press **Deploy** on the board and this machine runs a read-only scratch turn that proposes how the selected cards should be split across agents; accepting the proposal is what cuts worktrees and starts work. From **0.79.0** that planning turn is relayed the same way a session turn is — the reads, greps and thoughts the daemon was already printing behind `[plan]` now reach the press itself, along with the two facts only this side can see: the CLI actually starting, and the press waiting for the checkout while a ship or another turn holds it. A planning CLI that wedges is stopped after fifteen minutes and the press is reported failed in the machine's own words, rather than sitting silent until the server expires it half an hour later.
|
|
51
55
|
|
|
52
56
|
## Sharing a preview
|
|
53
57
|
|
package/bin/cli.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* running?", which otherwise ends in a pid hunt through `ps`.
|
|
27
27
|
*
|
|
28
28
|
* The daemon: install ONCE with a machine credential, then work entirely from
|
|
29
|
-
* Flowviant. It polls GET /api/
|
|
29
|
+
* Flowviant. It polls GET /api/fleet/agents, and the roster hands it the
|
|
30
30
|
* project's SESSIONS — the Workbench's tabs. Each session gets one persistent
|
|
31
31
|
* git worktree on its own `session/<id>` branch, held across turns (never reset
|
|
32
32
|
* to base: the branch outlives the tab). A turn spawns the session's CLI with a
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
*
|
|
45
45
|
* Env:
|
|
46
46
|
* FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
|
|
47
|
-
* FLOWVIANT_API_URL default https://api.flowviant.com/api
|
|
47
|
+
* FLOWVIANT_API_URL default https://api.flowviant.com/api
|
|
48
48
|
* FLOWVIANT_MCP_URL default <API_URL>/mcp
|
|
49
49
|
* FLOWVIANT_FLEET_URL default <API_URL>/fleet/agents
|
|
50
50
|
* RECONCILE_SECONDS roster poll cadence (default 10)
|
package/bin/lib/config.mjs
CHANGED
|
@@ -41,7 +41,28 @@ function argFlag(name) {
|
|
|
41
41
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* THE SERVER'S API ROOT.
|
|
46
|
+
*
|
|
47
|
+
* `/api`, not `/api/v2`. The server collapsed its two prefixes into one
|
|
48
|
+
* namespace on 2026-09-08 — they had held DISJOINT resources and neither was
|
|
49
|
+
* ever a version of the other, so there was nothing to choose between.
|
|
50
|
+
*
|
|
51
|
+
* ── DEPLOY ORDER IS NOW LOAD-BEARING FOR THIS LINE ──
|
|
52
|
+
*
|
|
53
|
+
* A daemon on this version calls `/api/fleet/agents`. An older SERVER does not
|
|
54
|
+
* serve that path, so publishing this release before the server that answers it
|
|
55
|
+
* 404s the roster poll for anyone who updates. The order in the app repo's
|
|
56
|
+
* ARCHITECTURE.md already says it and now it matters: deploy `flowviant-api`
|
|
57
|
+
* FIRST, publish this SECOND. The same applies to a rollback — rolling the
|
|
58
|
+
* server back past that merge strands every daemon at this version or newer.
|
|
59
|
+
*
|
|
60
|
+
* The other direction is safe and needs nothing: the server keeps `/api/v2` as
|
|
61
|
+
* an alias onto the same router precisely because every daemon published before
|
|
62
|
+
* this one has that string baked in and cannot be upgraded by a deploy. That
|
|
63
|
+
* alias retires when the app's `DAEMON_MIN_VERSION` clears this release.
|
|
64
|
+
*/
|
|
65
|
+
const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api';
|
|
45
66
|
export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
|
|
46
67
|
export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
|
|
47
68
|
// Push channel: the daemon holds this WebSocket open and the server nudges it
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fleet daemon. Install ONCE with a fleet credential; manage everything from
|
|
3
|
-
* Flowviant. The daemon polls GET /api/
|
|
3
|
+
* Flowviant. The daemon polls GET /api/fleet/agents, reconciles one persistent
|
|
4
4
|
* git worktree + worker loop per roster agent, rotates each worker's short-lived
|
|
5
5
|
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
6
|
*/
|
package/bin/lib/vault.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* private `git init` so every pass is versioned locally for free — the user's
|
|
7
7
|
* repository is never touched.
|
|
8
8
|
*
|
|
9
|
-
* Sync protocol (POST /api/
|
|
9
|
+
* Sync protocol (POST /api/fleet/wiki-vault, fleet-token auth): only files
|
|
10
10
|
* whose sha256 changed since the last successful sync are uploaded, chunked;
|
|
11
11
|
* the LAST request carries the finalize.manifest of a completed full sweep so
|
|
12
12
|
* the server prunes pages the sweep no longer has. The last-synced hashes live
|
package/bin/lib/work.mjs
CHANGED
|
@@ -188,6 +188,7 @@ export function createWorkManager({
|
|
|
188
188
|
const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
|
|
189
189
|
const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
|
|
190
190
|
const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
|
|
191
|
+
const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
|
|
191
192
|
const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
|
|
192
193
|
const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
|
|
193
194
|
const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
|
|
@@ -3637,6 +3638,65 @@ export function createWorkManager({
|
|
|
3637
3638
|
}
|
|
3638
3639
|
};
|
|
3639
3640
|
|
|
3641
|
+
/**
|
|
3642
|
+
* WHAT THE PLANNER IS DOING, RELAYED — the agent turn's narration channel,
|
|
3643
|
+
* on a PRESS instead of an agent.
|
|
3644
|
+
*
|
|
3645
|
+
* This turn was already streaming and the daemon was already reading it:
|
|
3646
|
+
* `streamJson` humanizes every read, grep, command and thought and prints
|
|
3647
|
+
* it behind `[plan]` on the operator's terminal. Every one of those lines
|
|
3648
|
+
* was then thrown away, so a Deploy press said "planning" on the board and
|
|
3649
|
+
* nothing else for as long as it took — which on an overnight queue is the
|
|
3650
|
+
* whole night. Forwarding the CLI's own tail costs nothing that is not
|
|
3651
|
+
* already being computed.
|
|
3652
|
+
*
|
|
3653
|
+
* IT IS THE CLI'S WORDS, OR THE MACHINE'S — never a stage, a percentage or
|
|
3654
|
+
* an estimate of how far along a model is. Flowviant relays.
|
|
3655
|
+
*
|
|
3656
|
+
* SCRUBBED AND CAPPED HERE rather than at each call site, so a phase marker
|
|
3657
|
+
* added later cannot skip either. Fire-and-forget with every failure
|
|
3658
|
+
* swallowed: narration is a readout, and it must never fail or delay the
|
|
3659
|
+
* turn it describes. A daemon that never calls this is a machine that has
|
|
3660
|
+
* not narrated, which is the only thing an absent line can mean.
|
|
3661
|
+
*/
|
|
3662
|
+
const postAgentPlanActivity = async (planId, line) => {
|
|
3663
|
+
const text = envScrub(String(line ?? '')).slice(0, 400);
|
|
3664
|
+
if (!text) return;
|
|
3665
|
+
try {
|
|
3666
|
+
await fetch(AGENT_PLAN_ACTIVITY_URL, {
|
|
3667
|
+
method: 'POST',
|
|
3668
|
+
headers: {
|
|
3669
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3670
|
+
'User-Agent': USER_AGENT,
|
|
3671
|
+
'Content-Type': 'application/json',
|
|
3672
|
+
},
|
|
3673
|
+
signal: AbortSignal.timeout(15_000),
|
|
3674
|
+
body: JSON.stringify({ planId, line: text }),
|
|
3675
|
+
});
|
|
3676
|
+
} catch {
|
|
3677
|
+
/* a readout — losing a line costs nothing */
|
|
3678
|
+
}
|
|
3679
|
+
};
|
|
3680
|
+
|
|
3681
|
+
/**
|
|
3682
|
+
* A WEDGED PLANNING CLI IS STOPPED AT FIFTEEN MINUTES.
|
|
3683
|
+
*
|
|
3684
|
+
* `runTurn` has no timer of its own. A CLI that hangs — a login prompt
|
|
3685
|
+
* nobody is there to answer, a model client stalled on a socket — therefore
|
|
3686
|
+
* runs until something else kills it, holding `planning`, which is what
|
|
3687
|
+
* blocks every auto-update on this machine, and spending whatever the
|
|
3688
|
+
* operator's account is charged for a live session.
|
|
3689
|
+
*
|
|
3690
|
+
* FIFTEEN because the server fails a claimed press at THIRTY (its
|
|
3691
|
+
* `PLAN_JOB_TTL_MS`, measured from `claimedAt`): half of that leaves this
|
|
3692
|
+
* side — the only side that knows the CLI is still running — time to stop it
|
|
3693
|
+
* and have its settle land, instead of the press expiring into a sentence
|
|
3694
|
+
* that blames a machine which never spoke. Nothing legitimate is cut off
|
|
3695
|
+
* either way: this turn reads a card selection and writes nothing, and the
|
|
3696
|
+
* shape of it is minutes.
|
|
3697
|
+
*/
|
|
3698
|
+
const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
|
|
3699
|
+
|
|
3640
3700
|
const claimAgentPlan = async (id) => {
|
|
3641
3701
|
try {
|
|
3642
3702
|
const res = await fetch(AGENT_PLAN_CLAIM_URL, {
|
|
@@ -3743,6 +3803,36 @@ export function createWorkManager({
|
|
|
3743
3803
|
await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
|
|
3744
3804
|
return;
|
|
3745
3805
|
}
|
|
3806
|
+
|
|
3807
|
+
/**
|
|
3808
|
+
* ONE LINE AT A TIME, AT MOST ONE EVERY TWO SECONDS — the throttle the
|
|
3809
|
+
* agent turn's narration keeps, for the same reason: a turn emits hundreds
|
|
3810
|
+
* of lines and only the latest is ever rendered. Held per RUN rather than
|
|
3811
|
+
* in a map keyed by press, because one press is one turn and there is
|
|
3812
|
+
* nothing for the clock to outlive.
|
|
3813
|
+
*/
|
|
3814
|
+
let lastPlanBeat = 0;
|
|
3815
|
+
const narrate = (line) => {
|
|
3816
|
+
const now = Date.now();
|
|
3817
|
+
if (now - lastPlanBeat < 2_000) return;
|
|
3818
|
+
lastPlanBeat = now;
|
|
3819
|
+
void postAgentPlanActivity(id, line);
|
|
3820
|
+
};
|
|
3821
|
+
/**
|
|
3822
|
+
* THE MACHINE'S OWN VOICE, for the moments only this side can see — the
|
|
3823
|
+
* CLI process actually starting, and this press waiting its turn for the
|
|
3824
|
+
* checkout. Both are FACTS measured here, not a reading of the model's
|
|
3825
|
+
* progress, and both are invisible from a browser: a press held behind a
|
|
3826
|
+
* ship's writer lock looks exactly like a press whose CLI is thinking.
|
|
3827
|
+
*
|
|
3828
|
+
* Never dropped by the throttle above — these are moments, not a stream —
|
|
3829
|
+
* and they stamp its clock so the next model line does not overwrite one
|
|
3830
|
+
* the instant it lands.
|
|
3831
|
+
*/
|
|
3832
|
+
const say = (line) => {
|
|
3833
|
+
lastPlanBeat = Date.now();
|
|
3834
|
+
void postAgentPlanActivity(id, line);
|
|
3835
|
+
};
|
|
3746
3836
|
const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
|
|
3747
3837
|
id: String(a?.id ?? ''),
|
|
3748
3838
|
name: String(a?.name ?? ''),
|
|
@@ -3763,9 +3853,29 @@ export function createWorkManager({
|
|
|
3763
3853
|
* a leaked entry keeps the daemon permanently "busy" — which blocks every
|
|
3764
3854
|
* auto-update from that moment on, silently, until a restart. */
|
|
3765
3855
|
let planChild = null;
|
|
3856
|
+
let planTimer = null;
|
|
3857
|
+
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
3858
|
+
let wedged = false;
|
|
3859
|
+
// Said BEFORE the lock is asked for, because a reader only waits when a
|
|
3860
|
+
// writer holds the checkout or is queued ahead of it — which is exactly
|
|
3861
|
+
// this condition, read one line before we join the queue.
|
|
3862
|
+
const busy = placeLocks.get(REPO_PLACE);
|
|
3863
|
+
if (busy && (busy.writing || busy.waiters.some((w) => w.write)))
|
|
3864
|
+
say('waiting for the checkout — a ship or another turn on this machine is holding it');
|
|
3766
3865
|
try {
|
|
3767
3866
|
await inPlace(REPO_PLACE, false, async () => {
|
|
3768
|
-
|
|
3867
|
+
/**
|
|
3868
|
+
* THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close`
|
|
3869
|
+
* after the kill — the shape the project check's timeout already
|
|
3870
|
+
* keeps. A SIGKILLed process whose stdio a grandchild still holds can
|
|
3871
|
+
* be slow to emit `close`, or never emit it, and this promise is what
|
|
3872
|
+
* holds the claimed press open.
|
|
3873
|
+
*/
|
|
3874
|
+
let stopWaiting = () => {};
|
|
3875
|
+
const capped = new Promise((r) => {
|
|
3876
|
+
stopWaiting = r;
|
|
3877
|
+
});
|
|
3878
|
+
const turn = runTurn({
|
|
3769
3879
|
prompt: AGENT_PLAN_KICKOFF({
|
|
3770
3880
|
tasks,
|
|
3771
3881
|
liveAgents,
|
|
@@ -3780,19 +3890,61 @@ export function createWorkManager({
|
|
|
3780
3890
|
streamJson: true,
|
|
3781
3891
|
answerFromResult: true,
|
|
3782
3892
|
label: c.cyan('[plan]'),
|
|
3893
|
+
// The CLI's own tail, forwarded to the press. Same rule as an agent
|
|
3894
|
+
// turn's: throttled, overwritten rather than appended, and never
|
|
3895
|
+
// awaited by the turn.
|
|
3896
|
+
onActivity: (a) => {
|
|
3897
|
+
if (a?.label) narrate(String(a.label));
|
|
3898
|
+
},
|
|
3783
3899
|
onSpawn: (ch) => {
|
|
3784
3900
|
planChild = ch;
|
|
3785
3901
|
workChildren.set(ch, null);
|
|
3902
|
+
say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
|
|
3903
|
+
/**
|
|
3904
|
+
* ARMED AT THE SPAWN, not at the claim: time spent waiting for the
|
|
3905
|
+
* checkout is not a wedged CLI, and a press that never got to run
|
|
3906
|
+
* is what the server's own clock is for.
|
|
3907
|
+
*
|
|
3908
|
+
* The CHILD, never its group — the rule teardown keeps. A
|
|
3909
|
+
* read-only planning turn starts no dev server, so there is
|
|
3910
|
+
* nothing behind it worth signalling and everything to lose by
|
|
3911
|
+
* signalling somebody else's.
|
|
3912
|
+
*/
|
|
3913
|
+
planTimer = setTimeout(() => {
|
|
3914
|
+
wedged = true;
|
|
3915
|
+
try {
|
|
3916
|
+
ch.kill('SIGKILL');
|
|
3917
|
+
} catch {
|
|
3918
|
+
/* already gone */
|
|
3919
|
+
}
|
|
3920
|
+
stopWaiting('');
|
|
3921
|
+
}, PLAN_TURN_TIMEOUT_MS);
|
|
3922
|
+
planTimer.unref?.();
|
|
3786
3923
|
},
|
|
3787
3924
|
});
|
|
3925
|
+
out = await Promise.race([turn, capped]);
|
|
3788
3926
|
});
|
|
3789
3927
|
} catch (e) {
|
|
3790
3928
|
await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
|
|
3791
3929
|
return;
|
|
3792
3930
|
} finally {
|
|
3931
|
+
if (planTimer) clearTimeout(planTimer);
|
|
3793
3932
|
if (planChild) workChildren.delete(planChild);
|
|
3794
3933
|
}
|
|
3795
3934
|
|
|
3935
|
+
if (wedged) {
|
|
3936
|
+
// NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
|
|
3937
|
+
// beneath this one. The machine's own words, because the machine is the
|
|
3938
|
+
// only side that knows: the server would have expired this press in
|
|
3939
|
+
// another fifteen minutes with a sentence blaming a daemon that had in
|
|
3940
|
+
// fact answered.
|
|
3941
|
+
await postAgentPlan({
|
|
3942
|
+
id,
|
|
3943
|
+
error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
|
|
3944
|
+
});
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3796
3948
|
const proposal = parseProposal(out);
|
|
3797
3949
|
if (!proposal) {
|
|
3798
3950
|
await postAgentPlan({
|
|
@@ -4151,6 +4303,20 @@ export function createWorkManager({
|
|
|
4151
4303
|
/* a missing marker only costs one un-resumed turn */
|
|
4152
4304
|
}
|
|
4153
4305
|
}
|
|
4306
|
+
/**
|
|
4307
|
+
* AN ACTION THAT CHANGES WHAT THE MACHINE WOULD MEASURE MUST CAUSE A
|
|
4308
|
+
* NEW MEASUREMENT — the rule every session settle keeps, and the one
|
|
4309
|
+
* lane that did not. An agent's branch diff, head sha and trailered
|
|
4310
|
+
* commits only refreshed on the ≤60s sweep, so review opened right
|
|
4311
|
+
* after a turn described the branch as it was BEFORE the work.
|
|
4312
|
+
*
|
|
4313
|
+
* Here rather than after the settle POST because the CLI has exited,
|
|
4314
|
+
* so the tree is final — and because a queue that just emptied runs
|
|
4315
|
+
* the project's check next, which may hold this function for ten
|
|
4316
|
+
* minutes. Fire-and-forget on an endpoint that already exists, so no
|
|
4317
|
+
* floor: it must never delay the settle behind it.
|
|
4318
|
+
*/
|
|
4319
|
+
void reportSessionWorktree(place).catch(() => {});
|
|
4154
4320
|
}
|
|
4155
4321
|
|
|
4156
4322
|
const commits = commitsBetween(wt, before);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.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": {
|