flowviant 0.23.0 → 0.25.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.
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { mkdtempSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
- import { SAFE } from './config.mjs';
11
+ import { SAFE, MODEL } from './config.mjs';
12
12
 
13
13
  // Multi-task loop (TOKEN / TOKENS modes): drain the whole queue in one session.
14
14
  export const SYSTEM_MULTI = `You are a Flowviant build agent running FULLY AUTONOMOUSLY via the "flowviant" MCP
@@ -291,6 +291,9 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
291
291
  const args = [];
292
292
  if (resume) args.push('--continue');
293
293
  args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system);
294
+ // Pin the model — never inherit the user's global default (which may be a
295
+ // 1M/long-context tier their subscription can't bill autonomous work on).
296
+ args.push('--model', MODEL);
294
297
  if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
295
298
  args.push(...PERM);
296
299
  // Force the user's Claude Code subscription — never the API. A key exported in
@@ -4,7 +4,15 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.23.0';
7
+ export const VERSION = '0.25.0';
8
+
9
+ // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
10
+ // inherits your interactive `~/.claude/settings.json` default. That matters: a
11
+ // default of `opus[1m]` puts big prompts (wiki-gen over a whole repo, >200K
12
+ // tokens) onto the 1M long-context premium tier, which a Max plan does NOT cover
13
+ // — the turn dies with "usage credits required for this model". Standard `opus`
14
+ // is fully covered. Override with FLOWVIANT_MODEL (e.g. `sonnet` for cheaper/faster).
15
+ export const MODEL = process.env.FLOWVIANT_MODEL || 'opus';
8
16
 
9
17
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
18
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
@@ -25,6 +33,12 @@ function argFlag(name) {
25
33
  const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api/v2';
26
34
  export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
27
35
  export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
36
+ // Push channel: the daemon holds this WebSocket open and the server nudges it
37
+ // the instant a job lands, so dispatch is ~a round-trip instead of a full poll.
38
+ // Derived from FLEET_URL (…/fleet/agents → …/fleet/stream, http→ws) unless set.
39
+ export const STREAM_URL =
40
+ process.env.FLOWVIANT_STREAM_URL ||
41
+ FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
28
42
  export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
29
43
  export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
30
44
  // Live mode: after this long idle-parked on a blocker, tear the session down to
package/bin/lib/fleet.mjs CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  import { runLiveWorker } from './live.mjs';
54
54
  import { reapOrphanPreviews } from './preview.mjs';
55
55
  import { preflight } from './preflight.mjs';
56
+ import { connectStream } from './stream.mjs';
56
57
 
57
58
  async function fetchRoster(haveIds) {
58
59
  const url = new URL(FLEET_URL);
@@ -206,12 +207,20 @@ export async function runFleetDaemon() {
206
207
  let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
207
208
  let mcpUrl = MCP_URL;
208
209
  const workers = new Map(); // agentId -> { state, promise, wt, label }
210
+ let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
211
+ let stream = null; // push channel handle (set once the loop is set up)
209
212
 
210
213
  // Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
211
214
  // resumes in place on the next run (the task marker matches). Worktrees are
212
215
  // only removed when an agent is deleted from the roster, or by
213
216
  // `flowviant clean`.
214
217
  const teardown = () => {
218
+ daemonAlive = false;
219
+ try {
220
+ stream?.close();
221
+ } catch {
222
+ /* best-effort */
223
+ }
215
224
  for (const [, w] of workers) {
216
225
  w.state.alive = false;
217
226
  try {
@@ -603,6 +612,38 @@ export async function runFleetDaemon() {
603
612
  let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
604
613
  let joinCount = 0; // for stable per-agent label colours
605
614
 
615
+ // ── Push channel: a server wake short-circuits the reconcile sleep so a job is
616
+ // picked up in ~a round trip instead of on the next poll. The socket only
617
+ // nudges — we still fetch the roster below — so it's pure latency, and the
618
+ // poll stays the fallback whenever the socket is down. `waitReconcile()`
619
+ // resolves on either a wake or the RECONCILE_SECONDS timeout, whichever first.
620
+ let wakeSignal = null; // { resolve, timer } while the loop is idling
621
+ let pendingWake = false; // a wake that landed mid-reconcile — honored next wait
622
+ const fireWake = () => {
623
+ if (wakeSignal) {
624
+ clearTimeout(wakeSignal.timer);
625
+ const { resolve } = wakeSignal;
626
+ wakeSignal = null;
627
+ resolve();
628
+ } else {
629
+ pendingWake = true; // not idling right now; don't lose the wake
630
+ }
631
+ };
632
+ const waitReconcile = () => {
633
+ if (pendingWake) {
634
+ pendingWake = false;
635
+ return Promise.resolve();
636
+ }
637
+ return new Promise((resolve) => {
638
+ const timer = setTimeout(() => {
639
+ wakeSignal = null;
640
+ resolve();
641
+ }, RECONCILE_SECONDS * 1000);
642
+ wakeSignal = { resolve, timer };
643
+ });
644
+ };
645
+ stream = connectStream({ onWake: () => fireWake(), isAlive: () => daemonAlive });
646
+
606
647
  // Which agents to tell the server we already hold a good token for. We keep
607
648
  // our token (omit a re-mint) UNLESS it's near expiry AND the worker is idle
608
649
  // (no child mid-turn) — then we drop it from `have` to force a fresh token,
@@ -776,6 +817,7 @@ export async function runFleetDaemon() {
776
817
  }
777
818
  }
778
819
 
779
- await sleep(RECONCILE_SECONDS);
820
+ // Idle until the next poll deadline OR a push wake — whichever comes first.
821
+ await waitReconcile();
780
822
  }
781
823
  }
package/bin/lib/live.mjs CHANGED
@@ -22,6 +22,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
22
22
  import {
23
23
  MCP_URL,
24
24
  SAFE,
25
+ MODEL,
25
26
  POLL_SECONDS,
26
27
  IDLE_SECONDS,
27
28
  PARK_TIMEOUT_SECONDS,
@@ -419,6 +420,9 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
419
420
  options: {
420
421
  cwd,
421
422
  env,
423
+ // Pin the model — never inherit the user's global default (which may be a
424
+ // 1M/long-context tier their subscription can't bill autonomous work on).
425
+ model: MODEL,
422
426
  permissionMode: SAFE ? 'default' : 'bypassPermissions',
423
427
  ...(SAFE ? { allowedTools: SAFE_TOOLS } : {}),
424
428
  systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_LIVE },
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Fleet daemon push channel — the best-practice endgame for dispatch latency.
3
+ *
4
+ * Holds a hibernatable WebSocket open to the server. When a job lands (a wiki
5
+ * regen, a dispatch, a merge request, an @mention), the server pushes a
6
+ * `{type:'wake'}` frame and the daemon reconciles IMMEDIATELY — its normal
7
+ * roster fetch — instead of waiting out the poll. That collapses pickup latency
8
+ * from ≤RECONCILE_SECONDS to ~a round trip.
9
+ *
10
+ * The socket carries NO authority: it's a dumb nudge, the roster HTTP fetch is
11
+ * the source of truth (notify-then-reconcile, à la k8s watch). So a dropped or
12
+ * duplicate frame is harmless, and if the socket can't connect we reconnect with
13
+ * backoff while the roster poll stays the fallback the entire time.
14
+ *
15
+ * Keepalive uses an APP-LEVEL "ping"/"pong" the server answers via its socket
16
+ * auto-response — that proves liveness both ways WITHOUT waking the hibernated
17
+ * Durable Object, so an idle connection stays cheap.
18
+ */
19
+
20
+ import WebSocket from 'ws';
21
+ import { STREAM_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
22
+ import { c, info, note, warn } from './ui.mjs';
23
+
24
+ const PING_MS = 30_000; // app-level ping cadence — NAT keepalive + liveness probe
25
+ const DEAD_AFTER_MS = 75_000; // no frame at all for this long → recycle the socket
26
+ const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 15_000]; // reconnect backoff, capped
27
+ const HANDSHAKE_TIMEOUT_MS = 15_000;
28
+
29
+ /**
30
+ * Open the push channel and keep it open (auto-reconnecting) until close().
31
+ *
32
+ * @param {object} opts
33
+ * @param {(reasons: string[]) => void} opts.onWake called on each wake frame
34
+ * @param {() => boolean} opts.isAlive daemon still running?
35
+ * @returns {{ close: () => void }}
36
+ */
37
+ export function connectStream({ onWake, isAlive }) {
38
+ let ws = null;
39
+ let attempt = 0;
40
+ let pingTimer = null;
41
+ let lastRxAt = 0;
42
+ let closed = false;
43
+ let announcedDown = false; // only warn once per outage, not per retry
44
+
45
+ const clearPing = () => {
46
+ if (pingTimer) {
47
+ clearInterval(pingTimer);
48
+ pingTimer = null;
49
+ }
50
+ };
51
+
52
+ const scheduleReconnect = () => {
53
+ if (closed || !isAlive()) return;
54
+ const delay = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
55
+ attempt += 1;
56
+ setTimeout(open, delay);
57
+ };
58
+
59
+ const open = () => {
60
+ if (closed || !isAlive()) return;
61
+ try {
62
+ ws = new WebSocket(STREAM_URL, {
63
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
64
+ handshakeTimeout: HANDSHAKE_TIMEOUT_MS,
65
+ });
66
+ } catch (e) {
67
+ // Bad URL / construction failure — treat as a disconnect and back off.
68
+ scheduleReconnect();
69
+ return;
70
+ }
71
+
72
+ ws.on('open', () => {
73
+ attempt = 0;
74
+ lastRxAt = Date.now();
75
+ if (announcedDown) {
76
+ announcedDown = false;
77
+ info(c.dim('push channel reconnected — instant dispatch back on'));
78
+ } else {
79
+ note(c.dim('push channel connected — instant dispatch on'));
80
+ }
81
+ clearPing();
82
+ pingTimer = setInterval(() => {
83
+ // If we've heard nothing (not even a pong) for too long, the connection
84
+ // is a zombie behind NAT — force it closed so 'close' triggers a
85
+ // reconnect. Otherwise send the app-level ping (auto-answered server-side).
86
+ if (Date.now() - lastRxAt > DEAD_AFTER_MS) {
87
+ try {
88
+ ws.terminate();
89
+ } catch {
90
+ /* already gone */
91
+ }
92
+ return;
93
+ }
94
+ try {
95
+ ws.send('ping');
96
+ } catch {
97
+ /* send after close — 'close' handler will reconnect */
98
+ }
99
+ }, PING_MS);
100
+ });
101
+
102
+ ws.on('message', (data) => {
103
+ lastRxAt = Date.now();
104
+ const text = typeof data === 'string' ? data : data.toString('utf8');
105
+ if (text === 'pong') return; // keepalive ack
106
+ let msg = null;
107
+ try {
108
+ msg = JSON.parse(text);
109
+ } catch {
110
+ return; // ignore non-JSON noise
111
+ }
112
+ if (msg && msg.type === 'wake') {
113
+ try {
114
+ onWake?.(Array.isArray(msg.reasons) ? msg.reasons : []);
115
+ } catch {
116
+ /* never let a handler throw kill the socket */
117
+ }
118
+ }
119
+ });
120
+
121
+ ws.on('close', (code) => {
122
+ clearPing();
123
+ // 1008/4401-ish auth closes can't be fixed by retrying, but the roster
124
+ // poll hits the same credential and exits the daemon cleanly — so we just
125
+ // back off here and let that path own the shutdown. Keep it quiet.
126
+ if (!announcedDown && !closed && isAlive()) {
127
+ announcedDown = true;
128
+ warn(c.dim(`push channel down (${code ?? '—'}) — falling back to polling, retrying`));
129
+ }
130
+ scheduleReconnect();
131
+ });
132
+
133
+ ws.on('error', () => {
134
+ // 'error' is always followed by 'close' — do the reconnect there so we
135
+ // don't double-schedule. Swallow to avoid an unhandled 'error' crash.
136
+ });
137
+ };
138
+
139
+ open();
140
+
141
+ return {
142
+ close: () => {
143
+ closed = true;
144
+ clearPing();
145
+ try {
146
+ ws?.close();
147
+ } catch {
148
+ /* best-effort */
149
+ }
150
+ },
151
+ };
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "node": ">=20"
16
16
  },
17
17
  "dependencies": {
18
- "@anthropic-ai/claude-agent-sdk": "^0.3.0"
18
+ "@anthropic-ai/claude-agent-sdk": "^0.3.0",
19
+ "ws": "^8.18.0"
19
20
  },
20
21
  "keywords": [
21
22
  "flowviant",