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
@@ -342,11 +342,40 @@ function parseCodexLine(line, cwd) {
342
342
  const activity = humanizeCodexItem(item, cwd);
343
343
  // Only the agent's MESSAGES are sentinel-bearing text. Reasoning is not:
344
344
  // a model that muses "I could output NOTHING here" must not end the turn.
345
- const text =
346
- (item.item_type ?? item.type) === 'agent_message'
347
- ? `${item.text ?? item.message ?? ''}\n`
348
- : '';
349
- return { activity, text };
345
+ const isMessage = (item.item_type ?? item.type) === 'agent_message';
346
+ const text = isMessage ? `${item.text ?? item.message ?? ''}\n` : '';
347
+ // `answer` is the SAME message, surfaced alone: `text` accumulates every
348
+ // message plus every error and stderr line, so a caller that needs "what
349
+ // the agent said LAST" (the agent lane's final JSON object) cannot recover
350
+ // it from `out`. Claude's stream has this for free under
351
+ // `answerFromResult`; codex needed it said.
352
+ return { activity, text, ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
353
+ }
354
+ // THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
355
+ // turn with `turn.completed` carrying `usage` — the codex twin of Claude's
356
+ // `result.usage`, and until now dropped on the floor, so a codex agent
357
+ // reported no spend at all. Mapped onto the daemon's four counters:
358
+ // OpenAI's `input_tokens` INCLUDES the cached part, Claude's excludes it,
359
+ // so the cached share is subtracted out rather than counted twice. Codex
360
+ // has no cache-creation figure; zero is what it reported, not a guess.
361
+ // Output already includes reasoning tokens on OpenAI's side.
362
+ case 'turn.completed': {
363
+ const u = ev.usage;
364
+ if (!u || typeof u !== 'object') return null;
365
+ const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
366
+ const cached = n(u.cached_input_tokens);
367
+ return {
368
+ activity: null,
369
+ text: '',
370
+ // `usageFromResult`'s shape (claude.mjs), so every consumer of
371
+ // `onUsage` reads one vocabulary whichever CLI counted it.
372
+ usage: {
373
+ input: Math.max(0, n(u.input_tokens) - cached),
374
+ output: n(u.output_tokens),
375
+ cacheCreate: 0,
376
+ cacheRead: cached,
377
+ },
378
+ };
350
379
  }
351
380
  case 'turn.failed': {
352
381
  // An error is prose too, and the sentence that explains a failed turn is
@@ -367,7 +396,12 @@ function parseCodexLine(line, cwd) {
367
396
  label: oneLine(ev.error?.message ?? 'turn failed'),
368
397
  ...(msg.trim() ? { full: msg } : {}),
369
398
  },
370
- text: '',
399
+ // THE FAILURE'S OWN WORDS REACH `text` (2026-09-24), the rule the bare
400
+ // `error` arm below already keeps. This returned '' — so a codex turn
401
+ // that failed on a usage limit handed the limit matcher nothing, the
402
+ // agent landed in Stuck as "produced no output", and every other codex
403
+ // agent kept spending into the same wall.
404
+ text: msg.trim() ? `${msg}\n` : '',
371
405
  };
372
406
  }
373
407
  // A bare `error` event — the shape an auth failure arrives in ("401
