@zswarm/core 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ #!/bin/sh
2
+ # zswarm host install: Node.js, zswarm and Zellij on a Linux or macOS host,
3
+ # as pinned upstream binaries under one prefix, linked into a bin dir.
4
+ # Idempotent; prints one line per step. Run as root (or with write access to
5
+ # the prefix and bin dir). Never touches other users or their services.
6
+ #
7
+ # host-install.sh [--prefix /usr/local/lib/zswarm] [--bin-dir /usr/local/bin]
8
+ # [--node V] [--zellij V] [--zswarm V]
9
+ set -eu
10
+
11
+ PREFIX=/usr/local/lib/zswarm
12
+ BIN=/usr/local/bin
13
+ NODE_VERSION=24.21.0
14
+ ZELLIJ_VERSION=0.45.1
15
+ ZSWARM_VERSION=latest
16
+
17
+ while [ $# -gt 0 ]; do
18
+ case "$1" in
19
+ --prefix) PREFIX="$2"; shift 2 ;;
20
+ --bin-dir) BIN="$2"; shift 2 ;;
21
+ --node) NODE_VERSION="$2"; shift 2 ;;
22
+ --zellij) ZELLIJ_VERSION="$2"; shift 2 ;;
23
+ --zswarm) ZSWARM_VERSION="$2"; shift 2 ;;
24
+ *) echo "host-install: unknown argument $1" >&2; exit 2 ;;
25
+ esac
26
+ done
27
+
28
+ step() { printf '%-10s %s\n' "$1" "$2"; }
29
+ fetch() { curl -fsSL --retry 3 -o "$2" "$1"; }
30
+ sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; }
31
+ # verify NAME FILE EXPECTED: stop on a mismatch; no expected sum means a
32
+ # version we have no pinned sum for, which installs but says so.
33
+ verify() {
34
+ if [ -z "$3" ]; then
35
+ step "$1" "unverified: no pinned sha256 for this version"
36
+ elif [ "$(sha256 "$2")" != "$3" ]; then
37
+ echo "host-install: $1 sha256 mismatch for $2 (expected $3)" >&2
38
+ exit 1
39
+ fi
40
+ }
41
+
42
+ os="$(uname -s)"; arch="$(uname -m)"
43
+ case "$os/$arch" in
44
+ Linux/x86_64) node_os=linux; node_arch=x64; zellij_target=x86_64-unknown-linux-musl ;;
45
+ Linux/aarch64|Linux/arm64) node_os=linux; node_arch=arm64; zellij_target=aarch64-unknown-linux-musl ;;
46
+ Darwin/arm64) node_os=darwin; node_arch=arm64; zellij_target=aarch64-apple-darwin ;;
47
+ Darwin/x86_64) node_os=darwin; node_arch=x64; zellij_target=x86_64-apple-darwin ;;
48
+ *) echo "host-install: unsupported platform $os/$arch" >&2; exit 2 ;;
49
+ esac
50
+ # Pinned sums for the default versions. Node: the release tarball
51
+ # (nodejs.org SHASUMS256.txt). Zellij: the extracted binary (the release's
52
+ # .sha256sum files hash the binary, not the tarball).
53
+ node_sha=; zellij_sha=
54
+ if [ "$NODE_VERSION" = 24.21.0 ]; then
55
+ case "$node_os-$node_arch" in
56
+ linux-x64) node_sha=6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff ;;
57
+ linux-arm64) node_sha=724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5 ;;
58
+ darwin-arm64) node_sha=bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057 ;;
59
+ darwin-x64) node_sha=1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097 ;;
60
+ esac
61
+ fi
62
+ if [ "$ZELLIJ_VERSION" = 0.45.1 ]; then
63
+ case "$zellij_target" in
64
+ x86_64-unknown-linux-musl) zellij_sha=d006c521dcb475a6005d741e9dd7c5758e5a23b28dd60a5c10cebfa4876319dd ;;
65
+ aarch64-unknown-linux-musl) zellij_sha=2a2c0621e6f3b11ecbb05d66939a48000a2508604620aa20727a5fd61c49f451 ;;
66
+ aarch64-apple-darwin) zellij_sha=ca5f9333735bdbc59a621f1d8ed8e24798845302a28ff175d253d4793d5a4a2c ;;
67
+ x86_64-apple-darwin) zellij_sha=e3afe876c04cb83ca3f68cb939113ec7b1abe5fb46c13727c102a64afcb4b7d4 ;;
68
+ esac
69
+ fi
70
+ command -v curl >/dev/null 2>&1 || { echo "host-install: needs curl" >&2; exit 2; }
71
+ command -v tar >/dev/null 2>&1 || { echo "host-install: needs tar" >&2; exit 2; }
72
+ mkdir -p "$PREFIX" "$BIN"
73
+
74
+ # --- Node.js ---------------------------------------------------------------
75
+ node_dir="$PREFIX/node-$NODE_VERSION"
76
+ if [ ! -x "$node_dir/bin/node" ]; then
77
+ tmp="$(mktemp -d)"
78
+ fetch "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-$node_os-$node_arch.tar.gz" "$tmp/node.tgz"
79
+ verify node "$tmp/node.tgz" "$node_sha"
80
+ mkdir -p "$node_dir" && tar -xzf "$tmp/node.tgz" -C "$node_dir" --strip-components 1
81
+ rm -rf "$tmp"
82
+ step node "installed $NODE_VERSION"
83
+ else
84
+ step node "present $NODE_VERSION"
85
+ fi
86
+ ln -sfn "$node_dir/bin/node" "$BIN/node"
87
+
88
+ # --- zswarm (npm, own prefix) ------------------------------------------------
89
+ npm_prefix="$PREFIX/npm"
90
+ have="$(PATH="$node_dir/bin:$PATH" npm ls -g --prefix "$npm_prefix" zswarm --depth=0 2>/dev/null | sed -n 's/.*zswarm@\([0-9][0-9A-Za-z.+-]*\).*/\1/p' || true)"
91
+ if [ "$ZSWARM_VERSION" = latest ] || [ "$have" != "$ZSWARM_VERSION" ]; then
92
+ PATH="$node_dir/bin:$PATH" npm install -g --prefix "$npm_prefix" "zswarm@$ZSWARM_VERSION" >/dev/null 2>&1
93
+ now="$(PATH="$node_dir/bin:$PATH" npm ls -g --prefix "$npm_prefix" zswarm --depth=0 2>/dev/null | sed -n 's/.*zswarm@\([0-9][0-9A-Za-z.+-]*\).*/\1/p' || true)"
94
+ if [ "$now" = "$have" ]; then step zswarm "present $now"; else step zswarm "installed $now (was ${have:-none})"; fi
95
+ else
96
+ step zswarm "present $have"
97
+ fi
98
+ ln -sfn "$npm_prefix/bin/zswarm" "$BIN/zswarm"
99
+ ln -sfn "$npm_prefix/bin/zswarm-mcp" "$BIN/zswarm-mcp"
100
+
101
+ # --- Zellij ------------------------------------------------------------------
102
+ have_zellij="$("$BIN/zellij" --version 2>/dev/null | awk '{print $2}' || true)"
103
+ if [ "$have_zellij" != "$ZELLIJ_VERSION" ]; then
104
+ tmp="$(mktemp -d)"
105
+ fetch "https://github.com/zellij-org/zellij/releases/download/v$ZELLIJ_VERSION/zellij-$zellij_target.tar.gz" "$tmp/z.tgz"
106
+ tar -xzf "$tmp/z.tgz" -C "$tmp"
107
+ verify zellij "$tmp/zellij" "$zellij_sha"
108
+ cp "$tmp/zellij" "$BIN/zellij.new" && chmod 0755 "$BIN/zellij.new" && mv "$BIN/zellij.new" "$BIN/zellij"
109
+ rm -rf "$tmp"
110
+ step zellij "installed $ZELLIJ_VERSION (was ${have_zellij:-none})"
111
+ else
112
+ step zellij "present $have_zellij"
113
+ fi
114
+
115
+ # --- Python (zswarm slot needs 3.9+; not installed here) ----------------------
116
+ if python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' 2>/dev/null; then
117
+ step python3 "present $(python3 -c 'import platform; print(platform.python_version())')"
118
+ else
119
+ step python3 "MISSING (zswarm slot needs Python 3.9+; install it with the system package manager)"
120
+ fi
@@ -0,0 +1,29 @@
1
+ #!/bin/sh
2
+ # zswarm host doctor, host side: read-only facts as KEY=VALUE lines.
3
+ # host-report.sh [session]
4
+ PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.local/bin"
5
+ session="${1:-}"
6
+ v() { "$@" 2>/dev/null | head -1; }
7
+ echo "os=$(uname -s)"
8
+ echo "arch=$(uname -m)"
9
+ echo "user=$(id -un)"
10
+ echo "node=$(v node --version | sed 's/^v//')"
11
+ zv="$(v zswarm --version)"
12
+ case "$zv" in
13
+ [0-9]*) ;;
14
+ *) command -v zswarm >/dev/null 2>&1 && zv="unknown (older than zswarm --version)" || zv="" ;;
15
+ esac
16
+ echo "zswarm=$zv"
17
+ echo "zellij=$(v zellij --version | awk '{print $2}')"
18
+ echo "python3=$(v python3 -c 'import platform; print(platform.python_version())')"
19
+ echo "disk_home_gb=$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}')"
20
+ echo "disk_tmp_gb=$(df -Pk "${TMPDIR:-/tmp}" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}')"
21
+ echo "sessions=$(zellij list-sessions --no-formatting 2>/dev/null | grep -v EXITED | awk '{print $1}' | tr '\n' ' ')"
22
+ if [ "$(uname -s)" = Linux ]; then
23
+ echo "linger=$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)"
24
+ systemctl --user list-units --all --plain --no-legend 'zswarm-*' 2>/dev/null | awk '{print "service=" $1 " " $3 " " $4}'
25
+ else
26
+ launchctl list 2>/dev/null | awk '$3 ~ /^zswarm\./ {print "service=" $3 " pid=" $1 " last=" $2}'
27
+ fi
28
+ [ -n "$session" ] && echo "session_wanted=$session"
29
+ exit 0
package/assets/slot.py ADDED
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ """zswarm slot: run one build or test command inside a machine-wide build slot.
3
+
4
+ zswarm slot [--project NAME] [--] COMMAND [ARG ...]
5
+ zswarm slot --status
6
+ zswarm slot --setup --slots N [--jobs J] [--group G] (root)
7
+ zswarm slot --setup-project NAME --cap M [--group G] (root)
8
+
9
+ A host has N build slots and each project may be capped lower. The command
10
+ runs only while it holds one global slot and, for a capped project, one of
11
+ its project's slots; both are flock(2) locks (msvcrt byte locks on Windows)
12
+ held by this process while it waits for the command. The descriptors are close-on-exec, so a
13
+ daemon the command leaves running (a compiler server, a cache) never
14
+ inherits them, and the slot frees when the command exits or slot dies. A
15
+ slot run inside another (ZSWARM_SLOT set) reuses the outer slot instead of
16
+ deadlocking on a second one.
17
+
18
+ Unless already set, the command gets CARGO_BUILD_JOBS, NEXTEST_TEST_THREADS
19
+ and MAKEFLAGS sized to the per-slot job count, and runs at nice 5 so other
20
+ work on the machine (a CI runner, say) keeps priority.
21
+
22
+ Layout:
23
+ <etc>/slots.conf slots=N jobs=J
24
+ <etc>/projects/<name>.conf slots=M
25
+ <dir>/global-<i>.lock, <dir>/<name>-<i>.lock
26
+ <etc> is ZSWARM_SLOT_ETC (default /etc/zswarm, %ProgramData%\\zswarm\\etc),
27
+ <dir> is ZSWARM_SLOT_DIR (default /var/lib/zswarm/slots, %ProgramData%\\zswarm\\slots).
28
+ The project defaults to ZSWARM_SLOT_PROJECT, else the user name.
29
+
30
+ This is cooperative capacity control, not isolation: a process that skips
31
+ slot is not stopped.
32
+ """
33
+
34
+ from __future__ import annotations # macOS ships Python 3.9
35
+
36
+ import os
37
+ import re
38
+ import signal
39
+ import subprocess
40
+ import sys
41
+ import time
42
+ from pathlib import Path
43
+
44
+ WINDOWS = os.name == "nt"
45
+ if WINDOWS:
46
+ import msvcrt
47
+ else:
48
+ import fcntl
49
+ import grp
50
+ import pwd
51
+
52
+ _win_root = Path(os.environ.get("ProgramData") or "C:/ProgramData") / "zswarm"
53
+ ETC = Path(os.environ.get("ZSWARM_SLOT_ETC") or (_win_root / "etc" if WINDOWS else "/etc/zswarm"))
54
+ SLOTS = Path(os.environ.get("ZSWARM_SLOT_DIR") or (_win_root / "slots" if WINDOWS else "/var/lib/zswarm/slots"))
55
+ NAME = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
56
+
57
+
58
+ def die(msg: str, code: int = 2) -> None:
59
+ print(f"slot: {msg}", file=sys.stderr)
60
+ raise SystemExit(code)
61
+
62
+
63
+ def read_conf(path: Path) -> dict[str, int]:
64
+ values: dict[str, int] = {}
65
+ try:
66
+ text = path.read_text()
67
+ except FileNotFoundError:
68
+ return values
69
+ for line in text.splitlines():
70
+ line = line.split("#", 1)[0].strip()
71
+ if not line:
72
+ continue
73
+ key, _, value = line.partition("=")
74
+ try:
75
+ values[key.strip()] = int(value.strip())
76
+ except ValueError:
77
+ die(f"{path}: {key.strip()} is not an integer")
78
+ return values
79
+
80
+
81
+ def bounded(value: int, what: str) -> int:
82
+ if not 1 <= value <= 64:
83
+ die(f"{what}={value} is outside 1..64")
84
+ return value
85
+
86
+
87
+ def host_conf() -> tuple[int, int]:
88
+ conf = read_conf(ETC / "slots.conf")
89
+ if "slots" not in conf:
90
+ die(f"{ETC / 'slots.conf'} missing or has no slots= line (run zswarm slot --setup)")
91
+ return bounded(conf["slots"], "slots"), bounded(conf.get("jobs", 4), "jobs")
92
+
93
+
94
+ def project_cap(project: str, host_slots: int) -> int | None:
95
+ """The project's cap, or None when it has no conf (only the host slots apply)."""
96
+ path = ETC / "projects" / f"{project}.conf"
97
+ if not path.exists():
98
+ return None
99
+ conf = read_conf(path)
100
+ return min(bounded(conf.get("slots", host_slots), "project slots"), host_slots)
101
+
102
+
103
+ def default_project() -> str:
104
+ if os.environ.get("ZSWARM_SLOT_PROJECT"):
105
+ return os.environ["ZSWARM_SLOT_PROJECT"]
106
+ return os.environ.get("USERNAME", "unknown").lower() if WINDOWS else pwd.getpwuid(os.getuid()).pw_name
107
+
108
+
109
+ def lock_nb(fd: int) -> bool:
110
+ """Take an exclusive lock without waiting; False if someone holds it."""
111
+ try:
112
+ if WINDOWS:
113
+ os.lseek(fd, 0, os.SEEK_SET)
114
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
115
+ else:
116
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
117
+ return True
118
+ except OSError: # BlockingIOError on POSIX, PermissionError on Windows
119
+ return False
120
+
121
+
122
+ def lock_paths(prefix: str, count: int) -> list[Path]:
123
+ return [SLOTS / f"{prefix}-{i}.lock" for i in range(count)]
124
+
125
+
126
+ def try_lock(paths: list[Path]) -> tuple[int, Path] | None:
127
+ if not any(p.exists() for p in paths):
128
+ die(f"no lock files {paths[0].name}.. in {SLOTS} (run zswarm slot --setup / --setup-project)")
129
+ for path in paths:
130
+ try:
131
+ flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NOINHERIT", 0)
132
+ fd = os.open(path, flags)
133
+ except FileNotFoundError:
134
+ continue # count raised before setup created the file
135
+ except PermissionError:
136
+ die(f"no access to {path} (not in the slot group?)")
137
+ if not lock_nb(fd):
138
+ os.close(fd)
139
+ continue
140
+ return fd, path
141
+ return None
142
+
143
+
144
+ def note_holder(fd: int, project: str, command: list[str]) -> None:
145
+ # The lock file itself carries who holds it, for --status.
146
+ line = f"{os.getpid()} {project} {int(time.time())} {' '.join(command)[:200]}\n"
147
+ if WINDOWS:
148
+ return # byte 0 is locked; the holder note lives only on POSIX hosts
149
+ os.ftruncate(fd, 0)
150
+ os.pwrite(fd, line.encode(), 0)
151
+
152
+
153
+ def status() -> int:
154
+ host_slots, jobs = host_conf()
155
+ print(f"host: {host_slots} slots, {jobs} jobs per slot")
156
+ busy = 0
157
+ for path in lock_paths("global", host_slots):
158
+ held = try_lock([path])
159
+ if held:
160
+ os.close(held[0])
161
+ print(f" {path.name}: free")
162
+ else:
163
+ busy += 1
164
+ try:
165
+ holder = path.read_text().strip() or "?"
166
+ except OSError:
167
+ holder = "(held)"
168
+ print(f" {path.name}: busy {holder}")
169
+ print(f"busy {busy}/{host_slots}")
170
+ return 0
171
+
172
+
173
+ def lock_file(path: Path, gid: int | None) -> None:
174
+ # Without a group every user must be able to open the lock read-write
175
+ # (the holder note is written through it). Slots are cooperative, so
176
+ # world-writable lock files cost nothing a user could not already do.
177
+ mode = 0o660 if gid is not None else 0o666
178
+ if not path.exists():
179
+ path.touch(mode=mode)
180
+ if gid is not None:
181
+ os.chown(path, 0, gid)
182
+ if not WINDOWS:
183
+ os.chmod(path, mode)
184
+
185
+
186
+ def setup_dirs(group: str | None) -> int | None:
187
+ if not WINDOWS and os.geteuid() != 0:
188
+ die("--setup needs root")
189
+ gid = None
190
+ if group and not WINDOWS:
191
+ try:
192
+ gid = grp.getgrnam(group).gr_gid
193
+ except KeyError:
194
+ die(f"group {group} does not exist (create it first)")
195
+ (ETC / "projects").mkdir(parents=True, exist_ok=True)
196
+ SLOTS.mkdir(parents=True, exist_ok=True)
197
+ if not WINDOWS:
198
+ os.chmod(ETC, 0o755)
199
+ os.chmod(ETC / "projects", 0o755)
200
+ if gid is not None:
201
+ os.chown(SLOTS, 0, gid)
202
+ os.chmod(SLOTS, 0o770 if gid is not None else 0o755)
203
+ return gid
204
+
205
+
206
+ def write_conf(path: Path, text: str) -> None:
207
+ tmp = path.with_name(path.name + ".new")
208
+ tmp.write_text(text)
209
+ if not WINDOWS:
210
+ os.chmod(tmp, 0o644)
211
+ os.replace(tmp, path)
212
+
213
+
214
+ def option(argv: list[str], name: str, default: str | None = None) -> str | None:
215
+ if name in argv:
216
+ i = argv.index(name)
217
+ if i + 1 >= len(argv):
218
+ die(f"{name} needs a value")
219
+ return argv[i + 1]
220
+ return default
221
+
222
+
223
+ def setup(argv: list[str]) -> int:
224
+ slots = bounded(int(option(argv, "--slots") or die("--setup needs --slots N")), "slots")
225
+ jobs = bounded(int(option(argv, "--jobs", "4")), "jobs")
226
+ gid = setup_dirs(option(argv, "--group"))
227
+ write_conf(ETC / "slots.conf", f"slots={slots}\njobs={jobs}\n")
228
+ for path in lock_paths("global", slots):
229
+ lock_file(path, gid)
230
+ print(f"slot: {slots} slots x {jobs} jobs in {SLOTS}")
231
+ return 0
232
+
233
+
234
+ def setup_project(argv: list[str]) -> int:
235
+ project = option(argv, "--setup-project")
236
+ if not project or not NAME.fullmatch(project):
237
+ die(f"invalid project name {project!r}")
238
+ cap = bounded(int(option(argv, "--cap") or die("--setup-project needs --cap M")), "cap")
239
+ gid = setup_dirs(option(argv, "--group"))
240
+ write_conf(ETC / "projects" / f"{project}.conf", f"slots={cap}\n")
241
+ for path in lock_paths(project, cap):
242
+ lock_file(path, gid)
243
+ print(f"slot: project {project} capped at {cap}")
244
+ return 0
245
+
246
+
247
+ def main(argv: list[str]) -> int:
248
+ if argv[:1] == ["--status"]:
249
+ return status()
250
+ if argv[:1] == ["--setup"]:
251
+ return setup(argv)
252
+ if argv[:1] == ["--setup-project"]:
253
+ return setup_project(argv)
254
+ project = None
255
+ if argv[:1] == ["--project"]:
256
+ if len(argv) < 2:
257
+ die("--project needs a name")
258
+ project, argv = argv[1], argv[2:]
259
+ if argv[:1] == ["--"]:
260
+ argv = argv[1:]
261
+ if not argv:
262
+ die("usage: zswarm slot [--project NAME] [--] COMMAND [ARG ...] | --status | --setup | --setup-project")
263
+ if os.environ.get("ZSWARM_SLOT"):
264
+ # Already inside a slot (a script run under slot calls slot again):
265
+ # the parent's slot covers this command; locking again would deadlock.
266
+ return run(argv, dict(os.environ), renice=False)
267
+ project = project or default_project()
268
+ if not NAME.fullmatch(project):
269
+ die(f"invalid project name {project!r}")
270
+
271
+ host_slots, jobs = host_conf()
272
+ cap = project_cap(project, host_slots)
273
+ project_locks = lock_paths(project, cap) if cap else []
274
+ global_locks = lock_paths("global", host_slots)
275
+
276
+ announced = False
277
+ while True:
278
+ mine = try_lock(project_locks) if project_locks else None
279
+ if mine or not project_locks:
280
+ shared = try_lock(global_locks)
281
+ if shared:
282
+ break
283
+ if mine:
284
+ os.close(mine[0])
285
+ if not announced:
286
+ capped = f"cap {cap}, " if cap else ""
287
+ print(f"slot: waiting for a build slot ({project} {capped}host {host_slots})", file=sys.stderr)
288
+ announced = True
289
+ time.sleep(float(os.environ.get("ZSWARM_SLOT_POLL", "1")))
290
+
291
+ for held in (mine, shared):
292
+ if held:
293
+ note_holder(held[0], project, argv)
294
+
295
+ env = dict(os.environ)
296
+ env.setdefault("CARGO_BUILD_JOBS", str(jobs))
297
+ env.setdefault("NEXTEST_TEST_THREADS", str(jobs))
298
+ env.setdefault("MAKEFLAGS", f"-j{jobs}")
299
+ env["ZSWARM_SLOT"] = shared[1].name
300
+ return run(argv, env)
301
+
302
+
303
+ def run(argv: list[str], env: dict[str, str], renice: bool = True) -> int:
304
+ """Run the command as a child and wait. The lock descriptors stay in this
305
+ process (close-on-exec), so a daemon the command leaves behind never
306
+ inherits them and the slot frees when the command exits."""
307
+ if renice and not WINDOWS:
308
+ try:
309
+ os.nice(5)
310
+ except OSError:
311
+ pass
312
+ try:
313
+ child = subprocess.Popen(argv, env=env)
314
+ except FileNotFoundError:
315
+ die(f"{argv[0]}: command not found", 127)
316
+ for sig in (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", None)):
317
+ if sig is not None:
318
+ signal.signal(sig, lambda signum, _frame: child.send_signal(signum))
319
+ code = child.wait()
320
+ return 128 - code if code < 0 else code
321
+
322
+
323
+ if __name__ == "__main__":
324
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Host commands: CLI-only (not MCP ops), because they run long, pass argv
3
+ * through or print plain text. `zswarm <command> --help` prints the usage.
4
+ */
5
+ /** `--flag value` pairs and positionals; boolean flags are listed in `bools`. */
6
+ export declare function parseHostArgs(argv: string[], bools?: string[]): {
7
+ flags: Record<string, string | true>;
8
+ rest: string[];
9
+ };
10
+ export declare const HOST_COMMANDS: readonly string[];
11
+ export declare function hostUsage(): string;
12
+ /** Run a host command; resolves to the process exit code. */
13
+ export declare function runHostCommand(argv: string[]): Promise<number>;