flowviant 0.97.0 → 0.100.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.
Files changed (50) hide show
  1. package/README.md +15 -13
  2. package/bin/cli.mjs +108 -28
  3. package/bin/lib/agentPlan.mjs +159 -37
  4. package/bin/lib/artifacts.mjs +136 -26
  5. package/bin/lib/authReject.mjs +32 -0
  6. package/bin/lib/authproxy.mjs +52 -16
  7. package/bin/lib/childEnv.mjs +33 -1
  8. package/bin/lib/claude.mjs +166 -71
  9. package/bin/lib/config.mjs +36 -9
  10. package/bin/lib/credentials.mjs +29 -5
  11. package/bin/lib/deploy.mjs +364 -30
  12. package/bin/lib/desktopContract.mjs +227 -0
  13. package/bin/lib/env.mjs +130 -13
  14. package/bin/lib/fleet.mjs +121 -30
  15. package/bin/lib/git.mjs +131 -3
  16. package/bin/lib/hooks/readGuard.mjs +24 -0
  17. package/bin/lib/instance.mjs +129 -14
  18. package/bin/lib/knowledge.mjs +180 -60
  19. package/bin/lib/landed.mjs +38 -30
  20. package/bin/lib/launchCommand.mjs +23 -0
  21. package/bin/lib/listeners.mjs +211 -0
  22. package/bin/lib/localSessions.mjs +29 -4
  23. package/bin/lib/login.mjs +36 -12
  24. package/bin/lib/machines.mjs +24 -14
  25. package/bin/lib/mcp-cli.mjs +4 -3
  26. package/bin/lib/preflight.mjs +2 -1
  27. package/bin/lib/preview.mjs +95 -6
  28. package/bin/lib/printable.mjs +42 -0
  29. package/bin/lib/procRegistry.mjs +65 -0
  30. package/bin/lib/processes.mjs +20 -7
  31. package/bin/lib/projectTools.mjs +248 -0
  32. package/bin/lib/prompts.mjs +16 -11
  33. package/bin/lib/resources.mjs +46 -4
  34. package/bin/lib/runtimeOfferings.mjs +75 -0
  35. package/bin/lib/runtimes.mjs +71 -9
  36. package/bin/lib/shipMerge.mjs +8 -4
  37. package/bin/lib/trace.mjs +16 -6
  38. package/bin/lib/update.mjs +114 -25
  39. package/bin/lib/work.mjs +1192 -4584
  40. package/bin/lib/workAgentMerges.mjs +504 -0
  41. package/bin/lib/workAgentPlans.mjs +447 -0
  42. package/bin/lib/workAgentReview.mjs +702 -0
  43. package/bin/lib/workAgentTurns.mjs +1151 -0
  44. package/bin/lib/workDiffs.mjs +107 -0
  45. package/bin/lib/workPreviews.mjs +351 -0
  46. package/bin/lib/workProcesses.mjs +236 -0
  47. package/bin/lib/workPullRequests.mjs +313 -0
  48. package/bin/lib/workShip.mjs +385 -0
  49. package/bin/lib/worktreeDiff.mjs +99 -43
  50. package/package.json +2 -3
package/bin/lib/env.mjs CHANGED
@@ -69,8 +69,19 @@
69
69
  * caller.
70
70
  */
71
71
 
72
- import { readdirSync, readFileSync, writeFileSync, mkdirSync, lstatSync } from 'node:fs';
73
- import { createHash } from 'node:crypto';
72
+ import {
73
+ readdirSync,
74
+ readFileSync,
75
+ mkdirSync,
76
+ lstatSync,
77
+ openSync,
78
+ writeSync,
79
+ fsyncSync,
80
+ closeSync,
81
+ linkSync,
82
+ unlinkSync,
83
+ } from 'node:fs';
84
+ import { createHash, randomBytes } from 'node:crypto';
74
85
  import { homedir } from 'node:os';
75
86
  import { join, dirname } from 'node:path';
76
87
  // STILL SUMO, and deliberately not narrowed to the standard build in the same
@@ -170,7 +181,17 @@ function characterClasses(value) {
170
181
  }
