@zswarm/core 0.1.6 → 0.1.7

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.
@@ -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, } 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,10 +110,11 @@ 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() });
@@ -85,34 +130,58 @@ export function createZellijClient(options = {}) {
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 new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
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}`);
168
+ }
169
+ if (isZellijNoSessionsOutput(result.stdout, result.stderr))
170
+ return [];
171
+ throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${detail}`);
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.7",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {