mu-agent 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.1] - 2026-10-24
6
+
7
+ **0.2.1 makes mude 1.0 the recommended judge on a Mac, set up from `mu setup`, and its install survives a power cut.**
8
+
9
+ ### Added
10
+
11
+ - `mu setup` offers mude 1.0 in its judge step on a Mac that can run it (Apple silicon, macOS 14 or later, 8 GB of memory or more). It is the first choice and the recommended one, so pressing Enter picks it; the Jev that was recommended before follows it and stays a choice. Picking it writes Jev as the judge, then installs mude 1.0 in front of it in the same terminal, with its progress. If the install doesn't finish, Jev stays the judge and `mu judge mude install` goes on from where it stopped. Scripts use `--judge mude`. On other machines the judge step is as before: free Jev is first and recommended.
12
+
13
+ ### Fixed
14
+
15
+ - `mu judge mude install` survives a power cut in the middle of the download. Every 32 MiB the partly downloaded file is synced to the disk and the amount on disk is recorded in `<file>.part.saved`. The next install goes on from there, so it never builds on an end the disk didn't keep. A part left by mu 0.2.0, which has no record, goes on from 32 MiB before its end.
16
+ - A resumed file that fails its sha256 check is downloaded once more from the start, instead of ending the install.
17
+ - The download rides out a lost connection: a try that gets some of the file starts the count of failed tries again. Tries that get nothing wait 2, 5, 10, 20 and then 30 s, about two minutes in all, before the install stops.
18
+ - Ctrl-C, or the desktop app ending an install, stops the download where it is, keeps what it got and says how much: "Stopped with 0.9 of 1.8 GB downloaded: mu judge mude install goes on from there". An install that goes on says how much is already there.
19
+ - An install that goes on from one that stopped keeps that one's `--local-only`: it is recorded in `install.json` until the install finishes. Before, running it again without the flag left the hosted judges behind mude.
20
+
5
21
  ## [0.2.0] - 2026-10-23
6
22
 
7
23
  **0.2.0 starts the era of mu's own models: `mu judge mude install` turns on mude 1.0 in one command.**
@@ -641,19 +641,21 @@ function readText(path) {
641
641
  import { createHash } from "node:crypto";
642
642
  import {
643
643
  createReadStream,
644
- createWriteStream,
645
644
  existsSync as existsSync3,
646
645
  mkdirSync as mkdirSync3,
647
646
  readFileSync as readFileSync4,
648
647
  renameSync as renameSync2,
649
648
  rmSync as rmSync2,
650
649
  statSync as statSync3,
650
+ truncateSync,
651
651
  writeFileSync as writeFileSync4
652
652
  } from "node:fs";
653
+ import { open } from "node:fs/promises";
653
654
  import { homedir } from "node:os";
654
655
  import { dirname as dirname2, join as join3 } from "node:path";
655
- import { Readable } from "node:stream";
656
656
  import { pipeline } from "node:stream/promises";
657
+ var CHECKPOINT_BYTES = 32 * 1024 * 1024;
658
+ var RETRY_WAITS = [2e3, 5e3, 1e4, 2e4, 3e4];
657
659
  function huggingFaceToken(env = process.env, home = homedir()) {
658
660
  const direct = env.HF_TOKEN?.trim();
659
661
  if (direct) return direct;
@@ -755,42 +757,70 @@ async function downloadFiles(options) {
755
757
  }
756
758
  rmSync2(target, { force: true });
757
759
  }
758
- await fetchFile(
759
- options,
760
- file,
761
- target,
762
- (bytes) => options.onProgress?.({ file: file.path, checking: false, done: settled + bytes, total })
763
- );
764
- options.onProgress?.({ file: file.path, checking: true, done: settled + file.size, total });
765
760
  const part = `${target}.part`;
766
- const actual = await sha256File(part);
761
+ const onBytes = (bytes) => options.onProgress?.({ file: file.path, checking: false, done: settled + bytes, total });
762
+ let resumed = await fetchFile(options, file, part, onBytes);
763
+ options.onProgress?.({ file: file.path, checking: true, done: settled + file.size, total });
764
+ let actual = await sha256File(part);
765
+ if (actual !== file.sha256 && resumed) {
766
+ dropPart(part);
767
+ resumed = await fetchFile(options, file, part, onBytes);
768
+ options.onProgress?.({ file: file.path, checking: true, done: settled + file.size, total });
769
+ actual = await sha256File(part);
770
+ }
767
771
  if (actual !== file.sha256) {
768
- rmSync2(part, { force: true });
772
+ dropPart(part);
769
773
  throw new DownloadError(
770
774
  `${file.path} does not match its pinned sha256 (got ${actual.slice(0, 12)}\u2026, want ${file.sha256.slice(0, 12)}\u2026); it was removed`,
771
775
  "integrity"
772
776
  );
773
777
  }
774
778
  renameSync2(part, target);
779
+ rmSync2(savedPath(part), { force: true });
775
780
  remember(dir, verified, file);
776
781
  settled += file.size;
777
782
  }
