flowviant 0.61.0 → 0.62.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.
@@ -236,7 +236,17 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
236
236
  //
237
237
  // Only ever REPORTED, never enforced. Flowviant does not decide what your
238
238
  // Claude can do; it relays what your Claude said it has.
239
- if (Array.isArray(ev.skills)) onInit?.({ skills: ev.skills.map(String) });
239
+ // `sessionId` rides along for one reason: a `-p` turn WRITES a transcript,
240
+ // and `localSessions.mjs` offers the newest ended session per directory as
241
+ // ADOPTABLE — so any headless turn we run for our own purposes would leave
242
+ // a phantom untitled session in the `+` menu. The caller that needs to
243
+ // clean up after itself cannot do so without this id.
244
+ if (Array.isArray(ev.skills) || typeof ev.session_id === 'string') {
245
+ onInit?.({
246
+ skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
247
+ sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
248
+ });
249
+ }
240
250
  } else if (ev.type === 'result') {
241
251
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
242
252
  if (typeof ev.result === 'string') appendText(ev.result + '\n');
@@ -0,0 +1,155 @@
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 }) {
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
+ onInit: (i) => {
131
+ if (i?.sessionId) sessionId = i.sessionId;
132
+ },
133
+ }),
134
+ new Promise((r) => {
135
+ timer = setTimeout(() => r(null), DEV_RESOLVE_TIMEOUT_MS);
136
+ timer.unref?.();
137
+ }),
138
+ ]);
139
+ if (out === null) {
140
+ return { error: 'your Claude did not finish working out how to start this project in time.' };
141
+ }
142
+ const command = pickCommand(out);
143
+ if (!command) {
144
+ return { error: 'your Claude could not work out how to start this project.' };
145
+ }
146
+ log?.(`dev: resolved start command — ${command}`);
147
+ return { command };
148
+ } catch (e) {
149
+ return { error: `your Claude could not be run here: ${e?.message ?? 'unknown error'}` };
150
+ } finally {
151
+ clearTimeout(timer);
152
+ // After the turn, so the delete does not race a child still writing.
153
+ if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
154
+ }
155
+ }
@@ -1054,7 +1054,18 @@ function transcriptCandidates(cwd, sessionId) {
1054
1054
  return out;
1055
1055
  }
1056
1056
 
