@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.
@@ -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 call = input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
398
- const remote = serveEnv(cfg, env);
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
- warning = "--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";
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(() => scrubRelayToken(dir));
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 };
@@ -2,9 +2,12 @@
2
2
  * Background services for host commands (serve, crew): systemd user units
3
3
  * on Linux (with linger, so they run without a login), launchd on macOS (a
4
4
  * LaunchAgent for the current user, or, from root with `runAs`, a
5
- * LaunchDaemon that runs as that user, for accounts that never log in).
6
- * Secrets never go into unit files or plists: services read them from
7
- * mode-600 files named in their environment.
5
+ * LaunchDaemon that runs as that user, for accounts that never log in). On
6
+ * Windows there is no systemd or launchd here: `serviceAccount` returns the
7
+ * signed-in user (`platform: "win32"`, uid/gid 0) so crew can install a
8
+ * scheduled task instead, and `--run-as` is refused. Secrets never go into
9
+ * unit files or plists: services read them from mode-600 files named in
10
+ * their environment.
8
11
  */
9
12
  export type ServiceKind =
10
13
  /** Long-running; restarted when it exits. */
@@ -31,7 +34,7 @@ export type ServiceSpec = {
31
34
  workingDir?: string;
32
35
  };
33
36
  export type ServiceAccount = {
34
- platform: "linux" | "darwin";
37
+ platform: "linux" | "darwin" | "win32";
35
38
  user: string;
36
39
  uid: number;
37
40
  gid: number;
@@ -1,10 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir, userInfo } from "node:os";
4
- import { posix } from "node:path";
4
+ import { dirname, posix } from "node:path";
5
5
  import { ZellijError } from "../errors.js";
6
- // Service accounts are Linux or macOS accounts: their paths are POSIX paths
7
- // even when this code runs (in tests) on Windows.
6
+ // Linux and macOS accounts carry POSIX paths even when this code runs (in
7
+ // tests) on Windows; a Windows crew account carries native Windows paths.
8
8
  const { join } = posix;
9
9
  const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
10
10
  export const defaultRunner = (cmd, args) => {
@@ -22,6 +22,12 @@ function hostSleep(host, ms) {
22
22
  /** The account services are installed for: the current user, or `runAs` when root. */
23
23
  export function serviceAccount(runAs, run = defaultRunner) {
24
24
  const platform = process.platform;
25
+ if (platform === "win32") {
26
+ if (runAs?.trim()) {
27
+ throw new ZellijError("usage", "--run-as is Linux and macOS only; a Windows crew task runs as the signed-in user");
28
+ }
29
+ return { platform: "win32", user: userInfo().username, uid: 0, gid: 0, home: process.env.USERPROFILE || homedir(), foreign: false };
30
+ }
25
31
  if (platform !== "linux" && platform !== "darwin") {
26
32
  throw new ZellijError("usage", `host services are for Linux and macOS (this is ${platform}); on Windows use serve --install`);
27
33
  }
@@ -72,6 +78,10 @@ function runAsAccount(account, argv, input) {
72
78
  }
73
79
  /** Write a file in the account's home as the account (see accountArgv). */
74
80
  export function writeAccountFile(account, path, text, mode) {
81
+ if (account.platform === "win32") {
82
+ writeFileSync(path, text);
83
+ return;
84
+ }
75
85
  if (!installingFor(account)) {
76
86
  writeFileSync(path, text, { mode });
77
87
  chmodSync(path, mode);
@@ -416,6 +426,10 @@ function must(res, what) {
416
426
  }
417
427
  /** Create dir and any missing parents, owned by the account when installing for it. */
418
428
  export function ensureAccountDir(account, dir, mode = 0o700) {
429
+ if (account.platform === "win32") {
430
+ mkdirSync(dir, { recursive: true });
431
+ return;
432
+ }
419
433
  const missing = [];
420
434
  for (let d = dir; d && d !== "/" && !existsSync(d); d = d.slice(0, d.lastIndexOf("/")) || "/")
421
435
  missing.unshift(d);
@@ -428,7 +442,7 @@ export function ensureAccountDir(account, dir, mode = 0o700) {
428
442
  }
429
443
  /** Write a mode-600 file for the account, creating its directory (700) as needed. */
430
444
  export function writeSecretFile(account, path, text) {
431
- ensureAccountDir(account, path.slice(0, path.lastIndexOf("/")));
445
+ ensureAccountDir(account, dirname(path));
432
446
  writeAccountFile(account, path, text, 0o600);
433
447
  }
434
448
  export function readSecretFile(path) {
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";
@@ -33,6 +34,6 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
33
34
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
34
35
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
35
36
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
36
- export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
37
+ export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
37
38
  export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
38
39
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.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";
@@ -31,6 +32,6 @@ export { HOST_COMMANDS, hostUsage, parseHostArgs, runHostCommand } from "./host/
31
32
  export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
32
33
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
33
34
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
34
- export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
35
+ export { crewCheck, crewClient, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
35
36
  export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
36
37
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
@@ -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: isTrue(args.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(dumped.text, max, keep);
503
+ const clipped = truncateDumpText(text, max, keep);
440
504
  return {
441
505
  ok: true,
442
506
  data: {
@@ -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
  }
@@ -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[];
@@ -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) {
@@ -295,6 +295,7 @@ export function busToPanes(snapshot) {
295
295
  focused: pane.focused,
296
296
  exited: pane.exited,
297
297
  floating: false,
298
+ contentColumns: null,
298
299
  };
299
300
  });
300
301
  }
@@ -59,6 +59,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
59
59
  session: string;
60
60
  paneId: string;
61
61
  full?: boolean;
62
+ ansi?: boolean;
62
63
  timeoutMs?: number;
63
64
  }) => Promise<{
64
65
  paneId: string;
@@ -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) {
@@ -10,6 +10,7 @@ export type ZellijPane = {
10
10
  focused: boolean;
11
11
  exited: boolean;
12
12
  floating: boolean;
13
+ contentColumns: number | null;
13
14
  };
14
15
  export declare function normalizePaneId(raw: string, isPlugin?: boolean): string;
15
16
  /** Parse `list-panes --json` output. */
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {