@zswarm/core 0.2.3 → 0.2.5

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/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
@@ -14,6 +14,11 @@ export type HarnessProfile = {
14
14
  * turn. `send` matches these against the trailing lines after the paste.
15
15
  */
16
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[];
17
22
  };
18
23
  /**
19
24
  * Identify the harness from a pane's command and title, case-insensitively.
@@ -29,3 +34,8 @@ export declare function resolveHarness(pane: {
29
34
  * Only the last few lines count: a quoted marker in scrollback is not a queue.
30
35
  */
31
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
@@ -69,11 +69,24 @@ const QUEUED = {
69
69
  gemini: GENERIC_QUEUED,
70
70
  pi: GENERIC_QUEUED,
71
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
+ };
72
84
  const UNKNOWN = {
73
85
  name: "unknown",
74
86
  submit: "auto",
75
87
  waiting: GENERIC_WAITING,
76
88
  queued: GENERIC_QUEUED,
89
+ busy: [/esc to interrupt/i],
77
90
  };
78
91
  function escapeRegExp(text) {
79
92
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -118,18 +131,36 @@ export function resolveHarness(pane) {
118
131
  const name = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
119
132
  if (name === "unknown")
120
133
  return UNKNOWN;
121
- return { name, submit: SUBMIT[name], waiting: WAITING[name], queued: QUEUED[name] };
134
+ return {
135
+ name,
136
+ submit: SUBMIT[name],
137
+ waiting: WAITING[name],
138
+ queued: QUEUED[name],
139
+ busy: BUSY[name],
140
+ };
122
141
  }
123
142
  /**
124
143
  * Does this screen's trailing lines show the harness's queued-message hint?
125
144
  * Only the last few lines count: a quoted marker in scrollback is not a queue.
126
145
  */
127
146
  export function queuedPrompt(screen, profile) {
128
- const lines = screen
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
129
161
  .replace(/\r/g, "")
130
162
  .split("\n")
131
163
  .map((line) => line.trimEnd())
132
164
  .filter((line) => line.length > 0)
133
165
  .slice(-6);
134
- return profile.queued.some((re) => lines.some((line) => re.test(line)));
135
166
  }
package/dist/host/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { ZellijError } from "../errors.js";
3
- import { crewCheck, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
3
+ import { createStateStore } from "../state.js";
4
+ import { crewCheck, crewClient, crewDown, crewStart, crewStatus, crewUp } from "./crew.js";
4
5
  import { hostDoctor, hostInstall, hostInstallArgs, hostScriptPath } from "./host.js";
6
+ import { listLeases } from "./lease.js";
5
7
  import { cancelAllTo, cancelRelay, listRelays, pruneRelays, runRelayDir, startRelay, waitForRelay } from "./relay.js";
6
8
  import { runSlot } from "./slot.js";
7
9
  /**
@@ -73,6 +75,10 @@ async function crew(argv) {
73
75
  return crewStart(session);
74
76
  case "check":
75
77
  return crewCheck(session);
78
+ case "client":
79
+ // What the minimized console window on Windows runs: attach (and create)
80
+ // the session so its panes render and take input.
81
+ return crewClient(session);
76
82
  default:
77
83
  process.stderr.write(`${COMMANDS.crew.usage}\n`);
78
84
  return 2;
@@ -80,7 +86,7 @@ async function crew(argv) {
80
86
  }
81
87
  /** A plain-text table for `relay --list`, one row per relay. */
82
88
  function relayTable(rows) {
83
- const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB"];
89
+ const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB", "LEASE"];
84
90
  const body = rows.map((r) => [
85
91
  r.id,
86
92
  r.state,
@@ -89,12 +95,26 @@ function relayTable(rows) {
89
95
  `${r.age}s`,
90
96
  r.lastCheck === undefined ? "-" : String(r.lastCheck),
91
97
  r.job ?? "-",
98
+ r.lease ? `${r.lease.relayId} (${r.lease.from})` : "-",
99
+ ]);
100
+ const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
101
+ return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
102
+ }
103
+ /** A plain-text table for `relay --leases`, one row per live lease. */
104
+ function leaseTable(leases) {
105
+ const header = ["KEY", "RELAY", "FROM", "PANE", "SINCE"];
106
+ const body = leases.map((l) => [
107
+ l.key,
108
+ l.relayId,
109
+ l.from,
110
+ l.pane,
111
+ new Date(l.at).toISOString(),
92
112
  ]);
93
113
  const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
94
114
  return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
95
115
  }
96
116
  async function relay(argv) {
97
- const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--cancel-all", "--json"]);
117
+ const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--leases", "--cancel-all", "--json", "--queue", "--force"]);
98
118
  const run = str(flags["--run"]);
99
119
  if (run)
100
120
  return runRelayDir(run);
@@ -115,6 +135,13 @@ async function relay(argv) {
115
135
  process.stdout.write(`${relayTable(rows)}\n`);
116
136
  return 0;
117
137
  }
138
+ if (flags["--leases"] === true) {
139
+ const leases = listLeases(createStateStore());
140
+ if (flags["--json"] === true)
141
+ return json(() => ({ leases }));
142
+ process.stdout.write(`${leaseTable(leases)}\n`);
143
+ return 0;
144
+ }
118
145
  const cancelId = str(flags["--cancel"]);
119
146
  if (cancelId) {
120
147
  const res = cancelRelay(cancelId);
@@ -153,6 +180,8 @@ async function relay(argv) {
153
180
  timeoutMin: flags["--timeout-min"] ? Number(str(flags["--timeout-min"])) : undefined,
154
181
  maxAgeMin: flags["--max-age"] ? Number(str(flags["--max-age"])) : undefined,
155
182
  foreground: flags["--foreground"] === true,
183
+ queue: flags["--queue"] === true,
184
+ force: flags["--force"] === true,
156
185
  }));
157
186
  }
158
187
  async function host(argv) {
@@ -202,10 +231,13 @@ const COMMANDS = {
202
231
  crew: {
203
232
  usage: [
204
233
  "zswarm crew up <session> --layout L.kdl [--config C.kdl] [--env-file F] [--first-pane NAME] [--run-as USER]",
205
- " keep the session running from its layout (service + 2-min watchdog)",
234
+ " keep the session running from its layout (service + 2-min watchdog;",
235
+ " on Windows: Scheduled Tasks under \\zswarm\\ + an attached client window)",
206
236
  "zswarm crew status <session> [--run-as USER] services, session, panes",
207
237
  "zswarm crew down <session> [--run-as USER] remove the services and kill the session",
208
238
  "zswarm crew start|check <session> what the service and the watchdog run",
239
+ "zswarm crew client <session> what the minimized console window runs (Windows)",
240
+ "--run-as installs for another account and is Linux/macOS only; a Windows crew runs as the signed-in user",
209
241
  ].join("\n"),
210
242
  run: crew,
211
243
  },
@@ -213,11 +245,14 @@ const COMMANDS = {
213
245
  usage: [
214
246
  "zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
215
247
  " [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
216
- " [--timeout-min 240] [--max-age MIN] [--foreground]",
248
+ " [--timeout-min 240] [--max-age MIN] [--queue] [--force] [--foreground]",
217
249
  " send the body to PANE over serve (ZSWARM_SERVE / ZSWARM_SERVE_TOKEN), then a detached relay",
218
250
  " waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane;",
219
251
  " --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",
252
+ " it leases its target pane until it ends; a second relay to a leased pane fails pane_leased",
253
+ " unless --queue waits for the lease to free or --force shares it",
254
+ "zswarm relay --list [--json] id, state, target, session, age, last check, job and lease per relay dir",
255
+ "zswarm relay --leases [--json] live pane leases and the relay that holds each",
221
256
  "zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
222
257
  "zswarm relay --wait ID [--timeout-min 240] block until relay ID has its message, print it (pull path)",
223
258
  "zswarm relay --prune [--days 14] remove old relay dirs and undelivered messages",
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Windows half of `zswarm crew`.
3
+ *
4
+ * A background Zellij session started with `zellij attach --create-background`
5
+ * on Windows renders empty pane grids and drops pasted input, because nothing
6
+ * is attached in a real console. The fix is two Scheduled Tasks under
7
+ * `\zswarm\` plus a minimized attached-client console window:
8
+ *
9
+ * zswarm-crew-<session> starts the session (interactive, highest)
10
+ * zswarm-crew-<session>-watch `crew check` every two minutes, so the crew
11
+ * restarts only when the session or its first
12
+ * layout pane is gone, and the client window is
13
+ * reopened when it is gone but the session lives
14
+ *
15
+ * This module is pure PowerShell text (unit-tested by text) plus thin async
16
+ * wrappers. It deliberately mirrors `buildServeTaskScript` in
17
+ * `ops/serve-install.ts`: same preamble helpers, same base64 UTF-8 round-trip
18
+ * for the cmd.exe action body, same `-EncodedCommand` invocation. It must not
19
+ * import `./crew.js`; that module imports this one.
20
+ */
21
+ /** Scheduled-task folder every crew task lives in. */
22
+ export declare const CREW_TASK_FOLDER = "\\zswarm\\";
23
+ /** Watchdog cadence, matching the Linux/macOS `crew-<s>-watch` timer. */
24
+ export declare const CREW_WATCH_MINUTES = 2;
25
+ export type PowerShellResult = {
26
+ code: number;
27
+ stdout: string;
28
+ stderr: string;
29
+ };
30
+ export type PowerShellExec = (script: string, options: {
31
+ timeoutMs: number;
32
+ signal?: AbortSignal;
33
+ }) => Promise<PowerShellResult>;
34
+ export type CrewTaskNames = {
35
+ folder: string;
36
+ start: string;
37
+ watch: string;
38
+ };
39
+ export type CrewTaskState = {
40
+ /** Which of the two crew tasks this is. */
41
+ name: "start" | "watch";
42
+ /** Task name only, e.g. `zswarm-crew-crew`. */
43
+ task: string;
44
+ /** `\zswarm\zswarm-crew-crew`. */
45
+ path: string;
46
+ /** `Ready` | `Running` | `Disabled` | `missing` ... */
47
+ state: string;
48
+ /** Registered at all: Ready/Running/Disabled count; `missing` does not. */
49
+ active: boolean;
50
+ lastResult: number | null;
51
+ lastRun: string | null;
52
+ };
53
+ export type CrewClientWindow = {
54
+ present: boolean;
55
+ pid: number | null;
56
+ count: number;
57
+ };
58
+ /** `zswarm-crew-<session>`: task names, and the client window's title marker. */
59
+ export declare function crewTaskNames(session: string): CrewTaskNames;
60
+ /** The token that proves a console window is this crew's client: its title and
61
+ * the string in its launcher's command line. */
62
+ export declare function crewClientMarker(session: string): string;
63
+ /** The cmd.exe action body for a task: `set "K=V"&& ...` then the CLI argv. */
64
+ export declare function crewTaskCommand(input: {
65
+ launch: {
66
+ execPath: string;
67
+ scriptPath: string;
68
+ };
69
+ mode: "start" | "check";
70
+ session: string;
71
+ env: NodeJS.ProcessEnv;
72
+ }): string;
73
+ /** Pure PowerShell text, unit-tested by text. */
74
+ export declare function buildCrewTaskScript(action: "register" | "inspect" | "start" | "unregister", params: {
75
+ session: string;
76
+ startCommand?: string;
77
+ watchCommand?: string;
78
+ intervalMinutes?: number;
79
+ }): string;
80
+ /**
81
+ * The command line the client window runs. The marker is both the window title
82
+ * and the token `ensure`/`close` look for in a process command line; the crew
83
+ * CLI does the actual `zellij attach --create`, so the window can read the crew
84
+ * dir (and its env file) itself instead of taking secrets on a command line.
85
+ */
86
+ export declare function crewClientCommand(input: {
87
+ session: string;
88
+ launch: {
89
+ execPath: string;
90
+ scriptPath: string;
91
+ };
92
+ }): string;
93
+ /** The minimized attached-client window that makes a Windows Zellij session render. */
94
+ export declare function buildCrewClientScript(action: "ensure" | "close", params: {
95
+ session: string;
96
+ clientCommand?: string;
97
+ }): string;
98
+ /** Last line that parses as a JSON object, else the first; null when none does. */
99
+ export declare function parseCrewJson(stdout: string): Record<string, unknown> | null;
100
+ /** Powershell runner used in production; injected in tests. Never throws. */
101
+ export declare const defaultPowerShellExec: PowerShellExec;
102
+ export declare function inspectCrewTasks(input: {
103
+ session: string;
104
+ runPowerShell: PowerShellExec;
105
+ timeoutMs?: number;
106
+ signal?: AbortSignal;
107
+ }): Promise<{
108
+ tasks: CrewTaskState[];
109
+ client: CrewClientWindow;
110
+ }>;
111
+ export declare function installCrewTasks(input: {
112
+ session: string;
113
+ startCommand: string;
114
+ watchCommand: string;
115
+ intervalMinutes?: number;
116
+ runPowerShell: PowerShellExec;
117
+ timeoutMs?: number;
118
+ signal?: AbortSignal;
119
+ }): Promise<{
120
+ lines: string[];
121
+ notes: string[];
122
+ tasks: CrewTaskState[];
123
+ client: CrewClientWindow;
124
+ }>;
125
+ export declare function removeCrewTasks(input: {
126
+ session: string;
127
+ runPowerShell: PowerShellExec;
128
+ timeoutMs?: number;
129
+ signal?: AbortSignal;
130
+ }): Promise<{
131
+ lines: string[];
132
+ tasks: CrewTaskState[];
133
+ }>;
134
+ export declare function ensureCrewClient(input: {
135
+ session: string;
136
+ clientCommand: string;
137
+ runPowerShell: PowerShellExec;
138
+ timeoutMs?: number;
139
+ signal?: AbortSignal;
140
+ }): Promise<{
141
+ opened: boolean;
142
+ pid: number | null;
143
+ title: string;
144
+ present: boolean;
145
+ count: number;
146
+ }>;
147
+ export declare function closeCrewClient(input: {
148
+ session: string;
149
+ runPowerShell: PowerShellExec;
150
+ timeoutMs?: number;
151
+ signal?: AbortSignal;
152
+ }): Promise<{
153
+ closed: number[];
154
+ count: number;
155
+ }>;