@zswarm/core 0.2.2 → 0.2.3

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.
@@ -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
- ln -sfn "$npm_prefix/bin/zswarm" "$BIN/zswarm"
99
- ln -sfn "$npm_prefix/bin/zswarm-mcp" "$BIN/zswarm-mcp"
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)"
@@ -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
- echo "node=$(v node --version | sed 's/^v//')"
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/dist/harness.d.ts CHANGED
@@ -9,6 +9,11 @@ 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[];
12
17
  };
13
18
  /**
14
19
  * Identify the harness from a pane's command and title, case-insensitively.
@@ -19,3 +24,8 @@ export declare function resolveHarness(pane: {
19
24
  command?: string | null;
20
25
  title?: string | null;
21
26
  }): HarnessProfile;
27
+ /**
28
+ * Does this screen's trailing lines show the harness's queued-message hint?
29
+ * Only the last few lines count: a quoted marker in scrollback is not a queue.
30
+ */
31
+ export declare function queuedPrompt(screen: string, profile: HarnessProfile): boolean;
package/dist/harness.js CHANGED
@@ -51,10 +51,29 @@ 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
+ };
54
72
  const UNKNOWN = {
55
73
  name: "unknown",
56
74
  submit: "auto",
57
75
  waiting: GENERIC_WAITING,
76
+ queued: GENERIC_QUEUED,
58
77
  };
