flowviant 0.63.0 → 0.65.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.
@@ -1,167 +0,0 @@
1
- /**
2
- * WHAT STARTS THIS PROJECT — asked of a Claude, on the machine, in the
3
- * background.
4
- *
5
- * WHY THIS EXISTS. Starting a dev server used to require a command a human had
6
- * typed into a sheet, prefilled `npm run dev`. That prefill is a guess about
7
- * somebody else's stack, and it was called out as one: "the option to run or
8
- * start the dev server shouldnt be npm run dev or show it as npm run dev
9
- * because thats not agnostic to everyones set up. no need to show that."
10
- *
11
- * The mechanism is the driver's own: "we are literally asking a claude session
12
- * to start it for us" — and, decisively, "i still want claude to start the
13
- * server for me but i dont want it to literally open a chat. have it do it in
14
- * the background." So this is a headless turn. It reaches no transcript, spends
15
- * no tab, and leaves no message anybody has to read.
16
- *
17
- * IT ANSWERS WITH A STRING AND STARTS NOTHING. The server parses what comes
18
- * back, through the same `parseDevCommand` a human's answer goes through, and
19
- * hands the machine an ordinary start job on the next poll. That split is the
20
- * whole safety story: the policy for what may execute has exactly one
21
- * implementation, and it lives in the component this repo can actually upgrade.
22
- * A daemon that decided for itself what counted as a legal command would be a
23
- * second copy of that policy, free to drift, published, and unrecallable.
24
- *
25
- * IT MAY INSTALL. That is not a loophole in the install refusal — it is the
26
- * refusal's own stated remedy. `parseDevCommand` refuses `npm install` because
27
- * a SPAWNED command runs lifecycle scripts from the repo and every transitive
28
- * dependency with no agent in the loop; the file says the remedy is "a turn in
29
- * the tab: a human asking, an agent doing it". This is exactly that turn, with
30
- * the human asking by pressing the button. And it is the case that matters: a
31
- * fresh worktree has no `node_modules` (they are gitignored, so they never come
32
- * across with the branch), which is precisely the dead end that produced "it
33
- * was stuck on 'working', does it really take that long to run dev?"
34
- *
35
- * IT CLEANS UP AFTER ITSELF. A `-p` turn writes a transcript, and
36
- * `localSessions.mjs` offers the newest ended session per directory as
37
- * adoptable — left behind, every resolve would drop a phantom untitled session
38
- * into the `+` menu.
39
- */
40
-
41
- import { runTurn } from './claude.mjs';
42
- import { removeProbeTranscript } from './runtimes.mjs';
43
-
44
- /** Long, because an install can sit in front of the answer. The SERVER holds
45
- * the real ceiling (`RESOLVE_TTL_MS`); this is the machine giving up first so
46
- * a wedged child does not hold a slot until then. */
47
- export const DEV_RESOLVE_TIMEOUT_MS = 10 * 60_000;
48
-
49
- /** The sentinel for "I could not tell", so an honest failure is distinguishable
50
- * from a model padding an answer it does not have. */
51
- export const NO_COMMAND = 'NONE';
52
-
53
- /**
54
- * The argv0s the server will accept. Named in the prompt NOT as a security
55
- * control — the server enforces it either way, and would refuse anything else
56
- * with the parser's own words — but because a model that knows the shape of an
57
- * acceptable answer gives one, and a refused proposal costs the asker a whole
58
- * round trip to learn nothing.
59
- */
60
- const ALLOWED = [
61
- 'npm', 'pnpm', 'yarn', 'bun', 'node', 'deno', 'go', 'python', 'python3',
62
- 'make', 'cargo', 'rails', 'php', 'dotnet',
63
- ];
64
-
65
- export function resolvePrompt() {
66
- return [
67
- 'Work out the ONE command that starts this project’s development server, and reply with only that command.',
68
- '',
69
- 'How to work it out: read the repo. Check package.json scripts, Makefile, Procfile, docker-compose, pyproject.toml, Cargo.toml, README — whatever this project actually uses. Prefer the script the project itself documents for local development.',
70
- '',
71
- 'You MAY install dependencies first if they are missing (for example a worktree with no node_modules). Do that before answering.',
72
- '',
73
- 'Rules for the answer:',
74
- `- It must begin with one of: ${ALLOWED.join(', ')} — or a ./path to a script in this repo.`,
75
- '- One line. No shell operators (&&, |, ;, >, $, backticks). If the project needs several steps, name a script in the repo that does them.',
76
- '- Not an install command. Install as part of your work above if needed; the answer is the command that RUNS the server.',
77
- '- It must not daemonize or background itself. It should stay in the foreground; something else supervises it.',
78
- `- If you genuinely cannot tell, reply exactly ${NO_COMMAND}.`,
79
- '',
80
- 'Reply with the command alone — no explanation, no backticks, no prose.',
81
- ].join('\n');
82
- }
83
-
84
- /**
85
- * Last plausible command line out of whatever the model said.
86
- *
87
- * DELIBERATELY FORGIVING, because the cost of being wrong is low and asymmetric:
88
- * the server parses this and refuses anything outside the policy, naming what
89
- * was proposed. Being strict here would turn a model that wrapped its answer in
90
- * backticks into a failure the asker cannot act on, having already paid for the
91
- * turn.
92
- */
93
- export function pickCommand(text) {
94
- const lines = String(text ?? '')
95
- .split('\n')
96
- .map((l) => l.trim())
97
- // Fence markers and bullet/quote decoration, which are formatting rather
98
- // than part of anybody's command.
99
- .filter((l) => l && !/^```/.test(l))
100
- .map((l) => l.replace(/^[-*>\s]+/, '').replace(/^`+|`+$/g, '').trim())
101
- .filter(Boolean);
102
- if (lines.length === 0) return null;
103
- // The LAST such line: a model that explains before complying puts the answer
104
- // at the end, and one that complies exactly has only one line anyway.
105
- const last = lines[lines.length - 1];
106
- if (!last || last === NO_COMMAND) return null;
107
- // A sentence is not a command. Cheap shape check so obvious prose becomes
108
- // "could not work it out" rather than a refusal quoting a paragraph back.
109
- if (last.split(/\s+/).length > 8 || /[.!?]$/.test(last)) return null;
110
- return last;
111
- }
112
-
113
- /**
114
- * Run the turn. Resolves `{ command }` or `{ error }` — never throws, because
115
- * the caller's only job with a failure is to relay it, and an exception at this
116
- * boundary would strand the row.
117
- */
118
- export async function resolveDevCommandOnMachine({ cwd, model, log, onActivity }) {
119
- let sessionId = null;
120
- let timer;
121
- try {
122
- const out = await Promise.race([
123
- runTurn({
124
- prompt: resolvePrompt(),
125
- cwd,
126
- streamJson: true,
127
- answerFromResult: true,
128
- model,
129
- label: 'dev',
130
- /**
131
- * THE TURN'S OWN HUMANIZED TAIL, forwarded so a browser can watch it.
132
- *
133
- * This is the only feature in the product where a Claude does work
134
- * nobody can see — no transcript, by design — and the driver's answer
135
- * to that is the right one: "we could have it stream the output for
136
- * transparency on the menu." So the same `read …` / `+ npm install …`
137
- * lines a tab relays are forwarded here. It is the CLI's own stdout
138
- * humanized, never an inference about it, which is the standing rule
139
- * for every activity readout in this product.
140
- */
141
- onActivity,
142
- onInit: (i) => {
143
- if (i?.sessionId) sessionId = i.sessionId;
144
- },
145
- }),
146
- new Promise((r) => {
147
- timer = setTimeout(() => r(null), DEV_RESOLVE_TIMEOUT_MS);
148
- timer.unref?.();
149
- }),
150
- ]);
151
- if (out === null) {
152
- return { error: 'your Claude did not finish working out how to start this project in time.' };
153
- }
154
- const command = pickCommand(out);
155
- if (!command) {
156
- return { error: 'your Claude could not work out how to start this project.' };
157
- }
158
- log?.(`dev: resolved start command — ${command}`);
159
- return { command };
160
- } catch (e) {
161
- return { error: `your Claude could not be run here: ${e?.message ?? 'unknown error'}` };
162
- } finally {
163
- clearTimeout(timer);
164
- // After the turn, so the delete does not race a child still writing.
165
- if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
166
- }
167
- }
@@ -1,306 +0,0 @@
1
- /**
2
- * RUNNING THE PROJECT'S DEV COMMAND IN A TAB'S WORKTREE.
3
- *
4
- * THIS FILE MUST NEVER READ A REPO FILE TO DECIDE WHAT TO EXECUTE. That is the
5
- * one rule, and it is the whole difference between this and the live-preview
6
- * target deleted in 2026-08-21, whose obituary is in `preview.mjs`: that one
7
- * read a command out of `.flowviant/preview.json` — a file the BRANCH controls
8
- * — or inferred one from package.json, then `spawn(cmd, {shell: true})` with
9
- * `{...process.env}`, running `npm install` and its lifecycle scripts and
10
- * handing the resulting internet-exposed process the daemon's own credential.
11
- * One click behind a button and a hostile branch owned the machine.
12
- *
13
- * Here the argv arrives ON THE JOB, already parsed from a string a human
14
- * approved once for this project and stored server-side. The branch cannot
15
- * change it. This file re-validates the SHAPE at its own boundary — one place
16
- * doing a check is one deploy away from being zero places — and spawns with
17
- * `shell: false`.
18
- *
19
- * THE HONEST LIMIT, stated here rather than implied: pinning the command does
20
- * not pin what the command does. `npm run dev` dereferences to `scripts.dev`,
21
- * which the branch writes. What this buys is that a human chose the ENTRYPOINT
22
- * in the open, plus a child environment that is a strict subset of what the
23
- * agent's own `npm run dev` gets today. The child runs as the SAME UID as this
24
- * daemon and `~/.flowviant/credentials.json` is 0600 and readable by it. The
25
- * env allowlist is a control against ACCIDENT AND INHERITANCE — a crash
26
- * reporter, an error page that dumps `process.env`, a build log — and it is NOT
27
- * confinement. Nothing in the UI may say "sandboxed" or "isolated".
28
- *
29
- * NO PORT IS EVER SCRAPED. The deleted feature learned its port from the
30
- * child's stdout and tunnelled a guess when it found none. A scraped port has
31
- * no attribution behind it, and "this port was measured, by cwd, inside THIS
32
- * worktree" is the only real security control this feature family has. The port
33
- * here comes from `listenersIn` or it does not come at all — and a running
34
- * server with no measured port is a REAL state that gets a sentence.
35
- */
36
-
37
- import { spawn } from 'node:child_process';
38
- import { existsSync } from 'node:fs';
39
- import { join } from 'node:path';
40
- import { homedir } from 'node:os';
41
- import { childEnv } from './childEnv.mjs';
42
- import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
43
- import { listenersIn } from './listeners.mjs';
44
- import { scrub } from './env.mjs';
45
-
46
- const FLOWVIANT_DIR = join(homedir(), '.flowviant');
47
- const REGISTRY = join(FLOWVIANT_DIR, 'devruns.json');
48
- const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'devruns.lock');
49
-
50
- /** Output we keep. Only the tail is ever uplinked, and it is scrubbed on the
51
- * way out: a dev server routinely prints connection strings. */
52
- const RING_BYTES = 512 * 1024;
53
- const TAIL_BYTES = 4096;
54
- /** How long we wait for the command to bind something inside the worktree
55
- * before reporting it as running-but-unmeasured. */
56
- const BIND_WATCH_MS = 45_000;
57
- /** At most this many restarts inside the window, and only for a run that bound
58
- * at least once — a server that never bound is a broken command, not a crash,
59
- * and restarting it burns the box. */
60
- const MAX_RESTARTS = 3;
61
- const RESTART_WINDOW_MS = 10 * 60_000;
62
- const BACKOFF_MS = [2_000, 8_000, 30_000];
63
- const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
64
-
65
- const remember = (entry) =>
66
- mutateRegistry(FLOWVIANT_DIR, REGISTRY, REGISTRY_LOCK, (list) => [
67
- ...list.filter((e) => e.pid !== entry.pid),
68
- entry,
69
- ]);
70
- const forget = (pid) =>
71
- mutateRegistry(FLOWVIANT_DIR, REGISTRY, REGISTRY_LOCK, (list) =>
72
- list.filter((e) => e.pid !== pid)
73
- );
74
-
75
- /** SIGTERM the GROUP, wait, then SIGKILL it. `npm run dev` spawns grandchildren
76
- * that outlive a kill of the parent, which is what `detached: true` and the
77
- * negative pid are for. */
78
- function killGroup(pid, graceMs = 8_000) {
79
- const signal = (sig) => {
80
- try {
81
- process.kill(-pid, sig);
82
- } catch {
83
- try {
84
- process.kill(pid, sig);
85
- } catch {
86
- /* already gone */
87
- }
88
- }
89
- };
90
- signal('SIGTERM');
91
- setTimeout(() => {
92
- if (processAlive(pid)) signal('SIGKILL');
93
- }, graceMs).unref?.();
94
- }
95
-
96
- /**
97
- * THE FRESH-WORKTREE ANSWER, measured rather than discovered as a bug.
98
- *
99
- * `ensureWorktree` is a bare `git worktree add`, so a new tab has source and no
100
- * `node_modules`, and the first run there would fail with something unhelpful.
101
- * This is a measurement, and it routes dependency installation to the one place
102
- * that should own it: a turn in the tab, with a human asking and an audit row
103
- * for it. The button is never silently broken and the remedy is one sentence.
104
- */
105
- export function missingDeps(worktree, argv) {
106
- if (!PACKAGE_MANAGERS.has(argv[0])) return false;
107
- return !existsSync(join(worktree, 'node_modules'));
108
- }
109
-
110
- /**
111
- * Start the command and supervise it.
112
- *
113
- * `onState` is called with `{started, port, pid, error, endedReason, logTail,
114
- * restarts}` at each transition; the caller reports it upward. Resolves once
115
- * the first outcome is known — bound, or running-unmeasured, or failed — and
116
- * keeps supervising after that.
117
- */
118
- export function startDevServer({ sessionId, worktree, argv, log, onState, onExit }) {
119
- if (missingDeps(worktree, argv)) {
120
- return Promise.resolve({
121
- ok: false,
122
- endedReason: 'no_deps',
123
- error:
124
- "No dependencies are installed in this tab's worktree. Ask your Claude to install them, then run dev again.",
125
- });
126
- }
127
-
128
- let ring = '';
129
- let child = null;
130
- let stopped = false;
131
- let everBound = false;
132
- let restarts = 0;
133
- const restartTimes = [];
134
-
135
- const append = (buf) => {
136
- ring = (ring + buf.toString('utf8')).slice(-RING_BYTES);
137
- };
138
- const tail = () => scrub(ring.slice(-TAIL_BYTES));
139
-
140
- const spawnOnce = () => {
141
- child = spawn(argv[0], argv.slice(1), {
142
- cwd: worktree,
143
- env: childEnv({ cwd: worktree }),
144
- // Its own process group, so the whole tree can be reaped. Load-bearing:
145
- // a package manager is a wrapper and the server is its grandchild.
146
- detached: true,
147
- shell: false,
148
- stdio: ['ignore', 'pipe', 'pipe'],
149
- });
150
- // PERMANENT DRAIN LISTENERS, and this is not optional. A detached
151
- // long-lived child on piped stdio with no reader BLOCKS ON WRITE once the
152
- // pipe buffer fills, so a dev server would hang after a few minutes of HMR
153
- // logs — the least diagnosable failure this feature could have.
154
- child.stdout?.on('data', append);
155
- child.stderr?.on('data', append);
156
- return child;
157
- };
158
-
159
- return new Promise((resolve) => {
160
- let settled = false;
161
- const finish = (v) => {
162
- if (settled) return;
163
- settled = true;
164
- resolve(v);
165
- };
166
-
167
- const attachExit = () => {
168
- child.once('error', (e) => {
169
- forget(child?.pid);
170
- finish({ ok: false, endedReason: 'spawn_failed', error: String(e?.message || e) });
171
- });
172
- child.once('exit', (code, signal) => {
173
- const pid = child?.pid;
174
- forget(pid);
175
- if (stopped) return;
176
- // A command that NEVER bound is a broken command, not a crash. Restarting
177
- // it would spin the box on somebody's typo.
178
- const now = Date.now();
179
- while (restartTimes.length && now - restartTimes[0] > RESTART_WINDOW_MS) restartTimes.shift();
180
- if (everBound && restartTimes.length < MAX_RESTARTS) {
181
- const delay = BACKOFF_MS[Math.min(restartTimes.length, BACKOFF_MS.length - 1)];
182
- restartTimes.push(now);
183
- restarts += 1;
184
- log?.(`dev server exited (${signal || code}); restarting in ${delay / 1000}s`);
185
- setTimeout(() => {
186
- if (stopped) return;
187
- spawnOnce();
188
- attachExit();
189
- remember(entryFor());
190
- onState?.({ started: true, port: null, pid: child.pid, restarts, logTail: tail() });
191
- }, delay).unref?.();
192
- return;
193
- }
194
- onExit?.({
195
- exitCode: typeof code === 'number' ? code : null,
196
- signal: signal || null,
197
- logTail: tail(),
198
- endedReason: everBound ? 'crashed' : 'spawn_failed',
199
- error: everBound
200
- ? `the dev server exited (${signal || `code ${code}`})`
201
- : `the command exited immediately (${signal || `code ${code}`}) without listening`,
202
- });
203
- finish({ ok: false, endedReason: everBound ? 'crashed' : 'spawn_failed' });
204
- });
205
- };
206
-
207
- const entryFor = () => ({
208
- sessionId,
209
- pid: child.pid,
210
- cwd: worktree,
211
- startedAt: Date.now(),
212
- owner: process.pid,
213
- });
214
-
215
- try {
216
- spawnOnce();
217
- } catch (e) {
218
- finish({ ok: false, endedReason: 'spawn_failed', error: String(e?.message || e) });
219
- return;
220
- }
221
- attachExit();
222
- remember(entryFor());
223
-
224
- // WATCH FOR THE BIND through `listenersIn` and nowhere else.
225
- const deadline = Date.now() + BIND_WATCH_MS;
226
- const poll = setInterval(() => {
227
- if (stopped || !child || child.exitCode !== null) {
228
- clearInterval(poll);
229
- return;
230
- }
231
- const found = listenersIn(worktree)[0];
232
- if (found) {
233
- clearInterval(poll);
234
- everBound = true;
235
- onState?.({ started: true, port: found.port, pid: child.pid, restarts, logTail: tail() });
236
- finish({ ok: true, port: found.port, pid: child.pid, stop, restarts });
237
- return;
238
- }
239
- if (Date.now() > deadline) {
240
- clearInterval(poll);
241
- // RUNNING, NOTHING MEASURED. A real state and not a spinner: a
242
- // `docker compose up` binds from inside a container whose cwd is not
243
- // this worktree and will never be attributed.
244
- onState?.({ started: true, port: null, pid: child.pid, restarts, logTail: tail() });
245
- finish({ ok: true, port: null, pid: child.pid, stop, restarts });
246
- }
247
- }, 1000);
248
- poll.unref?.();
249
-
250
- function stop() {
251
- stopped = true;
252
- clearInterval(poll);
253
- const pid = child?.pid;
254
- if (pid) {
255
- killGroup(pid);
256
- forget(pid);
257
- }
258
- }
259
- });
260
- }
261
-
262
- /**
263
- * What survived a daemon restart.
264
- *
265
- * IDENTITY IS NOT THE COMMAND STRING. `stillOurs` in `preview.mjs` matches a
266
- * substring of `/proc/<pid>/cmdline`, which cannot work here: `npm run dev` is
267
- * a human-authored string identical across two tabs, two worktrees, and the
268
- * driver's own hand-started server. Identity is the CWD — the same readlink
269
- * `listeners.mjs` already does — plus the pid still being alive. Both must
270
- * hold, and a could-not-measure is NOT a match and kills nothing.
271
- */
272
- export function adoptableDevRuns() {
273
- const out = [];
274
- for (const e of readRegistry(REGISTRY)) {
275
- if (!e || typeof e.sessionId !== 'string' || !Number.isInteger(e.pid)) continue;
276
- // Another LIVE daemon owns it — leave it alone entirely.
277
- if (e.owner && e.owner !== process.pid && processAlive(e.owner)) continue;
278
- if (!processAlive(e.pid)) continue;
279
- out.push(e);
280
- }
281
- return out;
282
- }
283
-
284
- /** Kill a registry entry's process group and drop the row. Used when the
285
- * session it belonged to is gone. */
286
- export function killDevRunEntry(entry) {
287
- if (!entry || !Number.isInteger(entry.pid)) return;
288
- killGroup(entry.pid);
289
- forget(entry.pid);
290
- }
291
-
292
- /** Adopt or reap what the previous process left behind. `activeIds` is the set
293
- * of sessions still live on the server; a run whose session is gone is killed,
294
- * and one whose session survives is handed back to the caller to re-supervise. */
295
- export function reapOrphanDevRuns(activeIds, log) {
296
- const adopt = [];
297
- for (const e of adoptableDevRuns()) {
298
- if (Array.isArray(activeIds) && !activeIds.includes(e.sessionId)) {
299
- log?.(`dev server for a closed tab (pid ${e.pid}) — stopping it`);
300
- killDevRunEntry(e);
301
- continue;
302
- }
303
- adopt.push(e);
304
- }
305
- return adopt;
306
- }