778
783
  options.onProgress?.({ file: "", checking: false, done: total, total });
779
784
  }
780
- async function fetchFile(options, file, target, onBytes) {
781
- const part = `${target}.part`;
782
- const attempts = options.attempts ?? 4;
785
+ var savedPath = (part) => `${part}.saved`;
786
+ function dropPart(part) {
787
+ rmSync2(part, { force: true });
788
+ rmSync2(savedPath(part), { force: true });
789
+ }
790
+ function resumeFrom(part, size, checkpointBytes = CHECKPOINT_BYTES) {
791
+ const have = Math.min(sizeOf(part), size);
792
+ let saved;
793
+ try {
794
+ const value = Number.parseInt(readFileSync4(savedPath(part), "utf8").trim(), 10);
795
+ if (Number.isSafeInteger(value) && value >= 0) saved = value;
796
+ } catch {
797
+ }
798
+ return saved === void 0 ? Math.max(0, have - checkpointBytes) : Math.min(saved, have);
799
+ }
800
+ function writeSaved(part, bytes) {
801
+ const path = savedPath(part);
802
+ writeFileSync4(`${path}.tmp`, `${bytes}
803
+ `);
804
+ renameSync2(`${path}.tmp`, path);
805
+ }
806
+ async function fetchFile(options, file, part, onBytes) {
807
+ const attempts = options.attempts ?? 8;
783
808
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
809
+ let resumed = false;
810
+ let failures = 0;
784
811
  let lastError;
785
- for (let attempt = 1; attempt <= attempts; attempt++) {
812
+ for (; ; ) {
786
813
  if (options.signal?.aborted) throw new DownloadError("The download was stopped", "aborted");
814
+ const before = resumeFrom(part, file.size, options.checkpointBytes);
787
815
  try {
788
- await fetchOnce(options, file, part, onBytes);
789
- return;
816
+ resumed = await fetchOnce(options, file, part, onBytes) || resumed;
817
+ return resumed;
790
818
  } catch (error) {
791
819
  if (error instanceof DownloadError && error.kind !== "network") throw error;
792
820
  lastError = error;
793
- if (attempt < attempts) await sleep([2e3, 5e3, 1e4][attempt - 1] ?? 1e4);
821
+ failures = resumeFrom(part, file.size, options.checkpointBytes) > before ? 1 : failures + 1;
822
+ if (failures >= attempts) break;
823
+ await sleep(RETRY_WAITS[Math.min(failures, RETRY_WAITS.length) - 1]);
794
824
  }
795
825
  }
796
826
  const reason = lastError instanceof Error ? lastError.message : String(lastError);
@@ -800,21 +830,20 @@ async function fetchFile(options, file, target, onBytes) {
800
830
  );
801
831
  }
802
832
  async function fetchOnce(options, file, part, onBytes) {
803
- let have = 0;
804
- try {
805
- have = statSync3(part).size;
806
- } catch {
807
- }
808
- if (have > file.size) {
809
- rmSync2(part, { force: true });
810
- have = 0;
833
+ const checkpointBytes = options.checkpointBytes ?? CHECKPOINT_BYTES;
834
+ let have = resumeFrom(part, file.size, checkpointBytes);
835
+ if (have === file.size) {
836
+ if (sizeOf(part) > file.size) truncateSync(part, file.size);
837
+ return true;
811
838
  }
812
- if (have === file.size) return;
813
839
  const controller = new AbortController();
814
840
  const stallMs = options.stallMs ?? 6e4;
815
841
  let stall = setTimeout(() => controller.abort(new Error(`no data for ${stallMs / 1e3} s`)), stallMs);
816
842
  const onAbort = () => controller.abort(options.signal?.reason);
817
843
  options.signal?.addEventListener("abort", onAbort, { once: true });
844
+ let handle;
845
+ let written = 0;
846
+ let synced = 0;
818
847
  try {
819
848
  const headers = {};
820
849
  if (have > 0) headers.Range = `bytes=${have}-`;
@@ -830,30 +859,52 @@ async function fetchOnce(options, file, part, onBytes) {
830
859
  "access"
831
860
  );
832
861
  }
833
- if (response.status === 416 && have > 0) {
834
- return;
862
+ if (response.status === 416) {
863
+ dropPart(part);
864
+ throw new Error("HTTP 416");
835
865
  }
836
866
  if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
837
- const resumed = response.status === 206;
867
+ const resumed = response.status === 206 && have > 0;
838
868
  if (!resumed) have = 0;
839
- const out = createWriteStream(part, { flags: resumed ? "a" : "w" });
840
- let received = have;
841
- onBytes(received);
842
- const body = Readable.fromWeb(response.body);
843
- body.on("data", (chunk) => {
844
- received += chunk.length;
869
+ mkdirSync3(dirname2(part), { recursive: true });
870
+ handle = await open(part, existsSync3(part) ? "r+" : "w");
871
+ await handle.truncate(have);
872
+ writeSaved(part, have);
873
+ written = have;
874
+ synced = have;
875
+ onBytes(written);
876
+ const reader = response.body.getReader();
877
+ for (; ; ) {
878
+ const { done, value: chunk } = await reader.read();
879
+ if (done) break;
845
880
  clearTimeout(stall);
846
881
  stall = setTimeout(() => controller.abort(new Error(`no data for ${stallMs / 1e3} s`)), stallMs);
847
- onBytes(received);
848
- });
849
- await pipeline(body, out);
850
- if (received !== file.size) throw new Error(`got ${received} of ${file.size} bytes`);
882
+ if (written + chunk.length > file.size) throw new Error(`got more than the ${file.size} bytes of the file`);
883
+ await handle.write(chunk, 0, chunk.length, written);
884
+ written += chunk.length;
885
+ if (written - synced >= checkpointBytes) {
886
+ await handle.sync();
887
+ synced = written;
888
+ writeSaved(part, synced);
889
+ }
890
+ onBytes(written);
891
+ }
892
+ if (written !== file.size) throw new Error(`got ${written} of ${file.size} bytes`);
893
+ return resumed;
851
894
  } catch (error) {
852
895
  if (options.signal?.aborted) throw new DownloadError("The download was stopped", "aborted");
853
896
  throw error;
854
897
  } finally {
855
898
  clearTimeout(stall);
856
899
  options.signal?.removeEventListener("abort", onAbort);
900
+ if (handle) {
901
+ try {
902
+ await handle.sync();
903
+ if (written > synced) writeSaved(part, written);
904
+ } catch {
905
+ }
906
+ await handle.close();
907
+ }
857
908
  }
858
909
  }
859
910
 
@@ -874,7 +925,8 @@ function mudePaths(muHome) {
874
925
  key: join4(root, "key"),
875
926
  pid: join4(root, "server.pid"),
876
927
  log: join4(root, "server.log"),
877
- state: join4(root, "state.json")
928
+ state: join4(root, "state.json"),
929
+ install: join4(root, "install.json")
878
930
  };
879
931
  }
880
932
 
@@ -1009,7 +1061,8 @@ ${installed2.output}`);
1009
1061
  var USAGE = `mu judge mude: mude 1.0, mu's own decision model optimized for coding, as the judge on this Mac
1010
1062
  (Apple silicon, macOS 14 or later)
1011
1063
 
1012
- mu judge mude install [--local-only] download it (${(MUDE_BYTES / 1e9).toFixed(1)} GB), set it up, start it and make it the judge
1064
+ mu judge mude install [--local-only] download it (${(MUDE_BYTES / 1e9).toFixed(1)} GB), set it up, start it and make it the judge;
1065
+ after a stop (Ctrl-C, a lost connection, a power cut), it goes on from there
1013
1066
  mu judge mude enable [--local-only | --with-fallback]
1014
1067
  make it the judge again, or change what happens when it can't answer
1015
1068
  mu judge mude disable back to the judges from before, and stop its server
@@ -1063,8 +1116,12 @@ function humanOut(write, tty) {
1063
1116
  line: (text) => write(` ${text}
1064
1117
  `),
1065
1118
  status: (status2) => write(describe(status2)),
1066
- error: (message) => write(`${message}
1067
- `)
1119
+ error: (message) => {
1120
+ if (shown >= 0 && tty) write("\n");
1121
+ shown = -1;
1122
+ write(`${message}
1123
+ `);
1124
+ }
1068
1125
  };
1069
1126
  }
1070
1127
  function describe(status2) {
@@ -1129,6 +1186,17 @@ function freeBytes(path) {
1129
1186
  return void 0;
1130
1187
  }
1131
1188
  }
1189
+ var gb = (bytes) => (bytes / 1e9).toFixed(1);
1190
+ function installLocalOnly(ctx, args) {
1191
+ if (args.includes("--local-only")) return true;
1192
+ if (args.includes("--with-fallback")) return false;
1193
+ try {
1194
+ const value = JSON.parse(readFileSync6(ctx.paths.install, "utf8"));
1195
+ return typeof value === "object" && value !== null && "localOnly" in value && value.localOnly === true;
1196
+ } catch {
1197
+ return false;
1198
+ }
1199
+ }
1132
1200
  function installed(ctx) {
1133
1201
  return missingFiles(ctx.paths.model, MUDE_FILES).length === 0;
1134
1202
  }
@@ -1186,17 +1254,29 @@ async function ensureInstalled(ctx, out) {
1186
1254
  `Not enough free disk space: mude 1.0 needs about ${((need + 2e9) / 1e9).toFixed(1)} GB, and ${(free / 1e9).toFixed(1)} GB are free`
1187
1255
  );
1188
1256
  }
