@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.
@@ -7,6 +7,19 @@ import { applyIpcTmpEnv } from "./ipc.js";
7
7
  export { createSshExec };
8
8
  export const DEFAULT_TIMEOUT_MS = 15_000;
9
9
  export { NOT_FOUND_EXIT };
10
+ /** Local Node spawn failure vs a process that actually started. */
11
+ export function originFromExecResult(result) {
12
+ return result.spawn?.origin === "local" ? "local_spawn" : "remote";
13
+ }
14
+ export function zellijExecDetails(result) {
15
+ const origin = originFromExecResult(result);
16
+ return result.spawn
17
+ ? { origin, spawn: { origin: result.spawn.origin, errno: result.spawn.errno, bin: result.spawn.bin } }
18
+ : { origin };
19
+ }
20
+ export function zellijMissingError(zellijPath, result) {
21
+ return new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`, zellijExecDetails(result));
22
+ }
10
23
  /** Expand a leading `~/` or `~\` using USERPROFILE/HOME. */
11
24
  export function expandHomePath(input, env = process.env) {
12
25
  const trimmed = input.trim();
@@ -51,7 +64,7 @@ export function looksLikeZswarmBinary(path) {
51
64
  export function assertZellijBinaryPath(path) {
52
65
  if (!looksLikeZswarmBinary(path))
53
66
  return;
54
- throw new ZellijError("zellij_wrong_bin", `ZSWARM_BIN/ZSWARM_PATH points at zswarm (${path}), not Zellij. Set it to the zellij binary (e.g. ~/.local/bin/zellij)`);
67
+ throw new ZellijError("zellij_wrong_bin", `ZSWARM_BIN/ZSWARM_PATH points at zswarm (${path}), not Zellij. Set it to the zellij binary (e.g. ~/.local/bin/zellij)`, { origin: "local_preflight", bin: path });
55
68
  }
56
69
  /**
57
70
  * ZSWARM_SSH is a destination, not a full ssh argv.
@@ -98,40 +111,48 @@ export function isZellijVersionOutput(stdout, stderr = "") {
98
111
  */
99
112
  export function identityCacheKey(zellijPath, ssh) {
100
113
  if (!ssh)
101
- return zellijPath;
102
- return [
114
+ return JSON.stringify([zellijPath]);
115
+ return JSON.stringify([
103
116
  zellijPath,
104
117
  ssh.host,
105
118
  ssh.remoteBin ?? "",
106
119
  ssh.mode ?? "ssh",
107
- ssh.options.join("\0"),
108
- ].join("|");
120
+ ssh.options,
121
+ ssh.sshBin ?? ssh.ssh ?? "",
122
+ ssh.remoteShell ?? "",
123
+ ]);
109
124
  }
110
125
  /**
111
126
  * Confirm the resolved binary is Zellij. Only verified identities are cached.
112
127
  * Transport timeouts leave the cache empty so a later call can retry.
113
128
  * Returns false when verification is unresolved; only true is cached.
129
+ * In-flight probes are never shared — each caller supplies its own timeout.
114
130
  */
115
- export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath) {
131
+ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath, signal) {
116
132
  if (identityCache.has(cacheKey))
117
133
  return true;
134
+ if (signal?.aborted)
135
+ return false;
118
136
  assertZellijBinaryPath(zellijPath);
119
137
  const result = await exec(["--version"], {
120
138
  timeoutMs: Math.min(timeoutMs, 5_000),
139
+ signal,
121
140
  });
122
141
  const text = `${result.stdout}\n${result.stderr}`;
123
142
  if (/usage:\s*zswarm/i.test(text) || /unknown arg:/i.test(text)) {
124
- throw new ZellijError("zellij_wrong_bin", `resolved binary is zswarm, not Zellij (${zellijPath}). Set ZSWARM_BIN to the zellij executable`);
143
+ throw new ZellijError("zellij_wrong_bin", `resolved binary is zswarm, not Zellij (${zellijPath}). Set ZSWARM_BIN to the zellij executable`, zellijExecDetails(result));
125
144
  }
126
145
  if (result.code === NOT_FOUND_EXIT) {
127
- throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
146
+ throw zellijMissingError(zellijPath, result);
128
147
  }
129
148
  if (result.code === 0) {
149
+ if (signal?.aborted)
150
+ return false;
130
151
  if (isZellijVersionOutput(result.stdout, result.stderr)) {
131
152
  identityCache.add(cacheKey);
132
153
  return true;
133
154
  }
134
- throw new ZellijError("zellij_wrong_bin", `resolved binary is not Zellij (${zellijPath}); --version returned: ${result.stdout.trim() || result.stderr.trim() || "empty"}`);
155
+ throw new ZellijError("zellij_wrong_bin", `resolved binary is not Zellij (${zellijPath}); --version returned: ${result.stdout.trim() || result.stderr.trim() || "empty"}`, zellijExecDetails(result));
135
156
  }
136
157
  // Timeout / transport failure: leave unresolved so a later call retries.
137
158
  return false;
@@ -142,32 +163,56 @@ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000,
142
163
  * Returns false when the transport cannot verify support.
143
164
  */
144
165
  const capabilityCache = new Set();
145
- export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath) {
166
+ export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath, signal) {
146
167
  if (capabilityCache.has(cacheKey))
147
168
  return true;
169
+ if (signal?.aborted)
170
+ return false;
148
171
  const result = await exec(["list-sessions", "--help"], {
149
172
  timeoutMs: Math.min(timeoutMs, 5_000),
173
+ signal,
150
174
  });
151
175
  const text = `${result.stdout}\n${result.stderr}`;
152
176
  if (/usage:\s*zswarm/i.test(text)) {
153
- throw new ZellijError("zellij_wrong_bin", `resolved binary is zswarm, not Zellij (${zellijPath})`);
177
+ throw new ZellijError("zellij_wrong_bin", `resolved binary is zswarm, not Zellij (${zellijPath})`, zellijExecDetails(result));
154
178
  }
155
179
  if (result.code === NOT_FOUND_EXIT) {
156
- throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath})`);
180
+ throw zellijMissingError(zellijPath, result);
157
181
  }
