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 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' +
@@ -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.8.1';
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 { mkdtempSync, rmSync, existsSync } from 'node:fs';
8
+ import { mkdirSync, existsSync } from 'node:fs';
9
9
  import { execFileSync } from 'node:child_process';
10
- import { tmpdir } from 'node:os';
11
- import { join } from 'node:path';
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
- const baseDir = mkdtempSync(join(tmpdir(), 'flowviant-fleet-'));
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 and freeing worktrees…');
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)) git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
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
- brief?.branch
95
- ? `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).`
96
- : `Start from the clean base checkout and open a fresh draft PR when done.`,
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 (parked on a blocker,
229
- // now resuming) its worktree holds hours of uncommitted work. Do NOT reset.
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.8.1",
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": {