flowviant 0.97.0 → 0.99.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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
package/bin/lib/fleet.mjs CHANGED
@@ -34,11 +34,15 @@ 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';
40
+ import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
41
+ import { credentialRejected } from './authReject.mjs';
39
42
  import { handleVersionSignal } from './update.mjs';
40
43
  import {
41
44
  git,
45
+ gitNetAsync,
42
46
  resetWorktree,
43
47
  repoRootOrDie,
44
48
  detectBaseRef,
@@ -88,6 +92,8 @@ import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
88
92
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
89
93
  import { repoState } from './repoState.mjs';
90
94
  import { claudeAuthContext } from './claudeAuth.mjs';
95
+ import { readBaseTools, runnerToolCapabilities, toolReadout } from './projectTools.mjs';
96
+ import { runtimeOfferings } from './runtimeOfferings.mjs';
91
97
 
92
98
  /** Said once per process — see the catch around `envQueryParams` below. */
93
99
  let warnedEnvIdentity = false;
@@ -163,7 +169,8 @@ async function fetchRoster(
163
169
  * rather than re-derived: `repoRootOrDie` is resolved once at startup and
164
170
  * running git on every poll to re-learn a constant would be a syscall for
165
171
  * a readout. */
166
- repoRoot = null
172
+ repoRoot = null,
173
+ baseRef = null
167
174
  ) {
168
175
  const url = new URL(FLEET_URL);
169
176
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
@@ -301,6 +308,18 @@ async function fetchRoster(
301
308
  } catch {
302
309
  /* best-effort — the poll must never fail on a readout */
303
310
  }
311
+ if (repoRoot && baseRef) {
312
+ try {
313
+ const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
314
+ ? runtimeOfferings(repoRoot, baseRef) : {};
315
+ for (const [param, report] of Object.entries(offerings)) {
316
+ if (Object.keys(report).length) {
317
+ const payload = JSON.stringify(report);
318
+ if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
319
+ }
320
+ }
321
+ } catch { /* local readouts never fail the poll */ }
322
+ }
304
323
  // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
305
324
  //
306
325
  // It carried two more params while the secrets vault existed: `envv` (the
@@ -424,10 +443,16 @@ async function fetchRoster(
424
443
  signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
425
444
  });
426
445
  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;
446
+ // Revoked or expired — retrying can't recover, so signal exit. But ONLY on
447
+ // the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
448
+ // very client class trips) is a retryable failure, and exiting on one took
449
+ // a machine offline for good over a credential that was still valid.
450
+ if (await credentialRejected(res)) {
451
+ const e = new Error(`fleet credential rejected (${res.status})`);
452
+ e.auth = true;
453
+ throw e;
454
+ }
455
+ throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
431
456
  }
432
457
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
433
458
  // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
@@ -761,6 +786,36 @@ export async function maybeReportEnv(repoRoot) {
761
786
  }
762
787
  }
763
788
 
