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.
- package/bin/lib/claude.mjs +4 -1
- package/bin/lib/config.mjs +15 -1
- package/bin/lib/fleet.mjs +43 -1
- package/bin/lib/live.mjs +4 -0
- package/bin/lib/stream.mjs +152 -0
- package/package.json +3 -2
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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",
|