flowviant 0.41.0 → 0.44.1

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.
package/bin/lib/fleet.mjs CHANGED
@@ -5,7 +5,13 @@
5
5
  * MCP token, and only spawns Claude when the server says an agent has work.
6
6
  */
7
7
 
8
- import { mkdirSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
8
+ import {
9
+ mkdirSync,
10
+ existsSync,
11
+ rmSync,
12
+ readdirSync,
13
+ statSync,
14
+ } from 'node:fs';
9
15
  import { execFileSync } from 'node:child_process';
10
16
  import { createHash } from 'node:crypto';
11
17
  import { homedir } from 'node:os';
@@ -57,8 +63,8 @@ import {
57
63
  SYSTEM_PLAN_CHECK,
58
64
  PLAN_CHECK_KICKOFF,
59
65
  REGROUND_KICKOFF,
60
- SYSTEM_CONSULT,
61
- CONSULT_KICKOFF,
66
+ SYSTEM_PLAN,
67
+ PLAN_TURN_KICKOFF,
62
68
  SYSTEM_QUICK_EDIT,
63
69
  QUICK_EDIT_KICKOFF,
64
70
  } from './claude.mjs';
@@ -77,6 +83,7 @@ import {
77
83
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
78
84
  import { machineSnapshot } from './resources.mjs';
79
85
  import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
86
+ import { createWorkManager } from './work.mjs';
80
87
 
81
88
  async function fetchRoster(haveIds) {
82
89
  const url = new URL(FLEET_URL);
@@ -451,6 +458,7 @@ export async function runFleetDaemon() {
451
458
  const workers = new Map(); // agentId -> { state, promise, wt, label }
452
459
  let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
453
460
  let stream = null; // push channel handle (set once the loop is set up)
461
+ let workShutdown = null; // kills live session-turn CLIs (set with the work manager below)
454
462
 
455
463
  // Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
456
464
  // resumes in place on the next run (the task marker matches). Worktrees are
@@ -471,6 +479,14 @@ export async function runFleetDaemon() {
471
479
  } catch {
472
480
  /* best-effort */
473
481
  }
482
+ // Session-turn CLIs die with the daemon too: an orphan keeps editing the
483
+ // session worktree and burning quota, and its live-pid lock would make the
484
+ // restarted daemon skip that tab's turns for as long as it survived.
485
+ try {
486
+ workShutdown?.();
487
+ } catch {
488
+ /* best-effort */
489
+ }
474
490
  for (const [, w] of workers) {
475
491
  w.state.alive = false;
476
492
  try {
@@ -494,6 +510,15 @@ export async function runFleetDaemon() {
494
510
  teardown();
495
511
  process.exit(130);
496
512
  });
513
+ // A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
514
+ // handler every child survived a `systemctl stop` — the exact orphaning the
515
+ // teardown exists to prevent.
516
+ process.on('SIGTERM', () => {
517
+ console.log('');
518
+ note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
519
+ teardown();
520
+ process.exit(143);
521
+ });
497
522
  // Keep the daemon alive on a stray rejection. Many loops here are fire-and-
498
523
  // forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
499
524
  // rejecting; Node ≥15 terminates the process on an unhandled rejection, which
@@ -748,21 +773,155 @@ export async function runFleetDaemon() {
748
773
  }
749
774
  };
750
775
 
751
- // Consults a planning question aimed at THIS machine, answered by reading
752
- // the repo. Deliberately the lightest job on the roster: same read-only
753
- // detached checkout the plan check uses, no MCP, no writes, no run recorded.
776
+ // ── Planning sessions ────────────────────────────────────────────────────
777
+ //
778
+ // A turn in a plan thread, answered inside a HELD session. This was the
779
+ // consult, which answered one question in prose and kept nothing: it existed
780
+ // because the planner was a different, weaker brain and this turn's only job
781
+ // was to correct it from the real code. That planner is gone, so the session
782
+ // reads the repo AND writes the plan, over many turns, in one context.
783
+ //
784
+ // Two things changed shape as a result.
785
+ //
786
+ // ONE WORKTREE PER PLAN, not the shared `wikiWt`. Every CLI here resumes with
787
+ // "continue the last session in this directory" (`--continue`, `resume
788
+ // --last`) rather than by session id, so the WORKING DIRECTORY *is* the
789
+ // session handle. A shared directory would have made two plans on one machine
790
+ // take turns wearing each other's context — and the wiki queue hard-resets
791
+ // that directory between tasks, which would pull the files out from under a
792
+ // session mid-argument. A private detached checkout per plan also means plan
793
+ // turns no longer queue behind the wiki lock.
794
+ //
795
+ // IT CARRIES MCP. A consult passed none — nothing to write. A session spawns
796
+ // slices, re-shapes them, drops them and maintains the spec, all of which are
797
+ // control-plane calls. The token is the fleet's PLAN principal, whose entire
798
+ // tool set is those five: it cannot claim, cannot open a worktree, cannot
799
+ // commit. That absence is the product rule, not a hardening measure — it is
800
+ // what makes "add a dark mode toggle" typed at a plan add a slice instead of
801
+ // building one, with nothing reading the sentence to decide.
754
802
  const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
803
+ const PLAN_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-token');
755
804
  const answering = new Set();
756
- const consultAttempts = new Map(); // consultId -> tries
757
- /** Give up after this many turns on one question. A /consult-done that never
805
+ const consultAttempts = new Map(); // turn id -> tries
806
+ /** Give up after this many turns on one message. A /consult-done that never
758
807
  * reaches the server (offline, 500) would otherwise re-run the whole Claude
759
808
  * turn every poll, forever, on the owner's quota. */
760
809
  const MAX_CONSULT_TRIES = 3;
761
- /** ONE consult at a time. They all read the same worktree, and the roster can
762
- * hand back a batch un-awaited spawns meant N pending questions became N
763
- * concurrent `claude` processes on someone's laptop. */
810
+ /** ONE planning turn at a time on this machine. Sessions are per-plan so they
811
+ * no longer collide on a directory, but the roster can hand back a batch, and
812
+ * un-awaited spawns would put N concurrent CLI processes on someone's laptop
813
+ * for what is, on the human's side, a chat. */
764
814
  let consultChain = Promise.resolve();
765
815
 
816
+ /**
817
+ * The plan credential, cached until it stops working.
818
+ *
819
+ * Minted lazily rather than at startup: most daemons never host a planning
820
+ * session, and a token nobody uses is a credential sitting on disk for no
821
+ * reason. Rotated by the server on every mint, so a re-mint after a 401 is the
822
+ * recovery path.
823
+ */
824
+ let planToken = null;
825
+ const mintPlanToken = async (force = false) => {
826
+ if (planToken && !force) return planToken;
827
+ try {
828
+ const res = await fetch(PLAN_TOKEN_URL, {
829
+ method: 'POST',
830
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
831
+ });
832
+ if (!res.ok) return null;
833
+ const data = await res.json().catch(() => null);
834
+ planToken = data?.data?.token ?? null;
835
+ return planToken;
836
+ } catch {
837
+ return null;
838
+ }
839
+ };
840
+
841
+ /**
842
+ * This plan's session directory — its context, expressed as a place.
843
+ *
844
+ * A detached checkout at base, like a consult's, but PRIVATE and PERSISTENT:
845
+ * private so `--continue` resumes this argument rather than whichever ran last
846
+ * on the box, persistent so it survives the daemon restarting or updating
847
+ * under it. Re-pointed at the current base each turn, because "reads your
848
+ * code" has to mean the code as it is now — a plan that runs for days would
849
+ * otherwise keep answering from the commit it was opened at.
850
+ *
851
+ * Returns null when the id is not a safe path segment: it comes off the wire.
852
+ */
853
+ const planWtFor = (planId) => {
854
+ if (!isSafePathSegment(planId)) return null;
855
+ const wt = join(baseDir, 'plans', planId);
856
+ const fresh = !existsSync(wt);
857
+ if (fresh) {
858
+ try {
859
+ git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
860
+ } catch {
861
+ git(['worktree', 'prune'], repoRoot);
862
+ try {
863
+ git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
864
+ } catch {
865
+ return null;
866
+ }
867
+ }
868
+ } else {
869
+ try {
870
+ git(['fetch', 'origin', '--quiet'], repoRoot);
871
+ git(['checkout', '--detach', baseRef], wt);
872
+ git(['reset', '--hard', baseRef], wt);
873
+ git(['clean', '-fd'], wt);
874
+ } catch {
875
+ /* offline, or a turn left it dirty — read what we have */
876
+ }
877
+ }
878
+ return { wt, fresh };
879
+ };
880
+
881
+ /**
882
+ * Retire the least-recently-touched session directories.
883
+ *
884
+ * The bound belongs HERE, in the machine, and never in the interface: ten
885
+ * plans open across a team is ten checkouts on one box, which is a resource
886
+ * question. Announcing a session limit in the app would be advertising
887
+ * capacity, which this product does not do. A retired session simply rebuilds
888
+ * from the spec next time it is asked for — the fallback the server already
889
+ * expects, and which the thread says out loud when it happens.
890
+ */
891
+ const MAX_PLAN_SESSIONS = 8;
892
+ const planTouched = new Map(); // planId -> ms
893
+ const retireIdlePlanSessions = () => {
894
+ const dir = join(baseDir, 'plans');
895
+ if (!existsSync(dir)) return;
896
+ let ids;
897
+ try {
898
+ ids = readdirSync(dir);
899
+ } catch {
900
+ return;
901
+ }
902
+ if (ids.length <= MAX_PLAN_SESSIONS) return;
903
+ const oldestFirst = ids.sort(
904
+ (a, b) => (planTouched.get(a) ?? 0) - (planTouched.get(b) ?? 0)
905
+ );
906
+ for (const id of oldestFirst.slice(0, ids.length - MAX_PLAN_SESSIONS)) {
907
+ try {
908
+ git(['worktree', 'remove', '--force', join(dir, id)], repoRoot);
909
+ } catch {
910
+ try {
911
+ rmSync(join(dir, id), { recursive: true, force: true });
912
+ } catch {
913
+ /* it is a directory we will overwrite next time; not worth failing a turn */
914
+ }
915
+ }
916
+ planTouched.delete(id);
917
+ }
918
+ try {
919
+ git(['worktree', 'prune'], repoRoot);
920
+ } catch {
921
+ /* best effort */
922
+ }
923
+ };
924
+
766
925
  // Quick edits — a SECOND Claude alongside a task this machine is already
767
926
  // building. Unlike every other roster job it does not get a worktree of its
768
927
  // own: the whole point is to work in the one the running task opened, on that
@@ -875,57 +1034,91 @@ export async function runFleetDaemon() {
875
1034
  answering.add(job.id);
876
1035
  consultChain = consultChain.then(async () => {
877
1036
  try {
878
- note(`${c.cyan('ask')} ${c.dim(`— ${job.askedByName || 'someone'} asked about "${job.planTitle || 'a plan'}"`)}`);
879
- // Serialised against the wiki queue as well: that queue hard-resets
880
- // this worktree mid-turn, which would pull the files out from under a
881
- // consult that is reading them.
882
- // A consult's prompt is steered by a question ANY project editor can
883
- // type, so the profile is the enforcement and a runtime that cannot
884
- // express it does not get the job. Today that means Claude; see the
885
- // `profiles` notes in runtimes.mjs for exactly what Codex is missing.
886
- const consultRt = pickRuntimeFor('consult');
887
- if (!consultRt) {
888
- warn('a consult is waiting, but no installed CLI can run a read-only turn');
1037
+ note(`${c.cyan('plan')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.planTitle || 'a plan'}"`)}`);
1038
+ // The profile is the enforcement, not the prompt: this turn is steered
1039
+ // by anything a project editor can type, and it holds write tools. A
1040
+ // runtime that cannot express `plan` does not get the job rather than
1041
+ // getting it with guarantees nobody wrote down which today excludes
1042
+ // Antigravity, whose mediated shape fits a build and not an argument.
1043
+ const planRt = pickRuntimeFor('plan');
1044
+ if (!planRt) {
1045
+ warn('a planning turn is waiting, but no installed CLI can run a planning session');
889
1046
  return;
890
1047
  }
891
- await withWikiLock(async () => {
892
- ensureWikiWorktree();
893
- const out = await runTurn({
894
- prompt: CONSULT_KICKOFF({
1048
+ const token = await mintPlanToken();
1049
+ if (!token) {
1050
+ warn('a planning turn is waiting, but the plan credential could not be minted');
1051
+ return;
1052
+ }
1053
+ const dir = planWtFor(job.taskId);
1054
+ if (!dir) {
1055
+ warn(`a planning turn is waiting, but its session directory could not be opened`);
1056
+ return;
1057
+ }
1058
+ planTouched.set(job.taskId, Date.now());
1059
+ // Resume only when this plan already HAS a session here. A fresh
1060
+ // directory means either the first turn or a session we retired, and
1061
+ // both want the same thing: start over from the spec, which the
1062
+ // kickoff carries. `--continue` against an empty directory is not an
1063
+ // error on every CLI, so asking `fresh` is what keeps it honest.
1064
+ const resume = !dir.fresh && Boolean(job.sessionRef);
1065
+ const mcp = mcpFor(planRt, token, mcpUrl);
1066
+ let out;
1067
+ try {
1068
+ out = await runTurn({
1069
+ prompt: PLAN_TURN_KICKOFF({
1070
+ planId: job.taskId,
895
1071
  planTitle: job.planTitle,
896
1072
  question: job.question,
897
1073
  askedByName: job.askedByName,
1074
+ // Sent only when we are NOT resuming: a live session already has
1075
+ // the argument in its context, and re-stating the spec every
1076
+ // turn would spend tokens telling it what it just wrote. On a
1077
+ // rebuild it is the whole inheritance.
1078
+ spec: resume ? null : job.spec,
898
1079
  }),
899
- resume: false,
900
- system: SYSTEM_CONSULT,
901
- cwd: wikiWt,
902
- // TRULY read-only no Write/Edit/rm, no MCP. The prompt also says
903
- // not to change anything, but the prompt is what an injected
904
- // question competes with; the toolset is what it cannot.
905
- readOnly: true,
906
- runtime: consultRt,
907
- label: c.cyan('[ask]'),
908
- });
909
- const answer = (out || '').trim();
910
- const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
911
- consultId: job.id,
912
- ok: answer.length > 0,
913
- // Scrub: an answer can quote config or env-adjacent code.
914
- answer: envScrub(answer).slice(0, 8000),
1080
+ resume,
1081
+ system: SYSTEM_PLAN,
1082
+ cwd: dir.wt,
1083
+ // Read the repo, write the PLAN. No Edit/Write/commit anywhere in
1084
+ // the toolset the prompt says so too, but the prompt is what an
1085
+ // injected message competes with.
1086
+ planPerm: true,
1087
+ mcpArgs: mcp.args,
1088
+ mcpEnv: mcp.env,
1089
+ runtime: planRt,
1090
+ label: c.cyan('[plan]'),
915
1091
  });
916
- if (posted) consultAttempts.delete(job.id);
917
- ok(`${c.cyan('ask')} ${c.dim('— answered in the plan thread')}`);
1092
+ } finally {
1093
+ if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
1094
+ }
1095
+ const answer = (out || '').trim();
1096
+ const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
1097
+ consultId: job.id,
1098
+ ok: answer.length > 0,
1099
+ // Scrub: a reply can quote config or env-adjacent code.
1100
+ answer: envScrub(answer).slice(0, 8000),
1101
+ // The handle the server stores, reported on EVERY turn: a session we
1102
+ // had to rebuild comes back under a new directory state, and a
1103
+ // stored handle that does not follow it leaves later turns trying to
1104
+ // resume something that is gone.
1105
+ sessionRef: dir.wt,
918
1106
  });
1107
+ if (posted) consultAttempts.delete(job.id);
1108
+ ok(`${c.cyan('plan')} ${c.dim('— replied in the plan thread')}`);
1109
+ retireIdlePlanSessions();
919
1110
  } catch (e) {
920
- // Settle it. A question that cannot be answered must not re-burn a
921
- // Claude turn every poll, and silence would leave the human waiting on
922
- // a machine that already gave up.
1111
+ // Settle it. A turn that cannot be answered must not re-burn quota
1112
+ // every poll, and silence would leave the human waiting on a machine
1113
+ // that already gave up.
923
1114
  await reportMergeOutcome(CONSULT_DONE_URL, {
924
1115
  consultId: job.id,
925
1116
  ok: false,
926
- answer: e?.message ?? 'the read failed',
1117
+ // Scrub, like the success path: an exception routinely quotes
1118
+ // command output, and command output can quote a synced secret.
1119
+ answer: envScrub(String(e?.message ?? 'the planning turn failed')).slice(0, 2000),
927
1120
  });
928
- warn(`consult failed: ${e?.message ?? e}`);
1121
+ warn(`planning turn failed: ${e?.message ?? e}`);
929
1122
  } finally {
930
1123
  answering.delete(job.id);
931
1124
  }
@@ -933,6 +1126,26 @@ export async function runFleetDaemon() {
933
1126
  }
934
1127
  };
935
1128
 
1129
+ // ── Work sessions — the Workbench tabs ─────────────────────────────────────
1130
+ //
1131
+ // The whole machinery — per-session turn/ship chains, per-session work
1132
+ // tokens, the settle-every-turn contract, the ship executor, worktree
1133
+ // retirement — lives in work.mjs; this hands it the loop's mutable state.
1134
+ const {
1135
+ flushWorkReports,
1136
+ processWorkTurns,
1137
+ processShipJobs,
1138
+ retireWorkSessions,
1139
+ shutdownWork,
1140
+ } = createWorkManager({
1141
+ repoRoot,
1142
+ baseDir,
1143
+ baseRef,
1144
+ getMcpUrl: () => mcpUrl,
1145
+ getLeaseTtl: () => leaseTtlSeconds,
1146
+ });
1147
+ workShutdown = shutdownWork; // teardown can now reach the live session CLIs
1148
+
936
1149
  const processMergeJobs = (jobs) => {
937
1150
  for (const job of jobs ?? []) {
938
1151
  if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
@@ -1525,10 +1738,22 @@ export async function runFleetDaemon() {
1525
1738
  });
1526
1739
  if (updating) return;
1527
1740
  }
1741
+ // Settle any turn/ship answers whose earlier report POST failed BEFORE
1742
+ // taking new work — the skip-if-pending guards make the ordering safe, but
1743
+ // delivering first keeps the tab honest a poll sooner.
1744
+ void flushWorkReports();
1528
1745
  processMergeJobs(roster.mergeJobs);
1529
1746
  processPatchRevertJobs(roster.patchRevertJobs);
1530
1747
  processPlanCheckJobs(roster.planCheckJobs);
1531
1748
  processConsultJobs(roster.consultJobs);
1749
+ processWorkTurns(roster.workTurnJobs);
1750
+ // The roster's live-session list rides along: an ENDED session's ship
1751
+ // must not be refused by checks whose remedies need a live tab.
1752
+ processShipJobs(roster.shipJobs, roster.activeWorkSessions);
1753
+ // AFTER the work/ship intake: retirement is the server saying which
1754
+ // sessions are LIVE, and the guards above (chains, shipping) are populated
1755
+ // by the intake this same tick.
1756
+ retireWorkSessions(roster.activeWorkSessions);
1532
1757
  processJoinJobs(roster.joinJobs);
1533
1758
  processCleanupJobs(roster.cleanupJobs);
1534
1759
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
package/bin/lib/live.mjs CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  restoreWip,
44
44
  clearWip,
45
45
  } from './git.mjs';
46
- import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
46
+ import { applyPatch, commitHistory, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
47
47
  import { RUNTIMES, runtimeById, drivableHere, mediated } from './runtimes.mjs';
48
48
  import { loadPreviewConfig, startPreview } from './preview.mjs';
49
49
  import { materializeInto, scrub as envScrub } from './env.mjs';
@@ -316,6 +316,33 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
316
316
  // The flowviant MCP endpoint handles tools/call statelessly with a bearer
317
317
  // worker token — no handshake — so this is all the daemon needs.
318
318
  let rpcId = 0;
319
+ /**
320
+ * Push this task's commits + real diffs to the control plane.
321
+ *
322
+ * The server used to fetch exactly this from github.com with a GitHub App
323
+ * installation token — the app existed largely for it. We are standing in the
324
+ * worktree that produced these commits, so we send them: the thread's diff
325
+ * timeline, the review quiz and the merge gate's approved-head pin all read
326
+ * what lands here.
327
+ *
328
+ * Best-effort by design. A failure here must never fail the run — the work is
329
+ * committed and the PR is open either way, and the next push reports again.
330
+ * What it costs when it does fail is visible rather than silent: the thread
331
+ * shows no diffs, which is the same thing it showed when GitHub was unreachable.
332
+ */
333
+ async function reportCommits({ mcpUrl, token, runId, cwd, baseRef }) {
334
+ try {
335
+ const base = baseRef ?? 'HEAD';
336
+ const commits = commitHistory(cwd, base);
337
+ if (commits.length === 0) return;
338
+ const headSha = commits[commits.length - 1].sha;
339
+ const res = await mcpCall(mcpUrl, token, 'report_commits', { runId, headSha, commits });
340
+ if (res?.ok === false) warn(`report_commits rejected: ${res.reason ?? 'unknown'}`);
341
+ } catch (e) {
342
+ warn(`report_commits skipped: ${e?.message ?? String(e)}`);
343
+ }
344
+ }
345
+
319
346
  async function mcpCall(mcpUrl, token, name, args) {
320
347
  const res = await fetch(mcpUrl, {
321
348
  method: 'POST',
@@ -1023,6 +1050,7 @@ async function driveMediated({
1023
1050
  ...(result.branch ? { branch: String(result.branch) } : {}),
1024
1051
  }).catch((e) => ({ ok: false, reason: e?.message ?? String(e) }));
1025
1052
  if (attached?.ok === false) warn(`attach_pr rejected: ${attached.reason ?? 'unknown'}`);
1053
+ else await reportCommits({ mcpUrl, token, runId, cwd, baseRef });
1026
1054
  }
1027
1055
  }
1028
1056
  clearTaskMarker(cwd);
package/bin/lib/patch.mjs CHANGED
@@ -125,12 +125,15 @@ const DIFF_STATUS = { A: 'added', D: 'removed', M: 'modified' };
125
125
  * pass back to `git diff -- <path>`. A rename showing up as a delete plus an add
126
126
  * is a slightly longer diff and a correct one.
127
127
  */
128
- export function fileDiffs(cwd, base) {
128
+ export function fileDiffs(cwd, base, { range, maxFiles = MAX_DIFF_FILES } = {}) {
129
+ // `range` lets the per-COMMIT walk reuse this (`sha^..sha`); without it the
130
+ // original meaning holds — everything the agent did since `base`.
131
+ const rev = range ?? `${base}..HEAD`;
129
132
  let numstat = '';
130
133
  let names = '';
131
134
  try {
132
- numstat = git(['diff', '--numstat', '--no-renames', `${base}..HEAD`], cwd);
133
- names = git(['diff', '--name-status', '--no-renames', `${base}..HEAD`], cwd);
135
+ numstat = git(['diff', '--numstat', '--no-renames', rev], cwd);
136
+ names = git(['diff', '--name-status', '--no-renames', rev], cwd);
134
137
  } catch {
135
138
  return [];
136
139
  }
@@ -144,7 +147,7 @@ export function fileDiffs(cwd, base) {
144
147
 
145
148
  const out = [];
146
149
  for (const line of numstat.split('\n')) {
147
- if (out.length >= MAX_DIFF_FILES) break;
150
+ if (out.length >= maxFiles) break;
148
151
  const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line.replace(/\n$/, ''));
149
152
  if (!m) continue;
150
153
  const path = m[3].trim();
@@ -154,7 +157,7 @@ export function fileDiffs(cwd, base) {
154
157
  let patch = null;
155
158
  if (!binary) {
156
159
  try {
157
- const full = git(['diff', `${base}..HEAD`, '--', path], cwd);
160
+ const full = git(['diff', rev, '--', path], cwd);
158
161
  // Drop git's own "diff --git a/… b/…" preamble; the card shows the path.
159
162
  const at = full.indexOf('@@');
160
163
  const hunks = at === -1 ? full : full.slice(at);
@@ -293,3 +296,68 @@ export function revertPatch({ repoRoot, shas }) {
293
296
  return { ok: false, error: e?.message ?? String(e) };
294
297
  }
295
298
  }
299
+
300
+
301
+ // How many commits of a task's branch we carry across. The server used to read
302
+ // this from GitHub and capped at 50 for the same reason: each commit costs a
303
+ // diff, and a runaway branch must not fan out unbounded work or produce a row
304
+ // too big to read on every card render. Truncation keeps the MOST RECENT
305
+ // commits — the tail is what a reviewer is looking at.
306
+ const MAX_COMMITS = 50;
307
+
308
+ /**
309
+ * A task branch's commits with their real per-file diffs, in the exact shape
310
+ * the server's GitHub read used to return (`TaskCommit[]`).
311
+ *
312
+ * This is the function that let the GitHub App die. The server used to resolve
313
+ * the project's linked repo, mint an installation token, fetch
314
+ * `GET /pulls/{n}/commits` and then run an N+1 of `GET /commits/{sha}` for the
315
+ * per-file patches — up to ~52 API calls to describe work THIS process had just
316
+ * performed, in a checkout it is standing in. Now the daemon reports it through
317
+ * `report_commits` and the server reads a row.
318
+ *
319
+ * Oldest → newest, because the thread appends chronologically.
320
+ */
321
+ export function commitHistory(cwd, base) {
322
+ let log = '';
323
+ try {
324
+ // %x1f/%x1e are unit/record separators: a commit subject can contain
325
+ // anything, tabs and pipes included, so the delimiters have to be bytes a
326
+ // human will never type.
327
+ log = git(
328
+ ['log', '--reverse', `--max-count=${MAX_COMMITS}`, '--format=%H%x1f%s%x1f%an%x1f%aI%x1e', `${base}..HEAD`],
329
+ cwd,
330
+ );
331
+ } catch {
332
+ return [];
333
+ }
334
+
335
+ const out = [];
336
+ for (const record of log.split('\x1e')) {
337
+ const line = record.trim();
338
+ if (!line) continue;
339
+ const [sha, message, authorName, committedAt] = line.split('\x1f');
340
+ if (!sha) continue;
341
+ // First-parent range for the commit itself. A root commit has no `^`, in
342
+ // which case git's empty-tree hash gives us the whole thing as an add.
343
+ let range = `${sha}^..${sha}`;
344
+ try {
345
+ git(['rev-parse', `${sha}^`], cwd);
346
+ } catch {
347
+ range = `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${sha}`;
348
+ }
349
+ const files = fileDiffs(cwd, null, { range });
350
+ out.push({
351
+ sha,
352
+ message: (message ?? '').slice(0, 500),
353
+ authorName: (authorName ?? '').slice(0, 200),
354
+ authorLogin: null,
355
+ committedAt: committedAt ?? new Date().toISOString(),
356
+ url: null,
357
+ additions: files.reduce((n, f) => n + f.additions, 0),
358
+ deletions: files.reduce((n, f) => n + f.deletions, 0),
359
+ files,
360
+ });
361
+ }
362
+ return out;
363
+ }