mu-agent 0.2.0 → 0.2.2

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,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.2] - 2026-10-24
6
+
7
+ **0.2.2 makes `mu judge mude install` cope with a full disk, a stop at any step and two installs at once.**
8
+
9
+ ### Changed
10
+
11
+ - English pass on the strings added since 0.1.10, against `docs/writing-style.md`: `mu judge mude` messages say how to resume ("Run mu judge mude install to resume"); errors say what happened and what to do next ("Couldn't create the Python environment (uv venv)", "Hugging Face returned 404 for …", the disk-space error); the kernel's settings descriptions read naturally (their translations kept); `mu --help` describes `mu judge mude install` as "install mude 1.0, mu's own decision model for coding, and make it the judge on this Mac".
12
+
13
+ ### Fixed
14
+
15
+ - `mu judge mude install`: Ctrl-C (or SIGTERM) during the wait between tries after a dropped connection now stops the download at once. It prints "Stopped with X of 1.8 GB downloaded. Run mu judge mude install to resume". Before, the wait (up to 30 s) ran on, and the command ended 5 s later with no message. What was downloaded was kept either way.
16
+ - `mu judge mude install`, disk space and stops:
17
+ - A full disk (ENOSPC or EDQUOT) during the download now ends the install at once with "The disk is full: couldn't write <file>. Free up some space, then run mu judge mude install to resume". Before, it was retried as a network error for about two minutes, and the desktop app said it couldn't reach Hugging Face.
18
+ - The free-space check before a download counts only the bytes still to come. Before, it counted the whole of every unfinished file, even when most of it was on the disk already.
19
+ - Ctrl-C (or SIGTERM) while the Python environment is being set up now says "Stopped while setting up the Python environment. Run mu judge mude install to resume", instead of "mude's Python packages could not be installed".
20
+ - `mu judge mude install` on a Mac without uv: downloading the pinned uv (about 20 MB, from GitHub) had no time limit and ignored a stop. It now ends after 5 minutes with "uv could not be downloaded from GitHub (no answer in 300 s): run mu judge mude install again", and Ctrl-C or the app's Cancel ends it at once ("Stopped while setting up the Python environment").
21
+ - `mu judge mude install` (also from `mu setup` and the desktop app) takes `local-judge/mude/install.lock`. A second install while one runs now stops at once with "Another mude 1.0 install is running (process N). Wait for it to finish, or stop it, then run mu judge mude install again". Before, both downloaded into the same `.part` files, and the second failed with `ENOENT … .part` when the first renamed a finished file. The lock records the process's pid and start time, so a lock left by a power cut or a kill is taken over, even if the pid has since been reused.
22
+ - `mu judge mude install`, `enable` and `start`: a stop (Ctrl-C, or SIGTERM from the app's Cancel) while waiting for the server's `/health` now ends the wait at once. It also stops the server if this command started it, with "Stopped while the mude 1.0 server was starting, and stopped the server too. Run mu judge mude install again to finish". Before, the wait ran on until the CLI's 5 s exit timer, with no message. The server is started `detached`, so it survived both that exit and the app's process-group kill, and kept about 4 GB of memory while mude was not the judge. A server that was already running before the command is left running: "Stopped waiting for the mude 1.0 server, which is still starting…".
23
+
24
+ ## [0.2.1] - 2026-10-24
25
+
26
+ **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.**
27
+
28
+ ### Added
29
+
30
+ - `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.
31
+
32
+ ### Fixed
33
+
34
+ - `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.
35
+ - A resumed file that fails its sha256 check is downloaded once more from the start, instead of ending the install.
36
+ - 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.
37
+ - 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.
38
+ - 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.
39
+
5
40
  ## [0.2.0] - 2026-10-23
6
41
 
7
42
  **0.2.0 starts the era of mu's own models: `mu judge mude install` turns on mude 1.0 in one command.**
@@ -92,21 +92,21 @@ function readEnvEntries(text) {
92
92
  }
93
93
  let body = rest.slice(1);
94
94
  let end = closingQuote(body, quote);
95
- const startedAt = index;
95
+ const startedAt2 = index;
96
96
  while (end < 0 && index + 1 < lines.length) {
97
97
  body += `
98
98
  ${lines[++index]}`;
99
99
  end = closingQuote(body, quote);
100
100
  }
101
101
  if (end < 0) {
102
- index = startedAt;
102
+ index = startedAt2;
103
103
  continue;
104
104
  }
105
105
  const value = body.slice(0, end);
106
106
  entries.push({
107
107
  name,
108
108
  value: quote === '"' ? value.replace(/\\(["\\$`])/g, "$1") : value,
109
- first: startedAt,
109
+ first: startedAt2,
110
110
  last: index
111
111
  });
112
112
  }
@@ -409,12 +409,21 @@ function tail(file, lines = 12) {
409
409
  return "";
410
410
  }
411
411
  }
412
+ var StartStopped = class extends Error {
413
+ /** Whether the server went too: one this call started does, one that was running before does not. */
414
+ serverStopped;
415
+ constructor(serverStopped = false) {
416
+ super("Stopped while the mude 1.0 server was starting");
417
+ this.name = "StartStopped";
418
+ this.serverStopped = serverStopped;
419
+ }
420
+ };
412
421
  async function startServer(paths, options) {
413
422
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
414
423
  const timeoutMs = options.timeoutMs ?? 18e4;
415
424
  const running = readPid(paths);
416
425
  if (running && serverAlive(paths, running)) {
417
- if (options.wait !== false) await untilReady(paths, running, options.port, timeoutMs, sleep);
426
+ if (options.wait !== false) await untilReady(paths, running, options.port, timeoutMs, sleep, options.signal);
418
427
  return { pid: running, port: options.port, already: true };
419
428
  }
420
429
  if (!await portFree(options.port)) {
@@ -463,12 +472,21 @@ async function startServer(paths, options) {
463
472
  child.unref();
464
473
  writeFileSync2(paths.pid, `${pid}
465
474
  `, { mode: 384 });
466
- if (options.wait !== false) await untilReady(paths, pid, options.port, timeoutMs, sleep);
475
+ if (options.wait !== false) {
476
+ try {
477
+ await untilReady(paths, pid, options.port, timeoutMs, sleep, options.signal);
478
+ } catch (error) {
479
+ if (!(error instanceof StartStopped)) throw error;
480
+ await stopServer(paths);
481
+ throw new StartStopped(true);
482
+ }
483
+ }
467
484
  return { pid, port: options.port, already: false };
468
485
  }
469
- async function untilReady(paths, pid, port2, timeoutMs, sleep) {
486
+ async function untilReady(paths, pid, port2, timeoutMs, sleep, signal) {
470
487
  const deadline = Date.now() + timeoutMs;
471
488
  while (Date.now() < deadline) {
489
+ if (signal?.aborted) throw new StartStopped();
472
490
  if ((await health(port2))?.status === "ready") return;
473
491
  if (!serverAlive(paths, pid)) {
474
492
  rmSync(paths.pid, { force: true });
@@ -641,19 +659,22 @@ function readText(path) {
641
659
  import { createHash } from "node:crypto";
642
660
  import {
643
661
  createReadStream,
644
- createWriteStream,
645
662
  existsSync as existsSync3,
646
663
  mkdirSync as mkdirSync3,
647
664
  readFileSync as readFileSync4,
648
665
  renameSync as renameSync2,
649
666
  rmSync as rmSync2,
650
667
  statSync as statSync3,
668
+ truncateSync,
651
669
  writeFileSync as writeFileSync4
652
670
  } from "node:fs";
671
+ import { open } from "node:fs/promises";
653
672
  import { homedir } from "node:os";
654
673
  import { dirname as dirname2, join as join3 } from "node:path";
655
- import { Readable } from "node:stream";
656
674
  import { pipeline } from "node:stream/promises";
675
+ import { setTimeout as wait } from "node:timers/promises";
676
+ var CHECKPOINT_BYTES = 32 * 1024 * 1024;
677
+ var RETRY_WAITS = [2e3, 5e3, 1e4, 2e4, 3e4];
657
678
  function huggingFaceToken(env = process.env, home = homedir()) {
658
679
  const direct = env.HF_TOKEN?.trim();
659
680
  if (direct) return direct;
@@ -755,66 +776,100 @@ async function downloadFiles(options) {
755
776
  }
756
777
  rmSync2(target, { force: true });
757
778
  }
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
779
  const part = `${target}.part`;
766
- const actual = await sha256File(part);
780
+ const onBytes = (bytes) => options.onProgress?.({ file: file.path, checking: false, done: settled + bytes, total });
781
+ let resumed = await fetchFile(options, file, part, onBytes);
782
+ options.onProgress?.({ file: file.path, checking: true, done: settled + file.size, total });
783
+ let actual = await sha256File(part);
784
+ if (actual !== file.sha256 && resumed) {
785
+ dropPart(part);
786
+ resumed = await fetchFile(options, file, part, onBytes);
787
+ options.onProgress?.({ file: file.path, checking: true, done: settled + file.size, total });
788
+ actual = await sha256File(part);
789
+ }
767
790
  if (actual !== file.sha256) {
768
- rmSync2(part, { force: true });
791
+ dropPart(part);
769
792
  throw new DownloadError(
770
793
  `${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
794
  "integrity"
772
795
  );
773
796
  }
774
797
  renameSync2(part, target);
798
+ rmSync2(savedPath(part), { force: true });
775
799
  remember(dir, verified, file);
776
800
  settled += file.size;
777
801
  }
778
802
  options.onProgress?.({ file: "", checking: false, done: total, total });
779
803
  }
780
- async function fetchFile(options, file, target, onBytes) {
781
- const part = `${target}.part`;
782
- const attempts = options.attempts ?? 4;
783
- const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
804
+ var savedPath = (part) => `${part}.saved`;
805
+ function dropPart(part) {
806
+ rmSync2(part, { force: true });
807
+ rmSync2(savedPath(part), { force: true });
808
+ }
809
+ function resumeFrom(part, size, checkpointBytes = CHECKPOINT_BYTES) {
810
+ const have = Math.min(sizeOf(part), size);
811
+ let saved;
812
+ try {
813
+ const value = Number.parseInt(readFileSync4(savedPath(part), "utf8").trim(), 10);
814
+ if (Number.isSafeInteger(value) && value >= 0) saved = value;
815
+ } catch {
816
+ }
817
+ return saved === void 0 ? Math.max(0, have - checkpointBytes) : Math.min(saved, have);
818
+ }
819
+ function writeSaved(part, bytes) {
820
+ const path = savedPath(part);
821
+ writeFileSync4(`${path}.tmp`, `${bytes}
822
+ `);
823
+ renameSync2(`${path}.tmp`, path);
824
+ }
825
+ async function fetchFile(options, file, part, onBytes) {
826
+ const attempts = options.attempts ?? 8;
827
+ const sleep = options.sleep ?? ((ms) => wait(ms, void 0, { signal: options.signal }).catch(() => {
828
+ }));
829
+ let resumed = false;
830
+ let failures = 0;
784
831
  let lastError;
785
- for (let attempt = 1; attempt <= attempts; attempt++) {
832
+ for (; ; ) {
786
833
  if (options.signal?.aborted) throw new DownloadError("The download was stopped", "aborted");
834
+ const before = resumeFrom(part, file.size, options.checkpointBytes);
787
835
  try {
788
- await fetchOnce(options, file, part, onBytes);
789
- return;
836
+ resumed = await fetchOnce(options, file, part, onBytes) || resumed;
837
+ return resumed;
790
838
  } catch (error) {
791
839
  if (error instanceof DownloadError && error.kind !== "network") throw error;
840
+ const code = error.code;
841
+ if (code === "ENOSPC" || code === "EDQUOT") {
842
+ throw new Error(
843
+ `The disk is full: couldn't write ${file.path}. Free up some space, then run mu judge mude install to resume`
844
+ );
845
+ }
792
846
  lastError = error;
793
- if (attempt < attempts) await sleep([2e3, 5e3, 1e4][attempt - 1] ?? 1e4);
847
+ failures = resumeFrom(part, file.size, options.checkpointBytes) > before ? 1 : failures + 1;
848
+ if (failures >= attempts) break;
849
+ await sleep(RETRY_WAITS[Math.min(failures, RETRY_WAITS.length) - 1]);
794
850
  }
795
851
  }
796
852
  const reason = lastError instanceof Error ? lastError.message : String(lastError);
797
853
  throw new DownloadError(
798
- `${file.path} could not be downloaded (${reason}); run the install again to go on from here`,
854
+ `${file.path} could not be downloaded (${reason}). Run mu judge mude install again to resume`,
799
855
  "network"
800
856
  );
801
857
  }
802
858
  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;
859
+ const checkpointBytes = options.checkpointBytes ?? CHECKPOINT_BYTES;
860
+ let have = resumeFrom(part, file.size, checkpointBytes);
861
+ if (have === file.size) {
862
+ if (sizeOf(part) > file.size) truncateSync(part, file.size);
863
+ return true;
811
864
  }
812
- if (have === file.size) return;
813
865
  const controller = new AbortController();
814
866
  const stallMs = options.stallMs ?? 6e4;
815
867
  let stall = setTimeout(() => controller.abort(new Error(`no data for ${stallMs / 1e3} s`)), stallMs);
816
868
  const onAbort = () => controller.abort(options.signal?.reason);
817
869
  options.signal?.addEventListener("abort", onAbort, { once: true });
870
+ let handle;
871
+ let written = 0;
872
+ let synced = 0;
818
873
  try {
819
874
  const headers = {};
820
875
  if (have > 0) headers.Range = `bytes=${have}-`;
@@ -826,34 +881,56 @@ async function fetchOnce(options, file, part, onBytes) {
826
881
  });
827
882
  if (response.status === 401 || response.status === 403 || response.status === 404) {
828
883
  throw new DownloadError(
829
- `Hugging Face answered ${response.status} for ${file.path}: the model is not public yet, or this machine's token cannot read it`,
884
+ `Hugging Face returned ${response.status} for ${file.path}: the model isn't public yet, or this machine's token can't access it`,
830
885
  "access"
831
886
  );
832
887
  }
833
- if (response.status === 416 && have > 0) {
834
- return;
888
+ if (response.status === 416) {
889
+ dropPart(part);
890
+ throw new Error("HTTP 416");
835
891
  }
836
892
  if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
837
- const resumed = response.status === 206;
893
+ const resumed = response.status === 206 && have > 0;
838
894
  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;
895
+ mkdirSync3(dirname2(part), { recursive: true });
896
+ handle = await open(part, existsSync3(part) ? "r+" : "w");
897
+ await handle.truncate(have);
898
+ writeSaved(part, have);
899
+ written = have;
900
+ synced = have;
901
+ onBytes(written);
902
+ const reader = response.body.getReader();
903
+ for (; ; ) {
904
+ const { done, value: chunk } = await reader.read();
905
+ if (done) break;
845
906
  clearTimeout(stall);
846
907
  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`);
908
+ if (written + chunk.length > file.size) throw new Error(`got more than the ${file.size} bytes of the file`);
909
+ await handle.write(chunk, 0, chunk.length, written);
910
+ written += chunk.length;
911
+ if (written - synced >= checkpointBytes) {
912
+ await handle.sync();
913
+ synced = written;
914
+ writeSaved(part, synced);
915
+ }
916
+ onBytes(written);
917
+ }
918
+ if (written !== file.size) throw new Error(`got ${written} of ${file.size} bytes`);
919
+ return resumed;
851
920
  } catch (error) {
852
921
  if (options.signal?.aborted) throw new DownloadError("The download was stopped", "aborted");
853
922
  throw error;
854
923
  } finally {
855
924
  clearTimeout(stall);
856
925
  options.signal?.removeEventListener("abort", onAbort);
926
+ if (handle) {
927
+ try {
928
+ await handle.sync();
929
+ if (written > synced) writeSaved(part, written);
930
+ } catch {
931
+ }
932
+ await handle.close();
933
+ }
857
934
  }
858
935
  }
859
936
 
@@ -874,7 +951,9 @@ function mudePaths(muHome) {
874
951
  key: join4(root, "key"),
875
952
  pid: join4(root, "server.pid"),
876
953
  log: join4(root, "server.log"),
877
- state: join4(root, "state.json")
954
+ state: join4(root, "state.json"),
955
+ install: join4(root, "install.json"),
956
+ installLock: join4(root, "install.lock")
878
957
  };
879
958
  }
880
959
 
@@ -941,9 +1020,20 @@ async function downloadUv(paths, options = {}) {
941
1020
  mkdirSync4(paths.tools, { recursive: true });
942
1021
  const archive = "uv.tar.gz";
943
1022
  options.onLine?.(`Downloading uv ${UV_RELEASE.version}`);
944
- const response = await (options.fetch ?? fetch)(UV_RELEASE.url, { redirect: "follow" });
945
- if (!response.ok) throw new Error(`uv could not be downloaded: HTTP ${response.status}`);
946
- const bytes = Buffer.from(await response.arrayBuffer());
1023
+ const timeoutMs = options.timeoutMs ?? 3e5;
1024
+ const timeout = AbortSignal.timeout(timeoutMs);
1025
+ let bytes;
1026
+ try {
1027
+ const response = await (options.fetch ?? fetch)(UV_RELEASE.url, {
1028
+ redirect: "follow",
1029
+ signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
1030
+ });
1031
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1032
+ bytes = Buffer.from(await response.arrayBuffer());
1033
+ } catch (error) {
1034
+ const reason = timeout.aborted ? `no answer in ${timeoutMs / 1e3} s` : error instanceof Error ? error.message : String(error);
1035
+ throw new Error(`uv could not be downloaded from GitHub (${reason}): run mu judge mude install again`);
1036
+ }
947
1037
  const actual = createHash2("sha256").update(bytes).digest("hex");
948
1038
  if (actual !== UV_RELEASE.sha256)
949
1039
  throw new Error("The downloaded uv does not match its pinned sha256; nothing was installed");
@@ -958,7 +1048,7 @@ async function downloadUv(paths, options = {}) {
958
1048
  ]);
959
1049
  rmSync3(join5(paths.tools, archive), { force: true });
960
1050
  if (unpacked.code !== 0 || !executable(join5(paths.tools, "uv")))
961
- throw new Error(`uv could not be unpacked: ${unpacked.output}`);
1051
+ throw new Error(`Couldn't unpack uv: ${unpacked.output}`);
962
1052
  return join5(paths.tools, "uv");
963
1053
  }
964
1054
  function lockDigest(lockFile) {
@@ -980,7 +1070,7 @@ async function ensureVenv(paths, lockFile, uv, options = {}) {
980
1070
  env,
981
1071
  onLine: options.onLine
982
1072
  });
983
- if (made.code !== 0) throw new Error(`The Python environment could not be made (uv venv):
1073
+ if (made.code !== 0) throw new Error(`Couldn't create the Python environment (uv venv):
984
1074
  ${made.output}`);
985
1075
  const installed2 = await run(
986
1076
  uv,
@@ -999,7 +1089,7 @@ ${made.output}`);
999
1089
  { env, onLine: options.onLine }
1000
1090
  );
1001
1091
  if (installed2.code !== 0)
1002
- throw new Error(`mude's Python packages could not be installed (uv pip install):
1092
+ throw new Error(`Couldn't install mude's Python packages (uv pip install):
1003
1093
  ${installed2.output}`);
1004
1094
  writeFileSync5(paths.venvLock, `${lockDigest(lockFile)}
1005
1095
  `);
@@ -1009,7 +1099,8 @@ ${installed2.output}`);
1009
1099
  var USAGE = `mu judge mude: mude 1.0, mu's own decision model optimized for coding, as the judge on this Mac
1010
1100
  (Apple silicon, macOS 14 or later)
1011
1101
 
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
1102
+ mu judge mude install [--local-only] download it (${(MUDE_BYTES / 1e9).toFixed(1)} GB), set it up, start it and make it the judge;
1103
+ after a stop (Ctrl-C, a lost connection, a power cut), it picks up where it left off
1013
1104
  mu judge mude enable [--local-only | --with-fallback]
1014
1105
  make it the judge again, or change what happens when it can't answer
1015
1106
  mu judge mude disable back to the judges from before, and stop its server
@@ -1063,8 +1154,12 @@ function humanOut(write, tty) {
1063
1154
  line: (text) => write(` ${text}
1064
1155
  `),
1065
1156
  status: (status2) => write(describe(status2)),
1066
- error: (message) => write(`${message}
1067
- `)
1157
+ error: (message) => {
1158
+ if (shown >= 0 && tty) write("\n");
1159
+ shown = -1;
1160
+ write(`${message}
1161
+ `);
1162
+ }
1068
1163
  };
1069
1164
  }
1070
1165
  function describe(status2) {
@@ -1075,7 +1170,7 @@ function describe(status2) {
1075
1170
  `;
1076
1171
  }
1077
1172
  lines.push(
1078
- `model ${status2.installed ? `installed (${MUDE_REPO} @ ${MUDE_REVISION.slice(0, 7)})` : status2.downloadedBytes > 0 ? `partly downloaded (${(status2.downloadedBytes / 1e9).toFixed(1)} of ${(MUDE_BYTES / 1e9).toFixed(1)} GB): mu judge mude install goes on from there` : "not installed: mu judge mude install"}`
1173
+ `model ${status2.installed ? `installed (${MUDE_REPO} @ ${MUDE_REVISION.slice(0, 7)})` : status2.downloadedBytes > 0 ? `partly downloaded (${(status2.downloadedBytes / 1e9).toFixed(1)} of ${(MUDE_BYTES / 1e9).toFixed(1)} GB): mu judge mude install resumes it` : "not installed: mu judge mude install"}`
1079
1174
  );
1080
1175
  if (status2.installed && !status2.pythonReady) lines.push("python not set up: mu judge mude install");
1081
1176
  lines.push(
@@ -1129,6 +1224,52 @@ function freeBytes(path) {
1129
1224
  return void 0;
1130
1225
  }
1131
1226
  }
1227
+ var gb = (bytes) => (bytes / 1e9).toFixed(1);
1228
+ function installLocalOnly(ctx, args) {
1229
+ if (args.includes("--local-only")) return true;
1230
+ if (args.includes("--with-fallback")) return false;
1231
+ try {
1232
+ const value = JSON.parse(readFileSync6(ctx.paths.install, "utf8"));
1233
+ return typeof value === "object" && value !== null && "localOnly" in value && value.localOnly === true;
1234
+ } catch {
1235
+ return false;
1236
+ }
1237
+ }
1238
+ function startedAt(pid) {
1239
+ try {
1240
+ return execFileSync2("/bin/ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" }).trim() || void 0;
1241
+ } catch {
1242
+ return void 0;
1243
+ }
1244
+ }
1245
+ function holdInstall(paths) {
1246
+ mkdirSync5(paths.root, { recursive: true });
1247
+ const mine = `${JSON.stringify({ pid: process.pid, started: startedAt(process.pid) ?? "" })}
1248
+ `;
1249
+ try {
1250
+ writeFileSync6(paths.installLock, mine, { flag: "wx" });
1251
+ } catch (error) {
1252
+ if (error.code !== "EEXIST") throw error;
1253
+ let holder = {};
1254
+ try {
1255
+ holder = JSON.parse(readFileSync6(paths.installLock, "utf8"));
1256
+ } catch {
1257
+ }
1258
+ const pid = typeof holder.pid === "number" ? holder.pid : 0;
1259
+ if (pid > 0 && pid !== process.pid && holder.started && startedAt(pid) === holder.started) {
1260
+ throw new Error(
1261
+ `Another mude 1.0 install is running (process ${pid}). Wait for it to finish, or stop it, then run mu judge mude install again`
1262
+ );
1263
+ }
1264
+ writeFileSync6(paths.installLock, mine);
1265
+ }
1266
+ return () => {
1267
+ try {
1268
+ if (readFileSync6(paths.installLock, "utf8") === mine) rmSync4(paths.installLock, { force: true });
1269
+ } catch {
1270
+ }
1271
+ };
1272
+ }
1132
1273
  function installed(ctx) {
1133
1274
  return missingFiles(ctx.paths.model, MUDE_FILES).length === 0;
1134
1275
  }
@@ -1180,29 +1321,52 @@ async function ensureInstalled(ctx, out) {
1180
1321
  const missing = missingFiles(ctx.paths.model, MUDE_FILES);
1181
1322
  if (missing.length > 0) {
1182
1323
  const need = missing.reduce((sum, file) => sum + file.size, 0);
1324
+ const already = downloadedBytes(ctx.paths.model, MUDE_FILES);
1183
1325
  const free = freeBytes(ctx.paths.root);
1184
- if (free !== void 0 && free < need + 2e9) {
1326
+ const room = MUDE_BYTES - already + 2e9;
1327
+ if (free !== void 0 && free < room) {
1185
1328
  throw new Error(
1186
- `Not enough free disk space: mude 1.0 needs about ${((need + 2e9) / 1e9).toFixed(1)} GB, and ${(free / 1e9).toFixed(1)} GB are free`
1329
+ `Not enough free disk space: mude 1.0 needs about ${gb(room)} GB, and only ${gb(free)} GB are free. Free up some space, then run mu judge mude install again`
1187
1330
  );
1188
1331
  }
1189
1332
  out.step(
1190
1333
  "download",
1191
- `Downloading mude 1.0 (${MUDE_REPO}, ${(need / 1e9).toFixed(1)} GB) and checking every file`
1334
+ already > 0 ? `Resuming the mude 1.0 download (${MUDE_REPO}): ${gb(already)} of ${gb(MUDE_BYTES)} GB already downloaded` : `Downloading mude 1.0 (${MUDE_REPO}, ${gb(need)} GB) and checking every file`
1192
1335
  );
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
- });
1336
+ try {
1337
+ await downloadFiles({
1338
+ dir: ctx.paths.model,
1339
+ files: MUDE_FILES,
1340
+ url: (path) => mudeFileUrl(path, ctx.env.HF_ENDPOINT || void 0),
1341
+ token: huggingFaceToken(ctx.env),
1342
+ onProgress: (event) => out.progress(event),
1343
+ signal: ctx.signal
1344
+ });
1345
+ } catch (error) {
1346
+ if (error instanceof DownloadError && error.kind === "aborted") {
1347
+ throw new DownloadError(
1348
+ `Stopped with ${gb(downloadedBytes(ctx.paths.model, MUDE_FILES))} of ${gb(MUDE_BYTES)} GB downloaded. Run mu judge mude install to resume`,
1349
+ "aborted"
1350
+ );
1351
+ }
1352
+ throw error;
1353
+ }
1200
1354
  }
1201
1355
  if (!venvReady(ctx.paths, ctx.lockFile)) {
1202
1356
  out.step("python", "Setting up the Python environment for mude 1.0 (MLX, transformers; about 1 GB, once)");
1203
- let uv = findUv(ctx.paths, ctx.env);
1204
- if (!uv) uv = await downloadUv(ctx.paths, { onLine: (line) => out.line(line) });
1205
- await ensureVenv(ctx.paths, ctx.lockFile, uv, { onLine: (line) => out.line(line), env: ctx.env });
1357
+ try {
1358
+ let uv = findUv(ctx.paths, ctx.env);
1359
+ if (!uv) uv = await downloadUv(ctx.paths, { onLine: (line) => out.line(line), signal: ctx.signal });
1360
+ await ensureVenv(ctx.paths, ctx.lockFile, uv, { onLine: (line) => out.line(line), env: ctx.env });
1361
+ } catch (error) {
1362
+ if (ctx.signal?.aborted) {
1363
+ throw new DownloadError(
1364
+ "Stopped while setting up the Python environment. Run mu judge mude install to resume",
1365
+ "aborted"
1366
+ );
1367
+ }
1368
+ throw error;
1369
+ }
1206
1370
  }
1207
1371
  }
1208
1372
  function requireInstalled(ctx) {
@@ -1210,13 +1374,23 @@ function requireInstalled(ctx) {
1210
1374
  throw new Error("mude 1.0 is not installed here yet: mu judge mude install");
1211
1375
  }
1212
1376
  }
1213
- async function start(ctx, out, wait = true) {
1377
+ async function start(ctx, out, command, wait2 = true) {
1214
1378
  const at = await port(ctx);
1215
1379
  out.step(
1216
1380
  "start",
1217
1381
  serverAlive(ctx.paths) ? `The mude 1.0 server is running on 127.0.0.1:${at}` : `Starting the mude 1.0 server on 127.0.0.1:${at} (loading the model takes a few seconds)`
1218
1382
  );
1219
- await startServer(ctx.paths, { port: at, key: key(ctx), wait });
1383
+ try {
1384
+ await startServer(ctx.paths, { port: at, key: key(ctx), wait: wait2, signal: ctx.signal });
1385
+ } catch (error) {
1386
+ if (error instanceof StartStopped) {
1387
+ throw new DownloadError(
1388
+ error.serverStopped ? `Stopped while the mude 1.0 server was starting, and stopped the server too. Run mu judge mude ${command} again to finish` : `Stopped waiting for the mude 1.0 server, which is still starting. Run mu judge mude ${command} again to finish`,
1389
+ "aborted"
1390
+ );
1391
+ }
1392
+ throw error;
1393
+ }
1220
1394
  return at;
1221
1395
  }
1222
1396
  function enable(ctx, out, at, localOnly) {
@@ -1253,16 +1427,25 @@ async function main(argv, ctx, write, tty = false) {
1253
1427
  }
1254
1428
  switch (command) {
1255
1429
  case "install": {
1256
- await ensureInstalled(ctx, out);
1257
- const at = await start(ctx, out);
1258
- enable(ctx, out, at, flag("--local-only"));
1430
+ const localOnly = installLocalOnly(ctx, args);
1431
+ const release2 = holdInstall(ctx.paths);
1432
+ try {
1433
+ writeFileSync6(ctx.paths.install, `${JSON.stringify({ localOnly })}
1434
+ `);
1435
+ await ensureInstalled(ctx, out);
1436
+ const at = await start(ctx, out, "install");
1437
+ enable(ctx, out, at, localOnly);
1438
+ rmSync4(ctx.paths.install, { force: true });
1439
+ } finally {
1440
+ release2();
1441
+ }
1259
1442
  break;
1260
1443
  }
1261
1444
  case "enable": {
1262
1445
  requireInstalled(ctx);
1263
1446
  const current = judgeSetting(ctx.files);
1264
1447
  const localOnly = flag("--local-only") ? true : flag("--with-fallback") ? false : current.enabled && current.localOnly;
1265
- const at = await start(ctx, out);
1448
+ const at = await start(ctx, out, "enable");
1266
1449
  enable(ctx, out, at, localOnly);
1267
1450
  break;
1268
1451
  }
@@ -1282,7 +1465,7 @@ async function main(argv, ctx, write, tty = false) {
1282
1465
  }
1283
1466
  case "start": {
1284
1467
  requireInstalled(ctx);
1285
- await start(ctx, out, !flag("--no-wait"));
1468
+ await start(ctx, out, "start", !flag("--no-wait"));
1286
1469
  break;
1287
1470
  }
1288
1471
  case "stop": {
@@ -1326,7 +1509,20 @@ function contextFromEnv(env = process.env, home = homedir3()) {
1326
1509
  }
1327
1510
 
1328
1511
  // packages/kyrn-judge/src/mude/cli.ts
1329
- main(process.argv.slice(2), contextFromEnv(), (text) => process.stdout.write(text), Boolean(process.stdout.isTTY)).then(
1512
+ var stop = new AbortController();
1513
+ var onSignal = (signal) => {
1514
+ if (stop.signal.aborted) process.exit(signal === "SIGINT" ? 130 : 143);
1515
+ stop.abort();
1516
+ setTimeout(() => process.exit(signal === "SIGINT" ? 130 : 143), 5e3).unref();
1517
+ };
1518
+ process.on("SIGINT", onSignal);
1519
+ process.on("SIGTERM", onSignal);
1520
+ main(
1521
+ process.argv.slice(2),
1522
+ { ...contextFromEnv(), signal: stop.signal },
1523
+ (text) => process.stdout.write(text),
1524
+ Boolean(process.stdout.isTTY)
1525
+ ).then(
1330
1526
  (code) => {
1331
1527
  process.exitCode = code;
1332
1528
  },