@@ -543,14 +577,19 @@ export const RUNTIMES = {
543
577
  * "consult" and "plan" currently mean. That is a statement about where the
544
578
  * contract was written, not a claim that only Claude could ever satisfy it.
545
579
  */
546
- profiles: ['build', 'wiki', 'consult', 'plan'],
580
+ // `design` and `research` (0.97.0) are Claude's alone: each is an
581
+ // `--allowedTools` list with a PATH-SCOPED write (claude.mjs), and neither
582
+ // codex's sandbox modes nor agy's flags were measured to express "write
583
+ // only this directory". A runtime that does not declare them is refused
584
+ // such a card before spawn, in words — never handed a build turn instead.
585
+ profiles: ['build', 'wiki', 'consult', 'plan', 'design', 'research'],
547
586
  mcp: claudeMcp,
548
587
  /**
549
588
  * Claude takes the operating contract as a real system prompt, which is the
550
589
  * strongest form of it available anywhere: `--append-system-prompt` sits
551
590
  * above the conversation rather than inside it.
552
591
  */
553
- args({ prompt, system, model, effort, resume, streamJson, perm, mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir }) {
592
+ args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools }) {
554
593
  const a = [];
555
594
  // THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
556
595
  // are mutually exclusive, never combined.
@@ -577,6 +616,17 @@ export const RUNTIMES = {
577
616
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
578
617
  else if (resume) a.push('--continue');
579
618
  a.push('-p', prompt, '--append-system-prompt', system);
619
+ if (agentTools) {
620
+ // Keep personal settings, but never load the agent-editable project's
621
+ // settings, hooks, skills or CLAUDE.md discovery. A resumed agent must
622
+ // render the CURRENT base snapshot, not the first turn's prompt cache.
623
+ a.push('--setting-sources', 'user', '--system-prompt-snapshot', 'off');
624
+ if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
625
+ }
626
+ // These fenced postures have no project MCP, even if Claude discovers a
627
+ // .mcp.json in the agent's worktree. Build gets its reviewed base config
628
+ // explicitly from the caller; the plan profile keeps its own principal.
629
+ if (['consult', 'wiki', 'design', 'research'].includes(profile)) a.push('--strict-mcp-config');
580
630
  a.push(...mcp, ...resultSchemaArgs);
581
631
  a.push('--model', model || MODEL);
582
632
  if (effort) a.push('--effort', effort);
@@ -629,6 +679,11 @@ export const RUNTIMES = {
629
679
  * user's repository, which is not a risk worth taking for a slightly
630
680
  * stickier instruction.
631
681
  *
682
+ * Agent turns additionally pass reviewed base instructions as a developer
683
+ * config override when the personal config has no developer instruction;
684
+ * the prompt always carries them too. Workbench tabs keep their normal
685
+ * checkout discovery.
686
+ *
632
687
  * `--skip-git-repo-check` is deliberately NOT passed: a task always builds
633
688
  * in a git worktree, and if it somehow is not one, failing loudly beats
634
689
  * silently editing files nobody can diff.
@@ -639,7 +694,7 @@ export const RUNTIMES = {
639
694
  * placed before it. Appending them after the positional is the kind of argv
640
695
  * that parses today and stops parsing on some future clap upgrade.
641
696
  */
642
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId }) {
697
+ args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools }) {
643
698
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
644
699
  // agy moves) — codex has no adoptable store wired yet. Reaching here
645
700
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -656,6 +711,13 @@ export const RUNTIMES = {
656
711
  if (resumeThreadId) a.push('resume', resumeThreadId);
657
712
  else if (resume) a.push('resume', '--last');
658
713
  a.push('--json');
714
+ if (agentTools) {
715
+ a.push('-c', 'project_doc_max_bytes=0');
716
+ // A person's existing developer instructions keep their authority.
717
+ // The base snapshot is also present in the per-turn prompt preamble.
718
+ if (!agentTools.personalDeveloperInstructions)
719
+ a.push('-c', `developer_instructions=${JSON.stringify(agentTools.instructions)}`);
720
+ }
659
721
  if (model) a.push('--model', model);
660
722
  // Effort is a config value on Codex rather than a flag.
661
723
  if (effort) a.push('-c', `model_reasoning_effort="${effort}"`);
@@ -1040,7 +1102,16 @@ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
1040
1102
  * the single drivability test, so a machine-wide MCP config disqualified it from
1041
1103
  * two jobs that never open an MCP connection.
1042
1104
  */
1043
- const PROFILE_NEEDS_MCP = { build: true, wiki: false, consult: false, plan: true };
1105
+ const PROFILE_NEEDS_MCP = {
1106
+ build: true,
1107
+ wiki: false,
1108
+ consult: false,
1109
+ plan: true,
1110
+ // An agent turn has no MCP at all (SYSTEM_AGENT's header) — everything it
1111
+ // says rides the final JSON — so neither non-code posture needs one.
1112
+ design: false,
1113
+ research: false,
1114
+ };
1044
1115
 
1045
1116
  /**
1046
1117
  * A build needs the control plane, but NOT necessarily an MCP config of its own.
@@ -1209,6 +1280,68 @@ export function knownSkills() {
1209
1280
  return skillsCache;
1210
1281
  }
1211
1282
 
1283
+ /**
1284
+ * WHICH MCP SERVERS AND CLAUDE.AI CONNECTORS THE CLI MOUNTED, AND HOW EACH
1285
+ * STANDS (2026-09-23, 0.97.0) — learned off the same `system.init` event the
1286
+ * skills are, so it costs nothing and cannot drift from what a turn reaches.
1287
+ *
1288
+ * MEASURED on Claude Code 2.1.281 (`claude -p hi --model haiku --output-format
1289
+ * stream-json --verbose`): the init event carries `mcp_servers: [{ name,
1290
+ * status, source }]` — statuses seen `connected`, `failed`, `needs-auth` and
1291
+ * `pending`; a claude.ai connector arrives named `claude.ai <Name>` with
1292
+ * `source: "claudeai"`, a server a turn mounts with `--mcp-config` with
1293
+ * `source: "dynamic"`. A connector that needs a one-time sign-in AT THIS BOX
1294
+ * reads `needs-auth` here long before a turn fails on it, and the app's
1295
+ * Machines page names it.
1296
+ *
1297
+ * `flowviant` IS EXCLUDED: it is the server THIS daemon mounts on every tab
1298
+ * turn, always present, and not something a person can act on. A status off
1299
+ * the closed list is relayed as `other` rather than dropped — the CLI said
1300
+ * something about the server, and silence would be the daemon deciding it did
1301
+ * not. Bounded (40 entries, names cut at 60) and sorted, the `recordSkills`
1302
+ * discipline: a stable param, so an unchanged report writes nothing.
1303
+ *
1304
+ * ONLY WHAT IS NOT CONNECTED LEAVES THE BOX (2026-09-23). The first cut
1305
+ * relayed every server, connected ones included — so the operator's whole set
1306
+ * of signed-in services (their mail, their brokerage, their calendar) rode the
1307
+ * poll to the server and into every teammate's read of the Machines page,
1308
+ * which renders only the servers that are NOT connected anyway. A connected
1309
+ * connector is good news and good news is not a line; it is also nobody
1310
+ * else's business. So `connected` is dropped HERE, before the cap, and what is
1311
+ * sent is exactly what the page can say something about.
1312
+ *
1313
+ * THREE STATES, the skills split: null = no turn has taught us (the param is
1314
+ * not sent), [] = nothing of the person's own needs anything (every server is
1315
+ * connected, or none is mounted), a list = the ones that do. Last turn wins,
1316
+ * so a connector somebody signs into leaves the list on the next turn's init,
1317
+ * and an all-connected turn sends `[]`, which clears a stale list server-side.
1318
+ */
1319
+ let mcpServersCache = null;
1320
+
1321
+ export const MCP_STATUSES = new Set(['connected', 'failed', 'needs-auth', 'pending']);
1322
+ export const MAX_MCP_SERVERS = 40;
1323
+ export const MCP_NAME_MAX = 60;
1324
+
1325
+ export function recordMcpServers(list) {
1326
+ if (!Array.isArray(list)) return;
1327
+ const seen = new Map();
1328
+ for (const e of list) {
1329
+ if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
1330
+ const n = e.name.trim().slice(0, MCP_NAME_MAX).trim();
1331
+ if (!n || n === 'flowviant' || seen.has(n)) continue;
1332
+ if (e.status === 'connected') continue; // see the header: never relayed
1333
+ seen.set(n, { n, s: MCP_STATUSES.has(e.status) ? e.status : 'other' });
1334
+ }
1335
+ mcpServersCache = [...seen.values()]
1336
+ .sort((a, b) => (a.n < b.n ? -1 : a.n > b.n ? 1 : 0))
1337
+ .slice(0, MAX_MCP_SERVERS);
1338
+ }
1339
+
1340
+ /** What to send on the roster poll as `mcp` — null until a turn has taught us. */
1341
+ export function knownMcpServers() {
1342
+ return mcpServersCache;
1343
+ }
1344
+
1212
1345
  /**
1213
1346
  * LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
1214
1347
  *
@@ -1338,6 +1471,8 @@ export function parseInitLine(line) {
1338
1471
  // record nothing. Conflating the two would keep the probe scanning a whole
1339
1472
  // turn's output on a CLI that does not report them.
1340
1473
  skills: Array.isArray(ev.skills) ? ev.skills : null,
1474
+ // The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
1475
+ mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
1341
1476
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
1342
1477
  };
1343
1478
  }
@@ -1384,9 +1519,14 @@ export function probeSkillsOnce(cwd) {
1384
1519
  timer.unref?.();
1385
1520
  child.on('error', () => finish(null));
1386
1521
  child.on('exit', () => finish(null));
1522
+ // UTF-8 mode on the STREAM, not a per-chunk `.toString()` — a multi-byte
1523
+ // character straddling a chunk boundary decodes to mojibake (or a stray
1524
+ // replacement byte) when each chunk is decoded on its own; the stream's
1525
+ // own decoder holds the partial sequence over to the next chunk instead.
1526
+ child.stdout.setEncoding('utf8');
1387
1527
  child.stdout.on('data', (d) => {
1388
1528
  if (settled) return;
1389
- buf += d.toString();
1529
+ buf += d;
1390
1530
  let nl;
1391
1531
  while (!settled && (nl = buf.indexOf('\n')) >= 0) {
1392
1532
  const line = buf.slice(0, nl);
@@ -1399,6 +1539,7 @@ export function probeSkillsOnce(cwd) {
1399
1539
  const init = parseInitLine(line);
1400
1540
  if (!init) continue;
1401
1541
  if (init.skills) recordSkills(init.skills);
1542
+ if (init.mcpServers) recordMcpServers(init.mcpServers);
1402
1543
  finish(init.sessionId);
1403
1544
  return;
1404
1545
  }
@@ -1,4 +1,4 @@
1
- import { baseBranchName } from './git.mjs';
1
+ import { baseBranchName, gitNet as gitNetDefault } from './git.mjs';
2
2
 
3
3
  /**
4
4
  * CARRY A SHIPPED TIP OUT ONTO BASE AND PUSH IT.
@@ -28,6 +28,10 @@ export function mergeOutward({
28
28
  baseRef,
29
29
  workingTree,
30
30
  warn,
31
+ // NETWORK calls only (push, fetch) — timed and non-interactive, so a
32
+ // stale credential or a half-open connection cannot freeze the ship
33
+ // path's writer lock. Injectable for tests; defaults to the real one.
34
+ gitNet = gitNetDefault,
31
35
  }) {
32
36
  const dropTmp = () => {
33
37
  try {
@@ -43,7 +47,7 @@ export function mergeOutward({
43
47
  ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
44
48
  tmpDir
45
49
  );
46
- git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir);
50
+ gitNet(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir, 120_000);
47
51
  };
48
52
  try {
49
53
  try {
@@ -74,7 +78,7 @@ export function mergeOutward({
74
78
  if (!isRaceRejection(e)) throw e;
75
79
  warn?.('ship: base moved under us — refetching and merging again');
76
80
  try {
77
- git(['fetch', 'origin', '--quiet'], repoRoot);
81
+ gitNet(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
78
82
  } catch {
79
83
  /* offline — the retry fails honestly on the same push */
80
84
  }
@@ -102,7 +106,7 @@ export function mergeOutward({
102
106
  */
103
107
  if (workingTree && branch && branch === baseBranchName(baseRef())) {
104
108
  try {
105
- git(['fetch', 'origin', '--quiet'], repoRoot);
109
+ gitNet(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
106
110
  git(['merge', '--ff-only', baseRef()], workingTree);
107
111
  } catch {
108
112
  /* a readout, not the ship — the merge already landed, and the next
package/bin/lib/trace.mjs CHANGED
@@ -38,10 +38,15 @@
38
38
  * second attempt then opened at `seq: 0` against a mark of three hundred, so
39
39
  * every batch it sent was trimmed to nothing and the surface showed the
40
40
  * ABANDONED attempt's steps with the new one's tail welded on, no seam, no
41
- * `dropped` to say so. `run` is a nonce per relay: the server rebases an
42
- * unfamiliar one onto its current mark, so a second attempt appends AFTER the
43
- * first instead of being deleted by it, and a retry inside one run still trims
44
- * exactly as before.
41
+ * `dropped` to say so. `run` is a nonce per relay: EACH RUN KEEPS ITS OWN
42
+ * HIGH-WATER MARK IN ITS OWN SEQ SPACE (2026-09-24) — an unfamiliar one starts
43
+ * its own mark at the server's shared `hw` rather than being rebased onto it,
44
+ * so a second attempt appends AFTER the first instead of being deleted by it,
45
+ * and a retry inside one run still trims exactly as before. The first cut
46
+ * rebased an unfamiliar run onto the one shared mark, so a takeover's old box,
47
+ * still flushing its last seconds, spliced its late steps between the new
48
+ * run's — a superseded run's late steps are COUNTED against the shared mark
49
+ * now (the trace is not lost) and never SPLICED between the live run's.
45
50
  *
46
51
  * SCRUB EVERY STRING. This is the CLI's own stdout — a command echoing an env
47
52
  * var, a read of a config file — riding the same uplink the answer does. Prose
@@ -163,8 +168,13 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
163
168
  dirty = true;
164
169
  return;
165
170
  }
166
- queue.splice(0, entries.length);
167
- base += entries.length;
171
+ // Remove only what of the batch is STILL at the head: a push during the
172
+ // await may have shed some of it already (advancing `base`), and
173
+ // splicing the whole batch length would then drop entries that were
174
+ // never sent — a gap the buffer had room to avoid.
175
+ const drop = Math.max(0, seq + entries.length - base);
176
+ queue.splice(0, drop);
177
+ base += drop;
168
178
  }
169
179
  };
170
180
 
@@ -100,6 +100,11 @@ function reexec(teardown, { viaNpx = false, target = null } = {}) {
100
100
  env: {
101
101
  ...process.env,
102
102
  FLOWVIANT_REEXEC: '1',
103
+ // WHICH DAEMON THE SUCCESSOR IS REPLACING. Under npx the successor's
104
+ // parent is `npm exec`, not us, so the instance lock's ppid check read us
105
+ // as a rival and the new daemon SIGTERMed this proxy. instance.mjs adopts
106
+ // the lock from this pid when it is really an ancestor of the successor.
107
+ FLOWVIANT_REEXEC_FROM: String(process.pid),
103
108
  // WHAT WE RESTARTED IN ORDER TO BECOME. The successor compares its own
104
109
  // VERSION against this: if it came back still short, the update did not
105
110
  // take (a registry serving a stale `latest`, an npx cache that refused to