flowviant 0.8.1 → 0.9.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/cli.mjs +27 -0
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +34 -16
- package/bin/lib/live.mjs +51 -10
- 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.0';
|
|
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 } 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,
|
|
@@ -151,7 +152,21 @@ export async function runFleetDaemon() {
|
|
|
151
152
|
console.log('');
|
|
152
153
|
preflight({ needGit: true });
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
156
|
+
// so Ctrl+C mid-task never loses local work. Keyed per repo path; each
|
|
157
|
+
// agent's worktree carries a task marker so a resumed claim keeps its files.
|
|
158
|
+
const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
|
|
159
|
+
const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
|
|
160
|
+
mkdirSync(baseDir, { recursive: true });
|
|
161
|
+
try {
|
|
162
|
+
const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
|
|
163
|
+
if (kb > 1024)
|
|
164
|
+
info(
|
|
165
|
+
`disk · worktrees ${(kb / 1024 / 1024).toFixed(1)} GB at ~/.flowviant/worktrees — \`flowviant clean\` reclaims`
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
/* du unavailable (Windows) — skip the disk line */
|
|
169
|
+
}
|
|
155
170
|
const tokenByAgent = new Map(); // agentId -> latest worker token
|
|
156
171
|
const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
|
|
157
172
|
const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
|
|
@@ -159,6 +174,10 @@ export async function runFleetDaemon() {
|
|
|
159
174
|
let mcpUrl = MCP_URL;
|
|
160
175
|
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
161
176
|
|
|
177
|
+
// Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
|
|
178
|
+
// resumes in place on the next run (the task marker matches). Worktrees are
|
|
179
|
+
// only removed when an agent is deleted from the roster, or by
|
|
180
|
+
// `flowviant clean`.
|
|
162
181
|
const teardown = () => {
|
|
163
182
|
for (const [, w] of workers) {
|
|
164
183
|
w.state.alive = false;
|
|
@@ -167,21 +186,11 @@ export async function runFleetDaemon() {
|
|
|
167
186
|
} catch {
|
|
168
187
|
/* best-effort */
|
|
169
188
|
}
|
|
170
|
-
try {
|
|
171
|
-
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
172
|
-
} catch {
|
|
173
|
-
/* best-effort */
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
try {
|
|
177
|
-
rmSync(baseDir, { recursive: true, force: true });
|
|
178
|
-
} catch {
|
|
179
|
-
/* best-effort */
|
|
180
189
|
}
|
|
181
190
|
};
|
|
182
191
|
process.on('SIGINT', () => {
|
|
183
192
|
console.log('');
|
|
184
|
-
note('shutting down — stopping workers
|
|
193
|
+
note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
185
194
|
teardown();
|
|
186
195
|
process.exit(130);
|
|
187
196
|
});
|
|
@@ -377,7 +386,16 @@ export async function runFleetDaemon() {
|
|
|
377
386
|
if (!workers.has(a.agentId)) {
|
|
378
387
|
const wt = join(baseDir, `agent-${a.agentId}`);
|
|
379
388
|
try {
|
|
380
|
-
if (!existsSync(wt))
|
|
389
|
+
if (!existsSync(wt)) {
|
|
390
|
+
try {
|
|
391
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
392
|
+
} catch {
|
|
393
|
+
// A stale registration (e.g. after `flowviant clean` rm'd the
|
|
394
|
+
// dir) blocks re-adding the same path — prune and retry once.
|
|
395
|
+
git(['worktree', 'prune'], repoRoot);
|
|
396
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
381
399
|
} catch (e) {
|
|
382
400
|
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
383
401
|
continue;
|
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 } 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,
|
|
@@ -88,12 +90,14 @@ your branch is started for you automatically — you do NOT need to open a tunne
|
|
|
88
90
|
or register a live target. NEVER merge — a human confirms done in the thread
|
|
89
91
|
(the merge card) and the merge runs separately.`;
|
|
90
92
|
|
|
91
|
-
function seedPrompt(runId, brief, transcript) {
|
|
93
|
+
function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
92
94
|
return [
|
|
93
95
|
`Your run id is ${runId}. Use it for every flowviant MCP tool call.`,
|
|
94
|
-
|
|
95
|
-
? `
|
|
96
|
-
:
|
|
96
|
+
resumedInPlace
|
|
97
|
+
? `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.`
|
|
98
|
+
: brief?.branch
|
|
99
|
+
? `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).`
|
|
100
|
+
: `Start from the clean base checkout and open a fresh draft PR when done.`,
|
|
97
101
|
``,
|
|
98
102
|
`Task brief:`,
|
|
99
103
|
JSON.stringify(brief ?? {}, null, 2),
|
|
@@ -180,6 +184,29 @@ function makeInput(seedText) {
|
|
|
180
184
|
};
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
// Task marker — WHICH intent this worktree was building, stored in the
|
|
188
|
+
// worktree's own git dir (never the working tree, so the agent can't commit
|
|
189
|
+
// it and `git clean` can't delete it). It's what lets a claim after a daemon
|
|
190
|
+
// restart recognize its own half-built worktree and resume IN PLACE instead
|
|
191
|
+
// of resetting away hours of uncommitted work.
|
|
192
|
+
function markerPath(cwd) {
|
|
193
|
+
return join(git(['rev-parse', '--absolute-git-dir'], cwd), 'flowviant-task');
|
|
194
|
+
}
|
|
195
|
+
function readTaskMarker(cwd) {
|
|
196
|
+
try {
|
|
197
|
+
return readFileSync(markerPath(cwd), 'utf8').trim() || null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function writeTaskMarker(cwd, intentId) {
|
|
203
|
+
try {
|
|
204
|
+
writeFileSync(markerPath(cwd), `${intentId}\n`);
|
|
205
|
+
} catch {
|
|
206
|
+
/* best-effort — worst case the next restart resets to base */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
// A stop word from any teammate halts the agent (interrupt at the next boundary,
|
|
184
211
|
// then hold for direction) — the "stop, you're going the wrong way" valve.
|
|
185
212
|
const STOP_RE = /(^|\W)stop(\W|$)/i;
|
|
@@ -225,22 +252,36 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
225
252
|
const { runId, intentId } = claim;
|
|
226
253
|
const brief = claim.brief ?? {};
|
|
227
254
|
const title = brief.title ?? 'a task';
|
|
228
|
-
// Re-claiming the SAME intent this worker was just working
|
|
229
|
-
//
|
|
255
|
+
// Re-claiming the SAME intent this worker was just working — either this
|
|
256
|
+
// daemon's own memory (parked on a blocker, now resuming) or the persistent
|
|
257
|
+
// task marker (the daemon restarted mid-task). Its worktree holds hours of
|
|
258
|
+
// uncommitted work. Do NOT reset.
|
|
230
259
|
const resuming = !!resumeIntentId && intentId === resumeIntentId;
|
|
260
|
+
const resumedInPlace = !resuming && readTaskMarker(cwd) === intentId;
|
|
231
261
|
|
|
232
262
|
// Revision resumes its PR branch; a genuinely fresh task gets a clean base
|
|
233
|
-
// checkout; a resume keeps its dirty worktree untouched.
|
|
263
|
+
// checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
|
|
234
264
|
if (brief.branch) {
|
|
235
265
|
try {
|
|
236
266
|
git(['fetch', 'origin', '--quiet'], cwd);
|
|
237
267
|
git(['checkout', brief.branch], cwd);
|
|
238
268
|
} catch {
|
|
239
|
-
if (!resuming) resetWorktree(cwd, baseRef);
|
|
269
|
+
if (!resuming && !resumedInPlace) resetWorktree(cwd, baseRef);
|
|
240
270
|
}
|
|
241
|
-
} else if (!resuming) {
|
|
271
|
+
} else if (!resuming && !resumedInPlace) {
|
|
242
272
|
resetWorktree(cwd, baseRef);
|
|
243
273
|
}
|
|
274
|
+
writeTaskMarker(cwd, intentId);
|
|
275
|
+
|
|
276
|
+
if (resumedInPlace) {
|
|
277
|
+
// Thread honesty: the team must see this is a genuine continuation with
|
|
278
|
+
// files intact — deterministic, not left to the model's self-narration.
|
|
279
|
+
await mcpCall(mcpUrl, token, 'stream_turn', {
|
|
280
|
+
runId,
|
|
281
|
+
turnId: `resume:${runId}`,
|
|
282
|
+
text: '⟲ Resumed after a daemon restart — local work survived; continuing in place.',
|
|
283
|
+
}).catch(() => {});
|
|
284
|
+
}
|
|
244
285
|
|
|
245
286
|
const env = { ...process.env };
|
|
246
287
|
delete env.ANTHROPIC_API_KEY; // force the user's Claude Code subscription
|
|
@@ -255,7 +296,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
255
296
|
.join('\n');
|
|
256
297
|
let afterId = priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null;
|
|
257
298
|
|
|
258
|
-
const input = makeInput(seedPrompt(runId, brief, transcript));
|
|
299
|
+
const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
|
|
259
300
|
const session = query({
|
|
260
301
|
prompt: input.stream(),
|
|
261
302
|
options: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|