171
182
  function worthRedacting(value) {
172
183
  if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
173
- if (SCRUB_PLAIN_RE.test(value)) return false;
184
+ // THE PLAIN-IDENTIFIER EXEMPTION DOES NOT COVER A CASE-AND-DIGIT MIX
185
+ // (2026-09-24, the audit). It was checked first and alone, so a generated
186
+ // password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
187
+ // character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
188
+ // [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
189
+ // this block's own promise that "a case mix" is redacted. A word, a hostname,
190
+ // a region or a version string does not draw on upper, lower AND digits at
191
+ // once; a minted secret almost always does.
192
+ if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
193
+ return false;
194
+ }
174
195
  if (value.length < 12 && characterClasses(value) < 3) return false;
175
196
  return true;
176
197
  }
@@ -277,19 +298,87 @@ export async function ensureKeypair() {
277
298
  };
278
299
  return keypair;
279
300
  }
280
- keypair = sodium.crypto_box_keypair();
281
- mkdirSync(dirname(KEYPAIR_PATH), { recursive: true });
282
- writeFileSync(
301
+ const minted = sodium.crypto_box_keypair();
302
+ const winner = mintKeypairFile(
283
303
  KEYPAIR_PATH,
284
304
  JSON.stringify({
285
- pub: sodium.to_base64(keypair.publicKey, B64()),
286
- priv: sodium.to_base64(keypair.privateKey, B64()),
287
- }),
288
- { mode: 0o600 }
305
+ pub: sodium.to_base64(minted.publicKey, B64()),
306
+ priv: sodium.to_base64(minted.privateKey, B64()),
307
+ })
289
308
  );
309
+ keypair = {
310
+ publicKey: sodium.from_base64(winner.pub, B64()),
311
+ privateKey: sodium.from_base64(winner.priv, B64()),
312
+ };
290
313
  return keypair;
291
314
  }
292
315
 
316
+ /**
317
+ * WRITE THE BOX'S IDENTITY ONCE, ATOMICALLY, AND LET THE FIRST WRITER WIN
318
+ * (2026-09-24, the audit). Returns the `{ pub, priv }` that is ON DISK after
319
+ * the call — ours, or the one a concurrent first start got there with.
320
+ *
321
+ * It was a plain `writeFileSync` reached by every process that saw ENOENT, so
322
+ * two daemons starting together on a fresh box (two projects, two units at
323
+ * boot) each minted a key and the LAST writer won: the first kept a key in
324
+ * memory the disk no longer held and arrived, after its next re-exec, as a
325
+ * different box. And a crash inside the write left a torn file that every later
326
+ * start refuses to read — the only-ENOENT-mints rule doing its job over a file
327
+ * nothing can fix.
328
+ *
329
+ * So the key goes to a private temp file, is fsync'd, and is LINKED into place:
330
+ * `link` fails with EEXIST instead of replacing, so exactly one identity is ever
331
+ * published and every loser reads the winner's. The file at the real path is
332
+ * either absent or complete — never half of one.
333
+ */
334
+ export function mintKeypairFile(path, body) {
335
+ mkdirSync(dirname(path), { recursive: true });
336
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
337
+ try {
338
+ const fd = openSync(tmp, 'wx', 0o600);
339
+ try {
340
+ writeSync(fd, body);
341
+ fsyncSync(fd);
342
+ } finally {
343
+ closeSync(fd);
344
+ }
345
+ try {
346
+ linkSync(tmp, path);
347
+ return JSON.parse(body);
348
+ } catch (e) {
349
+ if (e?.code === 'EEXIST') {
350
+ // Somebody else published first. Theirs is the identity; ours is dropped.
351
+ return JSON.parse(readFileSync(path, 'utf8'));
352
+ }
353
+ // A filesystem with no hard links (some network and FUSE mounts answer
354
+ // EPERM/ENOTSUP) must not cost the box its identity — the plain write
355
+ // this replaced worked there. Fall back to an EXCLUSIVE create: still
356
+ // first-writer-wins, only without the torn-write guarantee.
357
+ if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV'].includes(e?.code)) throw e;
358
+ let fd2;
359
+ try {
360
+ fd2 = openSync(path, 'wx', 0o600);
361
+ } catch (e2) {
362
+ if (e2?.code !== 'EEXIST') throw e2;
363
+ return JSON.parse(readFileSync(path, 'utf8'));
364
+ }
365
+ try {
366
+ writeSync(fd2, body);
367
+ fsyncSync(fd2);
368
+ } finally {
369
+ closeSync(fd2);
370
+ }
371
+ return JSON.parse(body);
372
+ }
373
+ } finally {
374
+ try {
375
+ unlinkSync(tmp);
376
+ } catch {
377
+ /* already gone */
378
+ }
379
+ }
380
+ }
381
+
293
382
  export function myPubB64() {
294
383
  return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
295
384
  }
