flowviant 0.7.0 → 0.8.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/README.md +6 -4
- package/bin/lib/config.mjs +7 -5
- package/bin/lib/fleet.mjs +80 -7
- package/bin/lib/live.mjs +37 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,14 +38,16 @@ Prefer an explicit token? Create a fleet credential in the app and pass it direc
|
|
|
38
38
|
FLOWVIANT_FLEET=fva_… npx flowviant
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
## Live mode (
|
|
41
|
+
## Live mode (the default)
|
|
42
|
+
|
|
43
|
+
Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer. When it finishes, it posts a delivery card (summary + checklist self-report) in the thread — a human confirms done by merging there.
|
|
44
|
+
|
|
45
|
+
Prefer the legacy one-shot poll mode (no streaming, no previews)? Escape hatch:
|
|
42
46
|
|
|
43
47
|
```bash
|
|
44
|
-
|
|
48
|
+
FLOWVIANT_POLL=1 npx flowviant
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer.
|
|
48
|
-
|
|
49
51
|
### Live previews
|
|
50
52
|
|
|
51
53
|
For UI/API tasks, the daemon can start the branch's dev server in the agent's worktree and open a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel so you can drive the real running change during review — no Cloudflare account needed (it's auto-fetched if missing). Configure it once per repo, or let it infer common setups:
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ 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.8.1';
|
|
8
8
|
|
|
9
9
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
10
10
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
|
@@ -35,10 +35,12 @@ export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
|
|
|
35
35
|
// so a long-lived daemon never silently 401s on an expired token.
|
|
36
36
|
export const REFRESH_BEFORE_SECONDS = Number(process.env.REFRESH_BEFORE_SECONDS || 3600);
|
|
37
37
|
export const SAFE = process.env.FLOWVIANT_SAFE === '1';
|
|
38
|
-
//
|
|
39
|
-
// the task channel, injectable, blocker-parks in place
|
|
40
|
-
//
|
|
41
|
-
|
|
38
|
+
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
39
|
+
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
40
|
+
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
|
41
|
+
// path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
|
|
42
|
+
// escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
|
|
43
|
+
export const LIVE = process.env.FLOWVIANT_POLL !== '1';
|
|
42
44
|
// Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
|
|
43
45
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
44
46
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -189,17 +189,19 @@ export async function runFleetDaemon() {
|
|
|
189
189
|
// Merge jobs (Flowvy-commanded): approved PRs to squash-merge to main on the
|
|
190
190
|
// user's own gh. `merging` guards against re-processing a job mid-flight.
|
|
191
191
|
const MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-done');
|
|
192
|
+
const MERGE_FAILED_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-failed');
|
|
192
193
|
const merging = new Set();
|
|
193
|
-
const
|
|
194
|
+
const mergeAttempts = new Map(); // job.id -> transient-failure count
|
|
195
|
+
const reportMergeOutcome = async (url, body) => {
|
|
194
196
|
try {
|
|
195
|
-
await fetch(
|
|
197
|
+
await fetch(url, {
|
|
196
198
|
method: 'POST',
|
|
197
199
|
headers: {
|
|
198
200
|
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
199
201
|
'User-Agent': USER_AGENT,
|
|
200
202
|
'Content-Type': 'application/json',
|
|
201
203
|
},
|
|
202
|
-
body: JSON.stringify(
|
|
204
|
+
body: JSON.stringify(body),
|
|
203
205
|
});
|
|
204
206
|
} catch {
|
|
205
207
|
/* best-effort — the job reappears next poll if this failed */
|
|
@@ -213,6 +215,7 @@ export async function runFleetDaemon() {
|
|
|
213
215
|
try {
|
|
214
216
|
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
215
217
|
let merged = false;
|
|
218
|
+
let failedReason = null; // permanent — tell the thread, clear the flag
|
|
216
219
|
try {
|
|
217
220
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
218
221
|
cwd: repoRoot,
|
|
@@ -221,14 +224,33 @@ export async function runFleetDaemon() {
|
|
|
221
224
|
merged = true;
|
|
222
225
|
} catch (e) {
|
|
223
226
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
227
|
+
const line = err.split('\n')[0] || 'gh pr merge failed';
|
|
224
228
|
if (/already merged|not open|closed/i.test(err)) merged = true;
|
|
225
|
-
else if (/conflict|not mergeable|CONFLICTING/i.test(err))
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
|
|
230
|
+
// Permanent until a human/agent acts — don't spin on it.
|
|
231
|
+
failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
|
|
232
|
+
} else {
|
|
233
|
+
// Transient (auth hiccup, network, CI requirement): retry a few
|
|
234
|
+
// polls, then surface it instead of silently looping forever.
|
|
235
|
+
const n = (mergeAttempts.get(job.id) ?? 0) + 1;
|
|
236
|
+
mergeAttempts.set(job.id, n);
|
|
237
|
+
if (n >= 3) failedReason = line;
|
|
238
|
+
else warn(`merge failed for "${job.title}": ${line} — will retry`);
|
|
239
|
+
}
|
|
228
240
|
}
|
|
229
241
|
if (merged) {
|
|
230
|
-
|
|
242
|
+
mergeAttempts.delete(job.id);
|
|
243
|
+
await reportMergeOutcome(MERGE_DONE_URL, { intentId: job.id });
|
|
231
244
|
ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
|
|
245
|
+
} else if (failedReason) {
|
|
246
|
+
// Report into the thread (server narrates + re-arms the merge
|
|
247
|
+
// button + notifies) — the job disappears from the roster.
|
|
248
|
+
mergeAttempts.delete(job.id);
|
|
249
|
+
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
250
|
+
intentId: job.id,
|
|
251
|
+
message: failedReason,
|
|
252
|
+
});
|
|
253
|
+
warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
|
|
232
254
|
}
|
|
233
255
|
} finally {
|
|
234
256
|
merging.delete(job.id);
|
|
@@ -237,6 +259,56 @@ export async function runFleetDaemon() {
|
|
|
237
259
|
}
|
|
238
260
|
};
|
|
239
261
|
|
|
262
|
+
// Cleanup jobs (task restarts): close the abandoned PR + delete its remote
|
|
263
|
+
// branch on the user's own gh, so a restart doesn't litter the repo.
|
|
264
|
+
const CLEANUP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/cleanup-done');
|
|
265
|
+
const cleaning = new Set();
|
|
266
|
+
const processCleanupJobs = (jobs) => {
|
|
267
|
+
for (const job of jobs ?? []) {
|
|
268
|
+
if (cleaning.has(job.id)) continue;
|
|
269
|
+
cleaning.add(job.id);
|
|
270
|
+
(async () => {
|
|
271
|
+
try {
|
|
272
|
+
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
273
|
+
if (job.prUrl) {
|
|
274
|
+
try {
|
|
275
|
+
execFileSync(
|
|
276
|
+
'gh',
|
|
277
|
+
[
|
|
278
|
+
'pr',
|
|
279
|
+
'close',
|
|
280
|
+
job.prUrl,
|
|
281
|
+
'--comment',
|
|
282
|
+
'Task restarted in Flowviant — this attempt was discarded.',
|
|
283
|
+
'--delete-branch',
|
|
284
|
+
],
|
|
285
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
286
|
+
);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// Already closed/merged/missing = fine; anything else we still
|
|
289
|
+
// report done — a restart must never wedge on stale remotes.
|
|
290
|
+
const err = e.stderr?.toString?.() || e.message || '';
|
|
291
|
+
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
292
|
+
}
|
|
293
|
+
} else if (job.branch) {
|
|
294
|
+
try {
|
|
295
|
+
execFileSync('git', ['push', 'origin', '--delete', job.branch], {
|
|
296
|
+
cwd: repoRoot,
|
|
297
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
/* branch already gone — fine */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
|
|
304
|
+
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
305
|
+
} finally {
|
|
306
|
+
cleaning.delete(job.id);
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
240
312
|
let connected = false; // log the first successful poll once
|
|
241
313
|
let rosterSig = null; // last roster membership, to log changes only
|
|
242
314
|
let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
|
|
@@ -276,6 +348,7 @@ export async function runFleetDaemon() {
|
|
|
276
348
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
277
349
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
278
350
|
processMergeJobs(roster.mergeJobs);
|
|
351
|
+
processCleanupJobs(roster.cleanupJobs);
|
|
279
352
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
280
353
|
|
|
281
354
|
// Announce roster size only when it changes (not every poll).
|
package/bin/lib/live.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live mode (
|
|
2
|
+
* Live mode (the DEFAULT since 0.8.0; FLOWVIANT_POLL=1 = legacy path). Instead of one-shot
|
|
3
3
|
* `claude -p` turns + sentinels, each task runs a PERSISTENT Agent-SDK session:
|
|
4
4
|
* the daemon claims, seeds the session with the brief, mirrors the model's
|
|
5
5
|
* streamed reply into the task channel (stream_turn), injects human @-messages
|
|
@@ -302,14 +302,26 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
302
302
|
let nudges = 0;
|
|
303
303
|
let held = false; // asked to stop — park for direction, don't nudge
|
|
304
304
|
|
|
305
|
+
// Liveness heartbeat: stream_turn refreshes the lease, but a long stretch of
|
|
306
|
+
// silent tool work streams no text — the app would read "stalled" while the
|
|
307
|
+
// agent is grinding. Beat at most once a minute on ANY session activity.
|
|
308
|
+
let lastBeat = Date.now();
|
|
309
|
+
const beat = () => {
|
|
310
|
+
if (Date.now() - lastBeat < 60_000) return;
|
|
311
|
+
lastBeat = Date.now();
|
|
312
|
+
void mcpCall(mcpUrl, token, 'heartbeat', { runId }).catch(() => {});
|
|
313
|
+
};
|
|
314
|
+
|
|
305
315
|
const flush = async () => {
|
|
306
|
-
if (turnId && turnText.trim())
|
|
316
|
+
if (turnId && turnText.trim()) {
|
|
317
|
+
lastBeat = Date.now(); // stream_turn refreshes the lease itself
|
|
307
318
|
await mcpCall(mcpUrl, token, 'stream_turn', {
|
|
308
319
|
runId,
|
|
309
320
|
turnId,
|
|
310
321
|
text: turnText.trim(),
|
|
311
322
|
createdAt: turnAt,
|
|
312
323
|
}).catch(() => {});
|
|
324
|
+
}
|
|
313
325
|
};
|
|
314
326
|
const inject = (msgs) => {
|
|
315
327
|
afterId = msgs[msgs.length - 1].id;
|
|
@@ -319,6 +331,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
319
331
|
try {
|
|
320
332
|
for await (const m of session) {
|
|
321
333
|
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
334
|
+
beat(); // any session traffic = alive (throttled to 1/min)
|
|
322
335
|
|
|
323
336
|
if (m.type === 'assistant') {
|
|
324
337
|
if (!turnId) {
|
|
@@ -368,6 +381,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
368
381
|
runId,
|
|
369
382
|
...(afterId ? { afterId } : {}),
|
|
370
383
|
}).catch(() => null);
|
|
384
|
+
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
385
|
+
// server killed this run — abandon the session, don't keep building.
|
|
386
|
+
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
387
|
+
return { outcome: 'torn_down', title, intentId };
|
|
388
|
+
}
|
|
371
389
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
372
390
|
|
|
373
391
|
if (fresh.some((f) => STOP_RE.test(f.content))) {
|
|
@@ -466,7 +484,16 @@ export async function runLiveWorker({
|
|
|
466
484
|
const cfg = loadPreviewConfig(cwd);
|
|
467
485
|
const kind = cfg?.ui ? 'ui' : cfg?.api ? 'api' : null;
|
|
468
486
|
const entry = kind ? cfg[kind] : null;
|
|
469
|
-
if (!entry || !intentId)
|
|
487
|
+
if (!entry || !intentId) {
|
|
488
|
+
// Say WHY there's no preview instead of skipping silently — this was a
|
|
489
|
+
// real "where's my preview?" support case.
|
|
490
|
+
info(
|
|
491
|
+
`${label} ${c.dim(
|
|
492
|
+
'no live preview: add .flowviant/preview.json ({"ui":{"cmd":"npm run dev","port":5173}}) — the framework could not be inferred from package.json.'
|
|
493
|
+
)}`
|
|
494
|
+
);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
470
497
|
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
471
498
|
preview = await startPreview({
|
|
472
499
|
worktree: cwd,
|
|
@@ -527,6 +554,13 @@ export async function runLiveWorker({
|
|
|
527
554
|
await startReviewPreview(res.intentId);
|
|
528
555
|
continue;
|
|
529
556
|
}
|
|
557
|
+
if (res.outcome === 'torn_down') {
|
|
558
|
+
// The human restarted/reassigned the task in Flowviant. Drop everything —
|
|
559
|
+
// the next fresh claim resets the worktree to base.
|
|
560
|
+
info(`${label} ${c.dim(`"${res.title}" was restarted/reassigned — abandoned this attempt`)}`);
|
|
561
|
+
phase = '';
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
530
564
|
if (res.outcome === 'parked') {
|
|
531
565
|
// Idle-parked too long on a blocker: we freed the Claude process. The intent
|
|
532
566
|
// stays claimed; a later poll re-claims + resumes (with transcript) once the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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": {
|