flowviant 0.77.5 → 0.78.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
@@ -38,7 +38,6 @@ import { handleVersionSignal } from './update.mjs';
38
38
  import {
39
39
  git,
40
40
  resetWorktree,
41
- ensureWorktree,
42
41
  repoRootOrDie,
43
42
  detectBaseRef,
44
43
  originSlug,
@@ -46,7 +45,6 @@ import {
46
45
  isValidPrUrl,
47
46
  isValidBranch,
48
47
  isSafePathSegment,
49
- worktreeDiffstat,
50
48
  } from './git.mjs';
51
49
  import { c, info, note, ok, warn, fail } from './ui.mjs';
52
50
  import { revertPatch, withPatchLock } from './patch.mjs';
@@ -85,7 +83,7 @@ import {
85
83
  RUNTIMES,
86
84
  } from './runtimes.mjs';
87
85
  import { createWorkManager } from './work.mjs';
88
- import { scanLocalSessions } from './localSessions.mjs';
86
+ import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
89
87
  import { repoState } from './repoState.mjs';
90
88
 
91
89
  async function fetchRoster(
@@ -253,7 +251,7 @@ let localSessionsUnsupported = false; // the server 404'd — quiet until restar
253
251
  let localSessionsScanAt = 0;
254
252
  let localSessionsSent = null; // last payload the server ACCEPTED, stringified
255
253
  let localSessionsSentAt = 0;
256
- async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
254
+ async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
257
255
  if (localSessionsUnsupported) return;
258
256
  if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
259
257
  localSessionsScanAt = Date.now();
@@ -261,7 +259,9 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
261
259
  try {
262
260
  // scanLocalSessions orders deterministically, so this string only changes
263
261
  // when the facts on disk do — the dedup below compares whole payloads.
264
- payload = JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs }) });
262
+ payload = JSON.stringify({
263
+ sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
264
+ });
265
265
  } catch {
266
266
  return; // presence must never throw into the poll loop
267
267
  }
@@ -319,7 +319,13 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
319
319
  repoStateScanAt = Date.now();
320
320
  let payload;
321
321
  try {
322
- const state = repoState(repoRoot, getBaseRef());
322
+ // The PARAM, and nothing else: this function sits at MODULE scope, where
323
+ // runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
324
+ // reached for it anyway, the ReferenceError landed in the catch below —
325
+ // written for an unreadable repo, silent by design — and this endpoint was
326
+ // never once posted to. The caller reads the loop's live `baseRef` at call
327
+ // time, so the value here is always current.
328
+ const state = repoState(repoRoot, baseRef);
323
329
  if (!state) return; // not readable — say nothing rather than say "none"
324
330
  payload = JSON.stringify(state);
325
331
  } catch {
@@ -771,33 +777,6 @@ export async function runFleetDaemon() {
771
777
  return run;
772
778
  };
773
779
 
774
- /** A clean detached checkout at base — what "the real code" has to mean for a
775
- * question about the repo, rather than whatever half-finished state an agent
776
- * worktree happens to be in. Shared by the plan check and consults. */
777
- const ensureWikiWorktree = () => {
778
- if (!existsSync(wikiWt)) {
779
- try {
780
- git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
781
- } catch {
782
- git(['worktree', 'prune'], repoRoot);
783
- git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
784
- }
785
- return;
786
- }
787
- // It already exists — which means it is pinned to whatever base pointed at
788
- // when it was FIRST created, possibly weeks ago. "Reads the real code" has
789
- // to mean the current base, so re-point it. Best-effort: a stale answer
790
- // beats no answer, and the next turn tries again.
791
- try {
792
- git(['fetch', 'origin', '--quiet'], repoRoot);
793
- git(['checkout', '--detach', baseRef], wikiWt);
794
- git(['reset', '--hard', baseRef], wikiWt);
795
- git(['clean', '-fd'], wikiWt);
796
- } catch {
797
- /* offline, or a turn left it dirty — read what we have */
798
- }
799
- };
800
-
801
780
  // Machine telemetry — what the box is doing with itself, for the admin view.
802
781
  const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
803
782
 
@@ -892,6 +871,7 @@ export async function runFleetDaemon() {
892
871
  execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
893
872
  cwd: repoRoot,
894
873
  stdio: ['ignore', 'pipe', 'pipe'],
874
+ timeout: 30_000,
895
875
  });
896
876
  } catch (e) {
897
877
  // Already targeting base is the common no-op; anything else is
@@ -912,6 +892,7 @@ export async function runFleetDaemon() {
912
892
  execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
913
893
  cwd: repoRoot,
914
894
  stdio: ['ignore', 'pipe', 'pipe'],
895
+ timeout: 120_000,
915
896
  });
916
897
  merged = true;
917
898
  } catch (e) {
@@ -989,7 +970,7 @@ export async function runFleetDaemon() {
989
970
  'Task restarted in Flowviant — this attempt was discarded.',
990
971
  '--delete-branch',
991
972
  ],
992
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
973
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
993
974
  );
994
975
  } catch (e) {
995
976
  // Already closed/merged/missing = fine; anything else we still
@@ -1031,10 +1012,22 @@ export async function runFleetDaemon() {
1031
1012
  const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
1032
1013
  const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
1033
1014
  const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
1015
+ const WIKI_ABANDONED_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-abandoned');
1034
1016
  const wikiQueue = [];
1035
1017
  let wikiBusy = false;
1036
1018
  let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
1037
1019
  let lastSweepAt = null; // dedup: run each Regenerate request once
1020
+ // …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
1021
+ // the server's `regen_requested_at` stays set and the roster keeps offering
1022
+ // the same `requestedAt` — which this dedup then swallowed forever. The
1023
+ // console said "retry from the app", to a console nobody reads. Bounded the
1024
+ // same way the re-ground path is: a full sweep is an expensive model turn, so
1025
+ // a repo that fails one every time must not be able to loop-burn quota.
1026
+ let sweepAttempts = 0;
1027
+ // Which request the counter belongs to, so a NEW Regenerate click starts with
1028
+ // a full budget rather than inheriting an exhausted one.
1029
+ let sweepAttemptsFor = null;
1030
+ const MAX_SWEEP_ATTEMPTS = 3;
1038
1031
  const groundedIntents = new Set(); // dedup: re-ground each delivery once
1039
1032
  // The vault is keyed by the server project this fleet credential serves
1040
1033
  // (learned from the roster); until the first poll names it, fall back to a
@@ -1077,8 +1070,38 @@ export async function runFleetDaemon() {
1077
1070
  }
1078
1071
  };
1079
1072
 
1073
+ /**
1074
+ * Tell the server this daemon has stopped retrying the pending sweep.
1075
+ *
1076
+ * The retry budget is a `let` in this process; `regen_requested_at` is a
1077
+ * durable column with a 24-hour TTL. Without this the two disagreed — the
1078
+ * daemon had permanently given up while every surface went on calling the
1079
+ * sweep queued, for the rest of the day. Best-effort: an older server 404s
1080
+ * once and the TTL is still the backstop it always was.
1081
+ */
1082
+ const postWikiAbandoned = async () => {
1083
+ try {
1084
+ await fetch(WIKI_ABANDONED_URL, {
1085
+ method: 'POST',
1086
+ headers: {
1087
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1088
+ 'User-Agent': USER_AGENT,
1089
+ 'Content-Type': 'application/json',
1090
+ },
1091
+ signal: AbortSignal.timeout(15_000),
1092
+ body: '{}',
1093
+ });
1094
+ } catch {
1095
+ /* best-effort — the request's own TTL still expires it */
1096
+ }
1097
+ };
1098
+
1080
1099
  const enqueueSweep = (job) => {
1081
1100
  if (!job || job.requestedAt === lastSweepAt) return;
1101
+ if (job.requestedAt !== sweepAttemptsFor) {
1102
+ sweepAttemptsFor = job.requestedAt;
1103
+ sweepAttempts = 0;
1104
+ }
1082
1105
  lastSweepAt = job.requestedAt;
1083
1106
  // A full sweep is expensive — never stack two. One queued sweep already
1084
1107
  // covers any newer Regenerate click (it reads the repo fresh when it runs).
@@ -1100,7 +1123,24 @@ export async function runFleetDaemon() {
1100
1123
  // the turn still reads the real changed files; this catches pages whose
1101
1124
  // frontmatter file list has drifted, or that document a concept rather
1102
1125
  // than a directory.
1103
- dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
1126
+ //
1127
+ // SANITIZED AT THE INTAKE, because every entry is server-supplied text
1128
+ // that ends up interpolated into a prompt and printed by the drain's
1129
+ // narration: a control byte can repaint the console it lands on, a
1130
+ // newline can break out of the prompt's own list framing, and an
1131
+ // unbounded array of unbounded strings is an unbounded prompt. This is
1132
+ // the belt at the intake; the prompt keeps its own fence at the
1133
+ // interpolation.
1134
+ dirtiesPages: (Array.isArray(dirtiesPages) ? dirtiesPages : [])
1135
+ .filter((p) => typeof p === 'string')
1136
+ .slice(0, 40)
1137
+ .map((p) =>
1138
+ p
1139
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
1140
+ .trim()
1141
+ .slice(0, 300)
1142
+ )
1143
+ .filter(Boolean),
1104
1144
  // THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
1105
1145
  // Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
1106
1146
  // shas on every reground job, this function never stored them, and the
@@ -1224,6 +1264,18 @@ export async function runFleetDaemon() {
1224
1264
  // thinking block or slow tool (which emit nothing until they finish) —
1225
1265
  // otherwise the cover would flap back to the empty state mid-sweep.
1226
1266
  let heartbeat = null;
1267
+ /**
1268
+ * Did THIS sweep finish? Read by the `finally` below, which owns the
1269
+ * retry decision for every way out of this block.
1270
+ *
1271
+ * It used to be decided inline on the one branch where the turn
1272
+ * returned without its sentinel — so the two OTHER ways a sweep fails,
1273
+ * `pickRuntimeFor` finding no CLI and the catch around the whole turn,
1274
+ * left the request pinned and never retried at all. Those are the
1275
+ * failures most worth retrying: a missing runtime is fixed by
1276
+ * installing one, and a thrown turn is exactly the transient case.
1277
+ */
1278
+ let sweepCompleted = false;
1227
1279
  try {
1228
1280
  // Immediate frame so the cover shows the daemon feed right away (the
1229
1281
  // "reading your code" phase), not a static message, while Claude warms up.
@@ -1283,6 +1335,7 @@ export async function runFleetDaemon() {
1283
1335
  }
1284
1336
  const wikiLabel = RUNTIMES[wikiRt].label;
1285
1337
  if (task.type === 'sweep') {
1338
+ sweepAttempts++;
1286
1339
  note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
1287
1340
  const out = await runTurn({
1288
1341
  prompt: WIKI_KICKOFF(sha, vaultDir),
@@ -1307,9 +1360,12 @@ export async function runFleetDaemon() {
1307
1360
  },
1308
1361
  });
1309
1362
  const complete = sawSentinel(out, 'WIKI_DONE');
1310
- if (complete) ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
1311
- else
1312
- warn('wiki sweep ended without WIKI_DONE partial pages synced; retry from the app.');
1363
+ if (complete) {
1364
+ sweepCompleted = true;
1365
+ ok(`${c.cyan('wiki')} ${c.dim('vault regenerated from your code.')}`);
1366
+ } else {
1367
+ warn('wiki sweep ended without WIKI_DONE — partial pages synced');
1368
+ }
1313
1369
  await runSync(complete);
1314
1370
  } else {
1315
1371
  const files = changedFilesForShas(task.shas);
@@ -1376,6 +1432,34 @@ export async function runFleetDaemon() {
1376
1432
  } finally {
1377
1433
  wikiChild = null;
1378
1434
  if (heartbeat) clearInterval(heartbeat);
1435
+ /**
1436
+ * THE RETRY DECISION, IN ONE PLACE, FOR EVERY WAY OUT OF THIS BLOCK.
1437
+ *
1438
+ * Deciding it inline on the no-sentinel branch covered one of the
1439
+ * three ways a sweep fails and silently declined the other two. Here
1440
+ * it covers the thrown turn and the no-runtime return as well, which
1441
+ * are the two most worth retrying.
1442
+ *
1443
+ * A successful sweep resets the budget. A failed one with budget left
1444
+ * clears `lastSweepAt` so the roster's next offer of the SAME request
1445
+ * is accepted — partial pages are synced without pruning either way,
1446
+ * so a retry resumes rather than starting over. A failed one with the
1447
+ * budget spent tells the SERVER, because the counter is process-local
1448
+ * and the request it bounds is durable for 24 hours.
1449
+ */
1450
+ if (task.type === 'sweep') {
1451
+ if (sweepCompleted) {
1452
+ sweepAttempts = 0;
1453
+ } else if (sweepAttempts < MAX_SWEEP_ATTEMPTS) {
1454
+ lastSweepAt = null;
1455
+ warn(`wiki sweep failed — retrying (${sweepAttempts}/${MAX_SWEEP_ATTEMPTS})`);
1456
+ } else {
1457
+ warn(
1458
+ `wiki sweep failed ${sweepAttempts} times — giving up on this request; press Regenerate to try again.`
1459
+ );
1460
+ await postWikiAbandoned();
1461
+ }
1462
+ }
1379
1463
  // Terminal frame so the app cover clears promptly (don't wait for the
1380
1464
  // freshness window to lapse). force-sent past the throttle.
1381
1465
  await postWikiProgress(frame({ done: true }), true);
@@ -1596,12 +1680,7 @@ export async function runFleetDaemon() {
1596
1680
  // tab. Without this the daemon that lost the lease removes the worktree the
1597
1681
  // winner is working in — absence would mean "somebody else won" instead of
1598
1682
  // "the tab closed".
1599
- retireWorkSessions(
1600
- Array.isArray(roster.activeWorkSessions)
1601
- ? roster.activeWorkSessions
1602
- : roster.activeWorkSessions,
1603
- roster.sessionsHeldElsewhere
1604
- );
1683
+ retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
1605
1684
  // Diffs somebody has open and is waiting on. Project-scoped rather than
1606
1685
  // per-session: `git show` runs from the repo ROOT, which can see a closed
1607
1686
  // tab's branch and a shipped commit on main alike.
@@ -1643,7 +1722,15 @@ export async function runFleetDaemon() {
1643
1722
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
1644
1723
  // the daemon's own worktrees are carved out (a session the daemon spawned
1645
1724
  // is already a tab, not something to offer adopting).
1646
- void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1725
+ void maybeReportLocalSessions({
1726
+ repoRoot,
1727
+ excludeDirs: [baseDir],
1728
+ // …and our OWN tabs' conversations. Only the CHECKOUT needs this: every
1729
+ // other place is under `baseDir` and already fenced by directory, while
1730
+ // the operator's tabs share the checkout with real terminal sessions and
1731
+ // cannot be. See `ourConversationIds`.
1732
+ excludeIds: ourConversationIds(repoRoot),
1733
+ });
1647
1734
  // …and the repo itself: every worktree and every branch, ours and not.
1648
1735
  // Never awaited, throttled inside, and silent on an older server.
1649
1736
  void maybeReportRepoState({ repoRoot, baseRef });
@@ -1736,7 +1823,10 @@ export async function runFleetDaemon() {
1736
1823
  // runs queued deploy jobs (the server only sends deployJobs to authorized
1737
1824
  // machines). Config report is cheap + dedup'd; jobs are single-flight.
1738
1825
  if (roster.env?.deployAuthorized) {
1739
- void reportDeployConfig(repoRoot);
1826
+ // The BASE branch's copy, not the working tree's — see readDeployConfig.
1827
+ // Reporting the working tree would advertise targets the runner will not
1828
+ // find, which is the same lie in the other direction.
1829
+ void reportDeployConfig(repoRoot, getBaseRef());
1740
1830
  processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
1741
1831
  }
1742
1832
 
package/bin/lib/git.mjs CHANGED
@@ -1,11 +1,6 @@
1
1
  /** Git worktree helpers (fleet & static-fleet modes). */
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
- import { existsSync, readFileSync, statSync } from 'node:fs';
5
- import { resolve, join } from 'node:path';
6
- import { rmSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
- import { materializedFiles } from './env.mjs';
9
4
 
10
5
  export function git(args, cwd) {
11
6
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -144,161 +139,6 @@ export function baseBranchName(baseRef) {
144
139
  return String(baseRef || '').replace(/^origin\//, '') || 'main';
145
140
  }
146
141
 
147
- /**
148
- * Get a detached worktree at `wt`, creating it at `ref` if it isn't there.
149
- *
150
- * Returns `{ path, fresh }` — `fresh` is the whole point. A worktree that
151
- * ALREADY existed is one somebody was mid-way through, and the caller must not
152
- * reset it; a freshly created one is at base by construction and has nothing to
153
- * preserve. That single bit replaces the in-memory `resuming` flag and the
154
- * on-disk task marker for the common case, because once a worktree is named
155
- * after its task, "does this directory exist" IS "am I resuming".
156
- *
157
- * The prune-and-retry is not paranoia: `git worktree add` refuses a path that
158
- * is still REGISTERED even when the directory is gone (`flowviant clean` rm's
159
- * the dirs, `git worktree list` keeps the stale entries), and that failure is
160
- * permanent until pruned.
161
- */
162
- export function ensureWorktree(repoRoot, wt, ref) {
163
- // Resolve first. `existsSync` answers relative to THIS process's cwd while
164
- // `git worktree add` answers relative to repoRoot, so a relative path makes
165
- // the two disagree: the check says "not there", the add says "already
166
- // exists", and the prune-and-retry can't fix a path that was never the one
167
- // we looked at. Callers pass absolute paths today; this makes that not matter.
168
- wt = resolve(wt);
169
- if (existsSync(wt)) return { path: wt, fresh: false };
170
- try {
171
- git(['worktree', 'add', '--detach', wt, ref], repoRoot);
172
- } catch {
173
- git(['worktree', 'prune'], repoRoot);
174
- git(['worktree', 'add', '--detach', wt, ref], repoRoot);
175
- }
176
- return { path: wt, fresh: true };
177
- }
178
-
179
- // ── WIP checkpoints: the sandbox's state, on the remote ────────────────────
180
- //
181
- // A task's uncommitted work used to exist in exactly one place — a directory on
182
- // whichever machine claimed it. That made the checkout precious: losing the box
183
- // lost the work, so a task was pinned to a host, the host had to be named in the
184
- // UI, and a container could never be thrown away. Pushing the work somewhere
185
- // durable inverts all of that. The sandbox becomes a cache.
186
- //
187
- // These snapshots go to `refs/flowviant-wip/<intentId>`, NOT to a branch: they
188
- // are machine state, not history, and they must never appear in a PR, a branch
189
- // listing, or anyone's `git log`. Force-pushed, because only the latest matters.
190
-
191
- const wipRef = (intentId) => `refs/flowviant-wip/${intentId}`;
192
-
193
- /** git, with extra environment — for GIT_INDEX_FILE and a committer identity we
194
- * can't assume the machine has configured. */
195
- function gitWithEnv(args, cwd, extraEnv) {
196
- return execFileSync('git', args, {
197
- cwd,
198
- encoding: 'utf8',
199
- stdio: ['ignore', 'pipe', 'pipe'],
200
- env: { ...process.env, ...extraEnv },
201
- }).trim();
202
- }
203
-
204
- /**
205
- * Snapshot everything in the worktree — staged, unstaged and untracked — and
206
- * push it, WITHOUT touching the agent's HEAD, index or files.
207
- *
208
- * That constraint is why this doesn't just commit. The agent is a live process
209
- * with its own git intentions; committing under it would rewrite state it is
210
- * mid-way through reasoning about, and `git stash` would rip the files out from
211
- * under an editor. Building the tree in a throwaway index leaves the agent's
212
- * world untouched — it cannot tell this happened.
213
- *
214
- * Returns the commit sha, or null if there was nothing dirty / no remote.
215
- *
216
- * This PUSHES, so what it stages is a security boundary, not a detail: the
217
- * daemon materializes plaintext env-vault secrets into this same worktree.
218
- * They are gitignored (materializeInto refuses to write them otherwise), and
219
- * `git add -A` honours .gitignore — but the whole point of the bug this guards
220
- * against was an exclusion mechanism that silently did nothing, so the paths
221
- * are ALSO subtracted by pathspec here. Two independent mechanisms, because one
222
- * of them failing quietly is exactly what put secrets on a remote branch.
223
- */
224
- export function checkpointWip(wt, intentId, baseRef) {
225
- if (!isSafePathSegment(intentId)) return null;
226
- const idx = join(tmpdir(), `flowviant-idx-${intentId}-${process.pid}`);
227
- const env = {
228
- GIT_INDEX_FILE: idx,
229
- // A snapshot must never fail because the machine has no user.name — this is
230
- // ours, not the user's, and it never lands in history anyone reads.
231
- GIT_AUTHOR_NAME: 'Flowviant',
232
- GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
233
- GIT_COMMITTER_NAME: 'Flowviant',
234
- GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
235
- };
236
- try {
237
- const head = git(['rev-parse', 'HEAD'], wt);
238
- gitWithEnv(['read-tree', head], wt, env);
239
- gitWithEnv(
240
- ['add', '-A', '--', '.', ...materializedFiles(wt).map((p) => `:(exclude)${p}`)],
241
- wt,
242
- env
243
- );
244
- const tree = gitWithEnv(['write-tree'], wt, env);
245
- // Nothing changed since HEAD — no snapshot worth pushing.
246
- if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
247
- const commit = gitWithEnv(
248
- ['commit-tree', tree, '-p', head, '-m', `flowviant wip ${intentId}`],
249
- wt,
250
- env
251
- );
252
- git(['push', '--force', 'origin', `${commit}:${wipRef(intentId)}`], wt);
253
- return commit;
254
- } catch {
255
- // Offline, no push rights, a repo with no origin — a checkpoint is an
256
- // optimisation, never a reason to fail a task.
257
- return null;
258
- } finally {
259
- try {
260
- rmSync(idx, { force: true });
261
- } catch {
262
- /* best-effort */
263
- }
264
- void baseRef;
265
- }
266
- }
267
-
268
- /**
269
- * Rebuild a worktree from its last pushed checkpoint. Returns true if one was
270
- * found and applied.
271
- *
272
- * The reset is MIXED on purpose: it leaves the snapshot's content in the files
273
- * with HEAD back at the parent, which is what the agent had before — dirty
274
- * working tree, nothing staged it didn't stage itself. A soft reset would hand
275
- * it a fully-staged index it never created.
276
- */
277
- export function restoreWip(wt, intentId) {
278
- if (!isSafePathSegment(intentId)) return false;
279
- const ref = wipRef(intentId);
280
- try {
281
- git(['fetch', 'origin', `+${ref}:${ref}`], wt);
282
- const commit = git(['rev-parse', ref], wt);
283
- const parent = git(['rev-parse', `${commit}^`], wt);
284
- git(['checkout', '--detach', commit], wt);
285
- git(['reset', parent], wt);
286
- return true;
287
- } catch {
288
- return false; // no checkpoint for this task, or it's unreachable
289
- }
290
- }
291
-
292
- /** Drop a task's checkpoint once its work has landed somewhere real. */
293
- export function clearWip(wt, intentId) {
294
- if (!isSafePathSegment(intentId)) return;
295
- try {
296
- git(['push', 'origin', '--delete', wipRef(intentId)], wt);
297
- } catch {
298
- /* already gone, or no remote */
299
- }
300
- }
301
-
302
142
  export function resetWorktree(wt, baseRef) {
303
143
  try {
304
144
  git(['fetch', 'origin', '--quiet'], wt);
@@ -314,106 +154,3 @@ export function resetWorktree(wt, baseRef) {
314
154
  }
315
155
  }
316
156
 
317
- /**
318
- * What has changed in this worktree since `baseRef` — committed or not.
319
- *
320
- * The definition matters. `git diff --numstat <base>` (no `..HEAD`) compares the
321
- * base against the WORKING TREE, so it covers commits the agent has made, staged
322
- * work, and edits it has not committed yet. Anything narrower would go blank at
323
- * the exact moments you look: right after a commit, or before the first one.
324
- *
325
- * Untracked files are added separately — they are invisible to `git diff` and
326
- * are usually the most interesting thing an agent has done (a new module, a new
327
- * test). Their line counts are read here rather than inferred; a file too large
328
- * to be source is reported as a path with no counts instead of being read into
329
- * memory.
330
- *
331
- * PATHS AND COUNTS ONLY. Nothing in here returns file content.
332
- *
333
- * Both git calls are `-z`, for the reason gitRaw exists: git's line-based output
334
- * QUOTES any path that is not plain ASCII, so an accented filename arrives as
335
- * "n\303\251w.txt" — a string that is not the path, cannot be stat'd, and reads
336
- * as garbage in the tray. `-z` emits paths verbatim.
337
- */
338
- export function worktreeDiffstat(cwd, baseRef, { maxFiles = 200 } = {}) {
339
- const files = [];
340
- let additions = 0;
341
- let deletions = 0;
342
-
343
- const add = (path, added, removed) => {
344
- additions += added;
345
- deletions += removed;
346
- files.push({ path, added, removed });
347
- };
348
-
349
- try {
350
- // `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
351
- // but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
352
- // then the new one. An empty path is therefore the rename marker, and the
353
- // next two fields belong to it — read line-wise instead, a rename would
354
- // report a file literally named "old => new".
355
- const fields = splitNul(gitRaw(['diff', '--numstat', '-z', baseRef, '--'], cwd));
356
- for (let i = 0; i < fields.length; i++) {
357
- const [a, d, ...rest] = fields[i].split('\t');
358
- let path = rest.join('\t');
359
- if (!path) {
360
- path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
361
- i += 2;
362
- if (!path) continue;
363
- }
364
- // Binary files report '-' for both counts; they changed, but not by lines.
365
- add(path, a === '-' ? 0 : Number(a) || 0, d === '-' ? 0 : Number(d) || 0);
366
- }
367
- } catch {
368
- // No base ref yet, or not a repo — nothing to report rather than a crash.
369
- return null;
370
- }
371
-
372
- try {
373
- const untracked = splitNul(
374
- gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], cwd)
375
- );
376
- for (const path of untracked) {
377
- let added = 0;
378
- // Past the cap this path will not be shown, so do not pay to read it.
379
- // This is the one place the totals can undercount, and reaching it takes
380
- // an untracked tree bigger than the list itself — a generated directory
381
- // .gitignore missed. Statting and reading all of it on a 20s interval
382
- // would block the roster poll and every other lane on this daemon.
383
- if (files.length < maxFiles) {
384
- try {
385
- const st = statSync(join(cwd, path));
386
- // Regular files ONLY. `git ls-files --others` will happily name a
387
- // symlink or a fifo, and readFileSync on a fifo or a character device
388
- // BLOCKS — on a 20s interval, on the daemon's single thread, that is
389
- // the whole process wedged waiting for a device that may never write.
390
- // 2 MB: past that it is a build artifact or a binary, and reading it
391
- // to count newlines would be the most expensive thing this daemon does.
392
- if (st.isFile() && st.size <= 2_000_000) {
393
- const text = readFileSync(join(cwd, path), 'utf8');
394
- // A NUL byte means binary. Counting "lines" in a PNG produces a
395
- // number that is not wrong so much as meaningless, and it was being
396
- // summed into the total shown beside git's real counts.
397
- if (text.includes('\0')) throw new Error('binary');
398
- // Lines, not segments. A file ending in a newline — i.e. essentially
399
- // every source file an agent writes — splits into one more piece
400
- // than it has lines, and that +1 was landing in the totals shown
401
- // beside git's own counts.
402
- added = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
403
- }
404
- } catch {
405
- /* vanished between listing and reading — report the path, no counts */
406
- }
407
- }
408
- add(path, added, 0);
409
- }
410
- } catch {
411
- /* untracked listing failed — the tracked half still stands */
412
- }
413
-
414
- if (files.length === 0) return null;
415
- // Totals stay whole while the LIST is capped: a truncated list must never
416
- // quietly shrink the number printed beside it.
417
- const truncated = Math.max(0, files.length - maxFiles);
418
- return { files: files.slice(0, maxFiles), additions, deletions, truncated };
419
- }
package/bin/lib/grant.mjs CHANGED
@@ -171,12 +171,33 @@ export function safePathname(url) {
171
171
  * `//evil.com` and `/\evil.com` are protocol-relative to a browser, so a naive
172
172
  * "starts with /" check turns the callback into an open redirect on the tunnel
173
173
  * origin. Anything that is not a single-slash relative path becomes '/'.
174
+ *
175
+ * STRUCTURAL, NOT LEXICAL, and that distinction is the whole fix. This used to
176
+ * be `/^\/(?![/\\])/` plus a CR/LF check — and an audit walked through it with
177
+ * a TAB: browsers STRIP tab, CR and LF out of a URL before parsing, so
178
+ * `"/\t/evil.com"` passed the guard, was written verbatim into Location by the
179
+ * callback, and navigated to `//evil.com`. Parsing against a dummy base and
180
+ * re-serialising reproduces exactly what the browser will do — there is no
181
+ * second implementation of URL parsing here to drift, and the value returned
182
+ * is the one the parser produced. The lexical checks stay in front of it as
183
+ * the cheap first line; the origin comparison is the guarantee. Ported from
184
+ * the server twins (previewAuthorize.routes.ts, preview-relay util.ts), which
185
+ * closed the same hole the same way.
174
186
  */
175
187
  export function safeRelative(raw) {
176
188
  if (!raw) return '/';
177
189
  const s = String(raw);
178
190
  if (s.length > 512) return '/';
179
- if (/[\r\n]/.test(s)) return '/';
180
- if (!/^\/(?![/\\])/.test(s)) return '/';
181
- return s;
191
+ if (!s.startsWith('/')) return '/';
192
+ if (/^\/[/\\]/.test(s)) return '/';
193
+ // Every C0 control, space and DEL — not just CR/LF.
194
+ // eslint-disable-next-line no-control-regex
195
+ if (/[\x00-\x20\x7f]/.test(s)) return '/';
196
+ try {
197
+ const u = new URL(s, 'https://x.invalid');
198
+ if (u.origin !== 'https://x.invalid') return '/';
199
+ return u.pathname + u.search + u.hash;
200
+ } catch {
201
+ return '/';
202
+ }
182
203
  }