1057
- function removeProbeTranscript(cwd, sessionId) {
1057
+ /**
1058
+ * Delete the transcript a headless `-p` turn left behind.
1059
+ *
1060
+ * EXPORTED because the skills probe is no longer the only thing that runs one:
1061
+ * resolving a project's dev command is a background Claude turn too, and every
1062
+ * such turn has the same footprint. `claude -p` writes
1063
+ * `~/.claude/projects/<munged-cwd>/<id>.jsonl` at startup, and
1064
+ * `localSessions.mjs` offers the newest ENDED session per directory as
1065
+ * ADOPTABLE — so anything we run for our own purposes would put a phantom
1066
+ * untitled session in somebody's `+` menu.
1067
+ */
1068
+ export function removeProbeTranscript(cwd, sessionId) {
1058
1069
  if (!sessionId || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return;
1059
1070
  for (const f of transcriptCandidates(cwd, sessionId)) {
1060
1071
  try {
package/bin/lib/work.mjs CHANGED
@@ -36,11 +36,13 @@ import {
36
36
  USER_AGENT,
37
37
  REFRESH_BEFORE_SECONDS,
38
38
  DAEMON_INSTANCE,
39
+ MODEL,
39
40
  } from './config.mjs';
40
41
  import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
41
42
  import { listenersIn, listenersSupported } from './listeners.mjs';
42
43
  import { openTunnel } from './preview.mjs';
43
44
  import { startDevServer, reapOrphanDevRuns, killDevRunEntry } from './devServer.mjs';
45
+ import { resolveDevCommandOnMachine } from './devResolve.mjs';
44
46
  import { c, note, ok, warn } from './ui.mjs';
45
47
  import { mcpFor, runTurn } from './claude.mjs';
46
48
  import {
@@ -109,6 +111,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
109
111
  const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
110
112
  const DEV_RUN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-claim');
111
113
  const DEV_RUN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-done');
114
+ const DEV_RUN_RESOLVED_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-resolved');
112
115
  const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
113
116
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
114
117
  const workAnswering = new Set(); // turn ids currently queued/running here
@@ -774,6 +777,30 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
774
777
  }
775
778
  };
776
779
 
780
+ /**
781
+ * The answer to a `resolve`, handed up as a STRING for the server to parse.
782
+ *
783
+ * Never argv: `parseDevCommand` is the single owner of what may execute, and
784
+ * a second implementation of that policy inside the one component a deploy
785
+ * cannot upgrade is exactly the drift this product keeps closing.
786
+ */
787
+ const postDevResolved = async (body) => {
788
+ try {
789
+ await fetch(DEV_RUN_RESOLVED_URL, {
790
+ method: 'POST',
791
+ headers: {
792
+ Authorization: `Bearer ${FLEET_TOKEN}`,
793
+ 'User-Agent': USER_AGENT,
794
+ 'Content-Type': 'application/json',
795
+ },
796
+ signal: AbortSignal.timeout(30_000),
797
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
798
+ });
799
+ } catch {
800
+ /* the row's own TTL is the backstop */
801
+ }
802
+ };
803
+
777
804
  const stopDevRun = async (sessionId, reason) => {
778
805
  const live = liveDevRuns.get(sessionId);
779
806
  liveDevRuns.delete(sessionId);
@@ -798,6 +825,64 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
798
825
  continue;
799
826
  }
800
827
 
828
+ /**
829
+ * RESOLVE — ask a Claude what starts this project, and say so. Starts
830
+ * NOTHING.
831
+ *
832
+ * It exists because the sheet used to open with a text field prefilled
833
+ * `npm run dev`, which presumes a stack. The turn is headless and reaches
834
+ * no transcript: "i still want claude to start the server for me but i
835
+ * dont want it to literally open a chat. have it do it in the
836
+ * background."
837
+ *
838
+ * IT TAKES THE PLACE LOCK, through the same `chainFor` every turn goes
839
+ * through, and that is deliberate rather than incidental. The turn reads
840
+ * the repo and may `npm install` into this worktree; running it beside a
841
+ * tab turn editing the same directory is the exact collision the chain
842
+ * exists to prevent. The cost is that a resolve makes the tab wait, which
843
+ * is honest — you cannot usefully build while an install is running
844
+ * anyway — and it is bounded, because this turn ENDS. That is the whole
845
+ * reason it answers with a command instead of running one: a foreground
846
+ * `npm run dev` would never return, and the lock would be held for as
847
+ * long as the server lived.
848
+ */
849
+ if (job?.action === 'resolve') {
850
+ if (liveDevRuns.has(sessionId)) continue;
851
+ if (devRunClaiming.has(sessionId)) continue;
852
+ devRunClaiming.add(sessionId);
853
+ void (async () => {
854
+ try {
855
+ if (!(await claimDevRun(sessionId))) return; // somebody else has it
856
+ const wt = placeDir(sessionId);
857
+ const out = await chainFor(placeOf(sessionId), () =>
858
+ resolveDevCommandOnMachine({
859
+ cwd: wt,
860
+ // The machine's own pin, exactly as a tab turn gets — never
861
+ // the user's global default, which for Claude may be a
862
+ // long-context tier their subscription cannot bill autonomous
863
+ // work on. This turn is autonomous by definition.
864
+ model: MODEL,
865
+ log: (m) => note(`${sessionId.slice(0, 8)}: ${m}`),
866
+ })
867
+ );
868
+ await postDevResolved({
869
+ sessionId,
870
+ command: out?.command ?? null,
871
+ error: out?.error ?? null,
872
+ });
873
+ } catch (e) {
874
+ await postDevResolved({
875
+ sessionId,
876
+ command: null,
877
+ error: `the machine could not run that turn: ${e?.message ?? 'unknown error'}`,
878
+ });
879
+ } finally {
880
+ devRunClaiming.delete(sessionId);
881
+ }
882
+ })();
883
+ continue;
884
+ }
885
+
801
886
  // RE-VALIDATE THE SHAPE at this boundary. The server parsed the string
802
887
  // and owns the policy; the machine owns the refusal to execute something
803
888
  // malformed, because one place doing a check is one deploy away from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {