flowviant 0.9.0 → 0.9.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/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +74 -8
- package/bin/lib/git.mjs +44 -0
- package/bin/lib/live.mjs +41 -5
- package/bin/lib/login.mjs +1 -0
- package/package.json +1 -1
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.9.
|
|
7
|
+
export const VERSION = '0.9.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.
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
@@ -23,6 +23,16 @@ import {
|
|
|
23
23
|
REFRESH_BEFORE_SECONDS,
|
|
24
24
|
LIVE,
|
|
25
25
|
} from './config.mjs';
|
|
26
|
+
import {
|
|
27
|
+
git,
|
|
28
|
+
resetWorktree,
|
|
29
|
+
repoRootOrDie,
|
|
30
|
+
detectBaseRef,
|
|
31
|
+
originSlug,
|
|
32
|
+
isValidPrUrl,
|
|
33
|
+
isValidBranch,
|
|
34
|
+
isSafePathSegment,
|
|
35
|
+
} from './git.mjs';
|
|
26
36
|
import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
|
|
27
37
|
import {
|
|
28
38
|
sleep,
|
|
@@ -34,7 +44,6 @@ import {
|
|
|
34
44
|
SINGLE_KICKOFF,
|
|
35
45
|
SINGLE_RESUME,
|
|
36
46
|
} from './claude.mjs';
|
|
37
|
-
import { git, repoRootOrDie, detectBaseRef, resetWorktree } from './git.mjs';
|
|
38
47
|
import { runLiveWorker } from './live.mjs';
|
|
39
48
|
import { preflight } from './preflight.mjs';
|
|
40
49
|
|
|
@@ -45,6 +54,7 @@ async function fetchRoster(haveIds) {
|
|
|
45
54
|
// Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
|
|
46
55
|
const res = await fetch(url, {
|
|
47
56
|
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
57
|
+
signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
|
|
48
58
|
});
|
|
49
59
|
if (res.status === 401 || res.status === 403) {
|
|
50
60
|
// Fleet credential revoked/expired — retrying can't recover; signal exit.
|
|
@@ -54,7 +64,20 @@ async function fetchRoster(haveIds) {
|
|
|
54
64
|
}
|
|
55
65
|
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
56
66
|
const body = await res.json();
|
|
57
|
-
|
|
67
|
+
// Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
|
|
68
|
+
// throws a NORMAL retryable error inside the loop's try/catch, instead of a
|
|
69
|
+
// `roster.agents.map` TypeError escaping to top-level and killing the daemon.
|
|
70
|
+
const data = body?.data;
|
|
71
|
+
if (!data || !Array.isArray(data.agents)) {
|
|
72
|
+
throw new Error('fleet poll returned an unexpected shape');
|
|
73
|
+
}
|
|
74
|
+
// Drop roster agents with an unsafe id BEFORE they're used as a path segment.
|
|
75
|
+
data.agents = data.agents.filter((a) => {
|
|
76
|
+
if (isSafePathSegment(a?.agentId)) return true;
|
|
77
|
+
warn(`ignoring roster agent with an invalid id: ${JSON.stringify(a?.agentId)}`);
|
|
78
|
+
return false;
|
|
79
|
+
});
|
|
80
|
+
return data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
@@ -186,6 +209,14 @@ export async function runFleetDaemon() {
|
|
|
186
209
|
} catch {
|
|
187
210
|
/* best-effort */
|
|
188
211
|
}
|
|
212
|
+
// Stop the detached preview (dev server + cloudflared tunnel) — it's its
|
|
213
|
+
// own process group and survives our exit, otherwise leaking a port-bound
|
|
214
|
+
// server + a live tunnel serving a stale branch until reboot.
|
|
215
|
+
try {
|
|
216
|
+
w.state.stopPreview?.();
|
|
217
|
+
} catch {
|
|
218
|
+
/* best-effort */
|
|
219
|
+
}
|
|
189
220
|
}
|
|
190
221
|
};
|
|
191
222
|
process.on('SIGINT', () => {
|
|
@@ -210,6 +241,7 @@ export async function runFleetDaemon() {
|
|
|
210
241
|
'User-Agent': USER_AGENT,
|
|
211
242
|
'Content-Type': 'application/json',
|
|
212
243
|
},
|
|
244
|
+
signal: AbortSignal.timeout(30_000),
|
|
213
245
|
body: JSON.stringify(body),
|
|
214
246
|
});
|
|
215
247
|
} catch {
|
|
@@ -225,6 +257,18 @@ export async function runFleetDaemon() {
|
|
|
225
257
|
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
226
258
|
let merged = false;
|
|
227
259
|
let failedReason = null; // permanent — tell the thread, clear the flag
|
|
260
|
+
// Refuse a PR URL that isn't an https github.com PR in THIS repo — a
|
|
261
|
+
// bad/hostile server must not merge a PR in another repo the user's
|
|
262
|
+
// gh can write to (and a leading '-' would be a gh flag).
|
|
263
|
+
if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
264
|
+
mergeAttempts.delete(job.id);
|
|
265
|
+
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
266
|
+
intentId: job.id,
|
|
267
|
+
message: 'refused: PR URL is not a pull request in this repository',
|
|
268
|
+
});
|
|
269
|
+
warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
228
272
|
try {
|
|
229
273
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
230
274
|
cwd: repoRoot,
|
|
@@ -234,8 +278,13 @@ export async function runFleetDaemon() {
|
|
|
234
278
|
} catch (e) {
|
|
235
279
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
236
280
|
const line = err.split('\n')[0] || 'gh pr merge failed';
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
// Only "already merged" is a real success; a CLOSED-without-merge PR
|
|
282
|
+
// also matches "not open"/"closed" but nothing landed on main —
|
|
283
|
+
// report it as a failure so the thread learns the truth.
|
|
284
|
+
if (/already merged/i.test(err)) merged = true;
|
|
285
|
+
else if (/not open|closed/i.test(err)) {
|
|
286
|
+
failedReason = 'the PR was closed without merging';
|
|
287
|
+
} else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
|
|
239
288
|
// Permanent until a human/agent acts — don't spin on it.
|
|
240
289
|
failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
|
|
241
290
|
} else {
|
|
@@ -279,7 +328,10 @@ export async function runFleetDaemon() {
|
|
|
279
328
|
(async () => {
|
|
280
329
|
try {
|
|
281
330
|
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
282
|
-
|
|
331
|
+
// Same guards as merge: only close a PR in THIS repo, only delete a
|
|
332
|
+
// well-formed non-base branch. A bad server must not close a stranger's
|
|
333
|
+
// PR or delete `main` (`--delete` with `main`) via a cleanup job.
|
|
334
|
+
if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
283
335
|
try {
|
|
284
336
|
execFileSync(
|
|
285
337
|
'gh',
|
|
@@ -299,15 +351,18 @@ export async function runFleetDaemon() {
|
|
|
299
351
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
300
352
|
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
301
353
|
}
|
|
302
|
-
} else if (job.branch) {
|
|
354
|
+
} else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
|
|
303
355
|
try {
|
|
304
|
-
|
|
356
|
+
// Explicit refspec form so a leading '-' can't be a git flag.
|
|
357
|
+
execFileSync('git', ['push', 'origin', `:refs/heads/${job.branch}`], {
|
|
305
358
|
cwd: repoRoot,
|
|
306
359
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
360
|
});
|
|
308
361
|
} catch {
|
|
309
362
|
/* branch already gone — fine */
|
|
310
363
|
}
|
|
364
|
+
} else if (job.prUrl || job.branch) {
|
|
365
|
+
warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
|
|
311
366
|
}
|
|
312
367
|
await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
|
|
313
368
|
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
@@ -417,6 +472,11 @@ export async function runFleetDaemon() {
|
|
|
417
472
|
onChild: (ch) => {
|
|
418
473
|
state.child = ch;
|
|
419
474
|
},
|
|
475
|
+
// Hold the preview's stop fn so teardown/removal can kill the detached
|
|
476
|
+
// dev-server + tunnel (they survive our exit otherwise).
|
|
477
|
+
onPreview: (stop) => {
|
|
478
|
+
state.stopPreview = stop;
|
|
479
|
+
},
|
|
420
480
|
// A turn that couldn't reach the MCP server: forget the cached token so
|
|
421
481
|
// the next reconcile poll re-mints a fresh one (self-heals a token that
|
|
422
482
|
// was rotated/expired out from under a running session).
|
|
@@ -441,6 +501,11 @@ export async function runFleetDaemon() {
|
|
|
441
501
|
} catch {
|
|
442
502
|
/* best-effort */
|
|
443
503
|
}
|
|
504
|
+
try {
|
|
505
|
+
w.state.stopPreview?.();
|
|
506
|
+
} catch {
|
|
507
|
+
/* best-effort */
|
|
508
|
+
}
|
|
444
509
|
try {
|
|
445
510
|
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
446
511
|
} catch {
|
|
@@ -449,6 +514,7 @@ export async function runFleetDaemon() {
|
|
|
449
514
|
workers.delete(id);
|
|
450
515
|
tokenByAgent.delete(id);
|
|
451
516
|
hasWorkByAgent.delete(id);
|
|
517
|
+
mintedAt.delete(id); // was leaked on removal (finding 14)
|
|
452
518
|
}
|
|
453
519
|
}
|
|
454
520
|
|
package/bin/lib/git.mjs
CHANGED
|
@@ -15,6 +15,50 @@ export function repoRootOrDie() {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// ── Server-value validation ────────────────────────────────────────────────
|
|
19
|
+
// prUrl / branch / agentId arrive from the fleet server. execFileSync blocks
|
|
20
|
+
// SHELL injection but NOT git/gh option injection (a leading '-' becomes a
|
|
21
|
+
// flag) or cross-repo/cross-path abuse. These guards make a malicious or buggy
|
|
22
|
+
// server unable to touch a repo/branch/path outside the expected scope.
|
|
23
|
+
|
|
24
|
+
/** The `owner/repo` the daemon is running inside, from origin's URL. Null if
|
|
25
|
+
* origin isn't a github remote. */
|
|
26
|
+
export function originSlug(repoRoot) {
|
|
27
|
+
try {
|
|
28
|
+
const url = git(['remote', 'get-url', 'origin'], repoRoot);
|
|
29
|
+
const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
30
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A PR URL is accepted only if it's an https github.com PR in THIS repo. */
|
|
37
|
+
export function isValidPrUrl(prUrl, slug) {
|
|
38
|
+
if (typeof prUrl !== 'string' || !slug) return false;
|
|
39
|
+
const m = prUrl.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+$/);
|
|
40
|
+
return !!m && m[1].toLowerCase() === slug.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A branch name is accepted only if git considers it a well-formed ref, it's
|
|
44
|
+
* not the base branch, and it doesn't start with '-' (option injection). */
|
|
45
|
+
export function isValidBranch(branch, repoRoot, baseRef) {
|
|
46
|
+
if (typeof branch !== 'string' || !branch || branch.startsWith('-')) return false;
|
|
47
|
+
if (baseRef && (branch === baseRef || `origin/${branch}` === baseRef)) return false;
|
|
48
|
+
try {
|
|
49
|
+
git(['check-ref-format', '--branch', branch], repoRoot);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A roster agent id used as a filesystem path segment — strict allowlist so
|
|
57
|
+
* it can't traverse (`..`, `/`) out of the worktrees dir. */
|
|
58
|
+
export function isSafePathSegment(id) {
|
|
59
|
+
return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
18
62
|
export function detectBaseRef(repoRoot) {
|
|
19
63
|
try {
|
|
20
64
|
return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
|
package/bin/lib/live.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
22
22
|
import {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from './config.mjs';
|
|
32
32
|
import { c, info, ok, warn } from './ui.mjs';
|
|
33
33
|
import { sleep } from './claude.mjs';
|
|
34
|
-
import { git, resetWorktree } from './git.mjs';
|
|
34
|
+
import { git, resetWorktree, isValidBranch } from './git.mjs';
|
|
35
35
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
36
36
|
|
|
37
37
|
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
@@ -46,6 +46,7 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
46
46
|
'User-Agent': USER_AGENT,
|
|
47
47
|
'Content-Type': 'application/json',
|
|
48
48
|
},
|
|
49
|
+
signal: AbortSignal.timeout(30_000),
|
|
49
50
|
body: JSON.stringify({ intentId, kind, url }),
|
|
50
51
|
});
|
|
51
52
|
} catch {
|
|
@@ -124,6 +125,7 @@ async function mcpCall(mcpUrl, token, name, args) {
|
|
|
124
125
|
// without this every live MCP call fails against api.flowviant.com.
|
|
125
126
|
'User-Agent': USER_AGENT,
|
|
126
127
|
},
|
|
128
|
+
signal: AbortSignal.timeout(30_000),
|
|
127
129
|
body: JSON.stringify({
|
|
128
130
|
jsonrpc: '2.0',
|
|
129
131
|
id: ++rpcId,
|
|
@@ -206,6 +208,13 @@ function writeTaskMarker(cwd, intentId) {
|
|
|
206
208
|
/* best-effort — worst case the next restart resets to base */
|
|
207
209
|
}
|
|
208
210
|
}
|
|
211
|
+
function clearTaskMarker(cwd) {
|
|
212
|
+
try {
|
|
213
|
+
rmSync(markerPath(cwd), { force: true });
|
|
214
|
+
} catch {
|
|
215
|
+
/* best-effort */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
209
218
|
|
|
210
219
|
// A stop word from any teammate halts the agent (interrupt at the next boundary,
|
|
211
220
|
// then hold for direction) — the "stop, you're going the wrong way" valve.
|
|
@@ -261,7 +270,10 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
261
270
|
|
|
262
271
|
// Revision resumes its PR branch; a genuinely fresh task gets a clean base
|
|
263
272
|
// checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
|
|
264
|
-
|
|
273
|
+
// The branch is server-supplied — validate it's a well-formed non-base ref
|
|
274
|
+
// (not a leading-'-' git option) before checkout; on a bad value fall back to
|
|
275
|
+
// a clean base rather than executing it.
|
|
276
|
+
if (brief.branch && isValidBranch(brief.branch, cwd, baseRef)) {
|
|
265
277
|
try {
|
|
266
278
|
git(['fetch', 'origin', '--quiet'], cwd);
|
|
267
279
|
git(['checkout', brief.branch], cwd);
|
|
@@ -284,7 +296,13 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
284
296
|
}
|
|
285
297
|
|
|
286
298
|
const env = { ...process.env };
|
|
287
|
-
|
|
299
|
+
// Force the user's Claude Code subscription — strip EVERY var that could
|
|
300
|
+
// divert to API billing or a proxy (poll mode strips these too; live mode
|
|
301
|
+
// was only clearing API_KEY, so an exported AUTH_TOKEN/BASE_URL silently
|
|
302
|
+
// billed the API on the default path).
|
|
303
|
+
delete env.ANTHROPIC_API_KEY;
|
|
304
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
305
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
288
306
|
|
|
289
307
|
// Prior channel transcript — present when resuming a parked/re-claimed task;
|
|
290
308
|
// seed it so a fresh session picks up where the conversation left off. afterId
|
|
@@ -401,7 +419,14 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
401
419
|
await flush();
|
|
402
420
|
turnId = null;
|
|
403
421
|
|
|
404
|
-
|
|
422
|
+
// Task finished — clear the marker so this worktree is NOT treated as a
|
|
423
|
+
// resume of this intent later (esp. if the task is restarted from
|
|
424
|
+
// scratch, which discards it: a stale marker would resume the discarded
|
|
425
|
+
// attempt's dirty files).
|
|
426
|
+
if (completed) {
|
|
427
|
+
clearTaskMarker(cwd);
|
|
428
|
+
return { outcome: 'done', title, intentId };
|
|
429
|
+
}
|
|
405
430
|
|
|
406
431
|
if (sawBlocker) {
|
|
407
432
|
const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
|
|
@@ -425,6 +450,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
425
450
|
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
426
451
|
// server killed this run — abandon the session, don't keep building.
|
|
427
452
|
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
453
|
+
// Discarded (restart/reassign): clear the marker AND reset the
|
|
454
|
+
// worktree so the next fresh claim starts from clean base, never
|
|
455
|
+
// resuming the abandoned attempt.
|
|
456
|
+
clearTaskMarker(cwd);
|
|
457
|
+
resetWorktree(cwd, baseRef);
|
|
428
458
|
return { outcome: 'torn_down', title, intentId };
|
|
429
459
|
}
|
|
430
460
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
@@ -493,6 +523,7 @@ export async function runLiveWorker({
|
|
|
493
523
|
isAlive,
|
|
494
524
|
onTokenSuspect,
|
|
495
525
|
onChild,
|
|
526
|
+
onPreview,
|
|
496
527
|
}) {
|
|
497
528
|
// The intent this worker is holding across iterations. When a task parks on a
|
|
498
529
|
// blocker its worktree keeps uncommitted work; on the resume claim we must NOT
|
|
@@ -519,6 +550,10 @@ export async function runLiveWorker({
|
|
|
519
550
|
}
|
|
520
551
|
preview = null;
|
|
521
552
|
}
|
|
553
|
+
// Detached preview children (dev server + tunnel) survive process exit, so
|
|
554
|
+
// the daemon's SIGINT teardown needs a handle to stop them — clear it here
|
|
555
|
+
// once they're down.
|
|
556
|
+
onPreview?.(null);
|
|
522
557
|
};
|
|
523
558
|
const startReviewPreview = async (intentId) => {
|
|
524
559
|
stopPreview();
|
|
@@ -544,6 +579,7 @@ export async function runLiveWorker({
|
|
|
544
579
|
log: (m) => info(`${label} ${c.dim(m)}`),
|
|
545
580
|
});
|
|
546
581
|
if (preview) {
|
|
582
|
+
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
547
583
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
548
584
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
549
585
|
}
|
package/bin/lib/login.mjs
CHANGED
|
@@ -37,6 +37,7 @@ async function post(url, body) {
|
|
|
37
37
|
const res = await fetch(url, {
|
|
38
38
|
method: 'POST',
|
|
39
39
|
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
40
|
+
signal: AbortSignal.timeout(30_000),
|
|
40
41
|
body: JSON.stringify(body),
|
|
41
42
|
});
|
|
42
43
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.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": {
|