flowviant 0.6.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/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/cli.mjs +78 -0
- package/bin/lib/claude.mjs +151 -0
- package/bin/lib/config.mjs +55 -0
- package/bin/lib/fleet.mjs +363 -0
- package/bin/lib/git.mjs +44 -0
- package/bin/lib/live.mjs +483 -0
- package/bin/lib/login.mjs +84 -0
- package/bin/lib/preflight.mjs +49 -0
- package/bin/lib/preview.mjs +169 -0
- package/bin/lib/single.mjs +66 -0
- package/bin/lib/ui.mjs +26 -0
- package/package.json +37 -0
package/bin/lib/live.mjs
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live mode (phase 2, opt-in via FLOWVIANT_LIVE=1). Instead of one-shot
|
|
3
|
+
* `claude -p` turns + sentinels, each task runs a PERSISTENT Agent-SDK session:
|
|
4
|
+
* the daemon claims, seeds the session with the brief, mirrors the model's
|
|
5
|
+
* streamed reply into the task channel (stream_turn), injects human @-messages
|
|
6
|
+
* as new turns, and bridges blockers (the session idle-parks; the daemon polls
|
|
7
|
+
* the human's answer and injects it to resume in place). Same session = the
|
|
8
|
+
* iterating loop, hosted through Flowviant.
|
|
9
|
+
*
|
|
10
|
+
* Auth invariant: the SDK runs the user's own Claude Code (subscription), never
|
|
11
|
+
* the API — we strip ANTHROPIC_API_KEY from the session env so a key in the
|
|
12
|
+
* user's shell can't silently divert to API billing.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: the SDK mechanics here (streaming-input continuity, tool_use visibility,
|
|
15
|
+
* one result per turn) are validated by spikes; the end-to-end task loop needs a
|
|
16
|
+
* live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
+
import {
|
|
21
|
+
MCP_URL,
|
|
22
|
+
SAFE,
|
|
23
|
+
POLL_SECONDS,
|
|
24
|
+
IDLE_SECONDS,
|
|
25
|
+
PARK_TIMEOUT_SECONDS,
|
|
26
|
+
FLEET_URL,
|
|
27
|
+
FLEET_TOKEN,
|
|
28
|
+
USER_AGENT,
|
|
29
|
+
} from './config.mjs';
|
|
30
|
+
import { c, info, ok, warn } from './ui.mjs';
|
|
31
|
+
import { sleep } from './claude.mjs';
|
|
32
|
+
import { git, resetWorktree } from './git.mjs';
|
|
33
|
+
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
34
|
+
|
|
35
|
+
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
36
|
+
// reviewer then drives it via "Open live preview" in the node.
|
|
37
|
+
const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
|
|
38
|
+
async function registerLiveTarget(intentId, kind, url) {
|
|
39
|
+
try {
|
|
40
|
+
await fetch(LIVE_TARGET_URL, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
44
|
+
'User-Agent': USER_AGENT,
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ intentId, kind, url }),
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
/* best-effort — the tunnel still works; it just isn't linked in the app */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SAFE_TOOLS = ['Edit', 'Write', 'Read', 'Grep', 'Glob', 'Bash', 'mcp__flowviant'];
|
|
55
|
+
|
|
56
|
+
// Appended to Claude Code's preset. The reliable copy of the contract also
|
|
57
|
+
// rides in the seed message below, so this degrades gracefully if the preset
|
|
58
|
+
// shape shifts between SDK versions.
|
|
59
|
+
const SYSTEM_LIVE = `You are a Flowviant build agent working ONE task inside a live, shared task
|
|
60
|
+
channel. START by stating your approach in plain language (a short plan) BEFORE
|
|
61
|
+
you touch any code — the whole team watches this channel and may redirect you.
|
|
62
|
+
A human teammate may message you mid-task; treat any injected "The human
|
|
63
|
+
answered…" or teammate line as a new instruction and adapt. There is NO terminal
|
|
64
|
+
and NO interactive prompt — your only channel to a human is the flowviant MCP
|
|
65
|
+
tools. When you hit a decision only a human can make, call report_blocker (with
|
|
66
|
+
options when you can) and then STOP your turn — do not spin or guess; you will be
|
|
67
|
+
resumed with the answer. When the work is done: call attach_evidence for EACH
|
|
68
|
+
acceptance criterion (this is the floor the human reviews against); open ONE draft
|
|
69
|
+
PR (git push + gh pr create --draft), call attach_pr, then call complete. A live
|
|
70
|
+
preview of your branch is started for you automatically for the review — you do
|
|
71
|
+
NOT need to open a tunnel or register a live target. NEVER merge — the human
|
|
72
|
+
reviews and merging is handled separately.`;
|
|
73
|
+
|
|
74
|
+
function seedPrompt(runId, brief, transcript) {
|
|
75
|
+
return [
|
|
76
|
+
`Your run id is ${runId}. Use it for every flowviant MCP tool call.`,
|
|
77
|
+
brief?.branch
|
|
78
|
+
? `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).`
|
|
79
|
+
: `Start from the clean base checkout and open a fresh draft PR when done.`,
|
|
80
|
+
``,
|
|
81
|
+
`Task brief:`,
|
|
82
|
+
JSON.stringify(brief ?? {}, null, 2),
|
|
83
|
+
...(transcript
|
|
84
|
+
? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
|
|
85
|
+
: []),
|
|
86
|
+
``,
|
|
87
|
+
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision; attach_evidence, open a draft PR, attach_pr, then complete when done.`,
|
|
88
|
+
].join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── MCP JSON-RPC client (the daemon's own calls, outside the session) ───────
|
|
92
|
+
// The flowviant MCP endpoint handles tools/call statelessly with a bearer
|
|
93
|
+
// worker token — no handshake — so this is all the daemon needs.
|
|
94
|
+
let rpcId = 0;
|
|
95
|
+
async function mcpCall(mcpUrl, token, name, args) {
|
|
96
|
+
const res = await fetch(mcpUrl, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${token}`,
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Accept: 'application/json',
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
jsonrpc: '2.0',
|
|
105
|
+
id: ++rpcId,
|
|
106
|
+
method: 'tools/call',
|
|
107
|
+
params: { name, arguments: args },
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) throw new Error(`mcp ${name} ${res.status}`);
|
|
111
|
+
const body = await res.json();
|
|
112
|
+
const text = body?.result?.content?.[0]?.text;
|
|
113
|
+
if (typeof text !== 'string') return null;
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(text);
|
|
116
|
+
} catch {
|
|
117
|
+
return { raw: text };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Flatten a tool_result's content (string | array of {type:'text',text}) to text.
|
|
122
|
+
function resultText(content) {
|
|
123
|
+
if (typeof content === 'string') return content;
|
|
124
|
+
if (Array.isArray(content))
|
|
125
|
+
return content.map((b) => (b?.type === 'text' ? b.text : '')).join('');
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
const BLOCKER_ID_RE = /"blockerId"\s*:\s*"([^"]+)"/;
|
|
129
|
+
|
|
130
|
+
// A streaming-input controller: seed message first, then push() more turns as
|
|
131
|
+
// they arrive (human @-messages, injected blocker answers). close() ends it.
|
|
132
|
+
function makeInput(seedText) {
|
|
133
|
+
const q = [{ type: 'user', message: { role: 'user', content: seedText }, parent_tool_use_id: null }];
|
|
134
|
+
let waker = null;
|
|
135
|
+
let closed = false;
|
|
136
|
+
return {
|
|
137
|
+
push(text, priority) {
|
|
138
|
+
q.push({
|
|
139
|
+
type: 'user',
|
|
140
|
+
message: { role: 'user', content: text },
|
|
141
|
+
parent_tool_use_id: null,
|
|
142
|
+
...(priority ? { priority } : {}),
|
|
143
|
+
});
|
|
144
|
+
if (waker) { waker(); waker = null; }
|
|
145
|
+
},
|
|
146
|
+
close() {
|
|
147
|
+
closed = true;
|
|
148
|
+
if (waker) { waker(); waker = null; }
|
|
149
|
+
},
|
|
150
|
+
async *stream() {
|
|
151
|
+
while (true) {
|
|
152
|
+
if (q.length === 0) {
|
|
153
|
+
if (closed) return;
|
|
154
|
+
await new Promise((r) => (waker = r));
|
|
155
|
+
if (closed && q.length === 0) return;
|
|
156
|
+
}
|
|
157
|
+
while (q.length) yield q.shift();
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A stop word from any teammate halts the agent (interrupt at the next boundary,
|
|
164
|
+
// then hold for direction) — the "stop, you're going the wrong way" valve.
|
|
165
|
+
const STOP_RE = /(^|\W)stop(\W|$)/i;
|
|
166
|
+
|
|
167
|
+
// Idle-park on a blocker: the session is idle (zero tokens); poll the human's
|
|
168
|
+
// answer. Bounded by PARK_TIMEOUT — after that we tear the session down (free
|
|
169
|
+
// the Claude process) and resume later, rather than hold it open forever.
|
|
170
|
+
// Returns {status:'resolved',answer} | {status:'timeout'} | {status:'aborted'}.
|
|
171
|
+
async function waitForResolution(mcpUrl, token, blockerId, isAlive) {
|
|
172
|
+
if (!blockerId) return { status: 'aborted' };
|
|
173
|
+
const deadline = Date.now() + PARK_TIMEOUT_SECONDS * 1000;
|
|
174
|
+
while (isAlive()) {
|
|
175
|
+
await sleep(POLL_SECONDS);
|
|
176
|
+
const r = await mcpCall(mcpUrl, token, 'get_blocker_resolution', { blockerId }).catch(() => null);
|
|
177
|
+
if (r?.resolved) return { status: 'resolved', answer: r.resolution ?? {} };
|
|
178
|
+
if (Date.now() >= deadline) return { status: 'timeout' };
|
|
179
|
+
}
|
|
180
|
+
return { status: 'aborted' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Park awaiting the next human message (used after a stop — no nudging).
|
|
184
|
+
// Returns the message, or null on shutdown/timeout.
|
|
185
|
+
async function waitForMessage(mcpUrl, token, runId, afterId, isAlive) {
|
|
186
|
+
const deadline = Date.now() + PARK_TIMEOUT_SECONDS * 1000;
|
|
187
|
+
while (isAlive()) {
|
|
188
|
+
await sleep(POLL_SECONDS);
|
|
189
|
+
const poll = await mcpCall(mcpUrl, token, 'poll_channel', {
|
|
190
|
+
runId,
|
|
191
|
+
...(afterId ? { afterId } : {}),
|
|
192
|
+
}).catch(() => null);
|
|
193
|
+
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
194
|
+
if (fresh.length) return fresh[fresh.length - 1];
|
|
195
|
+
if (Date.now() >= deadline) return null;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// One task: claim → seed → stream/mirror/inject/park → complete. Returns
|
|
201
|
+
// { outcome: 'nothing' | 'done' | 'blocked' | 'stalled' | 'error' }.
|
|
202
|
+
export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
|
|
203
|
+
const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
|
|
204
|
+
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
205
|
+
const { runId, intentId } = claim;
|
|
206
|
+
const brief = claim.brief ?? {};
|
|
207
|
+
const title = brief.title ?? 'a task';
|
|
208
|
+
|
|
209
|
+
// Revision resumes its PR branch; otherwise a clean base checkout.
|
|
210
|
+
if (brief.branch) {
|
|
211
|
+
try {
|
|
212
|
+
git(['fetch', 'origin', '--quiet'], cwd);
|
|
213
|
+
git(['checkout', brief.branch], cwd);
|
|
214
|
+
} catch {
|
|
215
|
+
resetWorktree(cwd, baseRef);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
resetWorktree(cwd, baseRef);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const env = { ...process.env };
|
|
222
|
+
delete env.ANTHROPIC_API_KEY; // force the user's Claude Code subscription
|
|
223
|
+
|
|
224
|
+
// Prior channel transcript — present when resuming a parked/re-claimed task;
|
|
225
|
+
// seed it so a fresh session picks up where the conversation left off. afterId
|
|
226
|
+
// starts at the last existing message so we never re-inject old ones as "new".
|
|
227
|
+
const prior = await mcpCall(mcpUrl, token, 'poll_channel', { runId }).catch(() => null);
|
|
228
|
+
const priorMsgs = prior?.messages ?? [];
|
|
229
|
+
const transcript = priorMsgs
|
|
230
|
+
.map((m) => `${m.authorName || m.role}: ${m.content}`)
|
|
231
|
+
.join('\n');
|
|
232
|
+
let afterId = priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null;
|
|
233
|
+
|
|
234
|
+
const input = makeInput(seedPrompt(runId, brief, transcript));
|
|
235
|
+
const session = query({
|
|
236
|
+
prompt: input.stream(),
|
|
237
|
+
options: {
|
|
238
|
+
cwd,
|
|
239
|
+
env,
|
|
240
|
+
permissionMode: SAFE ? 'default' : 'bypassPermissions',
|
|
241
|
+
...(SAFE ? { allowedTools: SAFE_TOOLS } : {}),
|
|
242
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_LIVE },
|
|
243
|
+
mcpServers: {
|
|
244
|
+
flowviant: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${token}` } },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
let turnId = null;
|
|
250
|
+
let turnText = '';
|
|
251
|
+
let turnAt = null;
|
|
252
|
+
let completed = false;
|
|
253
|
+
let sawBlocker = false;
|
|
254
|
+
let blockerId = null;
|
|
255
|
+
let nudges = 0;
|
|
256
|
+
let held = false; // asked to stop — park for direction, don't nudge
|
|
257
|
+
|
|
258
|
+
const flush = async () => {
|
|
259
|
+
if (turnId && turnText.trim())
|
|
260
|
+
await mcpCall(mcpUrl, token, 'stream_turn', {
|
|
261
|
+
runId,
|
|
262
|
+
turnId,
|
|
263
|
+
text: turnText.trim(),
|
|
264
|
+
createdAt: turnAt,
|
|
265
|
+
}).catch(() => {});
|
|
266
|
+
};
|
|
267
|
+
const inject = (msgs) => {
|
|
268
|
+
afterId = msgs[msgs.length - 1].id;
|
|
269
|
+
input.push(msgs.map((f) => (f.authorName ? `${f.authorName}: ` : '') + f.content).join('\n'));
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
for await (const m of session) {
|
|
274
|
+
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
275
|
+
|
|
276
|
+
if (m.type === 'assistant') {
|
|
277
|
+
if (!turnId) {
|
|
278
|
+
turnId = `t-${runId}-${Date.now()}`;
|
|
279
|
+
turnAt = new Date().toISOString();
|
|
280
|
+
turnText = '';
|
|
281
|
+
}
|
|
282
|
+
for (const b of m.message?.content ?? []) {
|
|
283
|
+
if (b.type === 'text' && b.text) turnText += b.text;
|
|
284
|
+
else if (b.type === 'tool_use') {
|
|
285
|
+
const n = String(b.name ?? '');
|
|
286
|
+
if (n.endsWith('complete')) completed = true;
|
|
287
|
+
else if (n.endsWith('report_blocker')) sawBlocker = true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await flush();
|
|
291
|
+
} else if (m.type === 'user') {
|
|
292
|
+
// tool_result echoes — capture the blockerId report_blocker returned.
|
|
293
|
+
for (const b of m.message?.content ?? []) {
|
|
294
|
+
if (b?.type === 'tool_result') {
|
|
295
|
+
const hit = BLOCKER_ID_RE.exec(resultText(b.content));
|
|
296
|
+
if (hit) blockerId = hit[1];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} else if (m.type === 'result') {
|
|
300
|
+
await flush();
|
|
301
|
+
turnId = null;
|
|
302
|
+
|
|
303
|
+
if (completed) return { outcome: 'done', title, intentId };
|
|
304
|
+
|
|
305
|
+
if (sawBlocker) {
|
|
306
|
+
const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
|
|
307
|
+
if (res.status === 'resolved') {
|
|
308
|
+
input.push(`The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue.`);
|
|
309
|
+
sawBlocker = false;
|
|
310
|
+
blockerId = null;
|
|
311
|
+
nudges = 0;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (res.status === 'timeout') return { outcome: 'parked', title, intentId };
|
|
315
|
+
return { outcome: 'blocked', title, intentId }; // aborted (shutdown)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Pick up new human @-messages (this is also where a stop lands — the
|
|
319
|
+
// checkpoint model: halt at the boundary, not a hard mid-tool kill).
|
|
320
|
+
const poll = await mcpCall(mcpUrl, token, 'poll_channel', {
|
|
321
|
+
runId,
|
|
322
|
+
...(afterId ? { afterId } : {}),
|
|
323
|
+
}).catch(() => null);
|
|
324
|
+
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
325
|
+
|
|
326
|
+
if (fresh.some((f) => STOP_RE.test(f.content))) {
|
|
327
|
+
if (fresh.length) afterId = fresh[fresh.length - 1].id;
|
|
328
|
+
held = true;
|
|
329
|
+
input.push('A teammate asked you to STOP. Halt, summarize where you are in one line, and wait for direction — do not continue until told.');
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (fresh.length) {
|
|
333
|
+
inject(fresh);
|
|
334
|
+
nudges = 0;
|
|
335
|
+
held = false;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Held after a stop — park for the next human message; never nudge.
|
|
340
|
+
if (held) {
|
|
341
|
+
const next = await waitForMessage(mcpUrl, token, runId, afterId, isAlive);
|
|
342
|
+
if (!next) return { outcome: 'parked', title, intentId };
|
|
343
|
+
held = false;
|
|
344
|
+
nudges = 0;
|
|
345
|
+
inject([next]);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Idle turn with no completion — nudge a couple of times, then stop.
|
|
350
|
+
if (nudges < 2) {
|
|
351
|
+
nudges++;
|
|
352
|
+
input.push('Continue until the task is complete: open a draft PR and call complete, or report a blocker.');
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
return { outcome: 'stalled', title, intentId };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { outcome: completed ? 'done' : 'stalled', title, intentId };
|
|
359
|
+
} catch (e) {
|
|
360
|
+
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
361
|
+
} finally {
|
|
362
|
+
input.close();
|
|
363
|
+
try {
|
|
364
|
+
await session.interrupt?.();
|
|
365
|
+
} catch {
|
|
366
|
+
/* session already ended */
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
await session.return?.();
|
|
370
|
+
} catch {
|
|
371
|
+
/* generator already closed */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Per-agent loop — same signature/scaffolding as runFleetWorker, but each task
|
|
377
|
+
// is a persistent SDK session instead of a one-shot claude turn.
|
|
378
|
+
export async function runLiveWorker({
|
|
379
|
+
agentId,
|
|
380
|
+
label,
|
|
381
|
+
cwd,
|
|
382
|
+
baseRef,
|
|
383
|
+
getToken,
|
|
384
|
+
getHasWork,
|
|
385
|
+
getMcpUrl,
|
|
386
|
+
isAlive,
|
|
387
|
+
onTokenSuspect,
|
|
388
|
+
}) {
|
|
389
|
+
let phase = '';
|
|
390
|
+
const enter = (p, fn, msg) => {
|
|
391
|
+
if (phase !== p) {
|
|
392
|
+
phase = p;
|
|
393
|
+
fn(`${label} ${msg}`);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// One live preview at a time — the branch of the task most recently finished,
|
|
398
|
+
// kept up while it's in review (a gated agent parks, so it lives until review
|
|
399
|
+
// resolves). Replaced when the next task finishes; torn down on shutdown.
|
|
400
|
+
let preview = null;
|
|
401
|
+
const stopPreview = () => {
|
|
402
|
+
if (preview) {
|
|
403
|
+
try {
|
|
404
|
+
preview.stop();
|
|
405
|
+
} catch {
|
|
406
|
+
/* already gone */
|
|
407
|
+
}
|
|
408
|
+
preview = null;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const startReviewPreview = async (intentId) => {
|
|
412
|
+
stopPreview();
|
|
413
|
+
const cfg = loadPreviewConfig(cwd);
|
|
414
|
+
const kind = cfg?.ui ? 'ui' : cfg?.api ? 'api' : null;
|
|
415
|
+
const entry = kind ? cfg[kind] : null;
|
|
416
|
+
if (!entry || !intentId) return; // no preview config → captured evidence only
|
|
417
|
+
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
418
|
+
preview = await startPreview({
|
|
419
|
+
worktree: cwd,
|
|
420
|
+
kind,
|
|
421
|
+
cmd: entry.cmd,
|
|
422
|
+
port: entry.port,
|
|
423
|
+
log: (m) => info(`${label} ${c.dim(m)}`),
|
|
424
|
+
});
|
|
425
|
+
if (preview) {
|
|
426
|
+
await registerLiveTarget(intentId, kind, preview.url);
|
|
427
|
+
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
while (isAlive()) {
|
|
432
|
+
const token = getToken(agentId);
|
|
433
|
+
if (!token) {
|
|
434
|
+
await sleep(IDLE_SECONDS);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (!getHasWork(agentId)) {
|
|
438
|
+
enter('idle', info, 'idle — no work assigned');
|
|
439
|
+
await sleep(IDLE_SECONDS);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
let res;
|
|
443
|
+
try {
|
|
444
|
+
res = await runLiveTask({ mcpUrl: getMcpUrl() ?? MCP_URL, token, cwd, baseRef, isAlive });
|
|
445
|
+
} catch (e) {
|
|
446
|
+
enter('error', warn, `${c.yellow('error')} ${c.dim(`— ${e?.message ?? e}`)}`);
|
|
447
|
+
await sleep(IDLE_SECONDS);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (!isAlive()) break;
|
|
451
|
+
if (res.outcome === 'nothing') {
|
|
452
|
+
enter('idle', info, 'idle — no work assigned');
|
|
453
|
+
await sleep(IDLE_SECONDS);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (res.outcome === 'done') {
|
|
457
|
+
ok(`${label} ${c.dim(`finished "${res.title}" — PR opened for your review`)}`);
|
|
458
|
+
phase = '';
|
|
459
|
+
await startReviewPreview(res.intentId);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (res.outcome === 'parked') {
|
|
463
|
+
// Idle-parked too long on a blocker: we freed the Claude process. The intent
|
|
464
|
+
// stays claimed; a later poll re-claims + resumes (with transcript) once the
|
|
465
|
+
// human answers. Idle, don't hard-stop the worker.
|
|
466
|
+
enter('parked', info, `${c.dim('parked — freed the session; resumes when you answer in Flowviant')}`);
|
|
467
|
+
await sleep(IDLE_SECONDS);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (res.outcome === 'blocked') {
|
|
471
|
+
// Only reached on shutdown mid-park; the intent stays claimed and resumes
|
|
472
|
+
// on reconnect. Nothing to do but stop cleanly.
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
// stalled / error — usually a stale token or a stuck turn. Refresh + retry.
|
|
476
|
+
enter('reconnect', warn, `${c.yellow(res.outcome)} ${c.dim('— refreshing token, retrying')}`);
|
|
477
|
+
onTokenSuspect?.(agentId);
|
|
478
|
+
phase = '';
|
|
479
|
+
await sleep(IDLE_SECONDS);
|
|
480
|
+
}
|
|
481
|
+
stopPreview();
|
|
482
|
+
info(`${label} stopped`);
|
|
483
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flowviant login` — device-auth, like `gh auth login`. Removes the
|
|
3
|
+
* paste-a-secret-into-your-shell friction: the daemon shows a short code, you
|
|
4
|
+
* approve it in Flowviant (in a project), and the freshly-minted fleet
|
|
5
|
+
* credential is stored locally at ~/.flowviant/credentials.json. After that,
|
|
6
|
+
* plain `flowviant` just runs — no token, no env var.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
13
|
+
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
14
|
+
import { sleep } from './claude.mjs';
|
|
15
|
+
|
|
16
|
+
const CRED_DIR = join(homedir(), '.flowviant');
|
|
17
|
+
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
18
|
+
const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
|
|
19
|
+
const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
|
|
20
|
+
const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
21
|
+
|
|
22
|
+
/** The locally-stored credential from a prior `login`, or null. Read by config. */
|
|
23
|
+
export function readStoredCredential() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(CRED_FILE, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function store(cred) {
|
|
32
|
+
mkdirSync(CRED_DIR, { recursive: true });
|
|
33
|
+
writeFileSync(CRED_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function post(url, body) {
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
43
|
+
const j = await res.json();
|
|
44
|
+
return j.data ?? j;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runLogin() {
|
|
48
|
+
console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
|
|
49
|
+
let start;
|
|
50
|
+
try {
|
|
51
|
+
start = await post(DEVICE_START, {});
|
|
52
|
+
} catch (e) {
|
|
53
|
+
fail(`couldn't reach Flowviant (${e.message}).`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
|
|
57
|
+
const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
|
|
58
|
+
console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Agents')} panel → ${c.bold('Connect a machine')}.`);
|
|
59
|
+
console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
|
|
60
|
+
info('waiting for you to approve…');
|
|
61
|
+
|
|
62
|
+
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
await sleep(intervalSeconds);
|
|
65
|
+
let poll;
|
|
66
|
+
try {
|
|
67
|
+
poll = await post(DEVICE_POLL, { deviceCode });
|
|
68
|
+
} catch {
|
|
69
|
+
continue; // transient — keep polling
|
|
70
|
+
}
|
|
71
|
+
if (poll.status === 'approved') {
|
|
72
|
+
store({ fleetToken: poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
|
|
73
|
+
ok('connected — credential saved to ~/.flowviant/credentials.json');
|
|
74
|
+
console.log(`\n Now just run: ${c.bold('npx flowviant')}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (poll.status === 'expired') {
|
|
78
|
+
warn('that code expired — run `flowviant login` again.');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
warn('login timed out — run `flowviant login` again.');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup preflight: this tool DRIVES your local CLIs (it never sees their
|
|
3
|
+
* credentials), so it checks they're present + signed in and tells you exactly
|
|
4
|
+
* what's missing, rather than failing cryptically mid-run.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { ok, warn, info, c } from './ui.mjs';
|
|
9
|
+
|
|
10
|
+
function present(cmd) {
|
|
11
|
+
try {
|
|
12
|
+
execFileSync(cmd, ['--version'], { stdio: 'ignore' });
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ghAuthed() {
|
|
20
|
+
try {
|
|
21
|
+
execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Prints a checklist. Returns false only if a *fatal* prereq (claude, or git
|
|
29
|
+
* when worktrees are used) is missing — callers may warn-and-continue. */
|
|
30
|
+
export function preflight({ needGit = true } = {}) {
|
|
31
|
+
const claude = present('claude');
|
|
32
|
+
const gh = present('gh');
|
|
33
|
+
const node18 = Number(process.versions.node.split('.')[0]) >= 18;
|
|
34
|
+
const git = needGit ? present('git') : true;
|
|
35
|
+
|
|
36
|
+
info('checking your setup (this tool drives these — it never sees their logins):');
|
|
37
|
+
claude
|
|
38
|
+
? ok(`claude installed ${c.dim('· must be signed in — run `claude` once if you haven’t')}`)
|
|
39
|
+
: warn('claude NOT found — install Claude Code: https://claude.com/claude-code');
|
|
40
|
+
gh && ghAuthed()
|
|
41
|
+
? ok('gh authenticated')
|
|
42
|
+
: warn(gh ? 'gh not signed in — run: gh auth login' : 'gh NOT found — install it + run: gh auth login (needed to open PRs)');
|
|
43
|
+
if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
|
|
44
|
+
node18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} — need 18+`);
|
|
45
|
+
console.log('');
|
|
46
|
+
|
|
47
|
+
if (!claude) warn('Without `claude` nothing can run — install + sign in, then restart.');
|
|
48
|
+
return claude && git;
|
|
49
|
+
}
|