59
78
  function escapeRegExp(text) {
60
79
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -99,5 +118,18 @@ export function resolveHarness(pane) {
99
118
  const name = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
100
119
  if (name === "unknown")
101
120
  return UNKNOWN;
102
- return { name, submit: SUBMIT[name], waiting: WAITING[name] };
121
+ return { name, submit: SUBMIT[name], waiting: WAITING[name], queued: QUEUED[name] };
122
+ }
123
+ /**
124
+ * Does this screen's trailing lines show the harness's queued-message hint?
125
+ * Only the last few lines count: a quoted marker in scrollback is not a queue.
126
+ */
127
+ export function queuedPrompt(screen, profile) {
128
+ const lines = screen
129
+ .replace(/\r/g, "")
130
+ .split("\n")
131
+ .map((line) => line.trimEnd())
132
+ .filter((line) => line.length > 0)
133
+ .slice(-6);
134
+ return profile.queued.some((re) => lines.some((line) => re.test(line)));
103
135
  }
package/dist/host/cli.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { ZellijError } from "../errors.js";
3
3
  import { crewCheck, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
4
4
  import { hostDoctor, hostInstall, hostInstallArgs, hostScriptPath } from "./host.js";
5
- import { pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
5
+ import { cancelAllTo, cancelRelay, listRelays, pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
6
6
  import { runSlot } from "./slot.js";
7
7
  /**
8
8
  * Host commands: CLI-only (not MCP ops), because they run long, pass argv
@@ -78,8 +78,23 @@ async function crew(argv) {
78
78
  return 2;
79
79
  }
80
80
  }
81
+ /** A plain-text table for `relay --list`, one row per relay. */
82
+ function relayTable(rows) {
83
+ const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB"];
84
+ const body = rows.map((r) => [
85
+ r.id,
86
+ r.state,
87
+ r.to,
88
+ r.session ?? "-",
89
+ `${r.age}s`,
90
+ r.lastCheck === undefined ? "-" : String(r.lastCheck),
91
+ r.job ?? "-",
92
+ ]);
93
+ const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
94
+ return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
95
+ }
81
96
  async function relay(argv) {
82
- const { flags } = parseHostArgs(argv, ["--foreground", "--prune"]);
97
+ const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--cancel-all", "--json"]);
83
98
  const run = str(flags["--run"]);
84
99
  if (run)
85
100
  return runRelayDir(run);
@@ -93,6 +108,33 @@ async function relay(argv) {
93
108
  process.stdout.write(message);
94
109
  return 0;
95
110
  }
111
+ if (flags["--list"] === true) {
112
+ const rows = listRelays();
113
+ if (flags["--json"] === true)
114
+ return json(() => ({ relays: rows }));
115
+ process.stdout.write(`${relayTable(rows)}\n`);
116
+ return 0;
117
+ }
118
+ const cancelId = str(flags["--cancel"]);
119
+ if (cancelId) {
120
+ const res = cancelRelay(cancelId);
121
+ if (flags["--json"] === true)
122
+ return json(() => res);
123
+ process.stdout.write(`${res.reason}\n`);
124
+ for (const line of res.actions)
125
+ process.stdout.write(`${line}\n`);
126
+ return 0;
127
+ }
128
+ if (flags["--cancel-all"] === true) {
129
+ const to = str(flags["--to"]);
130
+ if (!to)
131
+ throw new ZellijError("usage", "relay --cancel-all needs --to PANE");
132
+ const results = cancelAllTo(to);
133
+ if (flags["--json"] === true)
134
+ return json(() => ({ cancelled: results }));
135
+ process.stdout.write(results.length === 0 ? `no running relays to ${to}\n` : `${results.map((r) => r.reason).join("\n")}\n`);
136
+ return 0;
137
+ }
96
138
  if (flags["--prune"]) {
97
139
  return json(() => ({ removed: pruneRelays(Number(str(flags["--days"]) ?? 14)) }));
98
140
  }
@@ -109,6 +151,7 @@ async function relay(argv) {
109
151
  check: str(flags["--check"]),
110
152
  message: str(flags["--message"]),
111
153
  timeoutMin: flags["--timeout-min"] ? Number(str(flags["--timeout-min"])) : undefined,
154
+ maxAgeMin: flags["--max-age"] ? Number(str(flags["--max-age"])) : undefined,
112
155
  foreground: flags["--foreground"] === true,
113
156
  }));
114
157
  }
@@ -170,9 +213,12 @@ const COMMANDS = {
170
213
  usage: [
171
214
  "zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
172
215
  " [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
173
- " [--timeout-min 240] [--foreground]",
216
+ " [--timeout-min 240] [--max-age MIN] [--foreground]",
174
217
  " 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",
218
+ " waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane;",
219
+ " --max-age (default --timeout-min + 60) is a hard end that always yields a final status",
220
+ "zswarm relay --list [--json] id, state, target, session, age, last check and job per relay dir",
221
+ "zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
176
222
  "zswarm relay --wait ID [--timeout-min 240] block until relay ID has its message, print it (pull path)",
177
223
  "zswarm relay --prune [--days 14] remove old relay dirs and undelivered messages",
178
224
  ].join("\n"),
@@ -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] === "")
@@ -16,7 +16,14 @@ import type { OpsResult } from "../ops/types.js";
16
16
  * message.txt. It reads nothing else, so moving or updating the caller's files
17
17
  * while it waits cannot break it.
18
18
  */
19
- export type RelayStatus = "DONE" | "TIMEOUT" | "PANE_DIED" | "SERVE_DOWN" | "UNCONFIRMED";
19
+ export type RelayStatus = "DONE" | "TIMEOUT" | "MAX_AGE" | "CHECK_BROKEN" | "CANCELLED" | "PANE_DIED" | "SERVE_DOWN" | "UNCONFIRMED";
20
+ /** How a detached relay can be stopped: the launchd label, systemd unit, or pid. */
21
+ export type RelayJob = {
22
+ kind: "launchd" | "systemd" | "pid";
23
+ label?: string;
24
+ unit?: string;
25
+ pid?: number;
26
+ };
20
27
  export type RelayConfig = {
21
28
  id: string;
22
29
  serve: string;
@@ -28,6 +35,8 @@ export type RelayConfig = {
28
35
  replySession?: string;
29
36
  from: string;
30
37
  timeoutMin: number;
38
+ /** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
39
+ maxAgeMin?: number;
31
40
  check?: string;
32
41
  message?: string;
33
42
  /**
@@ -35,6 +44,13 @@ export type RelayConfig = {
35
44
  * pasted body, an earlier task's done line). Only a line beyond these counts.
36
45
  */
37
46
  seen?: string[];
47
+ /** Recorded by launchRelay so `--cancel` can stop the detached job. */
48
+ job?: RelayJob;
49
+ /** Final status once the loop ends; absent while the relay runs. */
50
+ status?: RelayStatus;
51
+ /** Set by `--cancel`; a late loop must not overwrite it. */
52
+ cancelled?: boolean;
53
+ cancelledAt?: number;
38
54
  };
39
55
  export type RelayDeps = {
40
56
  /** dispatchZswarm with an env; injected in tests. */
@@ -82,6 +98,8 @@ export type RelayStartInput = {
82
98
  check?: string;
83
99
  message?: string;
84
100
  timeoutMin?: number;
101
+ /** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
102
+ maxAgeMin?: number;
85
103
  foreground?: boolean;
86
104
  env?: NodeJS.ProcessEnv;
87
105
  deps?: RelayDeps;
@@ -112,5 +130,49 @@ export declare function waitForRelay(id: string, opts?: {
112
130
  sleep?: (ms: number) => Promise<void>;
113
131
  now?: () => number;
114
132
  }): Promise<string | null>;
133
+ export type RelayJobRunner = (cmd: string, args: string[]) => {
134
+ status: number;
135
+ stdout: string;
136
+ stderr?: string;
137
+ };
138
+ export type RelayAdminDeps = {
139
+ env?: NodeJS.ProcessEnv;
140
+ now?: () => number;
141
+ /** Runs launchctl/systemctl; injected in tests. */
142
+ run?: RelayJobRunner;
143
+ /** Liveness probe for a plain detached pid; injected in tests. */
144
+ alive?: (pid: number) => boolean;
145
+ /** Sends SIGTERM to a plain detached pid; injected in tests. */
146
+ kill?: (pid: number) => void;
147
+ /** A pid's command line (undefined when unknown); injected in tests. */
148
+ commandLine?: (pid: number) => string | undefined;
149
+ };
150
+ export type RelayRow = {
151
+ id: string;
152
+ dir: string;
153
+ to: string;
154
+ session?: string;
155
+ /** Epoch milliseconds the run started (the `<ts>` in its directory name). */
156
+ started: number;
157
+ /** Seconds since it started. */
158
+ age: number;
159
+ state: string;
160
+ lastCheck?: number;
161
+ /** The launchd label / systemd unit / pid, when the job is still loaded. */
162
+ job?: string;
163
+ };
164
+ export type RelayCancelResult = {
165
+ id: string;
166
+ cancelled: boolean;
167
+ state: string;
168
+ reason: string;
169
+ actions: string[];
170
+ };
171
+ /** One row per relay run directory; newest first. */
172
+ export declare function listRelays(deps?: RelayAdminDeps): RelayRow[];
173
+ /** Stop a running relay's job, mark its directory CANCELLED and scrub its token. */
174
+ export declare function cancelRelay(id: string, deps?: RelayAdminDeps): RelayCancelResult;
175
+ /** Cancel every running relay that targets `to`. */
176
+ export declare function cancelAllTo(to: string, deps?: RelayAdminDeps): RelayCancelResult[];
115
177
  /** Remove relay directories and undelivered messages older than `days`. */
116
178
  export declare function pruneRelays(days?: number, env?: NodeJS.ProcessEnv): string[];