@zswarm/core 0.2.2 → 0.2.4
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/assets/host-install.sh +36 -2
- package/assets/host-report.sh +15 -1
- package/assets/slot.py +35 -1
- package/dist/harness.d.ts +20 -0
- package/dist/harness.js +64 -1
- package/dist/host/cli.js +78 -4
- package/dist/host/crew.d.ts +2 -0
- package/dist/host/crew.js +13 -0
- package/dist/host/host.js +13 -0
- package/dist/host/lease.d.ts +68 -0
- package/dist/host/lease.js +138 -0
- package/dist/host/relay.d.ts +80 -1
- package/dist/host/relay.js +291 -9
- package/dist/host/services.d.ts +9 -2
- package/dist/host/services.js +105 -9
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/ops/delivery.d.ts +51 -2
- package/dist/ops/delivery.js +162 -31
- package/dist/ops/dispatch.js +151 -14
- package/dist/ops/util.d.ts +9 -0
- package/dist/ops/util.js +24 -0
- package/dist/schema.js +50 -3
- package/dist/state.d.ts +20 -0
- package/dist/state.js +20 -0
- package/dist/zellij/args.d.ts +1 -1
- package/dist/zellij/args.js +3 -1
- package/dist/zellij/bus.js +1 -0
- package/dist/zellij/client.d.ts +2 -0
- package/dist/zellij/client.js +17 -1
- package/dist/zellij/panes.d.ts +1 -0
- package/dist/zellij/panes.js +3 -0
- package/dist/zellij/session.d.ts +11 -0
- package/dist/zellij/session.js +35 -3
- package/package.json +1 -1
package/assets/host-install.sh
CHANGED
|
@@ -24,6 +24,8 @@ while [ $# -gt 0 ]; do
|
|
|
24
24
|
*) echo "host-install: unknown argument $1" >&2; exit 2 ;;
|
|
25
25
|
esac
|
|
26
26
|
done
|
|
27
|
+
case "$PREFIX" in /*) ;; *) PREFIX="$(pwd)/$PREFIX" ;; esac
|
|
28
|
+
case "$BIN" in /*) ;; *) BIN="$(pwd)/$BIN" ;; esac
|
|
27
29
|
|
|
28
30
|
step() { printf '%-10s %s\n' "$1" "$2"; }
|
|
29
31
|
fetch() { curl -fsSL --retry 3 -o "$2" "$1"; }
|
|
@@ -95,8 +97,40 @@ if [ "$ZSWARM_VERSION" = latest ] || [ "$have" != "$ZSWARM_VERSION" ]; then
|
|
|
95
97
|
else
|
|
96
98
|
step zswarm "present $have"
|
|
97
99
|
fi
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
# The npm bin is `#!/usr/bin/env node`, which sshd, sudo, launchd and systemd
|
|
101
|
+
# cannot run when no node is on PATH. Write a small POSIX sh launcher for each
|
|
102
|
+
# command that execs the Node.js this script installed by absolute path.
|
|
103
|
+
resolve() { # $1 path (possibly through symlinks) -> absolute target path
|
|
104
|
+
target="$1"
|
|
105
|
+
while [ -L "$target" ]; do
|
|
106
|
+
link="$(readlink "$target")"
|
|
107
|
+
case "$link" in
|
|
108
|
+
/*) target="$link" ;;
|
|
109
|
+
*) target="$(dirname "$target")/$link" ;;
|
|
110
|
+
esac
|
|
111
|
+
done
|
|
112
|
+
# npm links with a relative target, so fold away any `..` segments for a
|
|
113
|
+
# clean absolute path in the launcher. Logical pwd keeps the prefix as given
|
|
114
|
+
# (on macOS /var is a symlink to /private/var; -P would mix the two).
|
|
115
|
+
dir="$(cd "$(dirname "$target")" 2>/dev/null && pwd)" || dir="$(dirname "$target")"
|
|
116
|
+
printf '%s/%s\n' "$dir" "$(basename "$target")"
|
|
117
|
+
}
|
|
118
|
+
write_launcher() { # $1 command name, $2 npm bin symlink to the package script
|
|
119
|
+
pkg="$(resolve "$2")"
|
|
120
|
+
if [ ! -f "$pkg" ]; then
|
|
121
|
+
echo "host-install: cannot find the $1 script at $pkg" >&2
|
|
122
|
+
exit 1
|
|
123
|
+
fi
|
|
124
|
+
rm -f "$BIN/$1"
|
|
125
|
+
cat > "$BIN/$1" <<EOF
|
|
126
|
+
#!/bin/sh
|
|
127
|
+
# zswarm launcher: exec the Node.js host install pinned, whatever PATH says.
|
|
128
|
+
exec "$node_dir/bin/node" "$pkg" "\$@"
|
|
129
|
+
EOF
|
|
130
|
+
chmod 0755 "$BIN/$1"
|
|
131
|
+
}
|
|
132
|
+
write_launcher zswarm "$npm_prefix/bin/zswarm"
|
|
133
|
+
write_launcher zswarm-mcp "$npm_prefix/bin/zswarm-mcp"
|
|
100
134
|
|
|
101
135
|
# --- Zellij ------------------------------------------------------------------
|
|
102
136
|
have_zellij="$("$BIN/zellij" --version 2>/dev/null | awk '{print $2}' || true)"
|
package/assets/host-report.sh
CHANGED
|
@@ -7,7 +7,18 @@ v() { "$@" 2>/dev/null | head -1; }
|
|
|
7
7
|
echo "os=$(uname -s)"
|
|
8
8
|
echo "arch=$(uname -m)"
|
|
9
9
|
echo "user=$(id -un)"
|
|
10
|
-
|
|
10
|
+
# node: the runtime zswarm actually uses. When zswarm is a host-install launcher,
|
|
11
|
+
# read the Node.js it execs from the launcher, not the first node on PATH.
|
|
12
|
+
node_bin=""
|
|
13
|
+
zw="$(command -v zswarm 2>/dev/null || true)"
|
|
14
|
+
if [ -n "$zw" ] && [ -f "$zw" ]; then
|
|
15
|
+
node_bin="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$zw" | head -1)"
|
|
16
|
+
fi
|
|
17
|
+
if [ -n "$node_bin" ] && [ -x "$node_bin" ]; then
|
|
18
|
+
echo "node=$(v "$node_bin" --version | sed 's/^v//')"
|
|
19
|
+
else
|
|
20
|
+
echo "node=$(v node --version | sed 's/^v//')"
|
|
21
|
+
fi
|
|
11
22
|
zv="$(v zswarm --version)"
|
|
12
23
|
case "$zv" in
|
|
13
24
|
[0-9]*) ;;
|
|
@@ -19,6 +30,9 @@ echo "python3=$(v python3 -c 'import platform; print(platform.python_version())'
|
|
|
19
30
|
echo "disk_home_gb=$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}')"
|
|
20
31
|
echo "disk_tmp_gb=$(df -Pk "${TMPDIR:-/tmp}" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}')"
|
|
21
32
|
echo "sessions=$(zellij list-sessions --no-formatting 2>/dev/null | grep -v EXITED | awk '{print $1}' | tr '\n' ' ')"
|
|
33
|
+
exited_names="$(zellij list-sessions --no-formatting 2>/dev/null | awk '/EXITED/ {print $1}' | tr '\n' ' ')"
|
|
34
|
+
echo "exited_sessions=$(printf '%s' "$exited_names" | wc -w | tr -d ' ')"
|
|
35
|
+
echo "exited_session_names=$exited_names"
|
|
22
36
|
if [ "$(uname -s)" = Linux ]; then
|
|
23
37
|
echo "linger=$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)"
|
|
24
38
|
systemctl --user list-units --all --plain --no-legend 'zswarm-*' 2>/dev/null | awk '{print "service=" $1 " " $3 " " $4}'
|
package/assets/slot.py
CHANGED
|
@@ -35,6 +35,7 @@ from __future__ import annotations # macOS ships Python 3.9
|
|
|
35
35
|
|
|
36
36
|
import os
|
|
37
37
|
import re
|
|
38
|
+
import shutil
|
|
38
39
|
import signal
|
|
39
40
|
import subprocess
|
|
40
41
|
import sys
|
|
@@ -300,6 +301,39 @@ def main(argv: list[str]) -> int:
|
|
|
300
301
|
return run(argv, env)
|
|
301
302
|
|
|
302
303
|
|
|
304
|
+
def resolve_command(
|
|
305
|
+
argv: list[str],
|
|
306
|
+
env: dict[str, str],
|
|
307
|
+
os_name: str = os.name,
|
|
308
|
+
which=shutil.which,
|
|
309
|
+
) -> list[str] | str:
|
|
310
|
+
"""Rewrite argv so Windows can start argv[0], including .cmd/.bat shims.
|
|
311
|
+
|
|
312
|
+
CreateProcess does not search PATH or apply PATHEXT and cannot start a
|
|
313
|
+
batch file, so `slot -- pnpm ...` died 127 even though pnpm was on PATH.
|
|
314
|
+
On Windows, resolve argv[0] with shutil.which against the child's PATH;
|
|
315
|
+
shutil.which also applies PATHEXT from the environment this process
|
|
316
|
+
inherits (slot passes that environment straight through). A .cmd/.bat shim
|
|
317
|
+
runs through the command processor with `cmd.exe /d /s /c` and MS-quoted
|
|
318
|
+
arguments -- the documented way to run a batch file, and `cmd /c` returns
|
|
319
|
+
the batch's own exit code. A resolved .exe is spawned directly. POSIX and
|
|
320
|
+
anything that does not resolve are returned unchanged, so Popen still
|
|
321
|
+
reports the same FileNotFoundError and 127 as before.
|
|
322
|
+
"""
|
|
323
|
+
if os_name != "nt" or not argv:
|
|
324
|
+
return argv
|
|
325
|
+
exe = which(argv[0], path=env.get("PATH"))
|
|
326
|
+
if not exe:
|
|
327
|
+
return argv
|
|
328
|
+
if Path(exe).suffix.lower() in (".cmd", ".bat"):
|
|
329
|
+
comspec = env.get("ComSpec") or "cmd.exe"
|
|
330
|
+
# One command line, handed to CreateProcess as is: a list would be
|
|
331
|
+
# quoted again by subprocess, and cmd.exe does not understand \".
|
|
332
|
+
# With /s, cmd strips exactly the outer quote pair and runs the rest.
|
|
333
|
+
return f'"{comspec}" /d /s /c "{subprocess.list2cmdline([exe, *argv[1:]])}"'
|
|
334
|
+
return [exe, *argv[1:]]
|
|
335
|
+
|
|
336
|
+
|
|
303
337
|
def run(argv: list[str], env: dict[str, str], renice: bool = True) -> int:
|
|
304
338
|
"""Run the command as a child and wait. The lock descriptors stay in this
|
|
305
339
|
process (close-on-exec), so a daemon the command leaves behind never
|
|
@@ -310,7 +344,7 @@ def run(argv: list[str], env: dict[str, str], renice: bool = True) -> int:
|
|
|
310
344
|
except OSError:
|
|
311
345
|
pass
|
|
312
346
|
try:
|
|
313
|
-
child = subprocess.Popen(argv, env=env)
|
|
347
|
+
child = subprocess.Popen(resolve_command(argv, env), env=env)
|
|
314
348
|
except FileNotFoundError:
|
|
315
349
|
die(f"{argv[0]}: command not found", 127)
|
|
316
350
|
for sig in (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", None)):
|
package/dist/harness.d.ts
CHANGED
|
@@ -9,6 +9,16 @@ export type HarnessProfile = {
|
|
|
9
9
|
* against the trailing lines; a hit means the pane wants input, not time.
|
|
10
10
|
*/
|
|
11
11
|
waiting: readonly RegExp[];
|
|
12
|
+
/**
|
|
13
|
+
* A hint that the harness accepted the paste but queued it behind a running
|
|
14
|
+
* turn. `send` matches these against the trailing lines after the paste.
|
|
15
|
+
*/
|
|
16
|
+
queued: readonly RegExp[];
|
|
17
|
+
/**
|
|
18
|
+
* A hint that the harness is actively working on a turn. `status` matches
|
|
19
|
+
* these against the trailing lines to avoid treating a busy pane as idle.
|
|
20
|
+
*/
|
|
21
|
+
busy: readonly RegExp[];
|
|
12
22
|
};
|
|
13
23
|
/**
|
|
14
24
|
* Identify the harness from a pane's command and title, case-insensitively.
|
|
@@ -19,3 +29,13 @@ export declare function resolveHarness(pane: {
|
|
|
19
29
|
command?: string | null;
|
|
20
30
|
title?: string | null;
|
|
21
31
|
}): HarnessProfile;
|
|
32
|
+
/**
|
|
33
|
+
* Does this screen's trailing lines show the harness's queued-message hint?
|
|
34
|
+
* Only the last few lines count: a quoted marker in scrollback is not a queue.
|
|
35
|
+
*/
|
|
36
|
+
export declare function queuedPrompt(screen: string, profile: HarnessProfile): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Does this screen's trailing lines show the harness actively working? Only
|
|
39
|
+
* the last few lines count: a quoted marker in scrollback is not a live turn.
|
|
40
|
+
*/
|
|
41
|
+
export declare function busyPrompt(screen: string, profile: HarnessProfile): boolean;
|
package/dist/harness.js
CHANGED
|
@@ -51,10 +51,42 @@ const WAITING = {
|
|
|
51
51
|
],
|
|
52
52
|
pi: GENERIC_WAITING,
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Queued-message hints seen live. Claude Code prints
|
|
56
|
+
* "Press up to edit queued messages" while a turn runs and the pasted body is
|
|
57
|
+
* held above the prompt. No other harness shows an equally clear marker yet,
|
|
58
|
+
* so every profile carries only this base set.
|
|
59
|
+
*/
|
|
60
|
+
const GENERIC_QUEUED = [/press up to edit queued messages/i];
|
|
61
|
+
/**
|
|
62
|
+
* Queued lines per harness. Every observed harness currently carries only the
|
|
63
|
+
* generic Claude Code hint; add harness-specific patterns here when seen.
|
|
64
|
+
*/
|
|
65
|
+
const QUEUED = {
|
|
66
|
+
codex: GENERIC_QUEUED,
|
|
67
|
+
cursor: GENERIC_QUEUED,
|
|
68
|
+
opencode: GENERIC_QUEUED,
|
|
69
|
+
gemini: GENERIC_QUEUED,
|
|
70
|
+
pi: GENERIC_QUEUED,
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Busy (working) markers seen live. OpenCode draws a progress bar of filled
|
|
74
|
+
* and hollow squares while a turn runs; no other named harness has an equally
|
|
75
|
+
* reliable marker yet, so they rely on the lease only (`[]`).
|
|
76
|
+
*/
|
|
77
|
+
const BUSY = {
|
|
78
|
+
codex: [],
|
|
79
|
+
cursor: [],
|
|
80
|
+
opencode: [/[■⬝]{4,}/],
|
|
81
|
+
gemini: [],
|
|
82
|
+
pi: [],
|
|
83
|
+
};
|
|
54
84
|
const UNKNOWN = {
|
|
55
85
|
name: "unknown",
|
|
56
86
|
submit: "auto",
|
|
57
87
|
waiting: GENERIC_WAITING,
|
|
88
|
+
queued: GENERIC_QUEUED,
|
|
89
|
+
busy: [/esc to interrupt/i],
|
|
58
90
|
};
|
|
59
91
|
function escapeRegExp(text) {
|
|
60
92
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -99,5 +131,36 @@ export function resolveHarness(pane) {
|
|
|
99
131
|
const name = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
|
|
100
132
|
if (name === "unknown")
|
|
101
133
|
return UNKNOWN;
|
|
102
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
submit: SUBMIT[name],
|
|
137
|
+
waiting: WAITING[name],
|
|
138
|
+
queued: QUEUED[name],
|
|
139
|
+
busy: BUSY[name],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Does this screen's trailing lines show the harness's queued-message hint?
|
|
144
|
+
* Only the last few lines count: a quoted marker in scrollback is not a queue.
|
|
145
|
+
*/
|
|
146
|
+
export function queuedPrompt(screen, profile) {
|
|
147
|
+
const lines = trailingLines(screen);
|
|
148
|
+
return profile.queued.some((re) => lines.some((line) => re.test(line)));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Does this screen's trailing lines show the harness actively working? Only
|
|
152
|
+
* the last few lines count: a quoted marker in scrollback is not a live turn.
|
|
153
|
+
*/
|
|
154
|
+
export function busyPrompt(screen, profile) {
|
|
155
|
+
const lines = trailingLines(screen);
|
|
156
|
+
return profile.busy.some((re) => lines.some((line) => re.test(line)));
|
|
157
|
+
}
|
|
158
|
+
/** The six last non-empty, right-trimmed lines of a screen, CR stripped. */
|
|
159
|
+
function trailingLines(screen) {
|
|
160
|
+
return screen
|
|
161
|
+
.replace(/\r/g, "")
|
|
162
|
+
.split("\n")
|
|
163
|
+
.map((line) => line.trimEnd())
|
|
164
|
+
.filter((line) => line.length > 0)
|
|
165
|
+
.slice(-6);
|
|
103
166
|
}
|
package/dist/host/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { ZellijError } from "../errors.js";
|
|
3
|
+
import { createStateStore } from "../state.js";
|
|
3
4
|
import { crewCheck, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
|
|
4
5
|
import { hostDoctor, hostInstall, hostInstallArgs, hostScriptPath } from "./host.js";
|
|
5
|
-
import {
|
|
6
|
+
import { listLeases } from "./lease.js";
|
|
7
|
+
import { cancelAllTo, cancelRelay, listRelays, pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
|
|
6
8
|
import { runSlot } from "./slot.js";
|
|
7
9
|
/**
|
|
8
10
|
* Host commands: CLI-only (not MCP ops), because they run long, pass argv
|
|
@@ -78,8 +80,37 @@ async function crew(argv) {
|
|
|
78
80
|
return 2;
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
/** A plain-text table for `relay --list`, one row per relay. */
|
|
84
|
+
function relayTable(rows) {
|
|
85
|
+
const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB", "LEASE"];
|
|
86
|
+
const body = rows.map((r) => [
|
|
87
|
+
r.id,
|
|
88
|
+
r.state,
|
|
89
|
+
r.to,
|
|
90
|
+
r.session ?? "-",
|
|
91
|
+
`${r.age}s`,
|
|
92
|
+
r.lastCheck === undefined ? "-" : String(r.lastCheck),
|
|
93
|
+
r.job ?? "-",
|
|
94
|
+
r.lease ? `${r.lease.relayId} (${r.lease.from})` : "-",
|
|
95
|
+
]);
|
|
96
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
|
|
97
|
+
return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
|
|
98
|
+
}
|
|
99
|
+
/** A plain-text table for `relay --leases`, one row per live lease. */
|
|
100
|
+
function leaseTable(leases) {
|
|
101
|
+
const header = ["KEY", "RELAY", "FROM", "PANE", "SINCE"];
|
|
102
|
+
const body = leases.map((l) => [
|
|
103
|
+
l.key,
|
|
104
|
+
l.relayId,
|
|
105
|
+
l.from,
|
|
106
|
+
l.pane,
|
|
107
|
+
new Date(l.at).toISOString(),
|
|
108
|
+
]);
|
|
109
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
|
|
110
|
+
return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
|
|
111
|
+
}
|
|
81
112
|
async function relay(argv) {
|
|
82
|
-
const { flags } = parseHostArgs(argv, ["--foreground", "--prune"]);
|
|
113
|
+
const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--leases", "--cancel-all", "--json", "--queue", "--force"]);
|
|
83
114
|
const run = str(flags["--run"]);
|
|
84
115
|
if (run)
|
|
85
116
|
return runRelayDir(run);
|
|
@@ -93,6 +124,40 @@ async function relay(argv) {
|
|
|
93
124
|
process.stdout.write(message);
|
|
94
125
|
return 0;
|
|
95
126
|
}
|
|
127
|
+
if (flags["--list"] === true) {
|
|
128
|
+
const rows = listRelays();
|
|
129
|
+
if (flags["--json"] === true)
|
|
130
|
+
return json(() => ({ relays: rows }));
|
|
131
|
+
process.stdout.write(`${relayTable(rows)}\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (flags["--leases"] === true) {
|
|
135
|
+
const leases = listLeases(createStateStore());
|
|
136
|
+
if (flags["--json"] === true)
|
|
137
|
+
return json(() => ({ leases }));
|
|
138
|
+
process.stdout.write(`${leaseTable(leases)}\n`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
const cancelId = str(flags["--cancel"]);
|
|
142
|
+
if (cancelId) {
|
|
143
|
+
const res = cancelRelay(cancelId);
|
|
144
|
+
if (flags["--json"] === true)
|
|
145
|
+
return json(() => res);
|
|
146
|
+
process.stdout.write(`${res.reason}\n`);
|
|
147
|
+
for (const line of res.actions)
|
|
148
|
+
process.stdout.write(`${line}\n`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (flags["--cancel-all"] === true) {
|
|
152
|
+
const to = str(flags["--to"]);
|
|
153
|
+
if (!to)
|
|
154
|
+
throw new ZellijError("usage", "relay --cancel-all needs --to PANE");
|
|
155
|
+
const results = cancelAllTo(to);
|
|
156
|
+
if (flags["--json"] === true)
|
|
157
|
+
return json(() => ({ cancelled: results }));
|
|
158
|
+
process.stdout.write(results.length === 0 ? `no running relays to ${to}\n` : `${results.map((r) => r.reason).join("\n")}\n`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
96
161
|
if (flags["--prune"]) {
|
|
97
162
|
return json(() => ({ removed: pruneRelays(Number(str(flags["--days"]) ?? 14)) }));
|
|
98
163
|
}
|
|
@@ -109,7 +174,10 @@ async function relay(argv) {
|
|
|
109
174
|
check: str(flags["--check"]),
|
|
110
175
|
message: str(flags["--message"]),
|
|
111
176
|
timeoutMin: flags["--timeout-min"] ? Number(str(flags["--timeout-min"])) : undefined,
|
|
177
|
+
maxAgeMin: flags["--max-age"] ? Number(str(flags["--max-age"])) : undefined,
|
|
112
178
|
foreground: flags["--foreground"] === true,
|
|
179
|
+
queue: flags["--queue"] === true,
|
|
180
|
+
force: flags["--force"] === true,
|
|
113
181
|
}));
|
|
114
182
|
}
|
|
115
183
|
async function host(argv) {
|
|
@@ -170,9 +238,15 @@ const COMMANDS = {
|
|
|
170
238
|
usage: [
|
|
171
239
|
"zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
|
|
172
240
|
" [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
|
|
173
|
-
" [--timeout-min 240] [--foreground]",
|
|
241
|
+
" [--timeout-min 240] [--max-age MIN] [--queue] [--force] [--foreground]",
|
|
174
242
|
" send the body to PANE over serve (ZSWARM_SERVE / ZSWARM_SERVE_TOKEN), then a detached relay",
|
|
175
|
-
" waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane",
|
|
243
|
+
" waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane;",
|
|
244
|
+
" --max-age (default --timeout-min + 60) is a hard end that always yields a final status",
|
|
245
|
+
" it leases its target pane until it ends; a second relay to a leased pane fails pane_leased",
|
|
246
|
+
" unless --queue waits for the lease to free or --force shares it",
|
|
247
|
+
"zswarm relay --list [--json] id, state, target, session, age, last check, job and lease per relay dir",
|
|
248
|
+
"zswarm relay --leases [--json] live pane leases and the relay that holds each",
|
|
249
|
+
"zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
|
|
176
250
|
"zswarm relay --wait ID [--timeout-min 240] block until relay ID has its message, print it (pull path)",
|
|
177
251
|
"zswarm relay --prune [--days 14] remove old relay dirs and undelivered messages",
|
|
178
252
|
].join("\n"),
|
package/dist/host/crew.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type CrewDeps = {
|
|
|
12
12
|
};
|
|
13
13
|
/** Live session names; injected in tests. */
|
|
14
14
|
sessions?: (env: NodeJS.ProcessEnv) => Promise<string[]>;
|
|
15
|
+
/** Exited (resurrectable) session names; injected in tests. */
|
|
16
|
+
exitedSessions?: (env: NodeJS.ProcessEnv) => Promise<string[]>;
|
|
15
17
|
/** Pane titles (with exited flags) of a session; injected in tests. */
|
|
16
18
|
panes?: (session: string, env: NodeJS.ProcessEnv) => Promise<Array<{
|
|
17
19
|
title: string;
|
package/dist/host/crew.js
CHANGED
|
@@ -83,6 +83,14 @@ async function defaultSessions(env) {
|
|
|
83
83
|
return [];
|
|
84
84
|
return (res.data.sessions ?? []).map((s) => s.name ?? "");
|
|
85
85
|
}
|
|
86
|
+
async function defaultExitedSessions(env) {
|
|
87
|
+
const res = await dispatchZswarm({ op: "sessions", all: true }, undefined, { env });
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
return [];
|
|
90
|
+
return (res.data.sessions ?? [])
|
|
91
|
+
.filter((s) => s.exited === true)
|
|
92
|
+
.map((s) => s.name ?? "");
|
|
93
|
+
}
|
|
86
94
|
async function defaultPanes(session, env) {
|
|
87
95
|
const res = await dispatchZswarm({ op: "list", session }, undefined, { env });
|
|
88
96
|
if (!res.ok)
|
|
@@ -252,12 +260,17 @@ export async function crewStatus(input) {
|
|
|
252
260
|
? readCrewFiles(crewDir(account, session, env))
|
|
253
261
|
: undefined;
|
|
254
262
|
const live = (await (input.deps?.sessions ?? defaultSessions)(env)).includes(session);
|
|
263
|
+
const exited = (await (input.deps?.exitedSessions ?? defaultExitedSessions)(env)).includes(session);
|
|
255
264
|
const panes = live ? await (input.deps?.panes ?? defaultPanes)(session, { ...env, ZSWARM_BUS: "0" }) : [];
|
|
256
265
|
out.live = live;
|
|
266
|
+
out.exited = exited;
|
|
257
267
|
out.firstPane = files?.first ?? null;
|
|
258
268
|
out.layoutTook = files ? panes.some((p) => p.title === files.first) : null;
|
|
259
269
|
out.panes = panes;
|
|
260
270
|
out.healthy = services.every((s) => s.active) && live && out.layoutTook !== false && panes.every((p) => !p.exited);
|
|
271
|
+
if (live && exited) {
|
|
272
|
+
out.note = `an exited session named ${session} is also listed (resurrectable); remove it with zswarm sessions --prune-exited`;
|
|
273
|
+
}
|
|
261
274
|
return out;
|
|
262
275
|
}
|
|
263
276
|
export function crewDown(input) {
|
package/dist/host/host.js
CHANGED
|
@@ -90,6 +90,19 @@ export function hostChecks(facts, services, session) {
|
|
|
90
90
|
tool("zswarm", true, "zswarm host install");
|
|
91
91
|
tool("zellij", true, "zswarm host install");
|
|
92
92
|
tool("python3", false, "zswarm slot needs Python 3.9+");
|
|
93
|
+
// Exited sessions are resurrectable leftovers; a pile of them is a warning.
|
|
94
|
+
if (facts.exited_sessions !== undefined && facts.exited_sessions !== "") {
|
|
95
|
+
const exited = Number(facts.exited_sessions);
|
|
96
|
+
if (Number.isFinite(exited)) {
|
|
97
|
+
checks.push({
|
|
98
|
+
id: "exited_sessions",
|
|
99
|
+
state: exited > 10 ? "warn" : "pass",
|
|
100
|
+
detail: exited > 10
|
|
101
|
+
? `${exited} exited (resurrectable) sessions; prune with zswarm sessions --prune-exited`
|
|
102
|
+
: `${exited} exited`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
93
106
|
for (const id of ["disk_home_gb", "disk_tmp_gb"]) {
|
|
94
107
|
const gb = Number(facts[id]);
|
|
95
108
|
if (!Number.isFinite(gb) || facts[id] === "")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { LeaseRecord, StateStore } from "../state.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pane leases: a relay holds one on its target pane from before it sends until
|
|
4
|
+
* it ends, so a second relay or `send --if-idle` can refuse (or wait) instead
|
|
5
|
+
* of interleaving two tasks in one agent pane.
|
|
6
|
+
*
|
|
7
|
+
* The record lives in the zswarm state dir and is only ever read or written
|
|
8
|
+
* inside `StateStore.withLeases`, whose file lock serializes check-then-take
|
|
9
|
+
* across processes. A lease whose relay directory is gone or has finished is
|
|
10
|
+
* stale and is taken over.
|
|
11
|
+
*/
|
|
12
|
+
export type LeaseTarget = {
|
|
13
|
+
/** The serve endpoint that owns the pane, or undefined for a local pane. */
|
|
14
|
+
serve?: string;
|
|
15
|
+
session?: string;
|
|
16
|
+
/** The pane address as `--to` gives it (id, title or command). */
|
|
17
|
+
pane: string;
|
|
18
|
+
};
|
|
19
|
+
export type LeaseTakeInput = {
|
|
20
|
+
relayId: string;
|
|
21
|
+
from: string;
|
|
22
|
+
at: number;
|
|
23
|
+
dir: string;
|
|
24
|
+
target: LeaseTarget;
|
|
25
|
+
/** Send anyway: add this relay to the holder's shared list. */
|
|
26
|
+
force?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type LeaseTakeResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
lease: LeaseRecord;
|
|
31
|
+
shared: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
holder: LeaseRecord;
|
|
35
|
+
};
|
|
36
|
+
/** The target identity: serve endpoint (or `local`), session and pane address. */
|
|
37
|
+
export declare function leaseKey(target: LeaseTarget): string;
|
|
38
|
+
type LeaseJob = {
|
|
39
|
+
kind?: unknown;
|
|
40
|
+
pid?: unknown;
|
|
41
|
+
label?: unknown;
|
|
42
|
+
unit?: unknown;
|
|
43
|
+
};
|
|
44
|
+
export type LeaseStaleDeps = {
|
|
45
|
+
now?: () => number;
|
|
46
|
+
/** Whether a launchd/systemd relay job is still loaded; injected in tests. */
|
|
47
|
+
jobRunning?: (job: LeaseJob) => boolean | undefined;
|
|
48
|
+
};
|
|
49
|
+
export declare function leaseStale(rec: LeaseRecord, deps?: LeaseStaleDeps): boolean;
|
|
50
|
+
/** Live leases only; stale records are dropped from the store as they are seen. */
|
|
51
|
+
export declare function listLeases(store: StateStore, deps?: LeaseStaleDeps): LeaseRecord[];
|
|
52
|
+
/** The live lease for a target, if any. */
|
|
53
|
+
export declare function findLease(store: StateStore, target: LeaseTarget, deps?: LeaseStaleDeps): LeaseRecord | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Take the lease for a target. Returns the holder when a live lease is already
|
|
56
|
+
* held (caller decides: refuse, `--queue`, or `--force`). `force` shares the
|
|
57
|
+
* holder's lease by recording the new relay id.
|
|
58
|
+
*/
|
|
59
|
+
export declare function takeLease(store: StateStore, input: LeaseTakeInput): LeaseTakeResult;
|
|
60
|
+
/**
|
|
61
|
+
* Release a target's lease. With `relayId`, the lease is dropped only when that
|
|
62
|
+
* relay is the holder; a forced relay that ends is removed from `sharedBy` and
|
|
63
|
+
* the original holder keeps the lease.
|
|
64
|
+
*/
|
|
65
|
+
export declare function releaseLease(store: StateStore, target: LeaseTarget, relayId?: string): boolean;
|
|
66
|
+
/** `releaseLease` by the already-recorded key (the relay config stores it). */
|
|
67
|
+
export declare function releaseLeaseKey(store: StateStore, key: string, relayId?: string): boolean;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** The target identity: serve endpoint (or `local`), session and pane address. */
|
|
5
|
+
export function leaseKey(target) {
|
|
6
|
+
return [target.serve?.trim() || "local", target.session?.trim() ?? "", target.pane.trim()].join("|");
|
|
7
|
+
}
|
|
8
|
+
function pidAlive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
// ESRCH: gone. EPERM: exists, just unsignalable — still a live holder.
|
|
15
|
+
return err.code === "EPERM";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A lease is stale when its relay no longer runs: the run directory is gone,
|
|
20
|
+
* the relay recorded a final status or was cancelled, its message is written,
|
|
21
|
+
* or its plain detached pid is dead. (launchd/systemd jobs write a status on
|
|
22
|
+
* every final path; a killed job without one is recovered by `--cancel`.)
|
|
23
|
+
*/
|
|
24
|
+
/** Minutes past a relay's max age before its lease is dropped regardless. */
|
|
25
|
+
const LEASE_GRACE_MIN = 5;
|
|
26
|
+
/** True while a launchd label or systemd user unit is still loaded; undefined for other jobs. */
|
|
27
|
+
function serviceJobRunning(job) {
|
|
28
|
+
if (job.kind === "launchd" && typeof job.label === "string") {
|
|
29
|
+
return spawnSync("launchctl", ["list", job.label], { stdio: "ignore" }).status === 0;
|
|
30
|
+
}
|
|
31
|
+
if (job.kind === "systemd" && typeof job.unit === "string") {
|
|
32
|
+
const res = spawnSync("systemctl", ["--user", "is-active", job.unit], { encoding: "utf8" });
|
|
33
|
+
return res.status === 0 && /^(active|activating|reloading)$/.test((res.stdout ?? "").trim());
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
export function leaseStale(rec, deps = {}) {
|
|
38
|
+
if (!rec.dir || !existsSync(rec.dir))
|
|
39
|
+
return true;
|
|
40
|
+
let cfg;
|
|
41
|
+
try {
|
|
42
|
+
cfg = JSON.parse(readFileSync(join(rec.dir, "config.json"), "utf8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (cfg.cancelled === true || typeof cfg.status === "string")
|
|
48
|
+
return true;
|
|
49
|
+
if (existsSync(join(rec.dir, "message.txt")))
|
|
50
|
+
return true;
|
|
51
|
+
const job = cfg.job;
|
|
52
|
+
if (job && job.kind === "pid" && typeof job.pid === "number" && !pidAlive(job.pid))
|
|
53
|
+
return true;
|
|
54
|
+
// A launchd/systemd relay that is no longer loaded ended without a final
|
|
55
|
+
// status (removed by hand, killed, or the machine restarted).
|
|
56
|
+
if (job && (deps.jobRunning ?? serviceJobRunning)(job) === false)
|
|
57
|
+
return true;
|
|
58
|
+
// Backstop: a relay always ends by its max age, so its lease cannot outlive
|
|
59
|
+
// it (this also covers a pid that was reused by another process).
|
|
60
|
+
const maxAgeMin = typeof cfg.maxAgeMin === "number" ? cfg.maxAgeMin
|
|
61
|
+
: typeof cfg.timeoutMin === "number" ? cfg.timeoutMin + 60
|
|
62
|
+
: undefined;
|
|
63
|
+
if (maxAgeMin !== undefined && (deps.now ?? Date.now)() > rec.at + (maxAgeMin + LEASE_GRACE_MIN) * 60_000)
|
|
64
|
+
return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Live leases only; stale records are dropped from the store as they are seen. */
|
|
68
|
+
export function listLeases(store, deps = {}) {
|
|
69
|
+
return store.withLeases((leases) => {
|
|
70
|
+
for (const [key, rec] of Object.entries(leases)) {
|
|
71
|
+
if (leaseStale(rec, deps))
|
|
72
|
+
delete leases[key];
|
|
73
|
+
}
|
|
74
|
+
return Object.values(leases);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** The live lease for a target, if any. */
|
|
78
|
+
export function findLease(store, target, deps = {}) {
|
|
79
|
+
const rec = store.readLeases()[leaseKey(target)];
|
|
80
|
+
return rec && !leaseStale(rec, deps) ? rec : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Take the lease for a target. Returns the holder when a live lease is already
|
|
84
|
+
* held (caller decides: refuse, `--queue`, or `--force`). `force` shares the
|
|
85
|
+
* holder's lease by recording the new relay id.
|
|
86
|
+
*/
|
|
87
|
+
export function takeLease(store, input) {
|
|
88
|
+
const key = leaseKey(input.target);
|
|
89
|
+
return store.withLeases((leases) => {
|
|
90
|
+
const held = leases[key];
|
|
91
|
+
// Staleness on the taker's clock: the new lease's `at` is its now.
|
|
92
|
+
if (held && !leaseStale(held, { now: () => input.at })) {
|
|
93
|
+
if (!input.force)
|
|
94
|
+
return { ok: false, holder: held };
|
|
95
|
+
const sharedBy = [...new Set([...(held.sharedBy ?? []), input.relayId])];
|
|
96
|
+
const shared = { ...held, sharedBy };
|
|
97
|
+
leases[key] = shared;
|
|
98
|
+
return { ok: true, lease: shared, shared: true };
|
|
99
|
+
}
|
|
100
|
+
const lease = {
|
|
101
|
+
key,
|
|
102
|
+
relayId: input.relayId,
|
|
103
|
+
from: input.from,
|
|
104
|
+
at: input.at,
|
|
105
|
+
dir: input.dir,
|
|
106
|
+
serve: input.target.serve,
|
|
107
|
+
session: input.target.session,
|
|
108
|
+
pane: input.target.pane,
|
|
109
|
+
};
|
|
110
|
+
leases[key] = lease;
|
|
111
|
+
return { ok: true, lease, shared: false };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Release a target's lease. With `relayId`, the lease is dropped only when that
|
|
116
|
+
* relay is the holder; a forced relay that ends is removed from `sharedBy` and
|
|
117
|
+
* the original holder keeps the lease.
|
|
118
|
+
*/
|
|
119
|
+
export function releaseLease(store, target, relayId) {
|
|
120
|
+
return releaseLeaseKey(store, leaseKey(target), relayId);
|
|
121
|
+
}
|
|
122
|
+
/** `releaseLease` by the already-recorded key (the relay config stores it). */
|
|
123
|
+
export function releaseLeaseKey(store, key, relayId) {
|
|
124
|
+
return store.withLeases((leases) => {
|
|
125
|
+
const held = leases[key];
|
|
126
|
+
if (!held)
|
|
127
|
+
return false;
|
|
128
|
+
if (!relayId || held.relayId === relayId) {
|
|
129
|
+
delete leases[key];
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (held.sharedBy?.includes(relayId)) {
|
|
133
|
+
leases[key] = { ...held, sharedBy: held.sharedBy.filter((id) => id !== relayId) };
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
});
|
|
138
|
+
}
|