@zswarm/core 0.2.3 → 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/slot.py +35 -1
- package/dist/harness.d.ts +10 -0
- package/dist/harness.js +34 -3
- package/dist/host/cli.js +32 -4
- package/dist/host/lease.d.ts +68 -0
- package/dist/host/lease.js +138 -0
- package/dist/host/relay.d.ts +17 -0
- package/dist/host/relay.js +52 -7
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/ops/dispatch.js +69 -5
- package/dist/ops/util.d.ts +9 -0
- package/dist/ops/util.js +24 -0
- package/dist/schema.js +6 -0
- 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 +1 -0
- package/dist/zellij/client.js +1 -1
- package/dist/zellij/panes.d.ts +1 -0
- package/dist/zellij/panes.js +3 -0
- package/package.json +1 -1
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 {
|
|
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 { 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";
|
|
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
|
/**
|
|
@@ -80,7 +82,7 @@ async function crew(argv) {
|
|
|
80
82
|
}
|
|
81
83
|
/** A plain-text table for `relay --list`, one row per relay. */
|
|
82
84
|
function relayTable(rows) {
|
|
83
|
-
const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB"];
|
|
85
|
+
const header = ["ID", "STATE", "TO", "SESSION", "AGE", "LAST CHECK", "JOB", "LEASE"];
|
|
84
86
|
const body = rows.map((r) => [
|
|
85
87
|
r.id,
|
|
86
88
|
r.state,
|
|
@@ -89,12 +91,26 @@ function relayTable(rows) {
|
|
|
89
91
|
`${r.age}s`,
|
|
90
92
|
r.lastCheck === undefined ? "-" : String(r.lastCheck),
|
|
91
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(),
|
|
92
108
|
]);
|
|
93
109
|
const widths = header.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
|
|
94
110
|
return [header, ...body].map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
|
|
95
111
|
}
|
|
96
112
|
async function relay(argv) {
|
|
97
|
-
const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--cancel-all", "--json"]);
|
|
113
|
+
const { flags } = parseHostArgs(argv, ["--foreground", "--prune", "--list", "--leases", "--cancel-all", "--json", "--queue", "--force"]);
|
|
98
114
|
const run = str(flags["--run"]);
|
|
99
115
|
if (run)
|
|
100
116
|
return runRelayDir(run);
|
|
@@ -115,6 +131,13 @@ async function relay(argv) {
|
|
|
115
131
|
process.stdout.write(`${relayTable(rows)}\n`);
|
|
116
132
|
return 0;
|
|
117
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
|
+
}
|
|
118
141
|
const cancelId = str(flags["--cancel"]);
|
|
119
142
|
if (cancelId) {
|
|
120
143
|
const res = cancelRelay(cancelId);
|
|
@@ -153,6 +176,8 @@ async function relay(argv) {
|
|
|
153
176
|
timeoutMin: flags["--timeout-min"] ? Number(str(flags["--timeout-min"])) : undefined,
|
|
154
177
|
maxAgeMin: flags["--max-age"] ? Number(str(flags["--max-age"])) : undefined,
|
|
155
178
|
foreground: flags["--foreground"] === true,
|
|
179
|
+
queue: flags["--queue"] === true,
|
|
180
|
+
force: flags["--force"] === true,
|
|
156
181
|
}));
|
|
157
182
|
}
|
|
158
183
|
async function host(argv) {
|
|
@@ -213,11 +238,14 @@ const COMMANDS = {
|
|
|
213
238
|
usage: [
|
|
214
239
|
"zswarm relay --to PANE --done REGEX --reply-to PANE [--serve TARGET] [--session S] [--body-file F]",
|
|
215
240
|
" [--reply-session S] [--from NAME] [--check SCRIPT] [--message TEMPLATE] [--id ID]",
|
|
216
|
-
" [--timeout-min 240] [--max-age MIN] [--foreground]",
|
|
241
|
+
" [--timeout-min 240] [--max-age MIN] [--queue] [--force] [--foreground]",
|
|
217
242
|
" send the body to PANE over serve (ZSWARM_SERVE / ZSWARM_SERVE_TOKEN), then a detached relay",
|
|
218
243
|
" waits for REGEX (and SCRIPT exit 0) and pastes a message into the local --reply-to pane;",
|
|
219
244
|
" --max-age (default --timeout-min + 60) is a hard end that always yields a final status",
|
|
220
|
-
"
|
|
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",
|
|
221
249
|
"zswarm relay --cancel ID | --cancel-all --to PANE stop the job, mark it CANCELLED, paste nothing",
|
|
222
250
|
"zswarm relay --wait ID [--timeout-min 240] block until relay ID has its message, print it (pull path)",
|
|
223
251
|
"zswarm relay --prune [--days 14] remove old relay dirs and undelivered messages",
|
|
@@ -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
|
+
}
|
package/dist/host/relay.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OpsResult } from "../ops/types.js";
|
|
2
|
+
import { type StateStore } from "../state.js";
|
|
2
3
|
/**
|
|
3
4
|
* `zswarm relay`: hand a body to a pane on another host and tell a local pane
|
|
4
5
|
* when the work is done, without anyone polling.
|
|
@@ -51,6 +52,8 @@ export type RelayConfig = {
|
|
|
51
52
|
/** Set by `--cancel`; a late loop must not overwrite it. */
|
|
52
53
|
cancelled?: boolean;
|
|
53
54
|
cancelledAt?: number;
|
|
55
|
+
/** The pane lease this relay holds; released on every final status. */
|
|
56
|
+
leaseKey?: string;
|
|
54
57
|
};
|
|
55
58
|
export type RelayDeps = {
|
|
56
59
|
/** dispatchZswarm with an env; injected in tests. */
|
|
@@ -101,8 +104,14 @@ export type RelayStartInput = {
|
|
|
101
104
|
/** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
|
|
102
105
|
maxAgeMin?: number;
|
|
103
106
|
foreground?: boolean;
|
|
107
|
+
/** Wait for a leased pane to free instead of failing. */
|
|
108
|
+
queue?: boolean;
|
|
109
|
+
/** Send to a leased pane anyway; the lease is shared and noted. */
|
|
110
|
+
force?: boolean;
|
|
104
111
|
env?: NodeJS.ProcessEnv;
|
|
105
112
|
deps?: RelayDeps;
|
|
113
|
+
/** State store for the pane lease; injected in tests. */
|
|
114
|
+
store?: StateStore;
|
|
106
115
|
/** Starts the detached relay; injected in tests. */
|
|
107
116
|
launch?: (dir: string, env: NodeJS.ProcessEnv) => string;
|
|
108
117
|
};
|
|
@@ -138,6 +147,8 @@ export type RelayJobRunner = (cmd: string, args: string[]) => {
|
|
|
138
147
|
export type RelayAdminDeps = {
|
|
139
148
|
env?: NodeJS.ProcessEnv;
|
|
140
149
|
now?: () => number;
|
|
150
|
+
/** State store for the pane leases; injected in tests. */
|
|
151
|
+
store?: StateStore;
|
|
141
152
|
/** Runs launchctl/systemctl; injected in tests. */
|
|
142
153
|
run?: RelayJobRunner;
|
|
143
154
|
/** Liveness probe for a plain detached pid; injected in tests. */
|
|
@@ -160,6 +171,12 @@ export type RelayRow = {
|
|
|
160
171
|
lastCheck?: number;
|
|
161
172
|
/** The launchd label / systemd unit / pid, when the job is still loaded. */
|
|
162
173
|
job?: string;
|
|
174
|
+
/** The live pane lease held for this relay's target, if any. */
|
|
175
|
+
lease?: {
|
|
176
|
+
relayId: string;
|
|
177
|
+
from: string;
|
|
178
|
+
at: number;
|
|
179
|
+
};
|
|
163
180
|
};
|
|
164
181
|
export type RelayCancelResult = {
|
|
165
182
|
id: string;
|
package/dist/host/relay.js
CHANGED
|
@@ -4,7 +4,8 @@ import { basename, join } from "node:path";
|
|
|
4
4
|
import { ZellijError } from "../errors.js";
|
|
5
5
|
import { dispatchZswarm } from "../ops/dispatch.js";
|
|
6
6
|
import { resolveServeCliLaunch } from "../ops/serve-install.js";
|
|
7
|
-
import { defaultStateDir } from "../state.js";
|
|
7
|
+
import { createStateStore, defaultStateDir } from "../state.js";
|
|
8
|
+
import { findLease, leaseKey, releaseLeaseKey, takeLease } from "./lease.js";
|
|
8
9
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
10
|
/** Spread over about an hour: Zellij on a loaded machine can stall for minutes. */
|
|
10
11
|
const DEFAULT_DELAYS = [0, 30, 60, 120, 300, 600, 900, 1500];
|
|
@@ -17,6 +18,8 @@ const CHECK_BROKEN_TRIES = 10;
|
|
|
17
18
|
const DEFAULT_MAX_AGE_EXTRA_MIN = 60;
|
|
18
19
|
/** A wait that only finds `seen` lines returns at once; poll no faster than this. */
|
|
19
20
|
const STALE_MATCH_POLL_MS = 15_000;
|
|
21
|
+
/** `relay --queue` re-checks a leased pane this often. */
|
|
22
|
+
const LEASE_QUEUE_POLL_MS = 5_000;
|
|
20
23
|
export function relaysDir(env = process.env) {
|
|
21
24
|
return join(defaultStateDir(env), "relays");
|
|
22
25
|
}
|
|
@@ -394,12 +397,35 @@ export async function startRelay(input) {
|
|
|
394
397
|
// Read the body before the relay directory (and its token copy) exists.
|
|
395
398
|
const body = input.bodyFile ? readFileSync(input.bodyFile, "utf8") : undefined;
|
|
396
399
|
const dir = prepareRelay(cfg, { check: input.check, message: input.message }, env);
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
|
|
400
|
+
const store = input.store ?? createStateStore({ env });
|
|
401
|
+
const leaseTarget = { serve, session: cfg.session, pane: cfg.to };
|
|
400
402
|
let sent;
|
|
401
403
|
let warning;
|
|
402
404
|
let how;
|
|
405
|
+
const leaseNow = input.deps?.now ?? Date.now;
|
|
406
|
+
const leaseSleep = input.deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
407
|
+
const take = () => takeLease(store, { relayId: id, from: cfg.from, at: leaseNow(), dir, target: leaseTarget, force: input.force });
|
|
408
|
+
const leaseKeyValue = leaseKey(leaseTarget);
|
|
409
|
+
let taken = take();
|
|
410
|
+
if (!taken.ok && input.queue) {
|
|
411
|
+
const deadline = leaseNow() + cfg.timeoutMin * 60_000;
|
|
412
|
+
while (!taken.ok && leaseNow() < deadline) {
|
|
413
|
+
await leaseSleep(Math.min(LEASE_QUEUE_POLL_MS, Math.max(1, deadline - leaseNow())));
|
|
414
|
+
taken = take();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!taken.ok) {
|
|
418
|
+
rmSync(dir, { recursive: true, force: true });
|
|
419
|
+
const holder = taken.holder;
|
|
420
|
+
throw new ZellijError("pane_leased", `pane ${cfg.to} is leased by relay ${holder.relayId} (from ${holder.from}, since ${new Date(holder.at).toISOString()})${input.queue ? " and did not free before --timeout-min" : "; use --queue to wait or --force to share it"}`, { relayId: holder.relayId, from: holder.from, at: holder.at, dir: holder.dir });
|
|
421
|
+
}
|
|
422
|
+
updateConfig(dir, { leaseKey: leaseKeyValue });
|
|
423
|
+
if (taken.shared) {
|
|
424
|
+
warning = `pane ${cfg.to} is already leased by relay ${taken.lease.relayId}; --force shares its lease`;
|
|
425
|
+
}
|
|
426
|
+
const call = input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
|
|
427
|
+
const remote = serveEnv(cfg, env);
|
|
428
|
+
const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
|
|
403
429
|
try {
|
|
404
430
|
if (body !== undefined) {
|
|
405
431
|
const res = await call(target({ op: "send", to: cfg.to, body, from: cfg.from, settleMs: 800 }), remote);
|
|
@@ -418,7 +444,8 @@ export async function startRelay(input) {
|
|
|
418
444
|
}
|
|
419
445
|
}
|
|
420
446
|
if (doneLines(body, cfg.done).length > 0) {
|
|
421
|
-
|
|
447
|
+
const note = "--done matches a line of the body; that line is ignored while it stays on screen, so an identical done line is only seen once it scrolls off";
|
|
448
|
+
warning = warning ? `${warning}; ${note}` : note;
|
|
422
449
|
}
|
|
423
450
|
}
|
|
424
451
|
// Whatever already matches --done (the echoed body, an earlier task's done
|
|
@@ -431,21 +458,32 @@ export async function startRelay(input) {
|
|
|
431
458
|
how = (input.launch ?? launchRelay)(dir, env);
|
|
432
459
|
}
|
|
433
460
|
catch (err) {
|
|
434
|
-
// Nothing will run from this directory: take the token copy with it.
|
|
461
|
+
// Nothing will run from this directory: take the lease and token copy with it.
|
|
462
|
+
releaseLeaseKey(store, leaseKeyValue, id);
|
|
435
463
|
rmSync(dir, { recursive: true, force: true });
|
|
436
464
|
throw err;
|
|
437
465
|
}
|
|
438
466
|
const extra = warning ? { warning } : {};
|
|
439
467
|
if (input.foreground) {
|
|
440
|
-
const result = await relayLoop(dir, input.deps).finally(() =>
|
|
468
|
+
const result = await relayLoop(dir, input.deps).finally(() => {
|
|
469
|
+
releaseLeaseKey(store, leaseKeyValue, id);
|
|
470
|
+
scrubRelayToken(dir);
|
|
471
|
+
});
|
|
441
472
|
return { id, dir, sent: sent ?? null, ...extra, ...result };
|
|
442
473
|
}
|
|
443
474
|
return { id, dir, sent: sent ?? null, ...extra, relay: how };
|
|
444
475
|
}
|
|
445
476
|
/** What `zswarm relay --run <dir>` does inside the detached job. */
|
|
446
477
|
export async function runRelayDir(dir, deps = {}) {
|
|
478
|
+
const store = createStateStore({ env: deps.env ?? process.env });
|
|
479
|
+
const releaseLease = () => {
|
|
480
|
+
const cfg = readRelayConfig(dir);
|
|
481
|
+
if (cfg?.leaseKey && cfg.id)
|
|
482
|
+
releaseLeaseKey(store, cfg.leaseKey, cfg.id);
|
|
483
|
+
};
|
|
447
484
|
const stop = (sig) => () => {
|
|
448
485
|
appendFileSync(join(dir, "relay.log"), `${new Date().toISOString()} relay stopped by ${sig}\n`);
|
|
486
|
+
releaseLease();
|
|
449
487
|
scrubRelayToken(dir);
|
|
450
488
|
process.exit(143);
|
|
451
489
|
};
|
|
@@ -459,6 +497,7 @@ export async function runRelayDir(dir, deps = {}) {
|
|
|
459
497
|
code = res.delivered ? 0 : 3;
|
|
460
498
|
}
|
|
461
499
|
finally {
|
|
500
|
+
releaseLease();
|
|
462
501
|
scrubRelayToken(dir);
|
|
463
502
|
process.off("SIGTERM", onTerm);
|
|
464
503
|
process.off("SIGHUP", onHup);
|
|
@@ -613,12 +652,14 @@ function jobLoaded(job, deps, dir) {
|
|
|
613
652
|
export function listRelays(deps = {}) {
|
|
614
653
|
const env = deps.env ?? process.env;
|
|
615
654
|
const now = deps.now ?? Date.now;
|
|
655
|
+
const store = deps.store ?? createStateStore({ env });
|
|
616
656
|
const rows = [];
|
|
617
657
|
for (const run of runDirs(relaysDir(env))) {
|
|
618
658
|
const cfg = readRelayConfig(run.dir);
|
|
619
659
|
if (!cfg)
|
|
620
660
|
continue;
|
|
621
661
|
const loaded = cfg.job ? jobLoaded(cfg.job, deps, run.dir) : false;
|
|
662
|
+
const held = findLease(store, { serve: cfg.serve, session: cfg.session, pane: cfg.to }, { now });
|
|
622
663
|
rows.push({
|
|
623
664
|
id: cfg.id || run.id,
|
|
624
665
|
dir: run.dir,
|
|
@@ -629,6 +670,7 @@ export function listRelays(deps = {}) {
|
|
|
629
670
|
state: relayState(run.dir, cfg),
|
|
630
671
|
lastCheck: lastCheckResult(run.dir),
|
|
631
672
|
...(loaded && cfg.job ? { job: jobLabelText(cfg.job) } : {}),
|
|
673
|
+
...(held ? { lease: { relayId: held.relayId, from: held.from, at: held.at } } : {}),
|
|
632
674
|
});
|
|
633
675
|
}
|
|
634
676
|
return rows.sort((a, b) => b.started - a.started);
|
|
@@ -680,6 +722,9 @@ export function cancelRelay(id, deps = {}) {
|
|
|
680
722
|
: `${jobLabelText(cfg.job)} is no longer running this relay; nothing to stop`,
|
|
681
723
|
];
|
|
682
724
|
updateConfig(match.dir, { cancelled: true, status: "CANCELLED", cancelledAt: now() });
|
|
725
|
+
if (cfg.leaseKey && releaseLeaseKey(deps.store ?? createStateStore({ env }), cfg.leaseKey, cfg.id)) {
|
|
726
|
+
actions.push(`released the pane lease for ${cfg.to}`);
|
|
727
|
+
}
|
|
683
728
|
scrubRelayToken(match.dir);
|
|
684
729
|
actions.push(`${id} marked CANCELLED (nothing pasted)`);
|
|
685
730
|
return { id, cancelled: true, state: "cancelled", reason: `cancelled relay ${id}`, actions };
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPane
|
|
|
9
9
|
export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
|
|
10
10
|
export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
|
|
11
11
|
export { createZellijClient, type ZellijClient, type ZellijClientOptions, type ZellijTransport, } from "./zellij/client.js";
|
|
12
|
-
export { createStateStore, defaultStateDir, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
12
|
+
export { createStateStore, defaultStateDir, type LeaseRecord, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
13
|
+
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, type LeaseTakeInput, type LeaseTakeResult, type LeaseTarget, } from "./host/lease.js";
|
|
13
14
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
14
15
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy, } from "./policy.js";
|
|
15
16
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type IpcDiscoveryState, type SshExecFn, type SshTarget, } from "./exec.js";
|
|
@@ -23,7 +24,7 @@ export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
|
|
|
23
24
|
export { selectTargets } from "./ops/broadcast.js";
|
|
24
25
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
|
25
26
|
export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, type PeerState, } from "./ops/status.js";
|
|
26
|
-
export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
|
+
export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
28
|
export type { RoutingContext } from "./ops/routing.js";
|
|
28
29
|
export type { DispatchDeps, OpsResult, ServeInstallDeps } from "./ops/types.js";
|
|
29
30
|
export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, type DoctorCheck, type DoctorCheckScope, type DoctorCheckState, type DoctorReport, type DoctorRoute, type HostInspectInput, } from "./ops/doctor.js";
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply
|
|
|
10
10
|
export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, } from "./ops/bus.js";
|
|
11
11
|
export { createZellijClient, } from "./zellij/client.js";
|
|
12
12
|
export { createStateStore, defaultStateDir, } from "./state.js";
|
|
13
|
+
export { findLease, leaseKey, leaseStale, listLeases, releaseLease, releaseLeaseKey, takeLease, } from "./host/lease.js";
|
|
13
14
|
export { dispatchZswarm, resolveInvocationEnv } from "./ops/dispatch.js";
|
|
14
15
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./policy.js";
|
|
15
16
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
|
|
@@ -23,7 +24,7 @@ export { parseTabList, resolveTab } from "./zellij/tabs.js";
|
|
|
23
24
|
export { selectTargets } from "./ops/broadcast.js";
|
|
24
25
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
|
25
26
|
export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, } from "./ops/status.js";
|
|
26
|
-
export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
|
+
export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
27
28
|
export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, } from "./ops/doctor.js";
|
|
28
29
|
export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
|
|
29
30
|
export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { createGitClient } from "../git.js";
|
|
3
|
+
import { busyPrompt, resolveHarness } from "../harness.js";
|
|
4
|
+
import { findLease } from "../host/lease.js";
|
|
3
5
|
import { normalizeKeys } from "../keys.js";
|
|
4
6
|
import { createZellijClient } from "../zellij/client.js";
|
|
5
7
|
import { parseCreatedAge } from "../zellij/session.js";
|
|
@@ -20,7 +22,7 @@ import { invocationContext } from "./routing.js";
|
|
|
20
22
|
import { DEFAULT_STATUS_TIMEOUT_MS, peerStatus, } from "./status.js";
|
|
21
23
|
import { tailPane } from "./tail.js";
|
|
22
24
|
import { OP_NAMES } from "../schema.js";
|
|
23
|
-
import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
25
|
+
import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, unfoldScreen, } from "./util.js";
|
|
24
26
|
import { waitForPane } from "./wait.js";
|
|
25
27
|
import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
|
|
26
28
|
import { DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, uninstallServeLogon, } from "./serve-install.js";
|
|
@@ -110,11 +112,13 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
110
112
|
deadlineAt: budget.deadline, signal,
|
|
111
113
|
});
|
|
112
114
|
let panes = null;
|
|
115
|
+
let fromBus = false;
|
|
113
116
|
if (bus) {
|
|
114
117
|
const snapshot = busToPanes(bus.snapshot);
|
|
115
118
|
try {
|
|
116
119
|
client.resolvePane(snapshot, to);
|
|
117
120
|
panes = snapshot;
|
|
121
|
+
fromBus = true;
|
|
118
122
|
}
|
|
119
123
|
catch { /* An absent title/command/ID gets a fresh direct observation. */ }
|
|
120
124
|
}
|
|
@@ -160,7 +164,48 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
160
164
|
}
|
|
161
165
|
if (policy && op)
|
|
162
166
|
assertPaneAllowed(policy, pane, op);
|
|
163
|
-
return { session, panes, pane, firstCallMs, timeoutMs };
|
|
167
|
+
return { session, panes, pane, fromBus, firstCallMs, timeoutMs };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A pane's content width, for unfolding a glued dump. Only a bus-resolved pane
|
|
171
|
+
* reaches this: a direct listing already carried the field, and the manifest
|
|
172
|
+
* the bus pushes has no geometry. A single-line dump is the only caller.
|
|
173
|
+
*/
|
|
174
|
+
async function paneContentColumns(client, session, pane) {
|
|
175
|
+
const panes = await client.listPanes(session);
|
|
176
|
+
return panes.find((p) => p.id === pane.id)?.contentColumns ?? 0;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Zellij puts a newline before a row only when the row is marked canonical, and
|
|
180
|
+
* a full-screen TUI repaints by writing full-width rows back to back, so every
|
|
181
|
+
* row is a wrap continuation: `dump-screen` then hands back the whole viewport
|
|
182
|
+
* as one line. A pane whose rows are ended by a line feed reads normally and is
|
|
183
|
+
* left alone. The `--ansi` dump keeps the repainted rows full width, so it
|
|
184
|
+
* unfolds back into rows when it divides into the pane's column count exactly;
|
|
185
|
+
* anything else keeps the original text.
|
|
186
|
+
*/
|
|
187
|
+
async function unglueScreen(client, session, pane, text, full, fromBus) {
|
|
188
|
+
// The Zellij client prints the dump, so even a glued screen ends in one
|
|
189
|
+
// `println!` newline; only a newline *inside* the text separates rows.
|
|
190
|
+
const body = text.replace(/\n+$/, "");
|
|
191
|
+
if (!body || body.includes("\n"))
|
|
192
|
+
return text;
|
|
193
|
+
const columns = pane.contentColumns && pane.contentColumns > 1
|
|
194
|
+
? pane.contentColumns
|
|
195
|
+
: fromBus
|
|
196
|
+
? await paneContentColumns(client, session, pane)
|
|
197
|
+
: 0;
|
|
198
|
+
if (columns < 2 || body.length <= columns)
|
|
199
|
+
return text;
|
|
200
|
+
let ansi;
|
|
201
|
+
try {
|
|
202
|
+
ansi = (await client.dumpPane({ session, paneId: pane.id, full, ansi: true })).text;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// A Zellij too old for `--ansi` (or a failing call) must not break dump.
|
|
206
|
+
return text;
|
|
207
|
+
}
|
|
208
|
+
return unfoldScreen(ansi, columns) ?? text;
|
|
164
209
|
}
|
|
165
210
|
async function dispatchOperation(args, injected, deps, context, env) {
|
|
166
211
|
const op = String(args.op ?? "");
|
|
@@ -341,6 +386,23 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
341
386
|
const { session, pane, panes, firstCallMs, timeoutMs } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
342
387
|
assertWritable(client, pane, args, "send");
|
|
343
388
|
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
389
|
+
if (isTrue(args.ifIdle)) {
|
|
390
|
+
// Before delivery starts: a live pane lease (a relay working it) is a
|
|
391
|
+
// hard refusal; a harness that is visibly working is the softer one.
|
|
392
|
+
const to = String(args.to ?? "").trim();
|
|
393
|
+
const held = findLease(state(), {
|
|
394
|
+
serve: typeof args.serve === "string" ? args.serve : env.ZSWARM_SERVE,
|
|
395
|
+
session,
|
|
396
|
+
pane: to,
|
|
397
|
+
});
|
|
398
|
+
if (held) {
|
|
399
|
+
throw new ZellijError("pane_leased", `pane ${to} is leased by relay ${held.relayId} (from ${held.from}, since ${new Date(held.at).toISOString()})`, { relayId: held.relayId, from: held.from, at: held.at, dir: held.dir });
|
|
400
|
+
}
|
|
401
|
+
const screen = await client.dumpPane({ session, paneId: pane.id }).then((res) => res.text, () => null);
|
|
402
|
+
if (screen !== null && busyPrompt(screen, resolveHarness(pane))) {
|
|
403
|
+
throw new ZellijError("pane_busy", `pane ${to} is busy with a task; retry when it is idle`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
344
406
|
const labeled = withSenderLabel(args, {
|
|
345
407
|
env,
|
|
346
408
|
selfTitle: selfPaneTitle(client, panes),
|
|
@@ -428,15 +490,17 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
428
490
|
};
|
|
429
491
|
}
|
|
430
492
|
case "dump": {
|
|
431
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
493
|
+
const { session, pane, fromBus } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
494
|
+
const full = isTrue(args.full);
|
|
432
495
|
const dumped = await client.dumpPane({
|
|
433
496
|
session,
|
|
434
497
|
paneId: pane.id,
|
|
435
|
-
full
|
|
498
|
+
full,
|
|
436
499
|
});
|
|
500
|
+
const text = await unglueScreen(client, session, pane, dumped.text, full, fromBus);
|
|
437
501
|
const max = dumpMaxChars(args);
|
|
438
502
|
const keep = isTrue(args.head) ? "head" : "tail";
|
|
439
|
-
const clipped = truncateDumpText(
|
|
503
|
+
const clipped = truncateDumpText(text, max, keep);
|
|
440
504
|
return {
|
|
441
505
|
ok: true,
|
|
442
506
|
data: {
|
package/dist/ops/util.d.ts
CHANGED
|
@@ -44,6 +44,15 @@ export declare function truncateDumpText(text: string, maxChars: number, keep?:
|
|
|
44
44
|
};
|
|
45
45
|
/** Screen text with trailing blanks removed, so idle detection ignores padding. */
|
|
46
46
|
export declare function normalizeScreen(text: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Zellij's plain `dump-screen` puts a newline only before rows it marks
|
|
49
|
+
* canonical; a full-width repaint (a TUI) leaves every row a wrap
|
|
50
|
+
* continuation, so the whole viewport arrives as one line. The `--ansi`
|
|
51
|
+
* dump keeps those rows full width, so the text is a whole number of
|
|
52
|
+
* `columns`-wide rows once the SGR sequences are dropped. Returns null when
|
|
53
|
+
* that shape does not hold and the caller must keep the original text.
|
|
54
|
+
*/
|
|
55
|
+
export declare function unfoldScreen(text: string, columns: number): string | null;
|
|
47
56
|
export declare function numberArg(args: Record<string, unknown>, key: string, fallback: number, limits: {
|
|
48
57
|
min: number;
|
|
49
58
|
max: number;
|
package/dist/ops/util.js
CHANGED
|
@@ -71,6 +71,30 @@ export function normalizeScreen(text) {
|
|
|
71
71
|
.join("\n")
|
|
72
72
|
.replace(/\n+$/, "");
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Zellij's plain `dump-screen` puts a newline only before rows it marks
|
|
76
|
+
* canonical; a full-width repaint (a TUI) leaves every row a wrap
|
|
77
|
+
* continuation, so the whole viewport arrives as one line. The `--ansi`
|
|
78
|
+
* dump keeps those rows full width, so the text is a whole number of
|
|
79
|
+
* `columns`-wide rows once the SGR sequences are dropped. Returns null when
|
|
80
|
+
* that shape does not hold and the caller must keep the original text.
|
|
81
|
+
*/
|
|
82
|
+
export function unfoldScreen(text, columns) {
|
|
83
|
+
if (columns < 2)
|
|
84
|
+
return null;
|
|
85
|
+
const body = text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\n+$/, "");
|
|
86
|
+
if (body.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
if (body.length % columns !== 0)
|
|
89
|
+
return null;
|
|
90
|
+
if (body.length / columns < 2)
|
|
91
|
+
return null;
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (let i = 0; i < body.length; i += columns) {
|
|
94
|
+
rows.push(body.slice(i, i + columns).replace(/\s+$/, ""));
|
|
95
|
+
}
|
|
96
|
+
return rows.join("\n");
|
|
97
|
+
}
|
|
74
98
|
export function numberArg(args, key, fallback, limits) {
|
|
75
99
|
const raw = args[key];
|
|
76
100
|
if (raw === undefined || raw === null || raw === "")
|
package/dist/schema.js
CHANGED
|
@@ -399,6 +399,12 @@ export const PARAMS = [
|
|
|
399
399
|
flags: ["--confirm"],
|
|
400
400
|
description: 'send/broadcast: after the send, dump once more and report what it sees; paste again only on "not-delivered" (positive evidence nothing landed), press Enter when the body is still in the composer, and never paste the body twice',
|
|
401
401
|
},
|
|
402
|
+
{
|
|
403
|
+
name: "ifIdle",
|
|
404
|
+
type: "boolean",
|
|
405
|
+
flags: ["--if-idle"],
|
|
406
|
+
description: "send: refuse a pane that a relay has leased (pane_leased) or whose harness is visibly working (pane_busy) instead of pasting into it",
|
|
407
|
+
},
|
|
402
408
|
{
|
|
403
409
|
name: "expect",
|
|
404
410
|
type: "string",
|
package/dist/state.d.ts
CHANGED
|
@@ -29,6 +29,24 @@ export type BusMarkerRecord = {
|
|
|
29
29
|
configKey: string;
|
|
30
30
|
installedAt: number;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* One held pane lease: a relay is working this target until it ends. `key`
|
|
34
|
+
* names the target (serve endpoint or local, session, pane) so two processes
|
|
35
|
+
* racing to take it are serialized by the lease lock.
|
|
36
|
+
*/
|
|
37
|
+
export type LeaseRecord = {
|
|
38
|
+
key: string;
|
|
39
|
+
relayId: string;
|
|
40
|
+
from: string;
|
|
41
|
+
at: number;
|
|
42
|
+
/** The relay run directory; a missing/finished one makes the lease stale. */
|
|
43
|
+
dir: string;
|
|
44
|
+
serve?: string;
|
|
45
|
+
session?: string;
|
|
46
|
+
pane: string;
|
|
47
|
+
/** Relays that forced through while this lease was held. */
|
|
48
|
+
sharedBy?: string[];
|
|
49
|
+
};
|
|
32
50
|
/** How much of the end of the log `readLog` reads; older entries are not shown. */
|
|
33
51
|
export declare const LOG_TAIL_BYTES: number;
|
|
34
52
|
/**
|
|
@@ -55,6 +73,8 @@ export declare function createStateStore(options?: StateStoreOptions): {
|
|
|
55
73
|
readBus: (session: string) => BusMarkerRecord | null;
|
|
56
74
|
writeBus: (session: string, marker: BusMarkerRecord) => void;
|
|
57
75
|
clearBus: (session?: string) => void;
|
|
76
|
+
readLeases: () => Record<string, LeaseRecord>;
|
|
77
|
+
withLeases: <T>(fn: (leases: Record<string, LeaseRecord>) => T) => T;
|
|
58
78
|
reset: () => void;
|
|
59
79
|
};
|
|
60
80
|
export type StateStore = ReturnType<typeof createStateStore>;
|
package/dist/state.js
CHANGED
|
@@ -7,6 +7,8 @@ const SIGNALS_LOCK = "signals.lock";
|
|
|
7
7
|
const CURSORS_FILE = "cursors.json";
|
|
8
8
|
const CURSORS_LOCK = "cursors.lock";
|
|
9
9
|
const BUS_FILE = "bus.json";
|
|
10
|
+
const LEASES_FILE = "leases.json";
|
|
11
|
+
const LEASES_LOCK = "leases.lock";
|
|
10
12
|
/** How much of the end of the log `readLog` reads; older entries are not shown. */
|
|
11
13
|
export const LOG_TAIL_BYTES = 512 * 1024;
|
|
12
14
|
/**
|
|
@@ -462,6 +464,22 @@ export function createStateStore(options = {}) {
|
|
|
462
464
|
}
|
|
463
465
|
writeJson(BUS_FILE, { sessions });
|
|
464
466
|
}
|
|
467
|
+
function readLeases() {
|
|
468
|
+
return readJson(LEASES_FILE, {});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Atomic lease check-then-take/update. The callback mutates the map and its
|
|
472
|
+
* return value goes to the caller; the map is persisted while the state lock
|
|
473
|
+
* is held, so two processes racing to take one pane cannot both win.
|
|
474
|
+
*/
|
|
475
|
+
function withLeases(fn) {
|
|
476
|
+
return withFileLock(LEASES_LOCK, () => {
|
|
477
|
+
const leases = readJson(LEASES_FILE, {});
|
|
478
|
+
const result = fn(leases);
|
|
479
|
+
writeJson(LEASES_FILE, leases);
|
|
480
|
+
return result;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
465
483
|
/** Test helper: drop everything this store wrote. */
|
|
466
484
|
function reset() {
|
|
467
485
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -480,6 +498,8 @@ export function createStateStore(options = {}) {
|
|
|
480
498
|
readBus,
|
|
481
499
|
writeBus,
|
|
482
500
|
clearBus,
|
|
501
|
+
readLeases,
|
|
502
|
+
withLeases,
|
|
483
503
|
reset,
|
|
484
504
|
};
|
|
485
505
|
}
|
package/dist/zellij/args.d.ts
CHANGED
|
@@ -83,7 +83,7 @@ export declare function buildLaunchPluginArgs(input: LaunchPluginInput): string[
|
|
|
83
83
|
export declare function buildListPanesArgs(session: string): string[];
|
|
84
84
|
export declare function buildPasteArgs(session: string, paneId: string, text: string): string[];
|
|
85
85
|
export declare function buildSendEnterArgs(session: string, paneId: string): string[];
|
|
86
|
-
export declare function buildDumpArgs(session: string, paneId: string, full?: boolean): string[];
|
|
86
|
+
export declare function buildDumpArgs(session: string, paneId: string, full?: boolean, ansi?: boolean): string[];
|
|
87
87
|
export declare function buildSendKeysArgs(session: string, paneId: string, keys: string[]): string[];
|
|
88
88
|
export declare function buildWriteCharsArgs(session: string, paneId: string, chars: string): string[];
|
|
89
89
|
export declare function buildRenamePaneArgs(session: string, paneId: string, name: string): string[];
|
package/dist/zellij/args.js
CHANGED
|
@@ -122,7 +122,7 @@ export function buildSendEnterArgs(session, paneId) {
|
|
|
122
122
|
"Enter",
|
|
123
123
|
];
|
|
124
124
|
}
|
|
125
|
-
export function buildDumpArgs(session, paneId, full = false) {
|
|
125
|
+
export function buildDumpArgs(session, paneId, full = false, ansi = false) {
|
|
126
126
|
const args = [
|
|
127
127
|
...sessionPrefix(session),
|
|
128
128
|
"action",
|
|
@@ -132,6 +132,8 @@ export function buildDumpArgs(session, paneId, full = false) {
|
|
|
132
132
|
];
|
|
133
133
|
if (full)
|
|
134
134
|
args.push("--full");
|
|
135
|
+
if (ansi)
|
|
136
|
+
args.push("--ansi");
|
|
135
137
|
return args;
|
|
136
138
|
}
|
|
137
139
|
export function buildSendKeysArgs(session, paneId, keys) {
|
package/dist/zellij/bus.js
CHANGED
package/dist/zellij/client.d.ts
CHANGED
package/dist/zellij/client.js
CHANGED
|
@@ -209,7 +209,7 @@ export function createZellijClient(options = {}) {
|
|
|
209
209
|
}
|
|
210
210
|
async function dumpPane(input) {
|
|
211
211
|
const paneId = normalizePaneId(input.paneId);
|
|
212
|
-
const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen", input.timeoutMs ?? timeoutMs);
|
|
212
|
+
const result = await run(buildDumpArgs(input.session, paneId, input.full, input.ansi), "zellij action dump-screen", input.timeoutMs ?? timeoutMs);
|
|
213
213
|
return { paneId, session: input.session, text: result.stdout };
|
|
214
214
|
}
|
|
215
215
|
async function sendKeys(input) {
|
package/dist/zellij/panes.d.ts
CHANGED
package/dist/zellij/panes.js
CHANGED
|
@@ -32,6 +32,9 @@ function parsePaneRow(row) {
|
|
|
32
32
|
focused: row.is_focused === true,
|
|
33
33
|
exited: row.exited === true,
|
|
34
34
|
floating: row.is_floating === true,
|
|
35
|
+
contentColumns: typeof row.pane_content_columns === "number"
|
|
36
|
+
? row.pane_content_columns
|
|
37
|
+
: null,
|
|
35
38
|
};
|
|
36
39
|
}
|
|
37
40
|
/** Parse `list-panes --json` output. */
|