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 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.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 { mkdtempSync, rmSync, existsSync } from 'node:fs';
8
+ import { mkdirSync, existsSync, rmSync } 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,
@@ -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
- return body.data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
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
- const baseDir = mkdtempSync(join(tmpdir(), 'flowviant-fleet-'));
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
- git(['worktree', 'remove', '--force', w.wt], repoRoot);
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 and freeing worktrees…');
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
- if (/already merged|not open|closed/i.test(err)) merged = true;
229
- else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
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
- if (job.prUrl) {
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
- execFileSync('git', ['push', 'origin', '--delete', job.branch], {
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)) git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
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
- 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.`,
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 (parked on a blocker,
229
- // now resuming) its worktree holds hours of uncommitted work. Do NOT reset.
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
- if (brief.branch) {
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
- delete env.ANTHROPIC_API_KEY; // force the user's Claude Code subscription
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
- if (completed) return { outcome: 'done', title, intentId };
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.8.1",
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": {