1257
+ const already = downloadedBytes(ctx.paths.model, MUDE_FILES);
1189
1258
  out.step(
1190
1259
  "download",
1191
- `Downloading mude 1.0 (${MUDE_REPO}, ${(need / 1e9).toFixed(1)} GB) and checking every file`
1260
+ already > 0 ? `Going on with the mude 1.0 download (${MUDE_REPO}): ${gb(already)} of ${gb(MUDE_BYTES)} GB are here already` : `Downloading mude 1.0 (${MUDE_REPO}, ${gb(need)} GB) and checking every file`
1192
1261
  );
1193
- await downloadFiles({
1194
- dir: ctx.paths.model,
1195
- files: MUDE_FILES,
1196
- url: (path) => mudeFileUrl(path, ctx.env.HF_ENDPOINT || void 0),
1197
- token: huggingFaceToken(ctx.env),
1198
- onProgress: (event) => out.progress(event)
1199
- });
1262
+ try {
1263
+ await downloadFiles({
1264
+ dir: ctx.paths.model,
1265
+ files: MUDE_FILES,
1266
+ url: (path) => mudeFileUrl(path, ctx.env.HF_ENDPOINT || void 0),
1267
+ token: huggingFaceToken(ctx.env),
1268
+ onProgress: (event) => out.progress(event),
1269
+ signal: ctx.signal
1270
+ });
1271
+ } catch (error) {
1272
+ if (error instanceof DownloadError && error.kind === "aborted") {
1273
+ throw new DownloadError(
1274
+ `Stopped with ${gb(downloadedBytes(ctx.paths.model, MUDE_FILES))} of ${gb(MUDE_BYTES)} GB downloaded: mu judge mude install goes on from there`,
1275
+ "aborted"
1276
+ );
1277
+ }
1278
+ throw error;
1279
+ }
1200
1280
  }
