@zswarm/core 0.1.6 → 0.1.8

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/dist/ops/spawn.js CHANGED
@@ -27,7 +27,7 @@ export async function spawnPane(client, args, git, clock = { now: Date.now, slee
27
27
  : null;
28
28
  const cwd = worktree ? worktree.path : optionalString(args.cwd);
29
29
  const name = optionalString(args.name) ?? worktree?.branch ?? null;
30
- const beforePanes = await client.listPanes(session, budget.require());
30
+ const beforePanes = await client.listPanes(session, budget.require(), { fresh: true });
31
31
  const before = new Set(beforePanes.map((p) => p.id));
32
32
  let paneId = null;
33
33
  let tabId = null;
@@ -49,7 +49,7 @@ export async function spawnPane(client, args, git, clock = { now: Date.now, slee
49
49
  tabId = args.tabId;
50
50
  }
51
51
  else if (tabName) {
52
- tabId = client.resolveTab(await client.listTabs(session, budget.require()), tabName).id;
52
+ tabId = client.resolveTab(await client.listTabs(session, budget.require(), { fresh: true }), tabName).id;
53
53
  }
54
54
  else {
55
55
  // Address a tab explicitly even when no viewer is attached.
@@ -58,7 +58,7 @@ export async function spawnPane(client, args, git, clock = { now: Date.now, slee
58
58
  tabId = focused?.tabId ?? first?.tabId ?? null;
59
59
  tabSource = focused ? "focused-pane" : "first-tab";
60
60
  if (tabId === null) {
61
- const tabs = await client.listTabs(session, budget.require());
61
+ const tabs = await client.listTabs(session, budget.require(), { fresh: true });
62
62
  tabId = [...tabs].sort((a, b) => a.position - b.position)[0]?.id ?? null;
63
63
  }
64
64
  if (tabId === null)
@@ -83,7 +83,7 @@ export async function spawnPane(client, args, git, clock = { now: Date.now, slee
83
83
  firstRead = false;
84
84
  try {
85
85
  const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, observedUntil - clock.now()));
86
- const panes = await client.listPanes(session, left);
86
+ const panes = await client.listPanes(session, left, { fresh: true });
87
87
  throwIfAborted(signal);
88
88
  if (paneId) {
89
89
  pane = panes.find((p) => p.id === paneId && !p.isPlugin) ?? null;
@@ -163,9 +163,20 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
163
163
  }
164
164
  const sampleMs = Math.max(50, requested);
165
165
  const live = targets.filter((pane) => !pane.exited);
166
- if (isTrue(args.sinceLast) && supplied?.readChanged) {
166
+ // Explicit sampling retains its interval semantics. Ordinary status prefers
167
+ // one bus observation, including a single-pane crew, with no sample sleep.
168
+ const preferChanges = isTrue(args.sinceLast) || (args.sinceLast === undefined && args.sampleMs === undefined);
169
+ if (preferChanges && supplied?.readChanged) {
167
170
  const left = remaining();
168
- const changed = left > 0 ? await supplied.readChanged(live.map((p) => p.id), left) : null;
171
+ let changed = null;
172
+ try {
173
+ changed = live.length === 0 ? new Map() : left > 0 ? await supplied.readChanged(live.map((p) => p.id), left) : null;
174
+ }
175
+ catch (err) {
176
+ if (isCancelledError(err) || signal?.aborted)
177
+ throw new ZellijError("cancelled", "operation cancelled");
178
+ // A missing/older bus falls back to bounded screen samples below.
179
+ }
169
180
  throwIfAborted(signal);
170
181
  if (changed) {
171
182
  const peers = targets
@@ -174,13 +185,9 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
174
185
  return peerEntry(pane, "exited", "", verbose);
175
186
  }
176
187
  const row = changed.get(pane.id);
177
- const state = !row
178
- ? "unknown"
179
- : row.changed
180
- ? "busy"
181
- : promptHolds(row?.screen ?? "", resolveHarness(pane))
182
- ? "waiting"
183
- : "idle";
188
+ const state = !row ? "unknown"
189
+ : promptHolds(row.screen, resolveHarness(pane)) ? "waiting"
190
+ : row.first ? "unknown" : row.changed ? "busy" : "idle";
184
191
  return peerEntry(pane, state, row?.screen ?? "", verbose, {
185
192
  ...(row?.first ? { first: true } : {}),
186
193
  });
@@ -193,6 +200,7 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
193
200
  source,
194
201
  sampled: false,
195
202
  sinceLast: true,
203
+ observation: "bus-changes",
196
204
  peers,
197
205
  tabs: statusTabs(peers),
198
206
  free: peers.filter((p) => p.state === "idle").map((p) => p.id),
@@ -321,6 +329,7 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
321
329
  session,
322
330
  source,
323
331
  sampled: true,
332
+ observation: "samples",
324
333
  sampleMs,
325
334
  peers,
326
335
  tabs: statusTabs(peers),
@@ -1,4 +1,5 @@
1
1
  import type { RoutingContext } from "./routing.js";
2
+ import type { ServeTunnelManager } from "./serve-tunnel.js";
2
3
  export type OpsResult = ({
3
4
  ok: true;
4
5
  data: unknown;
@@ -25,6 +26,54 @@ export type DispatchDeps = {
25
26
  env?: NodeJS.ProcessEnv;
26
27
  /** MCP cancellation; aborted waits stop instead of running to timeout. */
27
28
  signal?: AbortSignal;
29
+ /** Process-owned ssh:// LocalForward manager. CLI disposes; MCP reuses. */
30
+ serveTunnels?: ServeTunnelManager;
31
+ /**
32
+ * Test seam for optional Tailscale `status --json`. Default runs the
33
+ * `tailscale` CLI with a bounded timeout and output cap.
34
+ * Used by doctor (peer mapping) and by serve bind verification.
35
+ */
36
+ tailscaleStatus?: (input: {
37
+ timeoutMs: number;
38
+ signal?: AbortSignal;
39
+ env: NodeJS.ProcessEnv;
40
+ }) => Promise<{
41
+ code: number;
42
+ stdout: string;
43
+ stderr: string;
44
+ }>;
45
+ /** Test seam for OS interface address ownership during Tailscale serve bind. */
46
+ networkInterfaces?: () => NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>;
47
+ /** Narrow injectables for Windows `serve --install` / `--clear`. */
48
+ serveInstall?: ServeInstallDeps;
49
+ };
50
+ export type ServePowerShellResult = {
51
+ code: number;
52
+ stdout: string;
53
+ stderr: string;
54
+ aborted?: boolean;
55
+ timedOut?: boolean;
56
+ };
57
+ export type ServeInstallDeps = {
58
+ platform?: NodeJS.Platform;
59
+ runPowerShell?: (script: string, options: {
60
+ timeoutMs: number;
61
+ signal?: AbortSignal;
62
+ }) => Promise<ServePowerShellResult>;
63
+ probeServe?: (target: string, options?: {
64
+ token?: string;
65
+ timeoutMs?: number;
66
+ signal?: AbortSignal;
67
+ }) => Promise<OpsResult>;
68
+ callServe?: (target: string, args: Record<string, unknown>, options: {
69
+ timeoutMs: number;
70
+ token?: string;
71
+ signal?: AbortSignal;
72
+ }) => Promise<OpsResult>;
73
+ execPath?: string;
74
+ scriptPath?: string;
75
+ argv?: string[];
76
+ launchId?: string;
28
77
  };
29
78
  export type Clock = {
30
79
  now: () => number;
package/dist/ops/util.js CHANGED
@@ -5,7 +5,14 @@ export const DEFAULT_DUMP_MAX_CHARS = 8_000;
5
5
  export const DEFAULT_WAIT_MAX_CHARS = 2_000;
6
6
  export function fail(err) {
7
7
  if (err instanceof ZellijError) {
8
- return { ok: false, error: { code: err.code, message: err.message } };
8
+ return {
9
+ ok: false,
10
+ error: {
11
+ code: err.code,
12
+ message: err.message,
13
+ ...(err.details ? { details: err.details } : {}),
14
+ },
15
+ };
9
16
  }
10
17
  const message = err instanceof Error ? err.message : String(err);
11
18
  return { ok: false, error: { code: "failed", message } };
@@ -62,7 +62,7 @@ export async function listPeerWorktrees(git, client, args) {
62
62
  let panes = [];
63
63
  try {
64
64
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
65
- panes = await client.listPanes(session);
65
+ panes = await client.listPanes(session, undefined, { fresh: true });
66
66
  }
67
67
  catch {
68
68
  // Worktrees are useful to list even with no live Zellij session.
@@ -118,7 +118,7 @@ export async function removePeerWorktree(git, client, args, env = process.env) {
118
118
  let occupants = [];
119
119
  try {
120
120
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
121
- occupants = panesIn(await client.listPanes(session), target.path);
121
+ occupants = panesIn(await client.listPanes(session, undefined, { fresh: true }), target.path);
122
122
  }
123
123
  catch (err) {
124
124
  // No live session means nobody is working in it. Any other failure must
package/dist/schema.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * One description of the zswarm surface. The MCP tool schema, the CLI flags,
3
3
  * and the CLI help text are all generated from it, so they cannot drift apart.
4
4
  */
5
- export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve"];
5
+ export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve", "doctor"];
6
6
  export type OpName = (typeof OP_NAMES)[number];
7
7
  /** Ops that address an existing pane through `to`. */
8
8
  export declare const TARGET_OPS: readonly OpName[];
package/dist/schema.js CHANGED
@@ -31,6 +31,7 @@ export const OP_NAMES = [
31
31
  "checkpoint",
32
32
  "bus",
33
33
  "serve",
34
+ "doctor",
34
35
  ];
35
36
  /** Ops that address an existing pane through `to`. */
36
37
  export const TARGET_OPS = [
@@ -51,7 +52,7 @@ export const PARAMS = [
51
52
  name: "session",
52
53
  type: "string",
53
54
  flags: ["--session", "-s"],
54
- description: "Zellij session name (optional if sole live session or ZSWARM_SESSION / ZELLIJ_SESSION_NAME)",
55
+ description: "Zellij session name (optional if sole live session or ZSWARM_SESSION / ZELLIJ_SESSION_NAME). serve --install: readiness target only — does not change the task's default routing",
55
56
  },
56
57
  {
57
58
  name: "to",
@@ -111,13 +112,13 @@ export const PARAMS = [
111
112
  name: "clear",
112
113
  type: "boolean",
113
114
  flags: ["--clear"],
114
- description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: unregister the Windows logon task",
115
+ description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: stop and unregister the owned Windows zswarm-serve logon task if present",
115
116
  },
116
117
  {
117
118
  name: "install",
118
119
  type: "boolean",
119
120
  flags: ["--install"],
120
- description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register a Windows logon task that listens for remote zswarm",
121
+ description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register the current-user Windows Interactive logon task and wait for authenticated hello plus host session visibility",
121
122
  },
122
123
  {
123
124
  name: "reset",
@@ -129,13 +130,25 @@ export const PARAMS = [
129
130
  name: "sampleMs",
130
131
  type: "number",
131
132
  flags: ["--sample-ms"],
132
- description: "status: gap between the two screen samples (default 400); 0 skips sampling and reports running/exited only",
133
+ description: "status: explicitly sample twice with this gap (fallback default 400); 0 reports running/exited only; default prefers bus changes",
133
134
  },
134
135
  {
135
136
  name: "sinceLast",
136
137
  type: "boolean",
137
138
  flags: ["--since-last"],
138
- description: "status: classify by what changed since the last status call instead of sampling twice; needs the event bus, no sample gap",
139
+ description: "status: classify changes since the previous bus observation (default when bus available); first observation is unknown unless a prompt is recognized",
140
+ },
141
+ {
142
+ name: "fresh",
143
+ type: "boolean",
144
+ flags: ["--fresh"],
145
+ description: "bypass the short-lived session/pane/tab listing cache for this call",
146
+ },
147
+ {
148
+ name: "serveAddress",
149
+ type: "string",
150
+ flags: ["--serve"],
151
+ description: "existing serve endpoint: host:port, tcp://host:port, or ssh://user@host[:sshPort]?servePort=9419. ssh:// opens a process-owned SSH LocalForward and probes hello before ops (desktop serve must already be running). Omit sshPort to use ssh_config Port. tcp:// also names a private Tailscale Serve frontend over loopback (docs/tailscale.md). Uses ZSWARM_SERVE_TOKEN",
139
152
  },
140
153
  {
141
154
  name: "limit",
@@ -245,7 +258,7 @@ export const PARAMS = [
245
258
  name: "timeoutMs",
246
259
  type: "number",
247
260
  flags: ["--timeout-ms"],
248
- description: "wait: timeout (default 60000); status/spawn: overall deadline (default 30000), including setup and observation",
261
+ description: "wait: timeout (default 60000); status/spawn: overall deadline (default 30000); doctor: overall deadline (default 10000), including Tailscale/SSH/hello/host checks; serve --install: overall install/readiness deadline (default 30000)",
249
262
  },
250
263
  {
251
264
  name: "keys",
@@ -422,7 +435,7 @@ export const PARAMS = [
422
435
  name: "listen",
423
436
  type: "string",
424
437
  flags: ["--listen"],
425
- description: "serve: bind address (default 127.0.0.1:9419). Reach it from another machine with ZSWARM_SERVE after an SSH tunnel",
438
+ description: "serve: bind address (default 127.0.0.1:9419). Loopback needs no Tailscale; a non-loopback literal must be a verified local Tailscale IP (see docs/tailscale.md). Reach via ZSWARM_SERVE / --serve (direct host:port, private Tailscale Serve tcp:// frontend, or ssh:// to remote loopback)",
426
439
  },
427
440
  {
428
441
  name: "verbose",
@@ -483,7 +496,7 @@ export function cliUsage() {
483
496
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
484
497
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
485
498
  }
486
- lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE (+ ZSWARM_SERVE_TOKEN). Serve binds loopback only and always requires a token.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN", "");
499
+ lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
487
500
  return lines.join("\n");
488
501
  }
489
502
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
package/dist/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const LOG_FILE = "log.jsonl";
@@ -9,9 +9,13 @@ const CURSORS_LOCK = "cursors.lock";
9
9
  const BUS_FILE = "bus.json";
10
10
  /** Keeps the log bounded without needing a rotation daemon. */
11
11
  const LOG_TAIL_BYTES = 512 * 1024;
12
+ /** Bound for waiting on a *live* lock holder before failing. */
12
13
  const LOCK_WAIT_MS = 5_000;
13
- /** A live pid older than this is treated as a recycle of a crashed holder. */
14
- const LOCK_STALE_MS = 30_000;
14
+ /**
15
+ * Fresh empty/malformed lock files this young are treated as an in-flight
16
+ * exclusive create (wait), not as abandoned debris (refuse).
17
+ */
18
+ const LOCK_PENDING_MS = LOCK_WAIT_MS;
15
19
  function sleepSync(ms) {
16
20
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
17
21
  }
@@ -103,32 +107,6 @@ export function createStateStore(options = {}) {
103
107
  return null;
104
108
  }
105
109
  }
106
- /**
107
- * Dead pid → steal now. This process's own leftover (unlink failed, or a
108
- * non-reentrant re-entry) → steal now. A live pid whose `at` is older than
109
- * LOCK_STALE_MS is a recycled pid, not a holder still inside fn().
110
- * Empty leftover from older writers (wx with no owner bytes) → steal once
111
- * mtime is older than the wait, so an in-flight create is not yanked out
112
- * from under the holder.
113
- * Owner is read once: a second read can see a pid that appeared after an
114
- * empty snapshot and would steal a live lock.
115
- */
116
- function lockIsStale(lockPath) {
117
- const owner = readLockOwner(lockPath);
118
- if (owner) {
119
- if (owner.pid === process.pid)
120
- return true;
121
- if (!pidAlive(owner.pid))
122
- return true;
123
- return Date.now() - owner.at >= LOCK_STALE_MS;
124
- }
125
- try {
126
- return Date.now() - statSync(lockPath).mtimeMs >= LOCK_WAIT_MS;
127
- }
128
- catch {
129
- return false;
130
- }
131
- }
132
110
  function lockBusy(code) {
133
111
  // Unix: O_EXCL on an existing file is EEXIST. Windows: a holder that still
134
112
  // has the handle open (or a delete-pending name) is EPERM / EACCES / EBUSY.
@@ -137,43 +115,185 @@ export function createStateStore(options = {}) {
137
115
  code === "EACCES" ||
138
116
  code === "EBUSY");
139
117
  }
140
- function unlinkLock(lockPath) {
118
+ /**
119
+ * Cursor/signal locks: exclusive create (`wx`) + owner stamp; release by
120
+ * generation-matched unlink of the stamp this process published.
121
+ *
122
+ * Foreign / abandoned / empty-old lock files are NOT auto-reclaimed.
123
+ * Portable check-then-rename/unlink reclaim can move a live holder's shared
124
+ * name aside and admit another writer into that critical section (demonstrated
125
+ * with real cooperating writeCursor processes). Prefer bounded fail-closed
126
+ * refusal over unsafe automatic recovery.
127
+ *
128
+ * A dead-looking owner observation is only refused when that same generation
129
+ * is still present. If normal release removed the path or another writer
130
+ * published a new generation between observation and the liveness check,
131
+ * contenders retry exclusive create within the original LOCK_WAIT_MS budget
132
+ * (generation churn does not reset the deadline).
133
+ *
134
+ * Self-pid leftovers (same process, prior unlink failed) may be removed when
135
+ * the on-disk generation still matches — this process is not inside `fn()`.
136
+ *
137
+ * Operator recovery: if acquisition refuses an abandoned lock, confirm the
138
+ * recorded pid is gone and no writer holds the file, then remove the lock
139
+ * path manually and retry.
140
+ */
141
+ function unlinkIfMatchingOwner(lockPath, expected) {
141
142
  const until = Date.now() + 500;
142
143
  while (true) {
144
+ const current = readLockOwner(lockPath);
145
+ if (!current ||
146
+ current.pid !== expected.pid ||
147
+ current.at !== expected.at) {
148
+ return false;
149
+ }
143
150
  try {
144
151
  rmSync(lockPath, { force: true });
145
- return;
152
+ return true;
146
153
  }
147
154
  catch (err) {
148
155
  if (!lockBusy(err.code) || Date.now() >= until) {
149
- return;
156
+ return false;
150
157
  }
151
158
  sleepSync(10);
152
159
  }
153
160
  }
154
161
  }
162
+ /** Only this process may clear its own leftover generation. */
163
+ function tryReclaimSelfLock(lockPath) {
164
+ const owner = readLockOwner(lockPath);
165
+ if (!owner || owner.pid !== process.pid)
166
+ return false;
167
+ return unlinkIfMatchingOwner(lockPath, owner);
168
+ }
169
+ function abandonedLockError(lockName, lockPath, observed) {
170
+ // Use the generation that justified refusal — do not re-read the path and
171
+ // accidentally describe a live successor as needing operator recovery.
172
+ if (observed) {
173
+ return new Error(`refusing automatic reclaim of ${lockName} (dead-or-abandoned owner pid=${observed.pid} at=${observed.at}); ` +
174
+ `remove ${lockPath} only after confirming that process is gone and no writer holds the file, then retry`);
175
+ }
176
+ return new Error(`refusing automatic reclaim of ${lockName} (empty or malformed lock without a safe owner record); ` +
177
+ `remove ${lockPath} only when no writer is using it, then retry`);
178
+ }
179
+ /**
180
+ * Classify a blocking lock against the *current* generation.
181
+ *
182
+ * `wait` = live holder or fresh in-flight create.
183
+ * `refuse` = unchanged abandoned foreign/empty debris — fail closed.
184
+ * `self` = reclaimable same-pid leftover.
185
+ * `retry` = observation went stale during the check (normal release removed
186
+ * the path, or a new generation superseded the departed owner). Contender
187
+ * must retry exclusive create within the original wait budget — not refuse
188
+ * a lock that is no longer abandoned, and not unlink/rename anything.
189
+ */
190
+ function classifyBlockingLock(lockPath) {
191
+ const owner = readLockOwner(lockPath);
192
+ if (owner) {
193
+ if (owner.pid === process.pid)
194
+ return { kind: "self", observed: owner };
195
+ if (pidAlive(owner.pid))
196
+ return { kind: "wait", observed: owner };
197
+ // Dead-looking foreign owner: only refuse if THIS generation is still
198
+ // current. Normal release+exit between read and liveness check leaves
199
+ // the path absent or replaced by a live successor — that is not
200
+ // abandoned debris.
201
+ const still = readLockOwner(lockPath);
202
+ if (!still)
203
+ return { kind: "retry", observed: null };
204
+ if (still.pid !== owner.pid || still.at !== owner.at) {
205
+ return { kind: "retry", observed: still };
206
+ }
207
+ return { kind: "refuse", observed: owner };
208
+ }
209
+ try {
210
+ if (Date.now() - statSync(lockPath).mtimeMs < LOCK_PENDING_MS) {
211
+ return { kind: "wait", observed: null };
212
+ }
213
+ }
214
+ catch {
215
+ // Path disappeared between the busy create and this check.
216
+ return { kind: "retry", observed: null };
217
+ }
218
+ // Old empty/malformed: confirm it is still present and still ownerless
219
+ // before refusing — a successor may have published while we inspected.
220
+ const again = readLockOwner(lockPath);
221
+ if (again) {
222
+ if (again.pid === process.pid)
223
+ return { kind: "self", observed: again };
224
+ if (pidAlive(again.pid))
225
+ return { kind: "wait", observed: again };
226
+ const confirm = readLockOwner(lockPath);
227
+ if (!confirm)
228
+ return { kind: "retry", observed: null };
229
+ if (confirm.pid !== again.pid || confirm.at !== again.at) {
230
+ return { kind: "retry", observed: confirm };
231
+ }
232
+ return { kind: "refuse", observed: again };
233
+ }
234
+ try {
235
+ statSync(lockPath);
236
+ }
237
+ catch {
238
+ return { kind: "retry", observed: null };
239
+ }
240
+ return { kind: "refuse", observed: null };
241
+ }
242
+ /**
243
+ * Drop this process's lock only when the generation we published is still at
244
+ * the well-known path. Under fail-closed foreign reclaim, generations are not
245
+ * stolen while we hold the critical section, so a matching unlink cannot
246
+ * remove a successor published by another cooperating writer.
247
+ */
248
+ function unlinkOwnedLock(lockPath, stamp) {
249
+ unlinkIfMatchingOwner(lockPath, stamp);
250
+ }
155
251
  function withFileLock(lockName, fn) {
156
252
  ensureDir();
157
253
  const lockPath = join(dir, lockName);
158
254
  const deadline = Date.now() + LOCK_WAIT_MS;
159
255
  while (true) {
256
+ const stamp = { pid: process.pid, at: Date.now() };
160
257
  try {
161
- const fd = openSync(lockPath, "wx");
258
+ // Exclusive create (`wx`) plus a write of the owner record. That is
259
+ // not one atomic publish of populated bytes — create and write still
260
+ // have an interval. The UTF-8 fast path keeps that interval in one
261
+ // native writeFileSync rather than two JS turns (openSync then write).
262
+ // Abandoned foreign/empty locks are refused (not renamed or unlinked).
263
+ writeFileSync(lockPath, JSON.stringify(stamp), {
264
+ encoding: "utf8",
265
+ flag: "wx",
266
+ });
162
267
  try {
163
- writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
164
268
  return fn();
165
269
  }
166
270
  finally {
167
- closeSync(fd);
168
- unlinkLock(lockPath);
271
+ unlinkOwnedLock(lockPath, stamp);
169
272
  }
170
273
  }
171
274
  catch (err) {
172
275
  const code = err.code;
173
276
  if (!lockBusy(code))
174
277
  throw err;
175
- if (lockIsStale(lockPath)) {
176
- unlinkLock(lockPath);
278
+ if (tryReclaimSelfLock(lockPath)) {
279
+ continue;
280
+ }
281
+ const { kind, observed } = classifyBlockingLock(lockPath);
282
+ if (kind === "self") {
283
+ if (tryReclaimSelfLock(lockPath))
284
+ continue;
285
+ }
286
+ if (kind === "refuse") {
287
+ throw abandonedLockError(lockName, lockPath, observed);
288
+ }
289
+ if (kind === "retry") {
290
+ // Observation changed (released or superseded). Retry exclusive
291
+ // create within the original budget — do not reset the deadline on
292
+ // generation churn (unbounded handoffs become a timeout).
293
+ if (Date.now() >= deadline) {
294
+ throw new Error(`timed out waiting for ${lockName}`);
295
+ }
296
+ sleepSync(10);
177
297
  continue;
178
298
  }
179
299
  if (Date.now() >= deadline) {
@@ -1,9 +1,16 @@
1
+ import { ZellijError } from "../errors.js";
1
2
  import { createSshExec, NOT_FOUND_EXIT, type ExecFn, type ExecResult, type SshTarget } from "../exec.js";
2
3
  export { createSshExec, type SshTarget };
3
4
  export type ZellijExecResult = ExecResult;
4
5
  export type ZellijExecFn = ExecFn;
6
+ /** Where a missing/wrong-Zellij failure was produced. */
7
+ export type ZellijFailureOrigin = "local_spawn" | "local_preflight" | "remote";
5
8
  export declare const DEFAULT_TIMEOUT_MS = 15000;
6
9
  export { NOT_FOUND_EXIT };
10
+ /** Local Node spawn failure vs a process that actually started. */
11
+ export declare function originFromExecResult(result: ExecResult): Exclude<ZellijFailureOrigin, "local_preflight">;
12
+ export declare function zellijExecDetails(result: ExecResult): Record<string, unknown>;
13
+ export declare function zellijMissingError(zellijPath: string, result: ExecResult): ZellijError;
7
14
  /** Expand a leading `~/` or `~\` using USERPROFILE/HOME. */
8
15
  export declare function expandHomePath(input: string, env?: NodeJS.ProcessEnv): string;
9
16
  /**
@@ -30,14 +37,27 @@ export declare function identityCacheKey(zellijPath: string, ssh?: {
30
37
  options: string[];
31
38
  mode?: string;
32
39
  remoteBin?: string;
40
+ /** SSH client binary (`SshTarget.ssh`). */
41
+ ssh?: string;
42
+ sshBin?: string;
43
+ remoteShell?: string;
33
44
  } | null): string;
34
45
  /**
35
46
  * Confirm the resolved binary is Zellij. Only verified identities are cached.
36
47
  * Transport timeouts leave the cache empty so a later call can retry.
37
48
  * Returns false when verification is unresolved; only true is cached.
49
+ * In-flight probes are never shared — each caller supplies its own timeout.
38
50
  */
39
- export declare function ensureZellijIdentity(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string): Promise<boolean>;
40
- export declare function ensureZellijCapabilities(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string): Promise<boolean>;
51
+ export declare function ensureZellijIdentity(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string, signal?: AbortSignal): Promise<boolean>;
52
+ export declare function ensureZellijCapabilities(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string, signal?: AbortSignal): Promise<boolean>;
53
+ /**
54
+ * Identity and capability are independent reads. Run them together under one
55
+ * remaining budget; do not share in-flight work with another caller.
56
+ */
57
+ export declare function ensureZellijProbes(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string, signal?: AbortSignal): Promise<{
58
+ identity: boolean;
59
+ capabilities: boolean;
60
+ }>;
41
61
  /** Test helper: drop cached identity/capability probes. */
42
62
  export declare function resetZellijIdentityCache(): void;
43
63
  export declare function resolveZellijBinary(env?: NodeJS.ProcessEnv): string;