789
+ const TOOLS_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/tools-report');
790
+ let toolsReportSent = null;
791
+ let toolsReportUnsupported = false;
792
+ let toolsReportAt = 0;
793
+ /** Repo tool readiness is a per-box daemon report. Never include env values. */
794
+ export async function maybeReportTools(repoRoot, baseRef) {
795
+ if (Date.now() - toolsReportAt < 60_000) return;
796
+ toolsReportAt = Date.now();
797
+ if (toolsReportUnsupported) return;
798
+ const pubkey = myPubB64();
799
+ if (!pubkey) return;
800
+ let payload;
801
+ try {
802
+ const snapshot = readBaseTools(repoRoot, baseRef);
803
+ const installed = detectRuntimes().filter((r) => r.installed);
804
+ const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
805
+ payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
806
+ } catch { return; }
807
+ if (payload === toolsReportSent) return;
808
+ try {
809
+ const res = await fetch(TOOLS_REPORT_URL, {
810
+ method: 'POST',
811
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
812
+ signal: AbortSignal.timeout(15_000), body: payload,
813
+ });
814
+ if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
815
+ toolsReportSent = res.ok ? payload : null;
816
+ } catch { toolsReportSent = null; }
817
+ }
818
+
764
819
  /**
765
820
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
766
821
  *
@@ -891,10 +946,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
891
946
  const took = holder.took;
892
947
  if (took && typeof took === 'object' && !Array.isArray(took)) {
893
948
  standbyKey = null;
894
- const from =
895
- typeof took.from === 'string' && took.from.trim()
896
- ? took.from.trim().slice(0, 64)
897
- : 'another machine';
949
+ // scrubbed before it reaches a terminal (2026-09-24, the audit) —
950
+ // see printable.mjs.
951
+ const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
898
952
  const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
899
953
  say(
900
954
  `took this project's machine from ${from}` +
@@ -913,10 +967,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
913
967
  // The NAME is all the response carries about the other box, so it is also
914
968
  // the only thing "a distinct holder" can be keyed on. An unnamed holder
915
969
  // 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;
970
+ // per poll. Scrubbed before it reaches a terminal (2026-09-24, the
971
+ // audit) — see printable.mjs.
972
+ const name = safeName(holder.name)?.slice(0, 64) ?? null;
920
973
  const key = name ?? '';
921
974
  if (key !== standbyKey) {
922
975
  standbyKey = key;
@@ -980,9 +1033,10 @@ export function createHolderWatch({ say = () => {} } = {}) {
980
1033
 
981
1034
  /** The sentence an in-flight agent turn is settled with when the machine moves
982
1035
  * 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. */
1036
+ * took over, and an unnamed one says so rather than guessing. Scrubbed
1037
+ * before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
984
1038
  export function displacedTurnSentence(by) {
985
- const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : 'another machine';
1039
+ const name = safeName(by)?.slice(0, 64) ?? 'another machine';
986
1040
  return `The project's machine moved to ${name} while this turn was running.`;
987
1041
  }
988
1042
 
@@ -1048,7 +1102,9 @@ export async function standDownDisplaced({
1048
1102
  t.unref?.();
1049
1103
  }),
1050
1104
  ]);
1051
- const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : null;
1105
+ // Scrubbed before it reaches this box's own console (2026-09-24, the
1106
+ // audit) — see printable.mjs.
1107
+ const name = safeName(by)?.slice(0, 64) ?? null;
1052
1108
  /**
1053
1109
  * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
1054
1110
  *
@@ -1064,8 +1120,7 @@ export async function standDownDisplaced({
1064
1120
  * drift and neither call site can invent a third.
1065
1121
  */
1066
1122
  const removed = kind === 'removed';
