flowviant 0.47.1 → 0.48.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/README.md +25 -0
- package/bin/lib/claude.mjs +21 -6
- package/bin/lib/fleet.mjs +12 -0
- package/bin/lib/prompts.mjs +35 -6
- package/bin/lib/work.mjs +213 -1
- package/bin/lib/worktreeDiff.mjs +147 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,31 @@ Flowviant only stores the tunnel URL; your browser talks to it directly.
|
|
|
68
68
|
| `FLOWVIANT_TOKENS=a,b,c` | a static fleet, one worktree each |
|
|
69
69
|
| `FLOWVIANT_SAFE=1` | restrict the toolset instead of running unattended |
|
|
70
70
|
|
|
71
|
+
## Security posture
|
|
72
|
+
|
|
73
|
+
Every project member with edit access can run turns on this machine —
|
|
74
|
+
Workbench tabs and @-dispatches both execute a coding agent with the daemon's
|
|
75
|
+
own OS permissions. Membership is the consent boundary, the same trust plane
|
|
76
|
+
as the shared repository: invite people you would give a shell to.
|
|
77
|
+
|
|
78
|
+
Two knobs bound the blast radius, and both are worth setting on a shared box:
|
|
79
|
+
|
|
80
|
+
- **Run the daemon under a dedicated OS user** that owns only the repository
|
|
81
|
+
checkout and `~/.flowviant`. This is the single biggest hardening available
|
|
82
|
+
— a session can then only touch that account's files, not your keys, your
|
|
83
|
+
home directory, or the rest of the machine. A plain separate account works;
|
|
84
|
+
a systemd unit with `ProtectHome=read-only` and `ReadWritePaths=` works
|
|
85
|
+
better.
|
|
86
|
+
- **`FLOWVIANT_SAFE=1`** narrows the toolset: Claude to an allowlist
|
|
87
|
+
(edit/read/search plus `git`/`gh`/`npm`/`bun` — no arbitrary shell), Codex
|
|
88
|
+
to a workspace-write sandbox. Antigravity has no per-invocation narrowing —
|
|
89
|
+
its permission engine is machine-wide — which is surfaced in the app rather
|
|
90
|
+
than papered over.
|
|
91
|
+
|
|
92
|
+
The posture is reported on every poll and shown in the project's
|
|
93
|
+
Settings → Machine section, so the team can see whether the box runs the
|
|
94
|
+
guarded toolset or full permissions.
|
|
95
|
+
|
|
71
96
|
## License
|
|
72
97
|
|
|
73
98
|
MIT — see [LICENSE](./LICENSE).
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -191,7 +191,13 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
|
|
|
191
191
|
// bursts before/between tools; emitting only tools left long silent gaps).
|
|
192
192
|
// Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
|
|
193
193
|
// sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
|
|
194
|
-
|
|
194
|
+
//
|
|
195
|
+
// `answerFromResult` narrows that last part for callers whose `out` IS the
|
|
196
|
+
// answer rather than a haystack to match sentinels in (a Workbench tab's turn):
|
|
197
|
+
// every intermediate text block still NARRATES, but only the final `result`
|
|
198
|
+
// event contributes text — otherwise the same sentences arrive twice, once as
|
|
199
|
+
// they stream and once in the result, and the tab posts the duplicate.
|
|
200
|
+
function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult }) {
|
|
195
201
|
let ev;
|
|
196
202
|
try {
|
|
197
203
|
ev = JSON.parse(line);
|
|
@@ -212,15 +218,23 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
|
|
|
212
218
|
// marker — enough to show Claude is actively reasoning, not hung.
|
|
213
219
|
push({ kind: 'think', label: b.thinking ? `thinking: ${oneLine(b.thinking)}` : 'thinking…' });
|
|
214
220
|
} else if (b.type === 'text' && b.text?.trim()) {
|
|
215
|
-
appendText(b.text + '\n');
|
|
221
|
+
if (!answerFromResult) appendText(b.text + '\n');
|
|
216
222
|
push({ kind: 'say', label: oneLine(b.text) });
|
|
217
223
|
} else if (b.type === 'tool_use') {
|
|
218
224
|
push(humanizeToolUse(b.name, b.input || {}, cwd));
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
|
-
} else if (ev.type === 'result'
|
|
227
|
+
} else if (ev.type === 'result') {
|
|
222
228
|
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
223
|
-
appendText(ev.result + '\n');
|
|
229
|
+
if (typeof ev.result === 'string') appendText(ev.result + '\n');
|
|
230
|
+
else if (ev.is_error || ev.subtype) {
|
|
231
|
+
// A result that carries no text is a FAILED turn (a limit, a refused
|
|
232
|
+
// permission, an aborted run). Under `answerFromResult` this is the only
|
|
233
|
+
// stdout that would have said so, and a caller whose `out` is the answer
|
|
234
|
+
// must not report "no output" for a turn that explained itself.
|
|
235
|
+
const msg = ev.error?.message ?? ev.error ?? ev.subtype;
|
|
236
|
+
appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
|
|
237
|
+
}
|
|
224
238
|
}
|
|
225
239
|
}
|
|
226
240
|
|
|
@@ -233,7 +247,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
|
|
|
233
247
|
// returned string for sentinel detection, and each activity is handed to
|
|
234
248
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
235
249
|
// off and keep the raw text passthrough + line sentinels.
|
|
236
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
250
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
237
251
|
return new Promise((resolve) => {
|
|
238
252
|
const rt = runtimeById(runtime);
|
|
239
253
|
if (!rt.args) {
|
|
@@ -344,7 +358,8 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
344
358
|
};
|
|
345
359
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
346
360
|
const onLine = (line) => {
|
|
347
|
-
if (!rt.parse)
|
|
361
|
+
if (!rt.parse)
|
|
362
|
+
return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult });
|
|
348
363
|
const ev = rt.parse(line, cwd);
|
|
349
364
|
if (!ev) return;
|
|
350
365
|
// The conversation id, when the runtime announces one (codex's
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -101,6 +101,12 @@ async function fetchRoster(haveIds) {
|
|
|
101
101
|
// our own package.json). Older servers ignore unknown params, so sending it
|
|
102
102
|
// unconditionally is always safe.
|
|
103
103
|
url.searchParams.set('dv', VERSION);
|
|
104
|
+
// The permission posture this machine runs turns under — '1' when
|
|
105
|
+
// FLOWVIANT_SAFE narrows the toolset, '0' when everything is granted. A
|
|
106
|
+
// statement of configuration, not a request: the app SHOWS it in Settings
|
|
107
|
+
// so a team can see whether the shared box runs wide open, and enforces
|
|
108
|
+
// nothing (membership is the consent boundary). Older servers ignore it.
|
|
109
|
+
url.searchParams.set('safe', SAFE ? '1' : '0');
|
|
104
110
|
// WHICH CLIs this machine actually has, so the app can stop guessing.
|
|
105
111
|
//
|
|
106
112
|
// Until now every surface that listed Gemini or Codex said "not wired up yet"
|
|
@@ -1207,6 +1213,7 @@ export async function runFleetDaemon() {
|
|
|
1207
1213
|
processWorkTurns,
|
|
1208
1214
|
processShipJobs,
|
|
1209
1215
|
retireWorkSessions,
|
|
1216
|
+
reportWorktrees,
|
|
1210
1217
|
shutdownWork,
|
|
1211
1218
|
} = createWorkManager({
|
|
1212
1219
|
repoRoot,
|
|
@@ -1825,6 +1832,11 @@ export async function runFleetDaemon() {
|
|
|
1825
1832
|
// sessions are LIVE, and the guards above (chains, shipping) are populated
|
|
1826
1833
|
// by the intake this same tick.
|
|
1827
1834
|
retireWorkSessions(roster.activeWorkSessions);
|
|
1835
|
+
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1836
|
+
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1837
|
+
// themselves from a browser, relayed. After retirement so a directory that
|
|
1838
|
+
// just went away is not reported as a place.
|
|
1839
|
+
reportWorktrees(roster.activeWorkSessions);
|
|
1828
1840
|
// Terminal-session presence, throttled + dedup'd inside; never awaited —
|
|
1829
1841
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
1830
1842
|
// is already a tab, not something to offer adopting).
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -458,26 +458,40 @@ MECHANICS OF THIS TAB:
|
|
|
458
458
|
4. NEVER merge to main, deploy, or force-push unless the human explicitly says
|
|
459
459
|
so in this conversation. Branch pushes and PRs are fine when asked. Shipping
|
|
460
460
|
is their word to say, not yours to infer.
|
|
461
|
+
5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. A real pick between known
|
|
462
|
+
options — not an open question — ends your reply with a fenced block the app
|
|
463
|
+
renders as buttons; their click composes their answer as the next message:
|
|
464
|
+
|
|
465
|
+
\`\`\`flowviant-ask
|
|
466
|
+
{"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
|
|
467
|
+
\`\`\`
|
|
468
|
+
|
|
469
|
+
ONE block per reply, and always the LAST thing in it. Two to eight options,
|
|
470
|
+
each label short enough to sit on a button. multiSelect true only for a
|
|
471
|
+
genuine check-several-of-these case. NEVER for an open question — ask those
|
|
472
|
+
in prose, like anyone would. And ask the question in prose above the block
|
|
473
|
+
as well: a client that doesn't render the fence shows it as plain text, so
|
|
474
|
+
the reply has to read as a question with its options either way.
|
|
461
475
|
|
|
462
476
|
THE LEDGER. This session's work is logged as CARDS as it happens, by you,
|
|
463
477
|
through tools — so a four-hour churn doesn't evaporate into scrollback. The
|
|
464
478
|
rules:
|
|
465
479
|
|
|
466
|
-
|
|
480
|
+
6. CLAIM WHAT YOU WORK. When they say "take the auth card" or "next", call
|
|
467
481
|
list_cards, then claim_card the one they mean. The card you hold is the
|
|
468
482
|
tab's "Now" — it is how they and their team see what this session is doing.
|
|
469
|
-
|
|
483
|
+
7. LOG DRIFT, don't ask permission for it. "Also fix that redirect" mid-flow:
|
|
470
484
|
do the work, and file_card it — check list_cards FIRST; if a planned card
|
|
471
485
|
already covers it, claim that one instead of filing a twin. One card per
|
|
472
486
|
shippable unit. Never card-ify chatter, questions, or exploration.
|
|
473
|
-
|
|
487
|
+
8. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
|
|
474
488
|
one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
|
|
475
489
|
OBSERVED (the merge, on their word). Never claim done, and never deliver
|
|
476
490
|
work that isn't committed.
|
|
477
|
-
|
|
491
|
+
9. RAISE WHAT YOU SPOT. A design flaw, a follow-up they named for later —
|
|
478
492
|
raise_card, queued, unheld. You do not start raised work.
|
|
479
|
-
|
|
480
|
-
|
|
493
|
+
10. BE PROPORTIONAL. A one-line typo fix inside the card you already hold is
|
|
494
|
+
that card's work, not a new card. When in doubt, fewer cards.
|
|
481
495
|
|
|
482
496
|
POSTURE: terminal, not ticket. Don't ask permission to look at things. Don't
|
|
483
497
|
narrate ceremony. Ground claims in files you opened. When they ask a question,
|
|
@@ -514,6 +528,21 @@ MECHANICS OF THIS TAB:
|
|
|
514
528
|
4. NEVER merge to main, deploy, or force-push unless the human explicitly says
|
|
515
529
|
so in this conversation. Branch pushes are fine when asked. Shipping is
|
|
516
530
|
their word to say, not yours to infer.
|
|
531
|
+
5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. You have no tools here, but
|
|
532
|
+
this one costs none — it is text. A real pick between known options (not an
|
|
533
|
+
open question) ends your reply with a fenced block the app renders as
|
|
534
|
+
buttons; their click composes their answer as the next message:
|
|
535
|
+
|
|
536
|
+
\`\`\`flowviant-ask
|
|
537
|
+
{"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
|
|
538
|
+
\`\`\`
|
|
539
|
+
|
|
540
|
+
ONE block per reply, and always the LAST thing in it. Two to eight options,
|
|
541
|
+
each label short enough to sit on a button. multiSelect true only for a
|
|
542
|
+
genuine check-several-of-these case. NEVER for an open question — ask those
|
|
543
|
+
in prose, like anyone would. And ask the question in prose above the block
|
|
544
|
+
as well: a client that doesn't render the fence shows it as plain text, so
|
|
545
|
+
the reply has to read as a question with its options either way.
|
|
517
546
|
|
|
518
547
|
POSTURE: terminal, not ticket. Don't ask permission to look at things. Ground
|
|
519
548
|
claims in files you opened. When they ask a question, answer it; when they ask
|
package/bin/lib/work.mjs
CHANGED
|
@@ -43,12 +43,54 @@ import {
|
|
|
43
43
|
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
44
44
|
import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
|
|
45
45
|
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
46
|
+
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
46
47
|
import { homedir } from 'node:os';
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The shape a per-tab model name must have before it rides argv as
|
|
51
|
+
* `--model <name>`. Conservative for the same reason the codex thread id is
|
|
52
|
+
* (below): it comes off the wire and lands in a child process's arguments —
|
|
53
|
+
* alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
|
|
54
|
+
* leading dash, which is an argv that parses as a flag.
|
|
55
|
+
*/
|
|
56
|
+
const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
|
|
57
|
+
|
|
58
|
+
/** The five efforts the CLIs actually accept. A literal set rather than a
|
|
59
|
+
* pattern: there is no such thing as an effort we haven't heard of, and the
|
|
60
|
+
* server's own union is exactly this list. */
|
|
61
|
+
const WORK_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
|
|
65
|
+
*
|
|
66
|
+
* Absent is the resting state and it must stay genuinely absent: every tab ran
|
|
67
|
+
* with no `--model` and no `--effort` until now, so a job that names neither
|
|
68
|
+
* has to produce the byte-identical argv it produced yesterday — Claude falling
|
|
69
|
+
* back to the machine's MODEL pin, codex and agy to their own defaults. Hence
|
|
70
|
+
* an object with the key MISSING rather than one holding null: a null would
|
|
71
|
+
* reach the builders as a value and Claude's `model || MODEL` is the only one
|
|
72
|
+
* that would survive it.
|
|
73
|
+
*
|
|
74
|
+
* A value that fails its guard is DROPPED, not passed through and not an error.
|
|
75
|
+
* The honest outcome of "the server named a model this machine can't spell" is
|
|
76
|
+
* the machine's own default — a turn that runs — rather than a flag no CLI
|
|
77
|
+
* understands and a tab that fails every message.
|
|
78
|
+
*/
|
|
79
|
+
function brainFor(job) {
|
|
80
|
+
const out = {};
|
|
81
|
+
const model = typeof job?.model === 'string' ? job.model.trim() : '';
|
|
82
|
+
if (model && WORK_MODEL_RE.test(model)) out.model = model;
|
|
83
|
+
const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
|
|
84
|
+
if (effort && WORK_EFFORTS.has(effort)) out.effort = effort;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
48
88
|
export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
|
|
49
89
|
const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
|
|
50
90
|
const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
|
|
51
91
|
const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
|
|
92
|
+
const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
|
|
93
|
+
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
52
94
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
53
95
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
54
96
|
const MAX_WORK_TRIES = 3;
|
|
@@ -129,6 +171,144 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
129
171
|
else pendingShipReports.delete(sessionId);
|
|
130
172
|
return r;
|
|
131
173
|
};
|
|
174
|
+
/**
|
|
175
|
+
* THE TAB'S LIVE NARRATION — the terminal's own stdout, relayed.
|
|
176
|
+
*
|
|
177
|
+
* A turn used to be a spinner: the tab said "working…" for minutes and the
|
|
178
|
+
* only thing that ever appeared was the finished reply. The CLI is printing
|
|
179
|
+
* the whole time (thinking, reads, greps, commands), so the honest fix is to
|
|
180
|
+
* FORWARD that, not to invent a progress model on the server. Flowviant
|
|
181
|
+
* relays; it does not narrate on its own behalf.
|
|
182
|
+
*
|
|
183
|
+
* Best-effort by construction: throttled to one POST per window (a turn can
|
|
184
|
+
* emit hundreds of lines), never awaited by the turn, and every failure is
|
|
185
|
+
* swallowed. A spinner must never be able to fail a build. The server clears
|
|
186
|
+
* the line at settle, so a daemon killed mid-turn cannot leave one stuck.
|
|
187
|
+
*/
|
|
188
|
+
const ACTIVITY_MIN_MS = 1_500;
|
|
189
|
+
const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
|
|
190
|
+
const makeNarrator = (sessionId) => {
|
|
191
|
+
const recent = [];
|
|
192
|
+
let lastSent = 0;
|
|
193
|
+
let dirty = false;
|
|
194
|
+
let timer = null;
|
|
195
|
+
let sending = false;
|
|
196
|
+
let stopped = false;
|
|
197
|
+
const send = async () => {
|
|
198
|
+
if (sending || stopped) return;
|
|
199
|
+
sending = true;
|
|
200
|
+
dirty = false;
|
|
201
|
+
lastSent = Date.now();
|
|
202
|
+
const lines = recent.slice(-ACTIVITY_KEEP);
|
|
203
|
+
try {
|
|
204
|
+
await fetch(ACTIVITY_URL, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: {
|
|
207
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
208
|
+
'User-Agent': USER_AGENT,
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
},
|
|
211
|
+
signal: AbortSignal.timeout(10_000),
|
|
212
|
+
body: JSON.stringify({ sessionId, lines }),
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
/* narration is decoration — a dropped line is not an incident */
|
|
216
|
+
}
|
|
217
|
+
sending = false;
|
|
218
|
+
if (dirty && !stopped) schedule();
|
|
219
|
+
};
|
|
220
|
+
const schedule = () => {
|
|
221
|
+
if (timer || stopped) return;
|
|
222
|
+
const wait = Math.max(0, ACTIVITY_MIN_MS - (Date.now() - lastSent));
|
|
223
|
+
timer = setTimeout(() => {
|
|
224
|
+
timer = null;
|
|
225
|
+
void send();
|
|
226
|
+
}, wait);
|
|
227
|
+
timer.unref?.(); // never hold the process open for a spinner
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
line(label) {
|
|
231
|
+
const s = String(label ?? '')
|
|
232
|
+
.replace(/\s+/g, ' ')
|
|
233
|
+
.trim()
|
|
234
|
+
.slice(0, 200);
|
|
235
|
+
if (!s || stopped) return;
|
|
236
|
+
recent.push(s);
|
|
237
|
+
if (recent.length > ACTIVITY_KEEP * 2) recent.shift();
|
|
238
|
+
dirty = true;
|
|
239
|
+
schedule();
|
|
240
|
+
},
|
|
241
|
+
stop() {
|
|
242
|
+
stopped = true;
|
|
243
|
+
if (timer) {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
timer = null;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* WHERE EACH TAB IS STANDING, and what it holds — the readout a human would
|
|
253
|
+
* get by running `git status` in the session's directory, which is the one
|
|
254
|
+
* thing they cannot do from a browser.
|
|
255
|
+
*
|
|
256
|
+
* Two triggers, both cheap: right after a turn settles (the moment the diff
|
|
257
|
+
* changed) and a throttled sweep over every live session (a human editing in
|
|
258
|
+
* the worktree, a build writing files, a ship landing). Best-effort like the
|
|
259
|
+
* narrator: never awaited by a turn, every failure swallowed.
|
|
260
|
+
*/
|
|
261
|
+
const WORKTREE_SWEEP_MS = 60_000;
|
|
262
|
+
let lastWorktreeSweep = 0;
|
|
263
|
+
let sweepingWorktrees = false;
|
|
264
|
+
const postWorktrees = async (reports) => {
|
|
265
|
+
if (!reports.length) return;
|
|
266
|
+
try {
|
|
267
|
+
await fetch(WORKTREES_URL, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: {
|
|
270
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
271
|
+
'User-Agent': USER_AGENT,
|
|
272
|
+
'Content-Type': 'application/json',
|
|
273
|
+
},
|
|
274
|
+
signal: AbortSignal.timeout(20_000),
|
|
275
|
+
body: JSON.stringify({ reports }),
|
|
276
|
+
});
|
|
277
|
+
} catch {
|
|
278
|
+
/* a readout — the next sweep carries it */
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const sessionWorktreeReport = (sessionId) => {
|
|
282
|
+
if (!isSafePathSegment(sessionId)) return null;
|
|
283
|
+
const d = worktreeDiff(join(baseDir, 'sessions', sessionId), baseRef);
|
|
284
|
+
return d ? { sessionId, ...d } : null;
|
|
285
|
+
};
|
|
286
|
+
/** One session, now — called after its turn settles. */
|
|
287
|
+
const reportSessionWorktree = async (sessionId) => {
|
|
288
|
+
const r = sessionWorktreeReport(sessionId);
|
|
289
|
+
if (r) await postWorktrees([r]);
|
|
290
|
+
};
|
|
291
|
+
/** Every live session, throttled — called from the reconcile loop. */
|
|
292
|
+
const reportWorktrees = (activeIds) => {
|
|
293
|
+
if (!Array.isArray(activeIds) || activeIds.length === 0) return;
|
|
294
|
+
if (sweepingWorktrees) return;
|
|
295
|
+
if (Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
|
|
296
|
+
sweepingWorktrees = true;
|
|
297
|
+
lastWorktreeSweep = Date.now();
|
|
298
|
+
void (async () => {
|
|
299
|
+
try {
|
|
300
|
+
const reports = [];
|
|
301
|
+
for (const id of activeIds.slice(0, 20)) {
|
|
302
|
+
const r = sessionWorktreeReport(id);
|
|
303
|
+
if (r) reports.push(r);
|
|
304
|
+
}
|
|
305
|
+
await postWorktrees(reports);
|
|
306
|
+
} finally {
|
|
307
|
+
sweepingWorktrees = false;
|
|
308
|
+
}
|
|
309
|
+
})();
|
|
310
|
+
};
|
|
311
|
+
|
|
132
312
|
let flushingReports = false;
|
|
133
313
|
const flushWorkReports = async () => {
|
|
134
314
|
if (flushingReports) return;
|
|
@@ -879,12 +1059,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
879
1059
|
const mcp = plainTab
|
|
880
1060
|
? { args: [], env: null, dir: null }
|
|
881
1061
|
: mcpFor(rt.id, mint.token, getMcpUrl());
|
|
1062
|
+
// The tab's model/effort, if it named any. Spread into turnArgs so
|
|
1063
|
+
// BOTH runTurn calls below carry it — the retry is the same turn on
|
|
1064
|
+
// the same brain, not a quieter second opinion.
|
|
1065
|
+
const brain = brainFor(job);
|
|
882
1066
|
// Attempts count RUNS: the infra refusals above consumed nothing and
|
|
883
1067
|
// settled on their own terms.
|
|
884
1068
|
workAttempts.set(job.id, tries + 1);
|
|
885
1069
|
let out;
|
|
886
1070
|
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
887
1071
|
const spawned = []; // this turn's children, for the teardown registry
|
|
1072
|
+
const narrator = makeNarrator(job.sessionId);
|
|
888
1073
|
try {
|
|
889
1074
|
const message = [job.body, adoptNote, carryNote].filter(Boolean).join('\n\n');
|
|
890
1075
|
const turnArgs = {
|
|
@@ -908,6 +1093,16 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
908
1093
|
// ordinary --continue resume path, unchanged.
|
|
909
1094
|
...(adopting ? { adoptResumeId: job.adopt.id } : {}),
|
|
910
1095
|
system: plainTab ? SYSTEM_WORK_PLAIN : SYSTEM_WORK,
|
|
1096
|
+
// Present only when the tab named one — see brainFor.
|
|
1097
|
+
...brain,
|
|
1098
|
+
// The tab watches the CLI work. Claude needs the flag to speak
|
|
1099
|
+
// events at all (codex and agy always do); `answerFromResult`
|
|
1100
|
+
// keeps `out` — which IS the reply posted to the transcript — to
|
|
1101
|
+
// the final result, so streamed prose is narrated once and
|
|
1102
|
+
// posted once. Every line goes to the narrator above, throttled.
|
|
1103
|
+
streamJson: true,
|
|
1104
|
+
answerFromResult: true,
|
|
1105
|
+
onActivity: (a) => narrator.line(a?.label),
|
|
911
1106
|
cwd: dir.wt,
|
|
912
1107
|
mcpArgs: mcp.args,
|
|
913
1108
|
mcpEnv: mcp.env,
|
|
@@ -953,6 +1148,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
953
1148
|
if (!adopting && resume && !(out || '').trim())
|
|
954
1149
|
out = await runTurn({ ...turnArgs, resume: false });
|
|
955
1150
|
} finally {
|
|
1151
|
+
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
1152
|
+
// is cleared server-side at settle — clearing it here would race
|
|
1153
|
+
// the settle and blank the tab a beat before the reply lands.
|
|
1154
|
+
narrator.stop();
|
|
956
1155
|
for (const ch of spawned) workChildren.delete(ch);
|
|
957
1156
|
if (lockPath) {
|
|
958
1157
|
try {
|
|
@@ -1038,6 +1237,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1038
1237
|
warn(`session turn failed: ${e?.message ?? e}`);
|
|
1039
1238
|
} finally {
|
|
1040
1239
|
workAnswering.delete(job.id);
|
|
1240
|
+
// The turn just changed the directory — say what it looks like now,
|
|
1241
|
+
// whether it succeeded or blew up (a failed turn can still have
|
|
1242
|
+
// written half a file, and the tab should show that honestly). NOT
|
|
1243
|
+
// awaited: this runs inside the session's chain, and a slow POST
|
|
1244
|
+
// would delay the next turn of that tab behind a readout.
|
|
1245
|
+
void reportSessionWorktree(job.sessionId).catch(() => {});
|
|
1041
1246
|
}
|
|
1042
1247
|
});
|
|
1043
1248
|
}
|
|
@@ -1375,5 +1580,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1375
1580
|
}
|
|
1376
1581
|
};
|
|
1377
1582
|
|
|
1378
|
-
return {
|
|
1583
|
+
return {
|
|
1584
|
+
flushWorkReports,
|
|
1585
|
+
processWorkTurns,
|
|
1586
|
+
processShipJobs,
|
|
1587
|
+
retireWorkSessions,
|
|
1588
|
+
reportWorktrees,
|
|
1589
|
+
shutdownWork,
|
|
1590
|
+
};
|
|
1379
1591
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session's worktree actually holds, measured — not guessed.
|
|
3
|
+
*
|
|
4
|
+
* A Workbench tab IS a directory on this machine, on its own `session/<id>`
|
|
5
|
+
* branch, and the human driving it from a browser cannot run `git status` in
|
|
6
|
+
* it. So the daemon runs it for them: the branch, how far ahead of base it is,
|
|
7
|
+
* and the per-file diffstat — the same numbers `git diff --stat` prints in that
|
|
8
|
+
* directory, relayed rather than interpreted.
|
|
9
|
+
*
|
|
10
|
+
* Measured against the MERGE-BASE with the project's base ref, and against the
|
|
11
|
+
* WORKING TREE rather than HEAD, so one number answers the question a human
|
|
12
|
+
* actually asks ("what has this session changed?") with committed and
|
|
13
|
+
* uncommitted work in the same total. Untracked files count too: git calls them
|
|
14
|
+
* nothing until they are added, and a human calls them new work.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is best-effort and read-only. A worktree mid-rebase, a
|
|
17
|
+
* deleted directory, a file that vanished between listing and reading — each
|
|
18
|
+
* degrades to a smaller answer, never to a thrown error. Nothing about a
|
|
19
|
+
* readout is worth failing a turn over.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { git } from './git.mjs';
|
|
25
|
+
|
|
26
|
+
/** Rows reported. The rail shows a handful; the totals below cover the rest. */
|
|
27
|
+
const MAX_FILES = 20;
|
|
28
|
+
/** Untracked paths we are willing to open. A stray build directory that isn't
|
|
29
|
+
* gitignored must not turn a 60-second sweep into a disk crawl. */
|
|
30
|
+
const MAX_UNTRACKED_SCAN = 200;
|
|
31
|
+
/** Past this we call a file binary rather than counting its lines. */
|
|
32
|
+
const MAX_COUNT_BYTES = 512 * 1024;
|
|
33
|
+
|
|
34
|
+
/** Lines in a buffer, the way a diff counts them: a trailing newline does not
|
|
35
|
+
* add a line, and a NUL byte anywhere means we are not looking at text. */
|
|
36
|
+
function countLines(buf) {
|
|
37
|
+
if (buf.includes(0)) return null; // binary — git's own heuristic
|
|
38
|
+
const s = buf.toString('utf8');
|
|
39
|
+
if (s === '') return 0;
|
|
40
|
+
const n = s.split('\n').length;
|
|
41
|
+
return s.endsWith('\n') ? n - 1 : n;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} wt the worktree directory
|
|
46
|
+
* @param {string} baseRef the project's base ref (e.g. `origin/main`)
|
|
47
|
+
* @returns {null | {branch:string, path:string, ahead:number, dirty:boolean,
|
|
48
|
+
* additions:number, deletions:number, fileCount:number, truncated:number,
|
|
49
|
+
* files:{path:string, added:number, deleted:number, binary?:boolean}[]}}
|
|
50
|
+
*/
|
|
51
|
+
export function worktreeDiff(wt, baseRef) {
|
|
52
|
+
if (!wt || !existsSync(wt)) return null;
|
|
53
|
+
let branch = '';
|
|
54
|
+
try {
|
|
55
|
+
branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], wt);
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // not a worktree (or not readable) — report nothing, not zeros
|
|
58
|
+
}
|
|
59
|
+
let base = '';
|
|
60
|
+
try {
|
|
61
|
+
base = git(['merge-base', 'HEAD', baseRef], wt);
|
|
62
|
+
} catch {
|
|
63
|
+
/* a branch with no common ancestor (or an unfetched base) — fall back to
|
|
64
|
+
HEAD below, which still reports the uncommitted half honestly */
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const files = [];
|
|
68
|
+
let additions = 0;
|
|
69
|
+
let deletions = 0;
|
|
70
|
+
const push = (path, added, deleted, binary = false) => {
|
|
71
|
+
if (!path) return;
|
|
72
|
+
files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
|
|
73
|
+
additions += added;
|
|
74
|
+
deletions += deleted;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Tracked: working tree vs base. `git diff <base>` (no --cached, no second
|
|
78
|
+
// ref) is exactly "everything this session did", committed or not.
|
|
79
|
+
try {
|
|
80
|
+
const raw = git(['diff', '--numstat', base || 'HEAD'], wt);
|
|
81
|
+
for (const line of raw.split('\n')) {
|
|
82
|
+
if (!line.trim()) continue;
|
|
83
|
+
const [a, d, ...rest] = line.split('\t');
|
|
84
|
+
const path = rest.join('\t');
|
|
85
|
+
const binary = a === '-' || d === '-';
|
|
86
|
+
push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
/* report what we have */
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Untracked, minus everything gitignored — new files are the most visible
|
|
93
|
+
// work a session does and they would otherwise show as nothing at all.
|
|
94
|
+
try {
|
|
95
|
+
const others = git(['ls-files', '--others', '--exclude-standard'], wt)
|
|
96
|
+
.split('\n')
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
|
|
99
|
+
try {
|
|
100
|
+
const full = join(wt, path);
|
|
101
|
+
const st = statSync(full);
|
|
102
|
+
if (!st.isFile()) continue;
|
|
103
|
+
if (st.size > MAX_COUNT_BYTES) {
|
|
104
|
+
push(path, 0, 0, true);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const lines = countLines(readFileSync(full));
|
|
108
|
+
if (lines === null) push(path, 0, 0, true);
|
|
109
|
+
else push(path, lines, 0);
|
|
110
|
+
} catch {
|
|
111
|
+
/* vanished between listing and reading — it wasn't there to report */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
/* report what we have */
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let ahead = 0;
|
|
119
|
+
try {
|
|
120
|
+
if (base) ahead = Number(git(['rev-list', '--count', `${base}..HEAD`], wt)) || 0;
|
|
121
|
+
} catch {
|
|
122
|
+
/* leave at 0 */
|
|
123
|
+
}
|
|
124
|
+
let dirty = false;
|
|
125
|
+
try {
|
|
126
|
+
dirty = git(['status', '--porcelain'], wt) !== '';
|
|
127
|
+
} catch {
|
|
128
|
+
/* leave at false */
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Biggest first: with a 20-row cap, the rows that survive should be the ones
|
|
132
|
+
// worth looking at. Ties break by path so the list doesn't shuffle per sweep.
|
|
133
|
+
files.sort(
|
|
134
|
+
(x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
|
|
135
|
+
);
|
|
136
|
+
return {
|
|
137
|
+
branch,
|
|
138
|
+
path: wt,
|
|
139
|
+
ahead,
|
|
140
|
+
dirty,
|
|
141
|
+
additions,
|
|
142
|
+
deletions,
|
|
143
|
+
fileCount: files.length,
|
|
144
|
+
truncated: Math.max(0, files.length - MAX_FILES),
|
|
145
|
+
files: files.slice(0, MAX_FILES),
|
|
146
|
+
};
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, 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": {
|