@@ -720,12 +809,35 @@ export function scanEnvForScrub(repoRoot, salt = PROJECT_ID) {
720
809
  * ordinary word, a short flag and anything under the length floor are simply
721
810
  * not in it. */
722
811
  export function scrub(text) {
723
- if (typeof text !== 'string' || !text || !values.length) return text;
812
+ if (typeof text !== 'string' || !text) return text;
724
813
  let out = text;
725
814
  for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
726
- return out;
815
+ return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
727
816
  }
728
817
 
818
+ /**
819
+ * EVERY FLOWVIANT CREDENTIAL, CAUGHT BY SHAPE (2026-09-24, the audit).
820
+ *
821
+ * The value lists above are the CHECKOUT'S secrets and the process
822
+ * environment's, and neither holds the one secret that costs the project
823
+ * itself: the MACHINE CREDENTIAL. It sits in `~/.flowviant/credentials.json` —
824
+ * for EVERY project connected on this box, 0600 and readable by every turn,
825
+ * since a turn runs as this uid — and the server mints more of the same shape
826
+ * per turn (the work and capture tokens a tab's MCP server carries). A turn
827
+ * that read the store and wrote it into an artifact, a trace or an answer
828
+ * shipped it verbatim, because `scrub` had no value to match; and a design
829
+ * artifact runs scripts in a frame that may navigate itself, so the page that
830
+ * held it could carry it anywhere.
831
+ *
832
+ * Every one of them is `fva_` + 40 characters of nanoid (the server's
833
+ * `AGENT_TOKEN_PREFIX`), so a SHAPE catches them all, including ones this
834
+ * process never saw, with no store read and nothing to keep in sync. Twenty is
835
+ * the floor so the 12-character prefix the app shows for identification
836
+ * (`fva_` + 8) still reads as itself.
837
+ */
838
+ const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
839
+ const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
840
+
729
841
  /**
730
842
  * The NAME of the first known secret whose value appears in `bytes` as an
731
843
  * exact byte substring, or null. For the payloads `scrub` cannot touch: a
@@ -738,7 +850,12 @@ export function scrub(text) {
738
850
  * it is used (artifacts.mjs).
739
851
  */
740
852
  export function secretIn(bytes) {
741
- if (!Buffer.isBuffer(bytes) || !bytes.length || !values.length) return null;
853
+ if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
742
854
  for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
855
+ // The token SHAPE too (see `scrub`). `latin1` maps every byte to one
856
+ // character, so the ASCII pattern matches exactly where the bytes do.
857
+ if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
858
+ return FLOWVIANT_TOKEN_NAME;
859
+ }
743
860
  return null;
744
861
  }
package/bin/lib/fleet.mjs CHANGED
@@ -34,11 +34,16 @@ import {
34
34
  LIVE,
35
35
  AUTO_UPDATE,
36
36
  CREDENTIAL,
37
+ learnProjectId,
38
+ storedCredentialInUse,
37
39
  } from './config.mjs';
38
- import { projectLabel, setStoredProjectName } from './credentials.mjs';
39
- import { handleVersionSignal } from './update.mjs';
40
+ import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
41
+ import { credentialRejected } from './authReject.mjs';
42
+ import { handleVersionSignal, cmpVersion } from './update.mjs';
43
+ import { emitMachineEvent, writeDaemonState } from './desktopContract.mjs';
40
44
  import {
41
45
  git,
46
+ gitNetAsync,
42
47
  resetWorktree,
43
48
  repoRootOrDie,
44
49
  detectBaseRef,
@@ -88,6 +93,8 @@ import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
88
93
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
89
94
  import { repoState } from './repoState.mjs';
90
95
  import { claudeAuthContext } from './claudeAuth.mjs';
96
+ import { readBaseTools, runnerToolCapabilities, toolReadout } from './projectTools.mjs';
97
+ import { runtimeOfferings } from './runtimeOfferings.mjs';
91
98
 
92
99
  /** Said once per process — see the catch around `envQueryParams` below. */
93
100
  let warnedEnvIdentity = false;
@@ -163,7 +170,8 @@ async function fetchRoster(
163
170
  * rather than re-derived: `repoRootOrDie` is resolved once at startup and
164
171
  * running git on every poll to re-learn a constant would be a syscall for
165
172
  * a readout. */
166
- repoRoot = null
173
+ repoRoot = null,
174
+ baseRef = null
167
175
  ) {
168
176
  const url = new URL(FLEET_URL);
169
177
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
@@ -301,6 +309,18 @@ async function fetchRoster(
301
309
  } catch {
302
310
  /* best-effort — the poll must never fail on a readout */
303
311
  }
312
+ if (repoRoot && baseRef) {
313
+ try {
314
+ const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
315
+ ? runtimeOfferings(repoRoot, baseRef) : {};
316
+ for (const [param, report] of Object.entries(offerings)) {
317
+ if (Object.keys(report).length) {
318
+ const payload = JSON.stringify(report);
319
+ if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
320
+ }
321
+ }
322
+ } catch { /* local readouts never fail the poll */ }
323
+ }
304
324
  // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
305
325
  //
306
326
  // It carried two more params while the secrets vault existed: `envv` (the
@@ -424,10 +444,16 @@ async function fetchRoster(
424
444
  signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
425
445
  });
426
446
  if (res.status === 401 || res.status === 403) {
427
- // Fleet credential revoked/expired — retrying can't recover; signal exit.
428
- const e = new Error(`fleet credential rejected (${res.status})`);
429
- e.auth = true;
430
- throw e;
447
+ // Revoked or expired — retrying can't recover, so signal exit. But ONLY on
448
+ // the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
449
+ // very client class trips) is a retryable failure, and exiting on one took
450
+ // a machine offline for good over a credential that was still valid.
451
+ if (await credentialRejected(res)) {
452
+ const e = new Error(`fleet credential rejected (${res.status})`);
453
+ e.auth = true;
454
+ throw e;
455
+ }
456
+ throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
431
457
  }
432
458
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
433
459
  // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
@@ -761,6 +787,36 @@ export async function maybeReportEnv(repoRoot) {
761
787
  }
762
788
  }
763
789
 
790
+ const TOOLS_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/tools-report');
791
+ let toolsReportSent = null;
792
+ let toolsReportUnsupported = false;
793
+ let toolsReportAt = 0;
794
+ /** Repo tool readiness is a per-box daemon report. Never include env values. */
795
+ export async function maybeReportTools(repoRoot, baseRef) {
796
+ if (Date.now() - toolsReportAt < 60_000) return;
797
+ toolsReportAt = Date.now();
798
+ if (toolsReportUnsupported) return;
799
+ const pubkey = myPubB64();
800
+ if (!pubkey) return;
801
+ let payload;
802
+ try {
803
+ const snapshot = readBaseTools(repoRoot, baseRef);
804
+ const installed = detectRuntimes().filter((r) => r.installed);
805
+ const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
806
+ payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
807
+ } catch { return; }
808
+ if (payload === toolsReportSent) return;
809
+ try {
810
+ const res = await fetch(TOOLS_REPORT_URL, {
811
+ method: 'POST',
812
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
813
+ signal: AbortSignal.timeout(15_000), body: payload,
814
+ });
815
+ if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
816
+ toolsReportSent = res.ok ? payload : null;
817
+ } catch { toolsReportSent = null; }
818
+ }
819
+
764
820
  /**
765
821
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
766
822
  *
@@ -891,10 +947,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
891
947
  const took = holder.took;
892
948
  if (took && typeof took === 'object' && !Array.isArray(took)) {
893
949
  standbyKey = null;
894
- const from =
895
- typeof took.from === 'string' && took.from.trim()
896
- ? took.from.trim().slice(0, 64)
897
- : 'another machine';
950
+ // scrubbed before it reaches a terminal (2026-09-24, the audit) —
951
+ // see printable.mjs.
952
+ const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
898
953
  const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
899
954
  say(
900
955
  `took this project's machine from ${from}` +
@@ -913,10 +968,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
913
968
  // The NAME is all the response carries about the other box, so it is also
914
969
  // the only thing "a distinct holder" can be keyed on. An unnamed holder
915
970
  // keys on the empty string, which is stable — one announcement, not one
916
- // per poll.
917
- const name = typeof holder.name === 'string' && holder.name.trim()
918
- ? holder.name.trim().slice(0, 64)
919
- : null;
971
+ // per poll. Scrubbed before it reaches a terminal (2026-09-24, the
972
+ // audit) — see printable.mjs.
973
+ const name = safeName(holder.name)?.slice(0, 64) ?? null;
920
974
  const key = name ?? '';
921
975
  if (key !== standbyKey) {
922
976
  standbyKey = key;
@@ -980,9 +1034,10 @@ export function createHolderWatch({ say = () => {} } = {}) {
980
1034
 
981
1035
  /** The sentence an in-flight agent turn is settled with when the machine moves
982
1036
  * out from under it. MEASURED, not inferred: the server named the box that
983
- * took over, and an unnamed one says so rather than guessing. */
1037
+ * took over, and an unnamed one says so rather than guessing. Scrubbed
1038
+ * before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
984
1039
  export function displacedTurnSentence(by) {
985
- const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : 'another machine';
1040
+ const name = safeName(by)?.slice(0, 64) ?? 'another machine';
986
1041
  return `The project's machine moved to ${name} while this turn was running.`;
987
1042
  }
988
1043
 
@@ -1048,7 +1103,9 @@ export async function standDownDisplaced({
1048
1103
  t.unref?.();
1049
1104
  }),
1050
1105
  ]);
1051
- const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : null;
1106
+ // Scrubbed before it reaches this box's own console (2026-09-24, the
1107
+ // audit) — see printable.mjs.
1108
+ const name = safeName(by)?.slice(0, 64) ?? null;
1052
1109
  /**
1053
1110
  * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
1054
1111
  *
@@ -1064,8 +1121,7 @@ export async function standDownDisplaced({
1064
1121
  * drift and neither call site can invent a third.
1065
1122
  */
1066
1123
  const removed = kind === 'removed';
1067
- const projectName =
1068
- typeof project === 'string' && project.trim() ? project.trim().slice(0, 64) : 'this project';
1124
+ const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
1069
1125
  log.warn(
1070
1126
  removed
1071
1127
  ? `removed from ${projectName} in the app — stopping.`
@@ -1073,6 +1129,8 @@ export async function standDownDisplaced({
1073
1129
  ? `this project's machine moved to ${name} — standing down.`
1074
1130
  : "this project's machine moved to another box — standing down."
1075
1131
  );
1132
+ if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
1133
+ else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
1076
1134
  // FIRST, and awaited: an unsettled turn is the one thing here that no later
1077
1135
  // poll from anybody can fix — this process holds the only copy of the fact
1078
1136
  // that it was running.
@@ -1104,7 +1162,11 @@ export async function standDownDisplaced({
1104
1162
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
1105
1163
  // base between tasks (fresh conversation), resume in place while on a blocker.
1106
1164
 
1107
- export async function runFleetDaemon() {
1165
+ export async function runFleetDaemon({ afterLock = null } = {}) {
1166
+ let desktopProjectId = CREDENTIAL?.entry?.projectId ?? null;
1167
+ let wasServing = false;
1168
+ if (process.env.FLOWVIANT_UPDATE_TARGET && cmpVersion(VERSION, process.env.FLOWVIANT_UPDATE_TARGET) >= 0)
1169
+ emitMachineEvent({ event: 'update-applied', from: process.env.FLOWVIANT_UPDATE_FROM ?? null, to: VERSION });
1108
1170
  console.log('');
1109
1171
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
1110
1172
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
@@ -1145,7 +1207,7 @@ export async function runFleetDaemon() {
1145
1207
  // about to serve" must not require a network round trip to answer. Only when
1146
1208
  // the credential came from the STORE: a --fleet/env token names no project
1147
1209
  // until the roster does.
1148
- if (CREDENTIAL?.entry) {
1210
+ if (CREDENTIAL?.entry && storedCredentialInUse()) {
1149
1211
  info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
1150
1212
  }
1151
1213
  info(`repo · ${repoRoot}`);
@@ -1214,6 +1276,17 @@ export async function runFleetDaemon() {
1214
1276
  }
1215
1277
  if (instance.unguarded)
1216
1278
  warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
1279
+ // The repo binding the start path's picker or confirm was answered with.
1280
+ // Persisted HERE, after the lock, so a refused start moves nothing: moving it
1281
+ // first stranded the daemon already serving that project's own checkout at
1282
+ // its next unattended restart. Best-effort — an unwritable store costs the
1283
+ // next start a question, never this one its machine.
1284
+ try {
1285
+ afterLock?.();
1286
+ } catch {
1287
+ /* the binding is asked again next time */
1288
+ }
1289
+ writeDaemonState(desktopProjectId, { limits: {}, holder: null });
1217
1290
 
1218
1291
  await preflight({ needGit: true });
1219
1292
 
@@ -1378,6 +1451,7 @@ export async function runFleetDaemon() {
1378
1451
  // A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
1379
1452
  };
1380
1453
  process.on('SIGINT', () => {
1454
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
1381
1455
  console.log('');
1382
1456
  note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1383
1457
  teardown();
@@ -1387,6 +1461,7 @@ export async function runFleetDaemon() {
1387
1461
  // handler every child survived a `systemctl stop` — the exact orphaning the
1388
1462
  // teardown exists to prevent.
1389
1463
  process.on('SIGTERM', () => {
1464
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
1390
1465
  console.log('');
1391
1466
  note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1392
1467
  teardown();
@@ -1736,10 +1811,11 @@ export async function runFleetDaemon() {
1736
1811
  } else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
1737
1812
  try {
1738
1813
  // Explicit refspec form so a leading '-' can't be a git flag.
1739
- execFileSync('git', ['push', 'origin', `:refs/heads/${job.branch}`], {
1740
- cwd: repoRoot,
1741
- stdio: ['ignore', 'pipe', 'pipe'],
1742
- });
1814
+ // NETWORK call, so timed and non-interactive (2026-09-24, the
1815
+ // audit): a bare execFileSync here had no timeout and could
1816
+ // prompt on /dev/tty, freezing every poll and lease on the
1817
+ // machine behind this cleanup's own async loop.
1818
+ await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
1743
1819
  } catch {
1744
1820
  /* branch already gone — fine */
1745
1821
  }
@@ -2336,10 +2412,12 @@ export async function runFleetDaemon() {
2336
2412
  livePreviewIds(),
2337
2413
  heldSessionIds(),
2338
2414
  admit('churn'),
2339
- repoRoot
2415
+ repoRoot,
2416
+ getBaseRef()
2340
2417
  );
2341
2418
  } catch (e) {
2342
2419
  if (e.auth) {
2420
+ emitMachineEvent({ event: 'stopped', reason: 'credential-revoked' });
2343
2421
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
2344
2422
  teardown();
2345
2423
  // EXIT 0, for the same reason the commanded-stop path does: a revoked
@@ -2361,7 +2439,7 @@ export async function runFleetDaemon() {
2361
2439
  // you're viewing project B's wiki) is obvious instead of a silent no-op.
2362
2440
  if (roster.project) {
2363
2441
  note(
2364
- `${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
2442
+ `${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
2365
2443
  );
2366
2444
  note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
2367
2445
  // Remember the NAME beside the stored credential, so the picker and
@@ -2400,6 +2478,8 @@ export async function runFleetDaemon() {
2400
2478
  }
2401
2479
  })();
2402
2480
  }
2481
+ if (roster.project?.id) desktopProjectId = roster.project.id;
2482
+ writeDaemonState(desktopProjectId, { lastPoll: new Date().toISOString() });
2403
2483
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
2404
2484
  /**
2405
2485
  * HOW MANY TURNS THE APP SAYS THIS MACHINE MAY RUN (2026-09-17).
@@ -2416,6 +2496,9 @@ export async function runFleetDaemon() {
2416
2496
  */
2417
2497
  setServerMaxTurns(roster.maxTurns);
2418
2498
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
2499
+ // The server's word on which project this token serves — settles the env
2500
+ // report's salt for a token that came from --fleet or the environment.
2501
+ learnProjectId(roster.project?.id);
2419
2502
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
2420
2503
  // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
2421
2504
  // this sits ABOVE the version signal rather than inside it. Both read the
@@ -2425,6 +2508,7 @@ export async function runFleetDaemon() {
2425
2508
  // going away.
2426
2509
  const stopSignal = shouldStop(roster.daemon);
2427
2510
  if (stopSignal) {
2511
+ emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
2428
2512
  warn(
2429
2513
  stopSignal.reason
2430
2514
  ? `stopped by Flowviant — ${stopSignal.reason}`
@@ -2510,7 +2594,7 @@ export async function runFleetDaemon() {
2510
2594
  // The project's NAME rather than the box that took over, because no box
2511
2595
  // took over. Falls back to "this project" inside — an unnamed project
2512
2596
  // says so rather than being guessed at.
2513
- project: roster.standDown.project ?? roster.project?.name,
2597
+ project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
2514
2598
  settleAgentTurns,
2515
2599
  flushReports: flushWorkReports,
2516
2600
  teardown,
@@ -2529,6 +2613,12 @@ export async function runFleetDaemon() {
2529
2613
  * would start deleting worktrees it cannot see the tabs for.
2530
2614
  */
2531
2615
  holderState = holderWatch.observe(roster.holder);
2616
+ writeDaemonState(desktopProjectId, {
2617
+ lastPoll: new Date().toISOString(),
2618
+ holder: ({ mine: 'serving', standby: 'inactive' })[holderState] ?? null,
2619
+ });
2620
+ if (holderState === 'mine' && !wasServing) emitMachineEvent({ event: 'serving' });
2621
+ wasServing = holderState === 'mine';
2532
2622
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
2533
2623
  // no workers are spawned yet). If it self-updates it re-execs into the new
2534
2624
  // version and this process becomes a proxy — stop the loop.
@@ -2550,7 +2640,7 @@ export async function runFleetDaemon() {
2550
2640
  safeToUpdate,
2551
2641
  teardown,
2552
2642
  });
2553
- if (updating) return;
2643
+ if (await updating) return;
2554
2644
  }
2555
2645
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
2556
2646
  // taking new work — the skip-if-pending guards make the ordering safe, but
@@ -2722,6 +2812,7 @@ export async function runFleetDaemon() {
2722
2812
  // throttled and deduped inside, and silent forever on an older server.
2723
2813
  // This is what replaced the vault's sync tick — see `maybeReportEnv`.
2724
2814
  void maybeReportEnv(repoRoot);
2815
+ void maybeReportTools(repoRoot, getBaseRef());
2725
2816
 
2726
2817
  // Tell the app what this machine is doing with itself. Every reconcile,
2727
2818
  // best-effort, and never awaited — telemetry that can delay a dispatch is
package/bin/lib/git.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Git worktree helpers (fleet & static-fleet modes). */
2
2
 
3
- import { execFileSync } from 'node:child_process';
3
+ import { execFileSync, spawn } from 'node:child_process';
4
4
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
 
@@ -8,6 +8,134 @@ export function git(args, cwd) {
8
8
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
9
9
  }
10
10
 
11
+ /**
12
+ * THE SSH COMMAND A NETWORK GIT CALL RUNS UNDER when the operator has not named
13
+ * one. `BatchMode` turns a passphrase or host-key prompt on /dev/tty into an
14
+ * immediate failure; the keepalive pair ends a half-open connection (a laptop
15
+ * that slept, a Wi-Fi change) in ~30s instead of when the kernel gives up hours
16
+ * later. An agent key still works — BatchMode disables prompts, not keys.
17
+ */
18
+ export const NET_SSH_COMMAND =
19
+ 'ssh -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=2';
20
+
21
+ /** The default bound on one network git call. */
22
+ export const GIT_NET_TIMEOUT_MS = 60_000;
23
+
24
+ /**
25
+ * The environment a NETWORK git call runs under — never interactive.
26
+ *
27
+ * `GIT_TERMINAL_PROMPT=0` makes git fail instead of opening /dev/tty for a
28
+ * username (an HTTPS remote whose credential cache expired), and
29
+ * `GCM_INTERACTIVE=never` says the same to Git Credential Manager. The SSH
30
+ * command is set only when the operator has not chosen one — `GIT_SSH_COMMAND`,
31
+ * `GIT_SSH` or `core.sshCommand` — because the env var OUTRANKS the config key,
32
+ * and overriding somebody's `-i ~/.ssh/deploy_key` would break every fetch to
33
+ * make it non-interactive. Their own command is still bounded by the timeout.
34
+ */
35
+ export function gitNetEnv(cwd, env = process.env) {
36
+ const out = { ...env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
37
+ if (env.GIT_SSH_COMMAND || env.GIT_SSH) return out;
38
+ let configured = '';
39
+ try {
40
+ configured = execFileSync('git', ['config', '--get', 'core.sshCommand'], {
41
+ cwd,
42
+ encoding: 'utf8',
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ timeout: 5_000,
45
+ }).trim();
46
+ } catch {
47
+ /* unset (exit 1) or unreadable — ours, then */
48
+ }
49
+ if (!configured) out.GIT_SSH_COMMAND = NET_SSH_COMMAND;
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * A NETWORK git call (fetch, push), TIMED AND NON-INTERACTIVE.
55
+ *
56
+ * `git()` above is `execFileSync` with no timeout: right for a local
57
+ * `rev-parse`, and a daemon-wide freeze for a `fetch` — the call blocks the
58
+ * whole event loop, so a remote that prompts on /dev/tty or a connection that
59
+ * went half-open stops every poll, settle, lease renewal and relay on the
60
+ * machine until it returns. Every call that talks to a remote goes through
61
+ * here. Returns the untrimmed stdout; throws like `git()` does (a timeout
62
+ * throws with `code: 'ETIMEDOUT'`).
63
+ */
64
+ export function gitNet(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
65
+ return execFileSync('git', args, {
66
+ cwd,
67
+ encoding: 'utf8',
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ timeout: ms,
70
+ killSignal: 'SIGKILL',
71
+ env: gitNetEnv(cwd),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * The same call WITHOUT BLOCKING — for the unattended periodic fetch, where
77
+ * even a bounded freeze every few minutes is a daemon that goes dark for a
78
+ * remote's bad day. Spawned as its own process group so a timeout takes the
79
+ * ssh or credential helper under git with it. Resolves stdout; rejects on a
80
+ * non-zero exit, a spawn error or the timeout.
81
+ */
82
+ export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
83
+ return new Promise((resolvePromise, reject) => {
84
+ let child;
85
+ try {
86
+ child = spawn('git', args, {
87
+ cwd,
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ env: gitNetEnv(cwd),
90
+ detached: true,
91
+ });
92
+ } catch (e) {
93
+ reject(e);
94
+ return;
95
+ }
96
+ let out = '';
97
+ let err = '';
98
+ let settled = false;
99
+ const finish = (fn, v) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ fn(v);
104
+ };
105
+ const timer = setTimeout(() => {
106
+ try {
107
+ process.kill(-child.pid, 'SIGKILL');
108
+ } catch {
109
+ try {
110
+ child.kill('SIGKILL');
111
+ } catch {
112
+ /* already gone */
113
+ }
114
+ }
115
+ const e = new Error(`git ${args[0]} timed out after ${ms}ms`);
116
+ e.code = 'ETIMEDOUT';
117
+ finish(reject, e);
118
+ }, ms);
119
+ timer.unref?.();
120
+ child.stdout.on('data', (d) => {
121
+ out += d;
122
+ });
123
+ child.stderr.on('data', (d) => {
124
+ err += d;
125
+ });
126
+ child.on('error', (e) => finish(reject, e));
127
+ child.on('close', (code) => {
128
+ if (code === 0) finish(resolvePromise, out);
129
+ else {
130
+ const e = new Error(`git ${args[0]} exited ${code}`);
131
+ e.stderr = err;
132
+ e.status = code;
133
+ finish(reject, e);
134
+ }
135
+ });
136
+ });
137
+ }
138
+
11
139
  /**
12
140
  * Same call, UNTRIMMED — for `-z` (NUL-separated) output, where trimming would
13
141
  * eat the final separator and the leading space of a status code.
@@ -143,9 +271,9 @@ export function baseBranchName(baseRef) {
143
271
 
144
272
  export function resetWorktree(wt, baseRef) {
145
273
  try {
146
- git(['fetch', 'origin', '--quiet'], wt);
274
+ gitNet(['fetch', 'origin', '--quiet'], wt);
147
275
  } catch {
148
- /* offline / no remote — reset to whatever we have */
276
+ /* offline / no remote / timed out — reset to whatever we have */
149
277
  }
150
278
  try {
151
279
  git(['checkout', '--detach', baseRef], wt);