flowviant 0.96.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 +174 -33
  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 +377 -45
  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 +146 -12
  12. package/bin/lib/fleet.mjs +125 -30
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +162 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +460 -23
  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 +354 -32
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +152 -11
  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 +1261 -4476
  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
@@ -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 {