flowviant 0.8.1 → 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/cli.mjs +27 -0
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +103 -19
- package/bin/lib/git.mjs +44 -0
- package/bin/lib/live.mjs +91 -14
- package/bin/lib/login.mjs +1 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -44,6 +44,33 @@ if (process.argv[2] === 'login') {
|
|
|
44
44
|
process.exit(0);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// `flowviant clean` — reclaim the persistent worktrees (~/.flowviant/worktrees).
|
|
48
|
+
// They're kept across runs so in-flight work survives Ctrl+C; this is the drain.
|
|
49
|
+
// Repos self-heal: the daemon runs `git worktree prune` if a stale registration
|
|
50
|
+
// blocks re-adding a path.
|
|
51
|
+
if (process.argv[2] === 'clean') {
|
|
52
|
+
const { rmSync, existsSync } = await import('node:fs');
|
|
53
|
+
const { join } = await import('node:path');
|
|
54
|
+
const { homedir } = await import('node:os');
|
|
55
|
+
const { execFileSync } = await import('node:child_process');
|
|
56
|
+
const dir = join(homedir(), '.flowviant', 'worktrees');
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
console.log('nothing to clean — no worktrees at ~/.flowviant/worktrees.');
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
let size = '';
|
|
62
|
+
try {
|
|
63
|
+
const kb = Number(execFileSync('du', ['-sk', dir], { encoding: 'utf8' }).split('\t')[0]);
|
|
64
|
+
size = ` (${(kb / 1024).toFixed(0)} MB reclaimed)`;
|
|
65
|
+
} catch {
|
|
66
|
+
/* du unavailable — skip the size */
|
|
67
|
+
}
|
|
68
|
+
console.log('note: stop any running flowviant daemon first — in-flight local work is discarded.');
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
console.log(`cleaned ~/.flowviant/worktrees${size}.`);
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
if (!FLEET_TOKEN && tokens.length === 0) {
|
|
48
75
|
console.error(
|
|
49
76
|
'error: no credential found. Easiest:\n' +
|
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.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,10 +5,11 @@
|
|
|
5
5
|
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join, basename } from 'node:path';
|
|
12
13
|
import {
|
|
13
14
|
VERSION,
|
|
14
15
|
FLEET_URL,
|
|
@@ -22,6 +23,16 @@ import {
|
|
|
22
23
|
REFRESH_BEFORE_SECONDS,
|
|
23
24
|
LIVE,
|
|
24
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';
|
|
25
36
|
import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
|
|
26
37
|
import {
|
|
27
38
|
sleep,
|
|
@@ -33,7 +44,6 @@ import {
|
|
|
33
44
|
SINGLE_KICKOFF,
|
|
34
45
|
SINGLE_RESUME,
|
|
35
46
|
} from './claude.mjs';
|
|
36
|
-
import { git, repoRootOrDie, detectBaseRef, resetWorktree } from './git.mjs';
|
|
37
47
|
import { runLiveWorker } from './live.mjs';
|
|
38
48
|
import { preflight } from './preflight.mjs';
|
|
39
49
|
|
|
@@ -44,6 +54,7 @@ async function fetchRoster(haveIds) {
|
|
|
44
54
|
// Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
|
|
45
55
|
const res = await fetch(url, {
|
|
46
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
|
|
47
58
|
});
|
|
48
59
|
if (res.status === 401 || res.status === 403) {
|
|
49
60
|
// Fleet credential revoked/expired — retrying can't recover; signal exit.
|
|
@@ -53,7 +64,20 @@ async function fetchRoster(haveIds) {
|
|
|
53
64
|
}
|
|
54
65
|
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
55
66
|
const body = await res.json();
|
|
56
|
-
|
|
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}] }
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
@@ -151,7 +175,21 @@ export async function runFleetDaemon() {
|
|
|
151
175
|
console.log('');
|
|
152
176
|
preflight({ needGit: true });
|
|
153
177
|
|
|
154
|
-
|
|
178
|
+
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
179
|
+
// so Ctrl+C mid-task never loses local work. Keyed per repo path; each
|
|
180
|
+
// agent's worktree carries a task marker so a resumed claim keeps its files.
|
|
181
|
+
const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
|
|
182
|
+
const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
|
|
183
|
+
mkdirSync(baseDir, { recursive: true });
|
|
184
|
+
try {
|
|
185
|
+
const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
|
|
186
|
+
if (kb > 1024)
|
|
187
|
+
info(
|
|
188
|
+
`disk · worktrees ${(kb / 1024 / 1024).toFixed(1)} GB at ~/.flowviant/worktrees — \`flowviant clean\` reclaims`
|
|
189
|
+
);
|
|
190
|
+
} catch {
|
|
191
|
+
/* du unavailable (Windows) — skip the disk line */
|
|
192
|
+
}
|
|
155
193
|
const tokenByAgent = new Map(); // agentId -> latest worker token
|
|
156
194
|
const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
|
|
157
195
|
const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
|
|
@@ -159,6 +197,10 @@ export async function runFleetDaemon() {
|
|
|
159
197
|
let mcpUrl = MCP_URL;
|
|
160
198
|
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
161
199
|
|
|
200
|
+
// Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
|
|
201
|
+
// resumes in place on the next run (the task marker matches). Worktrees are
|
|
202
|
+
// only removed when an agent is deleted from the roster, or by
|
|
203
|
+
// `flowviant clean`.
|
|
162
204
|
const teardown = () => {
|
|
163
205
|
for (const [, w] of workers) {
|
|
164
206
|
w.state.alive = false;
|
|
@@ -167,21 +209,19 @@ export async function runFleetDaemon() {
|
|
|
167
209
|
} catch {
|
|
168
210
|
/* best-effort */
|
|
169
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.
|
|
170
215
|
try {
|
|
171
|
-
|
|
216
|
+
w.state.stopPreview?.();
|
|
172
217
|
} catch {
|
|
173
218
|
/* best-effort */
|
|
174
219
|
}
|
|
175
220
|
}
|
|
176
|
-
try {
|
|
177
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
178
|
-
} catch {
|
|
179
|
-
/* best-effort */
|
|
180
|
-
}
|
|
181
221
|
};
|
|
182
222
|
process.on('SIGINT', () => {
|
|
183
223
|
console.log('');
|
|
184
|
-
note('shutting down — stopping workers
|
|
224
|
+
note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
185
225
|
teardown();
|
|
186
226
|
process.exit(130);
|
|
187
227
|
});
|
|
@@ -201,6 +241,7 @@ export async function runFleetDaemon() {
|
|
|
201
241
|
'User-Agent': USER_AGENT,
|
|
202
242
|
'Content-Type': 'application/json',
|
|
203
243
|
},
|
|
244
|
+
signal: AbortSignal.timeout(30_000),
|
|
204
245
|
body: JSON.stringify(body),
|
|
205
246
|
});
|
|
206
247
|
} catch {
|
|
@@ -216,6 +257,18 @@ export async function runFleetDaemon() {
|
|
|
216
257
|
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
217
258
|
let merged = false;
|
|
218
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
|
+
}
|
|
219
272
|
try {
|
|
220
273
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
221
274
|
cwd: repoRoot,
|
|
@@ -225,8 +278,13 @@ export async function runFleetDaemon() {
|
|
|
225
278
|
} catch (e) {
|
|
226
279
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
227
280
|
const line = err.split('\n')[0] || 'gh pr merge failed';
|
|
228
|
-
|
|
229
|
-
|
|
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)) {
|
|
230
288
|
// Permanent until a human/agent acts — don't spin on it.
|
|
231
289
|
failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
|
|
232
290
|
} else {
|
|
@@ -270,7 +328,10 @@ export async function runFleetDaemon() {
|
|
|
270
328
|
(async () => {
|
|
271
329
|
try {
|
|
272
330
|
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
273
|
-
|
|
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))) {
|
|
274
335
|
try {
|
|
275
336
|
execFileSync(
|
|
276
337
|
'gh',
|
|
@@ -290,15 +351,18 @@ export async function runFleetDaemon() {
|
|
|
290
351
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
291
352
|
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
292
353
|
}
|
|
293
|
-
} else if (job.branch) {
|
|
354
|
+
} else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
|
|
294
355
|
try {
|
|
295
|
-
|
|
356
|
+
// Explicit refspec form so a leading '-' can't be a git flag.
|
|
357
|
+
execFileSync('git', ['push', 'origin', `:refs/heads/${job.branch}`], {
|
|
296
358
|
cwd: repoRoot,
|
|
297
359
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
298
360
|
});
|
|
299
361
|
} catch {
|
|
300
362
|
/* branch already gone — fine */
|
|
301
363
|
}
|
|
364
|
+
} else if (job.prUrl || job.branch) {
|
|
365
|
+
warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
|
|
302
366
|
}
|
|
303
367
|
await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
|
|
304
368
|
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
@@ -377,7 +441,16 @@ export async function runFleetDaemon() {
|
|
|
377
441
|
if (!workers.has(a.agentId)) {
|
|
378
442
|
const wt = join(baseDir, `agent-${a.agentId}`);
|
|
379
443
|
try {
|
|
380
|
-
if (!existsSync(wt))
|
|
444
|
+
if (!existsSync(wt)) {
|
|
445
|
+
try {
|
|
446
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
447
|
+
} catch {
|
|
448
|
+
// A stale registration (e.g. after `flowviant clean` rm'd the
|
|
449
|
+
// dir) blocks re-adding the same path — prune and retry once.
|
|
450
|
+
git(['worktree', 'prune'], repoRoot);
|
|
451
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
381
454
|
} catch (e) {
|
|
382
455
|
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
383
456
|
continue;
|
|
@@ -399,6 +472,11 @@ export async function runFleetDaemon() {
|
|
|
399
472
|
onChild: (ch) => {
|
|
400
473
|
state.child = ch;
|
|
401
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
|
+
},
|
|
402
480
|
// A turn that couldn't reach the MCP server: forget the cached token so
|
|
403
481
|
// the next reconcile poll re-mints a fresh one (self-heals a token that
|
|
404
482
|
// was rotated/expired out from under a running session).
|
|
@@ -423,6 +501,11 @@ export async function runFleetDaemon() {
|
|
|
423
501
|
} catch {
|
|
424
502
|
/* best-effort */
|
|
425
503
|
}
|
|
504
|
+
try {
|
|
505
|
+
w.state.stopPreview?.();
|
|
506
|
+
} catch {
|
|
507
|
+
/* best-effort */
|
|
508
|
+
}
|
|
426
509
|
try {
|
|
427
510
|
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
428
511
|
} catch {
|
|
@@ -431,6 +514,7 @@ export async function runFleetDaemon() {
|
|
|
431
514
|
workers.delete(id);
|
|
432
515
|
tokenByAgent.delete(id);
|
|
433
516
|
hasWorkByAgent.delete(id);
|
|
517
|
+
mintedAt.delete(id); // was leaked on removal (finding 14)
|
|
434
518
|
}
|
|
435
519
|
}
|
|
436
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,6 +16,8 @@
|
|
|
16
16
|
* live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
19
21
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
20
22
|
import {
|
|
21
23
|
MCP_URL,
|
|
@@ -29,7 +31,7 @@ import {
|
|
|
29
31
|
} from './config.mjs';
|
|
30
32
|
import { c, info, ok, warn } from './ui.mjs';
|
|
31
33
|
import { sleep } from './claude.mjs';
|
|
32
|
-
import { git, resetWorktree } from './git.mjs';
|
|
34
|
+
import { git, resetWorktree, isValidBranch } from './git.mjs';
|
|
33
35
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
34
36
|
|
|
35
37
|
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
@@ -44,6 +46,7 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
44
46
|
'User-Agent': USER_AGENT,
|
|
45
47
|
'Content-Type': 'application/json',
|
|
46
48
|
},
|
|
49
|
+
signal: AbortSignal.timeout(30_000),
|
|
47
50
|
body: JSON.stringify({ intentId, kind, url }),
|
|
48
51
|
});
|
|
49
52
|
} catch {
|
|
@@ -88,12 +91,14 @@ your branch is started for you automatically — you do NOT need to open a tunne
|
|
|
88
91
|
or register a live target. NEVER merge — a human confirms done in the thread
|
|
89
92
|
(the merge card) and the merge runs separately.`;
|
|
90
93
|
|
|
91
|
-
function seedPrompt(runId, brief, transcript) {
|
|
94
|
+
function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
92
95
|
return [
|
|
93
96
|
`Your run id is ${runId}. Use it for every flowviant MCP tool call.`,
|
|
94
|
-
|
|
95
|
-
? `
|
|
96
|
-
:
|
|
97
|
+
resumedInPlace
|
|
98
|
+
? `You are RESUMING after a daemon restart: your worktree still contains your own uncommitted work from before the interruption. Run \`git status\` and \`git diff\` first, take stock, and CONTINUE from there — do not start over.`
|
|
99
|
+
: brief?.branch
|
|
100
|
+
? `This is a REVISION — your prior branch "${brief.branch}" is checked out; address the review feedback and push to the SAME branch (the PR updates in place).`
|
|
101
|
+
: `Start from the clean base checkout and open a fresh draft PR when done.`,
|
|
97
102
|
``,
|
|
98
103
|
`Task brief:`,
|
|
99
104
|
JSON.stringify(brief ?? {}, null, 2),
|
|
@@ -120,6 +125,7 @@ async function mcpCall(mcpUrl, token, name, args) {
|
|
|
120
125
|
// without this every live MCP call fails against api.flowviant.com.
|
|
121
126
|
'User-Agent': USER_AGENT,
|
|
122
127
|
},
|
|
128
|
+
signal: AbortSignal.timeout(30_000),
|
|
123
129
|
body: JSON.stringify({
|
|
124
130
|
jsonrpc: '2.0',
|
|
125
131
|
id: ++rpcId,
|
|
@@ -180,6 +186,36 @@ function makeInput(seedText) {
|
|
|
180
186
|
};
|
|
181
187
|
}
|
|
182
188
|
|
|
189
|
+
// Task marker — WHICH intent this worktree was building, stored in the
|
|
190
|
+
// worktree's own git dir (never the working tree, so the agent can't commit
|
|
191
|
+
// it and `git clean` can't delete it). It's what lets a claim after a daemon
|
|
192
|
+
// restart recognize its own half-built worktree and resume IN PLACE instead
|
|
193
|
+
// of resetting away hours of uncommitted work.
|
|
194
|
+
function markerPath(cwd) {
|
|
195
|
+
return join(git(['rev-parse', '--absolute-git-dir'], cwd), 'flowviant-task');
|
|
196
|
+
}
|
|
197
|
+
function readTaskMarker(cwd) {
|
|
198
|
+
try {
|
|
199
|
+
return readFileSync(markerPath(cwd), 'utf8').trim() || null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function writeTaskMarker(cwd, intentId) {
|
|
205
|
+
try {
|
|
206
|
+
writeFileSync(markerPath(cwd), `${intentId}\n`);
|
|
207
|
+
} catch {
|
|
208
|
+
/* best-effort — worst case the next restart resets to base */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function clearTaskMarker(cwd) {
|
|
212
|
+
try {
|
|
213
|
+
rmSync(markerPath(cwd), { force: true });
|
|
214
|
+
} catch {
|
|
215
|
+
/* best-effort */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
183
219
|
// A stop word from any teammate halts the agent (interrupt at the next boundary,
|
|
184
220
|
// then hold for direction) — the "stop, you're going the wrong way" valve.
|
|
185
221
|
const STOP_RE = /(^|\W)stop(\W|$)/i;
|
|
@@ -225,25 +261,48 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
225
261
|
const { runId, intentId } = claim;
|
|
226
262
|
const brief = claim.brief ?? {};
|
|
227
263
|
const title = brief.title ?? 'a task';
|
|
228
|
-
// Re-claiming the SAME intent this worker was just working
|
|
229
|
-
//
|
|
264
|
+
// Re-claiming the SAME intent this worker was just working — either this
|
|
265
|
+
// daemon's own memory (parked on a blocker, now resuming) or the persistent
|
|
266
|
+
// task marker (the daemon restarted mid-task). Its worktree holds hours of
|
|
267
|
+
// uncommitted work. Do NOT reset.
|
|
230
268
|
const resuming = !!resumeIntentId && intentId === resumeIntentId;
|
|
269
|
+
const resumedInPlace = !resuming && readTaskMarker(cwd) === intentId;
|
|
231
270
|
|
|
232
271
|
// Revision resumes its PR branch; a genuinely fresh task gets a clean base
|
|
233
|
-
// checkout; a resume keeps its dirty worktree untouched.
|
|
234
|
-
|
|
272
|
+
// checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
|
|
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)) {
|
|
235
277
|
try {
|
|
236
278
|
git(['fetch', 'origin', '--quiet'], cwd);
|
|
237
279
|
git(['checkout', brief.branch], cwd);
|
|
238
280
|
} catch {
|
|
239
|
-
if (!resuming) resetWorktree(cwd, baseRef);
|
|
281
|
+
if (!resuming && !resumedInPlace) resetWorktree(cwd, baseRef);
|
|
240
282
|
}
|
|
241
|
-
} else if (!resuming) {
|
|
283
|
+
} else if (!resuming && !resumedInPlace) {
|
|
242
284
|
resetWorktree(cwd, baseRef);
|
|
243
285
|
}
|
|
286
|
+
writeTaskMarker(cwd, intentId);
|
|
287
|
+
|
|
288
|
+
if (resumedInPlace) {
|
|
289
|
+
// Thread honesty: the team must see this is a genuine continuation with
|
|
290
|
+
// files intact — deterministic, not left to the model's self-narration.
|
|
291
|
+
await mcpCall(mcpUrl, token, 'stream_turn', {
|
|
292
|
+
runId,
|
|
293
|
+
turnId: `resume:${runId}`,
|
|
294
|
+
text: '⟲ Resumed after a daemon restart — local work survived; continuing in place.',
|
|
295
|
+
}).catch(() => {});
|
|
296
|
+
}
|
|
244
297
|
|
|
245
298
|
const env = { ...process.env };
|
|
246
|
-
|
|
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;
|
|
247
306
|
|
|
248
307
|
// Prior channel transcript — present when resuming a parked/re-claimed task;
|
|
249
308
|
// seed it so a fresh session picks up where the conversation left off. afterId
|
|
@@ -255,7 +314,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
255
314
|
.join('\n');
|
|
256
315
|
let afterId = priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null;
|
|
257
316
|
|
|
258
|
-
const input = makeInput(seedPrompt(runId, brief, transcript));
|
|
317
|
+
const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
|
|
259
318
|
const session = query({
|
|
260
319
|
prompt: input.stream(),
|
|
261
320
|
options: {
|
|
@@ -360,7 +419,14 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
360
419
|
await flush();
|
|
361
420
|
turnId = null;
|
|
362
421
|
|
|
363
|
-
|
|
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
|
+
}
|
|
364
430
|
|
|
365
431
|
if (sawBlocker) {
|
|
366
432
|
const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
|
|
@@ -384,6 +450,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
384
450
|
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
385
451
|
// server killed this run — abandon the session, don't keep building.
|
|
386
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);
|
|
387
458
|
return { outcome: 'torn_down', title, intentId };
|
|
388
459
|
}
|
|
389
460
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
@@ -452,6 +523,7 @@ export async function runLiveWorker({
|
|
|
452
523
|
isAlive,
|
|
453
524
|
onTokenSuspect,
|
|
454
525
|
onChild,
|
|
526
|
+
onPreview,
|
|
455
527
|
}) {
|
|
456
528
|
// The intent this worker is holding across iterations. When a task parks on a
|
|
457
529
|
// blocker its worktree keeps uncommitted work; on the resume claim we must NOT
|
|
@@ -478,6 +550,10 @@ export async function runLiveWorker({
|
|
|
478
550
|
}
|
|
479
551
|
preview = null;
|
|
480
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);
|
|
481
557
|
};
|
|
482
558
|
const startReviewPreview = async (intentId) => {
|
|
483
559
|
stopPreview();
|
|
@@ -503,6 +579,7 @@ export async function runLiveWorker({
|
|
|
503
579
|
log: (m) => info(`${label} ${c.dim(m)}`),
|
|
504
580
|
});
|
|
505
581
|
if (preview) {
|
|
582
|
+
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
506
583
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
507
584
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
508
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.
|
|
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": {
|