flowviant 0.78.1 → 0.79.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 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 work except you opening a tab and typing in it.
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/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
- out = await runTurn({
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.78.1",
3
+ "version": "0.79.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": {