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
@@ -492,9 +492,10 @@ const kickoff = ({ message, askedByName, head, tail }) => {
492
492
  * A CAPTURE TAB (server: work_session.kind='capture', flagged on the job as
493
493
  * `capture: true`) — the board's "New task" conversation. Its whole job is
494
494
  * turning what the person says into STAGED cards a human lands; it edits
495
- * nothing, and the permission profile enforces that (PLAN_PERM: read-only +
496
- * MCP — the same fence the scratch planner runs behind). This prompt is the
497
- * QUALITY half; the token scope and the permission list are the safety.
495
+ * nothing, and the permission profile enforces that (`planPermFor`:
496
+ * read-only + MCP — the same fence the scratch planner runs behind). This
497
+ * prompt is the QUALITY half; the token scope and the permission list are
498
+ * the safety.
498
499
  *
499
500
  * THE CHAT ASKS IN CHIPS, AND SAYS WHAT IT ASSUMED (2026-09-23, 0.97.0). Rule
500
501
  * 5 used to be one sentence — "clarify before staging" — and the owner asked
@@ -600,7 +601,9 @@ MECHANICS OF THIS CHAT:
600
601
  something they said.
601
602
  10. REFERENCE WHAT THE PROJECT KEPT. When the ask names a kept design or
602
603
  write-up ("implement design A"), call list_library and pass its id as
603
- \`references\` on the card you stage, so the agent reads it.`;
604
+ \`references\` on the card you stage, so the agent reads it. When the person's
605
+ message includes attached library items, pass those ids as \`references\` on
606
+ the cards you stage from that message.`;
604
607
 
605
608
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
606
609
  kickoff({
@@ -684,11 +687,11 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
684
687
  * no code, edits no files and starts nothing: a person reads what it proposes,
685
688
  * edits it on the board, and accepting is what spawns anything.
686
689
  *
687
- * It runs READ-ONLY IN THE CHECKOUT under CONSULT_PERM — no Write, no Edit, no
688
- * mkdir, no rm, and no MCP at all. The proposal comes back as its final
689
- * message, not through a tool, which is what lets that permission set be this
690
- * narrow. A planner authors a decision, and there is no file on this machine it
691
- * has any business touching.
690
+ * It runs READ-ONLY IN THE CHECKOUT under `consultPermFor` — no Write, no
691
+ * Edit, no mkdir, no rm, and no MCP at all. The proposal comes back as its
692
+ * final message, not through a tool, which is what lets that permission set
693
+ * be this narrow. A planner authors a decision, and there is no file on this
694
+ * machine it has any business touching.
692
695
  *
693
696
  * The two facts it is asked to weigh are the only two that are actually
694
697
  * knowable here: what the cards SAY, and what each live agent has already
@@ -861,13 +864,15 @@ export const AGENT_PLAN_KICKOFF = ({ tasks, liveAgents, agentCap }) => {
861
864
  * a person one reply; a turn that guesses costs them a review, a rejection and
862
865
  * a second run — and the guess arrives wearing a confident summary.
863
866
  */
864
- export const SYSTEM_AGENT = `You are the human's own Claude, working one task in a git worktree of their
867
+ export const SYSTEM_AGENT = `You are the human's own coding agent, working one task in a git worktree of their
865
868
  repository. Nobody is watching this run. You have the repo and nothing else —
866
869
  no project tools, no board, no chat.
867
870
 
868
871
  WHAT TO DO:
869
872
 
870
- 1. Do the card you are given. Read whatever you need first. Follow the
873
+ 1. Do the card you are given. Read whatever you need first — including
874
+ CLAUDE.md and AGENTS.md at the repository root, when either exists: they are
875
+ the repository's own instructions, whichever CLI you are. Follow the
871
876
  repository's own conventions over anything you would do by default.
872
877
 
873
878
  2. COMMIT YOUR WORK before you finish. Small, real commit messages. Uncommitted
@@ -46,7 +46,7 @@
46
46
 
47
47
  import { execFileSync } from 'node:child_process';
48
48
  import { readFileSync, readdirSync, statfsSync } from 'node:fs';
49
- import { freemem, loadavg, platform } from 'node:os';
49
+ import { cpus, freemem, loadavg, platform } from 'node:os';
50
50
  import { MACHINE } from './config.mjs';
51
51
 
52
52
  const MiB = 1024 * 1024;
@@ -71,12 +71,38 @@ const readFile = (p) => {
71
71
  function memoryUsed() {
72
72
  const cur = readFile('/sys/fs/cgroup/memory.current');
73
73
  if (cur !== null) {
74
- const n = Number(cur);
74
+ const n = cgroupWorkingSet(Number(cur), readFile);
75
75
  if (Number.isFinite(n) && n > 0) return n;
76
76
  }
77
77
  return Math.max(0, MACHINE.memBytes - freemem());
78
78
  }
79
79
 
80
+ /**
81
+ * THE CGROUP'S WORKING SET, NOT ITS CHARGE.
82
+ *
83
+ * `memory.current` counts the page cache charged to the cgroup — every file a
84
+ * worktree add, an npm install or a repo read touched. On a container that has
85
+ * been up a while it sits just under `memory.max` while almost all of it is
86
+ * reclaimable, and the kernel reclaims only when something ALLOCATES. A guard
87
+ * reading the raw charge therefore defers every unattended turn, which spawns
88
+ * nothing, which allocates nothing, which never reclaims: a permanent refusal
89
+ * relaying a "low memory" figure that is false. This is the same trap
90
+ * `memAvailableBytes` rejects `freemem()` for, one layer down.
91
+ *
92
+ * So subtract `inactive_file` from `memory.stat`, which is what kubelet and
93
+ * `docker stats` call the working set. An unreadable or malformed stat leaves
94
+ * the charge as it was — the conservative direction, and never a guess.
95
+ */
96
+ function cgroupWorkingSet(current, read) {
97
+ if (!Number.isFinite(current)) return current;
98
+ const stat = read('/sys/fs/cgroup/memory.stat');
99
+ const m = stat && stat.match(/^inactive_file\s+(\d+)$/m);
100
+ if (!m) return current;
101
+ const inactive = Number(m[1]);
102
+ if (!Number.isFinite(inactive) || inactive < 0) return current;
103
+ return Math.max(0, current - inactive);
104
+ }
105
+
80
106
  /**
81
107
  * Resident memory of a process AND everything it spawned.
82
108
  *
@@ -176,7 +202,8 @@ export function memAvailableBytes(read = readFile) {
176
202
  const max = read('/sys/fs/cgroup/memory.max');
177
203
  if (max && max !== 'max') {
178
204
  const limit = Number(max);
179
- const cur = Number(read('/sys/fs/cgroup/memory.current'));
205
+ const raw = read('/sys/fs/cgroup/memory.current');
206
+ const cur = raw === null ? NaN : cgroupWorkingSet(Number(raw), read);
180
207
  if (Number.isFinite(limit) && limit > 0 && Number.isFinite(cur)) {
181
208
  const room = Math.max(0, limit - cur);
182
209
  avail = avail === null ? room : Math.min(avail, room);
@@ -248,6 +275,21 @@ const sizeWord = (n) =>
248
275
  const PRESSURE_CACHE_MS = 2_000;
249
276
  let lastMeasure = { at: 0, m: null };
250
277
 
278
+ /**
279
+ * THE CORES THE LOAD AVERAGE IS A LOAD ON.
280
+ *
281
+ * `/proc/loadavg` is not namespaced: inside a container it reports the whole
282
+ * HOST. `MACHINE.cores` is clamped to the cgroup's `cpu.max`, which is right
283
+ * for sizing how many turns this box runs and wrong as the divisor of a host
284
+ * figure — a `--cpus=2` container on a busy 32-core host read "load 10 on 2
285
+ * cores" and deferred every unattended turn over load it did not cause. Compare
286
+ * like with like: a host figure against the host's core count. Exported so the
287
+ * pairing can be pinned.
288
+ */
289
+ export function loadCores(hostCores = cpus().length) {
290
+ return Number.isFinite(hostCores) && hostCores > 0 ? hostCores : MACHINE.cores;
291
+ }
292
+
251
293
  /** What the box says about itself, cached briefly. Exported so a caller can
252
294
  * hold one reading across several verdicts — and so tests can pass their own
253
295
  * instead of depending on the machine they run on. */
@@ -260,7 +302,7 @@ export function measurePressure() {
260
302
  // Unix only; Windows reports zeroes, which are sent as null rather than as
261
303
  // a very calm-looking 0.00 — the same rule the snapshot keeps.
262
304
  load1: loadavg()[0] || null,
263
- cores: MACHINE.cores,
305
+ cores: loadCores(),
264
306
  };
265
307
  lastMeasure = { at: now, m };
266
308
  return m;
@@ -0,0 +1,75 @@
1
+ /** Local, bounded Codex readouts. No agent turn is started for these reports. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { readBaseTools } from './projectTools.mjs';
7
+
8
+ const TTL = 5 * 60_000;
9
+ let cachedAt = 0;
10
+ let cached = null;
11
+ let repoCached = null;
12
+ const home = (env) => env.CODEX_HOME || join(homedir(), '.codex');
13
+ const nameOk = (name) => typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(name) && name !== 'flowviant';
14
+ const run = (args, env) => execFileSync('codex', args, {
15
+ encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024,
16
+ stdio: ['ignore', 'pipe', 'ignore'], env,
17
+ });
18
+
19
+ export function localCodexOfferings({ env = process.env, runCodex = run, read = readFileSync, list = readdirSync } = {}) {
20
+ const result = {};
21
+ try {
22
+ const cachePath = join(home(env), 'models_cache.json');
23
+ if (read === readFileSync && statSync(cachePath).size > 2 * 1024 * 1024) throw Error('model cache too large');
24
+ const data = JSON.parse(read(cachePath, 'utf8'));
25
+ if (Array.isArray(data.models)) result.models = data.models
26
+ .filter((m) => m?.visibility === 'list' && nameOk(m.slug))
27
+ .slice(0, 60)
28
+ .map((m) => ({ slug: m.slug, displayName: String(m.display_name || m.slug).slice(0, 100),
29
+ efforts: Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels
30
+ .map((e) => e?.effort).filter((e) => typeof e === 'string' && /^[a-z]{1,20}$/.test(e)).slice(0, 8) : [] }));
31
+ } catch { /* absent or unreadable is unknown */ }
32
+ try {
33
+ const lines = runCodex(['features', 'list'], env).split('\n');
34
+ result.capabilities = lines.some((line) => /^image_generation\s+\S+\s+true\s*$/.test(line)) ? ['image'] : [];
35
+ } catch { /* a failed local probe is unknown */ }
36
+ try {
37
+ result.skills = list(join(home(env), 'skills'), { withFileTypes: true })
38
+ .filter((entry) => entry.isDirectory() && nameOk(entry.name))
39
+ .map((entry) => entry.name).slice(0, 80);
40
+ } catch (error) { if (error?.code === 'ENOENT') result.skills = []; }
41
+ try {
42
+ const servers = JSON.parse(runCodex(['mcp', 'list', '--json'], env));
43
+ if (Array.isArray(servers)) result.mcp = servers
44
+ // Unknown/unsupported auth is not proof of disconnection. Keep their
45
+ // names local, just as connected servers stay local.
46
+ .filter((s) => nameOk(s?.name) && ['needs_auth', 'failed'].includes(s.auth_status))
47
+ .slice(0, 80)
48
+ .map((s) => ({ n: s.name, s: s.auth_status === 'needs_auth' ? 'needs-auth' : 'failed' }));
49
+ } catch { /* no read is unknown */ }
50
+ return result;
51
+ }
52
+
53
+ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
54
+ if (!cached || refresh || Date.now() - cachedAt >= TTL) {
55
+ cached = localCodexOfferings();
56
+ cachedAt = Date.now();
57
+ }
58
+ const out = { rtm: {}, rtc: {}, rts: {}, rtp: {} };
59
+ if (cached.models) out.rtm.codex = cached.models;
60
+ if (cached.capabilities) out.rtc.codex = cached.capabilities;
61
+ if (cached.skills) out.rts.codex = cached.skills;
62
+ if (cached.mcp) out.rtp.codex = cached.mcp;
63
+ // Repo skills and MCP are read from the base commit, like the turn itself.
64
+ try {
65
+ if (!repoCached || repoCached.root !== repoRoot || repoCached.ref !== baseRef || refresh || Date.now() - repoCached.at >= TTL) {
66
+ repoCached = { root: repoRoot, ref: baseRef, at: Date.now(), snapshot: readBaseTools(repoRoot, baseRef) };
67
+ }
68
+ const snapshot = repoCached.snapshot;
69
+ if (out.rts.codex) out.rts.codex = [...new Set([...out.rts.codex, ...snapshot.skills.map((s) => s.path.split('/')[2])])].slice(0, 80);
70
+ if (out.rtp.codex) out.rtp.codex = [...new Map([...out.rtp.codex, ...snapshot.tools
71
+ .filter((t) => t.state !== 'ready' || t.config.type === 'sse')
72
+ .map((t) => ({ n: t.name, s: 'failed' }))].map((server) => [server.n, server])).values()].slice(0, 80);
73
+ } catch { /* keep local measurements */ }
74
+ return out;
75
+ }
@@ -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
@@ -555,7 +589,7 @@ export const RUNTIMES = {
555
589
  * strongest form of it available anywhere: `--append-system-prompt` sits
556
590
  * above the conversation rather than inside it.
557
591
  */
558
- 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 }) {
559
593
  const a = [];
560
594
  // THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
561
595
  // are mutually exclusive, never combined.
@@ -582,6 +616,17 @@ export const RUNTIMES = {
582
616
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
583
617
  else if (resume) a.push('--continue');
584
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');
585
630
  a.push(...mcp, ...resultSchemaArgs);
586
631
  a.push('--model', model || MODEL);
587
632
  if (effort) a.push('--effort', effort);
@@ -634,6 +679,11 @@ export const RUNTIMES = {
634
679
  * user's repository, which is not a risk worth taking for a slightly
635
680
  * stickier instruction.
636
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
+ *
637
687
  * `--skip-git-repo-check` is deliberately NOT passed: a task always builds
638
688
  * in a git worktree, and if it somehow is not one, failing loudly beats
639
689
  * silently editing files nobody can diff.
@@ -644,7 +694,7 @@ export const RUNTIMES = {
644
694
  * placed before it. Appending them after the positional is the kind of argv
645
695
  * that parses today and stops parsing on some future clap upgrade.
646
696
  */
647
- 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 }) {
648
698
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
649
699
  // agy moves) — codex has no adoptable store wired yet. Reaching here
650
700
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -661,6 +711,13 @@ export const RUNTIMES = {
661
711
  if (resumeThreadId) a.push('resume', resumeThreadId);
662
712
  else if (resume) a.push('resume', '--last');
663
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
+ }
664
721
  if (model) a.push('--model', model);
665
722
  // Effort is a config value on Codex rather than a flag.
666
723
  if (effort) a.push('-c', `model_reasoning_effort="${effort}"`);
@@ -1462,9 +1519,14 @@ export function probeSkillsOnce(cwd) {
1462
1519
  timer.unref?.();
1463
1520
  child.on('error', () => finish(null));
1464
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');
1465
1527
  child.stdout.on('data', (d) => {
1466
1528
  if (settled) return;
1467
- buf += d.toString();
1529
+ buf += d;
1468
1530
  let nl;
1469
1531
  while (!settled && (nl = buf.indexOf('\n')) >= 0) {
1470
1532
  const line = buf.slice(0, nl);
@@ -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