1201
1281
  if (!venvReady(ctx.paths, ctx.lockFile)) {
1202
1282
  out.step("python", "Setting up the Python environment for mude 1.0 (MLX, transformers; about 1 GB, once)");
@@ -1253,9 +1333,14 @@ async function main(argv, ctx, write, tty = false) {
1253
1333
  }
1254
1334
  switch (command) {
1255
1335
  case "install": {
1336
+ const localOnly = installLocalOnly(ctx, args);
1337
+ mkdirSync5(ctx.paths.root, { recursive: true });
1338
+ writeFileSync6(ctx.paths.install, `${JSON.stringify({ localOnly })}
1339
+ `);
1256
1340
  await ensureInstalled(ctx, out);
1257
1341
  const at = await start(ctx, out);
1258
- enable(ctx, out, at, flag("--local-only"));
1342
+ enable(ctx, out, at, localOnly);
1343
+ rmSync4(ctx.paths.install, { force: true });
1259
1344
  break;
1260
1345
  }
1261
1346
  case "enable": {
@@ -1326,7 +1411,20 @@ function contextFromEnv(env = process.env, home = homedir3()) {
1326
1411
  }
1327
1412
 
1328
1413
  // packages/kyrn-judge/src/mude/cli.ts
1329
- main(process.argv.slice(2), contextFromEnv(), (text) => process.stdout.write(text), Boolean(process.stdout.isTTY)).then(
1414
+ var stop = new AbortController();
1415
+ var onSignal = (signal) => {
1416
+ if (stop.signal.aborted) process.exit(signal === "SIGINT" ? 130 : 143);
1417
+ stop.abort();
1418
+ setTimeout(() => process.exit(signal === "SIGINT" ? 130 : 143), 5e3).unref();
1419
+ };
1420
+ process.on("SIGINT", onSignal);
1421
+ process.on("SIGTERM", onSignal);
1422
+ main(
1423
+ process.argv.slice(2),
1424
+ { ...contextFromEnv(), signal: stop.signal },
1425
+ (text) => process.stdout.write(text),
1426
+ Boolean(process.stdout.isTTY)
1427
+ ).then(
1330
1428
  (code) => {
1331
1429
  process.exitCode = code;
1332
1430
  },