158
182
  // Require the flags this client always passes.
159
183
  if (result.code === 0 &&
160
184
  /--no-formatting/i.test(text) &&
161
185
  /list-sessions/i.test(text)) {
186
+ if (signal?.aborted)
187
+ return false;
162
188
  capabilityCache.add(cacheKey);
163
189
  return true;
164
190
  }
165
191
  if (result.code === 0) {
166
- throw new ZellijError("zellij_incompatible", `Zellij at ${zellijPath} does not advertise list-sessions --no-formatting; upgrade Zellij (≥ 0.42) or zswarm`);
192
+ throw new ZellijError("zellij_incompatible", `Zellij at ${zellijPath} does not advertise list-sessions --no-formatting; upgrade Zellij (≥ 0.42) or zswarm`, zellijExecDetails(result));
167
193
  }
168
194
  // Soft: leave unresolved on transport failure.
169
195
  return false;
170
196
  }
197
+ /**
198
+ * Identity and capability are independent reads. Run them together under one
199
+ * remaining budget; do not share in-flight work with another caller.
200
+ */
201
+ export async function ensureZellijProbes(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath, signal) {
202
+ if (signal?.aborted)
203
+ return { identity: false, capabilities: false };
204
+ const probeTimeout = Math.min(timeoutMs, 5_000);
205
+ // Settle both so a wrong-bin throw does not leave the other SSH probe running.
206
+ const settled = await Promise.allSettled([
207
+ ensureZellijIdentity(exec, zellijPath, probeTimeout, cacheKey, signal),
208
+ ensureZellijCapabilities(exec, zellijPath, probeTimeout, cacheKey, signal),
209
+ ]);
210
+ if (settled[0].status === "rejected")
211
+ throw settled[0].reason;
212
+ if (settled[1].status === "rejected")
213
+ throw settled[1].reason;
214
+ return { identity: settled[0].value, capabilities: settled[1].value };
215
+ }
171
216
  /** Test helper: drop cached identity/capability probes. */