1067
- const projectName =
1068
- typeof project === 'string' && project.trim() ? project.trim().slice(0, 64) : 'this project';
1123
+ const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
1069
1124
  log.warn(
1070
1125
  removed
1071
1126
  ? `removed from ${projectName} in the app — stopping.`
@@ -1104,7 +1159,7 @@ export async function standDownDisplaced({
1104
1159
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
1105
1160
  // base between tasks (fresh conversation), resume in place while on a blocker.
1106
1161
 
1107
- export async function runFleetDaemon() {
1162
+ export async function runFleetDaemon({ afterLock = null } = {}) {
1108
1163
  console.log('');
1109
1164
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
1110
1165
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
@@ -1145,7 +1200,7 @@ export async function runFleetDaemon() {
1145
1200
  // about to serve" must not require a network round trip to answer. Only when
1146
1201
  // the credential came from the STORE: a --fleet/env token names no project
1147
1202
  // until the roster does.
1148
- if (CREDENTIAL?.entry) {
1203
+ if (CREDENTIAL?.entry && storedCredentialInUse()) {
1149
1204
  info(`serves · ${projectLabel(CREDENTIAL.entry)} ${c.dim(`(${CREDENTIAL.entry.projectId.slice(0, 8)}…)`)}`);
1150
1205
  }
1151
1206
  info(`repo · ${repoRoot}`);
@@ -1214,6 +1269,16 @@ export async function runFleetDaemon() {
1214
1269
  }
1215
1270
  if (instance.unguarded)
1216
1271
  warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
1272
+ // The repo binding the start path's picker or confirm was answered with.
1273
+ // Persisted HERE, after the lock, so a refused start moves nothing: moving it
1274
+ // first stranded the daemon already serving that project's own checkout at
1275
+ // its next unattended restart. Best-effort — an unwritable store costs the
1276
+ // next start a question, never this one its machine.
1277
+ try {
1278
+ afterLock?.();
1279
+ } catch {
1280
+ /* the binding is asked again next time */
1281
+ }
1217
1282
 
1218
1283
  await preflight({ needGit: true });
1219
1284
 
@@ -1736,10 +1801,11 @@ export async function runFleetDaemon() {
1736
1801
  } else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
1737
1802
  try {
1738
1803
  // 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
- });
1804
+ // NETWORK call, so timed and non-interactive (2026-09-24, the
1805
+ // audit): a bare execFileSync here had no timeout and could
1806
+ // prompt on /dev/tty, freezing every poll and lease on the
1807
+ // machine behind this cleanup's own async loop.
1808
+ await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
1743
1809
  } catch {
1744
1810
  /* branch already gone — fine */
1745
1811
  }
@@ -2336,7 +2402,8 @@ export async function runFleetDaemon() {
2336
2402
  livePreviewIds(),
2337
2403
  heldSessionIds(),
2338
2404
  admit('churn'),
2339
- repoRoot
2405
+ repoRoot,
2406
+ getBaseRef()
2340
2407
  );
2341
2408
  } catch (e) {
2342
2409
  if (e.auth) {
@@ -2361,7 +2428,7 @@ export async function runFleetDaemon() {
2361
2428
  // you're viewing project B's wiki) is obvious instead of a silent no-op.
2362
2429
  if (roster.project) {
2363
2430
  note(
2364
- `${c.cyan('project')} · ${c.bold(roster.project.name)} ${c.dim(`(${roster.project.id})`)}`
2431
+ `${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
2365
2432
  );
2366
2433
  note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
2367
2434
  // Remember the NAME beside the stored credential, so the picker and
@@ -2416,6 +2483,9 @@ export async function runFleetDaemon() {
2416
2483
  */
2417
2484
  setServerMaxTurns(roster.maxTurns);
2418
2485
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
2486
+ // The server's word on which project this token serves — settles the env
2487
+ // report's salt for a token that came from --fleet or the environment.
2488
+ learnProjectId(roster.project?.id);
2419
2489
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
2420
2490
  // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
2421
2491
  // this sits ABOVE the version signal rather than inside it. Both read the
@@ -2510,7 +2580,7 @@ export async function runFleetDaemon() {
2510
2580
  // The project's NAME rather than the box that took over, because no box
2511
2581
  // took over. Falls back to "this project" inside — an unnamed project
2512
2582
  // says so rather than being guessed at.
2513
- project: roster.standDown.project ?? roster.project?.name,
2583
+ project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
2514
2584
  settleAgentTurns,
2515
2585
  flushReports: flushWorkReports,
2516
2586
  teardown,
@@ -2722,6 +2792,7 @@ export async function runFleetDaemon() {
2722
2792
  // throttled and deduped inside, and silent forever on an older server.
2723
2793
  // This is what replaced the vault's sync tick — see `maybeReportEnv`.
2724
2794
  void maybeReportEnv(repoRoot);
2795
+ void maybeReportTools(repoRoot, getBaseRef());
2725
2796
 
2726
2797
  // Tell the app what this machine is doing with itself. Every reconcile,
2727
2798
  // 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);
@@ -38,6 +38,12 @@
38
38
  * into a flag), a newline. A read-only turn runs ONE plain command; every
39
39
  * one of these is a second command or a redirect wearing the first one's
40
40
  * permission.
41
+ * · shell syntax that REWRITES A WORD after this guard read it — braces,
42
+ * parentheses and glob characters (`{ } ( ) * ? [`). `--outp{u,u}t=x` is
43
+ * `--output=x` by the time git sees it, and the substring tests below can
44
+ * only judge the word as written. (Quoted or not: the normalisation above
45
+ * has already dropped the quotes, so a quoted `*` is refused too — the
46
+ * over-refusal the paragraph above accepts.)
41
47
  * · the git arguments above, as substrings (so `--config` covers
42
48
  * `--config-env`, `--output` covers `--output-directory`), plus a bare `-c`
43
49
  * token and any token beginning `-O`.
@@ -75,6 +81,24 @@ const SHELL = [
75
81
  ['$', 'an expansion'],
76
82
  ['\n', 'a second line'],
77
83
  ['\r', 'a second line'],
84
+ // SHELL SYNTAX THE SUBSTRING TESTS BELOW DO NOT MODEL (2026-09-24, the
85
+ // audit). The shell rewrites a word AFTER this guard has read it, so a banned
86
+ // flag can be ASSEMBLED past a substring check: `--outp{u,u}t=x` and
87
+ // `-{c,c}` brace-expand into `--output=x` and `-c`, and `git log -1
88
+ // --outp{u,u}t=pwned.txt` wrote the file with this guard returning null
89
+ // (measured, git 2.55). Globs do the same with a planted file name —
90
+ // `-?` expands to a file called `-c` — and zsh adds glob QUALIFIERS
91
+ // (`*(e:…:)`, which evaluate code). None of them is something a single
92
+ // plain read needs, so the guard refuses the syntax rather than trying to
93
+ // expand it the way some shell would: over-refusal is the safe direction,
94
+ // and the Glob tool is the tool-shaped way to match names.
95
+ ['{', 'brace expansion'],
96
+ ['}', 'brace expansion'],
97
+ ['(', 'a subshell or glob qualifier'],
98
+ [')', 'a subshell or glob qualifier'],
99
+ ['*', 'a glob'],
100
+ ['?', 'a glob'],
101
+ ['[', 'a glob'],
78
102
  ];
79
103
 
80
104
  /**
@@ -97,7 +97,7 @@ import {
97
97
  import { homedir, platform } from 'node:os';
98
98
  import { join } from 'node:path';
99
99
  import { createHash } from 'node:crypto';
100
- import { VERSION } from './config.mjs';
100
+ import { VERSION, PROCESS_STARTED_AT } from './config.mjs';
101
101
 
102
102
  /** Deliberately a HASH: a credential must never become a filename. */
103
103
  export function instanceLockPath(fleetToken) {
@@ -139,11 +139,42 @@ function readHolder(path) {
139
139
  }
140
140
  }
141
141
 
142
+ /**
143
+ * argv[1] AS IT WAS TYPED, when that differs from the resolved form.
144
+ *
145
+ * Node resolves `process.argv[1]` to an absolute path, while `/proc/<pid>/cmdline`
146
+ * and `ps` show the argv the process was actually given. A daemon started as
147
+ * `node bin/cli.mjs` (a dev checkout, `npm start`, a Docker CMD with a WORKDIR)
148
+ * therefore recorded `/abs/bin/cli.mjs` and was matched against `node
149
+ * bin/cli.mjs`, judged stale WHILE ALIVE — a second start ran beside it and
150
+ * `flowviant stop` signalled nothing. The raw token is recorded beside `entry`
151
+ * so both spellings identify the process. Linux only: `ps` joins argv with
152
+ * spaces, so no token can be recovered from it reliably, and there the absolute
153
+ * form plus the start-time check stand as before.
154
+ */
155
+ function rawEntry() {
156
+ if (platform() !== 'linux') return null;
157
+ try {
158
+ const tokens = readFileSync('/proc/self/cmdline', 'utf8').split('\0');
159
+ const raw = tokens[1 + process.execArgv.length];
160
+ return raw && raw !== process.argv[1] ? raw : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
142
166
  const record = (repoRoot) =>
143
167
  JSON.stringify({
144
168
  pid: process.pid,
145
169
  repoRoot,
146
- startedAt: new Date().toISOString(),
170
+ // THE PROCESS'S START, not the moment the lock was written. The start path
171
+ // can wait on a person at the picker or the binding confirm for as long as
172
+ // they take (there is no answer time limit), and a lock line written three
173
+ // minutes after the process began put a LIVE daemon outside the 120s window
174
+ // startedAroundLockWrite brackets — so a second start cleared its "stale"
175
+ // lock and ran beside it, and stop and disconnect skipped it. The window now
176
+ // measures clock granularity only, whatever a prompt cost.
177
+ startedAt: PROCESS_STARTED_AT,
147
178
  // The script we were started from, and what we are. A takeover matches the
148
179
  // live command line against `entry` before signalling anything — a lock
149
180
  // records a PID, and a crashed daemon's PID can be reused by anything.
@@ -151,9 +182,68 @@ const record = (repoRoot) =>
151
182
  // matched on the holder's process START TIME instead; stillTheHolder says
152
183
  // why that is the weaker of the two claims and still strong enough.
153
184
  entry: process.argv[1] || '',
185
+ ...(rawEntry() ? { entryRaw: rawEntry() } : {}),
154
186
  version: VERSION,
155
187
  });
156
188
 
189
+ /** Up to this many parents are walked looking for the daemon we re-exec'd from.
190
+ * `npx -y flowviant@latest` puts `npm exec` (and a `sh -c`) between the old
191
+ * daemon and the new one; eight is several times that chain. */
192
+ const ANCESTOR_DEPTH = 8;
193
+
194
+ /** The parent pid of `pid`, or null where it cannot be read. */
195
+ function parentOf(pid) {
196
+ try {
197
+ if (platform() === 'linux') {
198
+ const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
199
+ const close = raw.lastIndexOf(')');
200
+ if (close < 0) return null;
201
+ const ppid = Number.parseInt(raw.slice(close + 1).trim().split(/\s+/)[1], 10);
202
+ return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
203
+ }
204
+ if (platform() === 'darwin') {
205
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
206
+ encoding: 'utf8',
207
+ stdio: ['ignore', 'pipe', 'ignore'],
208
+ timeout: 3000,
209
+ });
210
+ const ppid = Number.parseInt(out.trim(), 10);
211
+ return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
212
+ }
213
+ } catch {
214
+ /* unreadable — ancestry unknown */
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /**
220
+ * IS THIS HOLDER THE DAEMON THAT RE-EXEC'D US? — the self-update case.
221
+ *
222
+ * The direct parent is the global-install path: update.mjs spawns
223
+ * `process.execPath` itself, so the successor's ppid IS the holder. Under npx
224
+ * — the documented launch — the successor is spawned by `npm exec`, which the
225
+ * old daemon spawned, so the holder is a GRANDPARENT and a ppid check read it
226
+ * as a rival: the new daemon SIGTERMed its own proxy, the foreground process
227
+ * exited 143 while the successor ran on detached (or, with --no-takeover, both
228
+ * exited and the machine went dark after every update). So update.mjs names
229
+ * the pid it re-exec'd from, and that pid is adopted only when it really is an
230
+ * ANCESTOR of this process: the env var alone is inherited by anything the
231
+ * daemon spawns and proves nothing on its own. Ancestry that cannot be read
232
+ * adopts nothing — the pre-existing takeover rules then apply.
233
+ */
234
+ function isOurReexecParent(pid) {
235
+ if (!Number.isInteger(pid) || pid <= 0) return false;
236
+ if (pid === process.ppid) return true;
237
+ if (process.env.FLOWVIANT_REEXEC_FROM !== String(pid)) return false;
238
+ let p = process.ppid;
239
+ for (let i = 0; i < ANCESTOR_DEPTH && p && p > 1; i++) {
240
+ const up = parentOf(p);
241
+ if (up === pid) return true;
242
+ p = up;
243
+ }
244
+ return false;
245
+ }
246
+
157
247
  /** Same directory, whatever it is spelled as — symlinks and trailing slashes
158
248
  * included. A repo compared by string would let `/repo` and `/repo/` past. */
159
249
  function samePath(a, b) {
@@ -208,7 +298,7 @@ export function daemonInSameRepo(repoRoot, ownPath) {
208
298
  if (path === ownPath) continue; // our own credential — the lock above owns that question
209
299
  const holder = readHolder(path);
210
300
  if (!holder || !alive(holder.pid)) continue;
211
- if (holder.pid === process.ppid) continue; // ourselves mid self-update re-exec
301
+ if (isOurReexecParent(holder.pid)) continue; // ourselves mid self-update re-exec
212
302
  if (samePath(holder.repoRoot, repoRoot)) {
213
303
  NEIGHBOUR_PATHS.set(holder, path);
214
304
  return holder;
@@ -226,7 +316,10 @@ export function daemonInSameRepo(repoRoot, ownPath) {
226
316
  * around 41s. 120s is ~3x that worst path and ~4000x the typical one, and it is
227
317
  * still short enough that pid reuse cannot reach into it: reuse means cycling
228
318
  * the entire pid space (4194304 by default), which no machine does inside two
229
- * minutes. */
319
+ * minutes. (Since the lock records the PROCESS start rather than the moment it
320
+ * was written, a lock from this version sits a few milliseconds from its
321
+ * process whatever the start path cost; the window's width is for locks older
322
+ * daemons wrote, which still stamp the write.) */
230
323
  const TAKEOVER_START_WINDOW_MS = 120_000;
231
324
 
232
325
  /** ...and how far the OTHER way, which is a unit problem rather than a real
@@ -421,15 +514,20 @@ function stillTheHolder(holder) {
421
514
  // alive, so it takes the same road as a missing one.
422
515
  if (!want) return startedAroundLockWrite(holder);
423
516
  let cmdline;
517
+ let tokens;
424
518
  try {
425
- cmdline =
426
- platform() === 'linux'
427
- ? readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ')
428
- : execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
429
- encoding: 'utf8',
430
- stdio: ['ignore', 'pipe', 'ignore'],
431
- timeout: 3000,
432
- });
519
+ if (platform() === 'linux') {
520
+ const raw = readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8');
521
+ tokens = raw.split('\0');
522
+ cmdline = raw.replace(/\0/g, ' ');
523
+ } else {
524
+ cmdline = execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
525
+ encoding: 'utf8',
526
+ stdio: ['ignore', 'pipe', 'ignore'],
527
+ timeout: 3000,
528
+ });
529
+ tokens = cmdline.trim().split(/\s+/);
530
+ }
433
531
  } catch {
434
532
  // NOT `false`. takeOverFrom already returned early if the pid were gone, so
435
533
  // reaching here means the process is alive and we could not READ it —
@@ -437,7 +535,10 @@ function stillTheHolder(holder) {
437
535
  // here is what made the refusal claim the pid belonged to somebody else.
438
536
  return null;
439
537
  }
440
- if (!cmdline.includes(want)) return false;
538
+ // The typed spelling is matched as a whole TOKEN, never a substring: a
539
+ // relative `bin/cli.mjs` is a fragment of countless command lines.
540
+ const raw = typeof holder.entryRaw === 'string' && holder.entryRaw ? holder.entryRaw : null;
541
+ if (!cmdline.includes(want) && !(raw && tokens.includes(raw))) return false;
441
542
  // AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
442
543
  // entry path under a global install, so "cmdline contains this cli.mjs"
443
544
  // proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
@@ -711,7 +812,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
711
812
  // ownership-checked, so it will not delete the lock it handed over.
712
813
  // (`flowviant login` also proxies a child, but that parent never reached
713
814
  // the daemon and holds nothing — the child simply acquires.)
714
- if (holder.pid === process.ppid) {
815
+ if (isOurReexecParent(holder.pid)) {
715
816
  try {
716
817
  writeFileSync(path, record(repoRoot));
717
818
  } catch {