flowviant 0.60.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
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -824,6 +824,7 @@ export async function runFleetDaemon() {
824
824
  // retirement — lives in work.mjs; this hands it the loop's mutable state.
825
825
  const {
826
826
  flushWorkReports,
827
+ learnPlaces,
827
828
  processWorkTurns,
828
829
  processShipJobs,
829
830
  processDiffJobs,
@@ -1549,6 +1550,10 @@ export async function runFleetDaemon() {
1549
1550
  void flushWorkReports();
1550
1551
  processMergeJobs(roster.mergeJobs);
1551
1552
  processPatchRevertJobs(roster.patchRevertJobs);
1553
+ // WHERE each live tab works, BEFORE anything measures one: the sweep below
1554
+ // and every preview check ask `placeOf`, and a tab nobody has typed into
1555
+ // yet has taught it nothing.
1556
+ learnPlaces(roster.sessionPlaces);
1552
1557
  processWorkTurns(roster.workTurnJobs);
1553
1558
  // The roster's live-session list rides along: an ENDED session's ship
1554
1559
  // must not be refused by checks whose remedies need a live tab.
@@ -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
@@ -355,6 +358,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
355
358
  */
356
359
  const sessionPlaces = new Map();
357
360
  const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
361
+ /**
362
+ * Places straight off the roster, so a tab is measured the moment it EXISTS
363
+ * rather than after somebody types into it.
364
+ *
365
+ * Learning only from turn jobs meant a tab nobody had spoken to yet was
366
+ * measured at `sessions/<id>` — a directory that does not exist for a tab
367
+ * working in the checkout — so it reported no branch, no diffstat and no
368
+ * ports, and every control reading those had nothing to render.
369
+ *
370
+ * The roster is authoritative and turn jobs still agree with it; a session
371
+ * the server does not name keeps whatever a turn taught, and failing that its
372
+ * own id, which is the pre-places default.
373
+ */
374
+ const learnPlaces = (map) => {
375
+ if (!map || typeof map !== 'object') return;
376
+ for (const [sid, place] of Object.entries(map)) {
377
+ if (typeof place === 'string' && place) sessionPlaces.set(sid, place);
378
+ }
379
+ };
358
380
  /** The DIRECTORY a session works in. Every path that used to build
359
381
  * `sessions/<id>` by hand goes through here, or a tab in the checkout gets
360
382
  * measured against a directory that does not exist. */
@@ -755,6 +777,30 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
755
777
  }
756
778
  };
757
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
+
758
804
  const stopDevRun = async (sessionId, reason) => {
759
805
  const live = liveDevRuns.get(sessionId);
760
806
  liveDevRuns.delete(sessionId);
@@ -779,6 +825,64 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
779
825
  continue;
780
826
  }
781
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
+
782
886
  // RE-VALIDATE THE SHAPE at this boundary. The server parsed the string
783
887
  // and owns the policy; the machine owns the refusal to execute something
784
888
  // malformed, because one place doing a check is one deploy away from
@@ -2617,6 +2721,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2617
2721
 
2618
2722
  return {
2619
2723
  flushWorkReports,
2724
+ learnPlaces,
2620
2725
  processWorkTurns,
2621
2726
  processShipJobs,
2622
2727
  processDiffJobs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.60.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": {