172
217
  export function resetZellijIdentityCache() {
173
218
  identityCache.clear();
@@ -74,7 +74,7 @@ export function parseChangedReply(stdout) {
74
74
  if (!row || typeof row !== "object")
75
75
  continue;
76
76
  const p = row;
77
- if (typeof p.id !== "string")
77
+ if (typeof p.id !== "string" || typeof p.changed !== "boolean" || typeof p.first !== "boolean" || !Array.isArray(p.viewport))
78
78
  continue;
79
79
  panes.push({
80
80
  id: p.id,
@@ -0,0 +1,24 @@
1
+ /** Short-lived, completed observations only. Never shares a caller's promise or budget. */
2
+ export declare class ListingCache {
3
+ private readonly limit;
4
+ private entries;
5
+ private scopes;
6
+ constructor(limit?: number);
7
+ begin(scope: string): {
8
+ state: {
9
+ version: number;
10
+ revision?: string;
11
+ };
12
+ version: number;
13
+ };
14
+ get<T>(key: string, now: number): T | undefined;
15
+ put<T>(key: string, scope: string, ticket: ReturnType<ListingCache["begin"]>, value: T, ttl: number, now: number): void;
16
+ invalidate(scope: string): void;
17
+ observe(scope: string, revision: string): boolean;
18
+ clear(): void;
19
+ }
20
+ export declare const sharedListings: ListingCache;
21
+ export declare function executorIdentity(exec?: object): number | null;
22
+ /** Environment fields that select local sockets or an SSH identity/config. */
23
+ export declare function routingEnvironment(env: NodeJS.ProcessEnv): Record<string, string | null>;
24
+ export declare function listingTtl(env: NodeJS.ProcessEnv): number;
@@ -0,0 +1,90 @@
1
+ /** Short-lived, completed observations only. Never shares a caller's promise or budget. */
2
+ export class ListingCache {
3
+ limit;
4
+ entries = new Map();
5
+ scopes = new Map();
6
+ constructor(limit = 512) {
7
+ this.limit = limit;
8
+ }
9
+ begin(scope) {
10
+ let state = this.scopes.get(scope);
11
+ if (!state) {
12
+ state = { version: 0 };
13
+ this.scopes.set(scope, state);
14
+ if (this.scopes.size > this.limit) {
15
+ const oldest = this.scopes.keys().next().value;
16
+ this.scopes.delete(oldest);
17
+ for (const [key, entry] of this.entries)
18
+ if (entry.scope === oldest)
19
+ this.entries.delete(key);
20
+ }
21
+ }
22
+ return { state, version: state.version };
23
+ }
24
+ get(key, now) {
25
+ const entry = this.entries.get(key);
26
+ if (!entry)
27
+ return undefined;
28
+ if (now >= entry.expiresAt) {
29
+ this.entries.delete(key);
30
+ return undefined;
31
+ }
32
+ return structuredClone(entry.value);
33
+ }
34
+ put(key, scope, ticket, value, ttl, now) {
35
+ // A mutation/event during this read must not resurrect an older manifest.
36
+ if (ttl <= 0 || this.scopes.get(scope) !== ticket.state || ticket.state.version !== ticket.version)
37
+ return;
38
+ this.entries.delete(key);
39
+ this.entries.set(key, { scope, value: structuredClone(value), expiresAt: now + ttl });
40
+ while (this.entries.size > this.limit)
41
+ this.entries.delete(this.entries.keys().next().value);
42
+ }
43
+ invalidate(scope) {
44
+ this.begin(scope).state.version++;
45
+ for (const [key, entry] of this.entries)
46
+ if (entry.scope === scope)
47
+ this.entries.delete(key);
48
+ }
49
+ observe(scope, revision) {
50
+ const { state } = this.begin(scope);
51
+ if (state.revision === revision)
52
+ return false;
53
+ this.invalidate(scope);
54
+ state.revision = revision;
55
+ return true;
56
+ }
57
+ clear() {
58
+ this.entries.clear();
59
+ this.scopes.clear();
60
+ }
61
+ }
62
+ export const sharedListings = new ListingCache();
63
+ const executors = new WeakMap();
64
+ let nextExecutor = 0;
65
+ export function executorIdentity(exec) {
66
+ if (!exec)
67
+ return null;
68
+ let id = executors.get(exec);
69
+ if (id === undefined) {
70
+ id = ++nextExecutor;
71
+ executors.set(exec, id);
72
+ }
73
+ return id;
74
+ }
75
+ /** Environment fields that select local sockets or an SSH identity/config. */
76
+ export function routingEnvironment(env) {
77
+ return Object.fromEntries([
78
+ "ZSWARM_TMP", "ZELLIJ_SOCKET_DIR", "TMPDIR", "TEMP", "TMP", "XDG_RUNTIME_DIR",
79
+ "HOME", "USERPROFILE", "USER", "USERNAME", "LOGNAME", "USERDOMAIN", "SSH_AUTH_SOCK",
80
+ "PATH", "Path", "PATHEXT", "XDG_CONFIG_HOME", "ZELLIJ_CONFIG_DIR",
81
+ "ZELLIJ_SESSION_NAME", "ZSWARM_SESSION",
82
+ ].map((key) => [key, env[key] ?? null]));
83
+ }
84
+ export function listingTtl(env) {
85
+ const value = env.ZSWARM_CACHE_TTL_MS;
86
+ if (value === undefined || value.trim() === "")
87
+ return 500;
88
+ const ttl = Number(value);
89
+ return Number.isFinite(ttl) ? Math.max(0, Math.min(5000, ttl)) : 500;
90
+ }
@@ -1,3 +1,4 @@
1
+ import { ListingCache } from "./cache.js";
1
2
  import { buildClosePaneArgs, buildDumpArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, type WaitRequest, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PipeInput } from "./args.js";
2
3
  import { resolveTab, type ZellijTab } from "./tabs.js";
3
4
  import { type IpcDiscoveryState } from "../exec.js";
@@ -12,6 +13,10 @@ export type ZellijClientOptions = {
12
13
  signal?: AbortSignal;
13
14
  /** Skip the one-time `zellij --version` identity probe (tests). */
14
15
  skipIdentityProbe?: boolean;
16
+ /** Completed listing cache shared by long-lived MCP/serve calls; false bypasses reads. */
17
+ cache?: ListingCache | false;
18
+ /** Clock for cache TTLs; process deadlines continue using wall time. */
19
+ now?: () => number;
15
20
  };
16
21
  export type ZellijTransport = {
17
22
  kind: "local" | "ssh";
@@ -23,16 +28,23 @@ export type ZellijTransport = {
23
28
  /** Outcome of `ZSWARM_TMP=auto` discovery when SSH routing is active. */
24
29
  ipc?: IpcDiscoveryState;
25
30
  };
26
- /** Thin, stateless wrapper over the `zellij` binary. */
31
+ /** Zellij wrapper with bounded caches of completed discovery observations. */
27
32
  export declare function createZellijClient(options?: ZellijClientOptions): {
28
33
  zellijPath: string;
29
34
  selfPaneId: string | null;
35
+ contextKey: () => string;
36
+ invalidateListings: (session: string) => void;
37
+ observeManifest: (session: string, revision: string) => void;
30
38
  readonly transport: ZellijTransport;
31
39
  /** A `file:` plugin url only names a path on the machine running Zellij. */
32
40
  remote: boolean;
33
- listSessions: (callTimeoutMs?: number) => Promise<ZellijSession[]>;
41
+ listSessions: (callTimeoutMs?: number, opts?: {
42
+ fresh?: boolean;
43
+ }) => Promise<ZellijSession[]>;
34
44
  resolveSession: (explicit?: string | null, callTimeoutMs?: number) => Promise<ZellijSessionResolve>;
35
- listPanes: (session: string, callTimeoutMs?: number) => Promise<ZellijPane[]>;
45
+ listPanes: (session: string, callTimeoutMs?: number, opts?: {
46
+ fresh?: boolean;
47
+ }) => Promise<ZellijPane[]>;
36
48
  resolvePane: typeof resolvePane;
37
49
  injectPane: (input: {
38
50
  session: string;
@@ -112,7 +124,9 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
112
124
  paneId: string;
113
125
  session: string;
114
126
  }>;
115
- listTabs: (session: string, callTimeoutMs?: number) => Promise<ZellijTab[]>;
127
+ listTabs: (session: string, callTimeoutMs?: number, opts?: {
128
+ fresh?: boolean;
129
+ }) => Promise<ZellijTab[]>;
116
130
  dumpLayout: (session: string) => Promise<string>;
117
131
  stackPanes: (input: {
118
132
  session: string;
@@ -1,12 +1,13 @@
1
1
  import { ZellijError } from "../errors.js";
2
+ import { ListingCache, executorIdentity, listingTtl, routingEnvironment, sharedListings } from "./cache.js";
2
3
  import { buildClosePaneArgs, buildDumpArgs, buildDumpLayoutArgs, buildFocusPaneArgs, buildLaunchPluginArgs, buildListPanesArgs, buildListTabsArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildRenamePaneArgs, buildRenameTabArgs, buildSendEnterArgs, buildSendKeysArgs, buildStackPanesArgs, buildWriteCharsArgs, changedPayload, scrollbackPayload, waitPayload, } from "./args.js";
3
4
  import { DEFAULT_BUS_TIMEOUT_MS, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, } from "./bus.js";
4
5
  import { parseTabList, resolveTab } from "./tabs.js";
5
6
  import { createSshExec } from "../exec.js";
6
- import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijCapabilities, ensureZellijIdentity, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
7
+ import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijProbes, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, zellijExecDetails, zellijMissingError, } from "./binary.js";
7
8
  import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
8
9
  import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, sessionFromEnv, sessionFromList, } from "./session.js";
9
- /** Thin, stateless wrapper over the `zellij` binary. */
10
+ /** Zellij wrapper with bounded caches of completed discovery observations. */
10
11
  export function createZellijClient(options = {}) {
11
12
  const env = options.env ?? process.env;
12
13
  // A remote crew never resolves a local binary. An injected exec is a unit
@@ -21,13 +22,56 @@ export function createZellijClient(options = {}) {
21
22
  : resolveZellijBinary(env));
22
23
  const sshExec = options.exec || !ssh
23
24
  ? null
24
- : createSshExec(ssh, sanitizeZellijEnv(env));
25
+ : createSshExec(ssh, sanitizeZellijEnv(env), { pinIpc: true });
25
26
  const rawExec = options.exec ??
26
27
  (sshExec ? sshExec : defaultExec(zellijPath, env));
27
28
  const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
28
29
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
30
  const selfPaneId = resolveSelfPaneId(env);
30
- const probeKey = identityCacheKey(zellijPath, ssh);
31
+ const probeKey = JSON.stringify([identityCacheKey(zellijPath, ssh), routingEnvironment(env)]);
32
+ const now = options.now ?? Date.now;
33
+ const cache = options.cache instanceof ListingCache ? options.cache : sharedListings;
34
+ const ttl = options.cache === false || (options.exec && !options.cache) ? 0 : listingTtl(env);
35
+ const baseContext = JSON.stringify([zellijPath, ssh, executorIdentity(options.exec), routingEnvironment(env)]);
36
+ const scopeFor = (session) => JSON.stringify([baseContext, session ?? null]);
37
+ const contextKey = () => JSON.stringify([baseContext, readTransport().ipc ?? null]);
38
+ const cacheUsable = () => {
39
+ const ipc = readTransport().ipc;
40
+ return ttl > 0 && (ipc?.requested?.toLowerCase() !== "auto" || ipc.status === "resolved");
41
+ };
42
+ function invalidateListings(session) {
43
+ cache.invalidate(scopeFor(session));
44
+ cache.invalidate(scopeFor());
45
+ }
46
+ function observeManifest(session, revision) {
47
+ if (cache.observe(scopeFor(session), revision))
48
+ cache.invalidate(scopeFor());
49
+ }
50
+ async function listing(kind, session, budget, fresh, read) {
51
+ const remaining = operationBudget(budget, `zellij ${kind}`);
52
+ remaining(); // An aborted/expired caller cannot consume a cached value.
53
+ // Resolve auto IPC before selecting the namespace, including cold reads.
54
+ // A sibling can refresh discovery while this read is in flight, so keep
55
+ // the original namespace and refuse to cache under a different one later.
56
+ if (ssh?.tmp?.trim().toLowerCase() === "auto") {
57
+ await sshExec.prepareIpc(remaining(), options.signal);
58
+ remaining();
59
+ }
60
+ const key = () => JSON.stringify([contextKey(), session ?? null, kind]);
61
+ const startedKey = key();
62
+ if (cacheUsable() && !fresh) {
63
+ const hit = cache.get(startedKey, now());
64
+ if (hit !== undefined)
65
+ return hit;
66
+ }
67
+ const scope = scopeFor(session);
68
+ const ticket = cache.begin(scope);
69
+ const value = await read(remaining);
70
+ remaining();
71
+ if (cacheUsable() && key() === startedKey)
72
+ cache.put(startedKey, scope, ticket, value, ttl, now());
73
+ return value;
74
+ }
31
75
  function readTransport() {
32
76
  if (!ssh)
33
77
  return { kind: "local", mode: "local" };
@@ -66,53 +110,78 @@ export function createZellijClient(options = {}) {
66
110
  return;
67
111
  // Only the helpers' positively verified results are cached. In-flight or
68
112
  // unresolved probes must not tie this call to another operation's budget.
69
- await ensureZellijIdentity(exec, zellijPath, remaining(), probeKey);
70
- await ensureZellijCapabilities(exec, zellijPath, remaining(), probeKey);
113
+ if (sshExec?.prepareIpc)
114
+ await sshExec.prepareIpc(remaining(), options.signal);
115
+ await ensureZellijProbes(exec, zellijPath, remaining(), probeKey, options.signal);
71
116
  }
72
- async function run(args, label, callTimeoutMs = timeoutMs) {
117
+ async function runCommand(args, label, callTimeoutMs = timeoutMs) {
73
118
  const remaining = operationBudget(callTimeoutMs, label);
74
119
  await ensureIdentity(remaining);
75
120
  const result = await exec(args, { timeoutMs: remaining() });
76
121
  if (result.code === NOT_FOUND_EXIT) {
77
- throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
122
+ throw zellijMissingError(zellijPath, result);
78
123
  }
79
124
  if (result.code !== 0) {
80
125
  const detail = result.stderr.trim() || result.stdout.trim() || "no output";
81
126
  if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
82
- throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`);
127
+ throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`, zellijExecDetails(result));
83
128
  }
84
- throw new ZellijError("zellij_failed", `${label} failed (exit ${result.code}): ${detail}`);
129
+ throw new ZellijError("zellij_failed", `${label} failed (exit ${result.code}): ${detail}`, zellijExecDetails(result));
85
130
  }
86
131
  return result;
87
132
  }
88
- async function listSessions(callTimeoutMs = timeoutMs) {
89
- const remaining = operationBudget(callTimeoutMs, "zellij list-sessions");
90
- await ensureIdentity(remaining);
91
- // Keep annotations (EXITED / current); --short drops them.
92
- const result = await exec(["list-sessions", "--no-formatting"], {
93
- timeoutMs: remaining(),
94
- });
95
- if (result.code === NOT_FOUND_EXIT) {
96
- throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
133
+ const mutations = new Set([
134
+ "new-pane", "new-tab", "close-pane", "rename-pane", "rename-tab-by-id",
135
+ "focus-pane-id", "stack-panes", "launch-or-focus-plugin", "paste", "send-keys", "write-chars",
136
+ ]);
137
+ async function run(args, label, callTimeoutMs = timeoutMs) {
138
+ const action = args[args.indexOf("action") + 1];
139
+ const index = args.indexOf("--session");
140
+ const session = index >= 0 ? args[index + 1] : undefined;
141
+ const mutation = session && mutations.has(action ?? "");
142
+ if (mutation)
143
+ invalidateListings(session);
144
+ try {
145
+ return await runCommand(args, label, callTimeoutMs);
97
146
  }
98
- if (result.code !== 0) {
99
- const detail = result.stderr.trim() || result.stdout.trim() || "no output";
100
- if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
101
- throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`);
102
- }
103
- if (isZellijNoSessionsOutput(result.stdout, result.stderr))
104
- return [];
105
- throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${detail}`);
147
+ finally {
148
+ // Failed/uncertain writes can still have reached Zellij. Drop both reads
149
+ // from before the write and reads that raced while it was in flight.
150
+ if (mutation)
151
+ invalidateListings(session);
106
152
  }
107
- return parseSessionList(result.stdout);
153
+ }
154
+ async function listSessions(callTimeoutMs = timeoutMs, opts = {}) {
155
+ return listing("list-sessions", undefined, callTimeoutMs, opts.fresh === true, async (remaining) => {
156
+ await ensureIdentity(remaining);
157
+ // Keep annotations (EXITED / current); --short drops them.
158
+ const result = await exec(["list-sessions", "--no-formatting"], {
159
+ timeoutMs: remaining(),
160
+ });
161
+ if (result.code === NOT_FOUND_EXIT) {
162
+ throw zellijMissingError(zellijPath, result);
163
+ }
164
+ if (result.code !== 0) {
165
+ const detail = result.stderr.trim() || result.stdout.trim() || "no output";
166
+ if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
167
+ throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`, zellijExecDetails(result));
168
+ }
169
+ if (isZellijNoSessionsOutput(result.stdout, result.stderr))
170
+ return [];
171
+ throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${detail}`, zellijExecDetails(result));
172
+ }
173
+ return parseSessionList(result.stdout);
174
+ });
108
175
  }
109
176
  async function resolveSession(explicit, callTimeoutMs = timeoutMs) {
110
177
  return (sessionFromEnv(env, explicit) ??
111
178
  sessionFromList(await listSessions(callTimeoutMs)));
112
179
  }
113
- async function listPanes(session, callTimeoutMs = timeoutMs) {
114
- const result = await run(buildListPanesArgs(session), "zellij action list-panes", callTimeoutMs);
115
- return parsePaneList(result.stdout);
180
+ async function listPanes(session, callTimeoutMs = timeoutMs, opts = {}) {
181
+ return listing("list-panes", session, callTimeoutMs, opts.fresh === true, async (remaining) => {
182
+ const result = await run(buildListPanesArgs(session), "zellij action list-panes", remaining());
183
+ return parsePaneList(result.stdout);
184
+ });
116
185
  }
117
186
  async function injectPane(input) {
118
187
  const paneId = normalizePaneId(input.paneId);
@@ -164,9 +233,11 @@ export function createZellijClient(options = {}) {
164
233
  await run(buildFocusPaneArgs(input.session, paneId), "zellij action focus-pane-id");
165
234
  return { paneId, session: input.session };
166
235
  }
167
- async function listTabs(session, callTimeoutMs = timeoutMs) {
168
- const result = await run(buildListTabsArgs(session), "zellij action list-tabs", callTimeoutMs);
169
- return parseTabList(result.stdout);
236
+ async function listTabs(session, callTimeoutMs = timeoutMs, opts = {}) {
237
+ return listing("list-tabs", session, callTimeoutMs, opts.fresh === true, async (remaining) => {
238
+ const result = await run(buildListTabsArgs(session), "zellij action list-tabs", remaining());
239
+ return parseTabList(result.stdout);
240
+ });
170
241
  }
171
242
  async function dumpLayout(session) {
172
243
  const result = await run(buildDumpLayoutArgs(session), "zellij action dump-layout");
@@ -282,6 +353,9 @@ export function createZellijClient(options = {}) {
282
353
  return {
283
354
  zellijPath,
284
355
  selfPaneId,
356
+ contextKey,
357
+ invalidateListings,
358
+ observeManifest,
285
359
  get transport() {
286
360
  return readTransport();
287
361
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {