@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.
package/dist/ops/panes.js CHANGED
@@ -16,7 +16,7 @@ export async function renameTarget(client, args, policy) {
16
16
  const sess = await session(client, args);
17
17
  const tabKey = optionalString(args.tab);
18
18
  if (tabKey) {
19
- const tab = client.resolveTab(await client.listTabs(sess), tabKey);
19
+ const tab = client.resolveTab(await client.listTabs(sess, undefined, { fresh: true }), tabKey);
20
20
  const renamed = await client.renameTab({
21
21
  session: sess,
22
22
  tabId: tab.id,
@@ -30,7 +30,7 @@ export async function renameTarget(client, args, policy) {
30
30
  const to = optionalString(args.to);
31
31
  if (!to)
32
32
  throw new ZellijError("missing_peer", "to (or tab) required");
33
- const pane = client.resolvePane(await client.listPanes(sess), to);
33
+ const pane = client.resolvePane(await client.listPanes(sess, undefined, { fresh: true }), to);
34
34
  if (policy)
35
35
  assertPaneAllowed(policy, pane, "rename");
36
36
  await client.renamePane({ session: sess, paneId: pane.id, name });
@@ -44,7 +44,7 @@ export async function focusTarget(client, args, policy) {
44
44
  if (!to)
45
45
  throw new ZellijError("missing_peer", "to required");
46
46
  const sess = await session(client, args);
47
- const pane = client.resolvePane(await client.listPanes(sess), to);
47
+ const pane = client.resolvePane(await client.listPanes(sess, undefined, { fresh: true }), to);
48
48
  if (policy)
49
49
  assertPaneAllowed(policy, pane, "focus");
50
50
  // Zellij exits non-zero when asked to focus the already-focused pane.
@@ -104,7 +104,7 @@ export async function stackTargets(client, args, policy) {
104
104
  if (!list)
105
105
  throw new ZellijError("missing_peer", "to (comma list) required");
106
106
  const sess = await session(client, args);
107
- const panes = await client.listPanes(sess);
107
+ const panes = await client.listPanes(sess, undefined, { fresh: true });
108
108
  const ids = list
109
109
  .split(",")
110
110
  .map((k) => k.trim())
@@ -13,7 +13,7 @@ export function invocationContext(args, env, injected) {
13
13
  session: selected?.session ?? null,
14
14
  origin: {
15
15
  transport: injected ? "injected" : isTrue(args.local) ? "--local"
16
- : optionalString(args.ssh) ? "--ssh" : serve ? "ZSWARM_SERVE"
16
+ : optionalString(args.ssh) ? "--ssh" : optionalString(args.serveAddress) ? "--serve" : serve ? "ZSWARM_SERVE"
17
17
  : ssh ? "ZSWARM_SSH" : "default",
18
18
  session: selected?.source ?? "unresolved",
19
19
  },
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),
@@ -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.js CHANGED
@@ -129,13 +129,25 @@ export const PARAMS = [
129
129
  name: "sampleMs",
130
130
  type: "number",
131
131
  flags: ["--sample-ms"],
132
- description: "status: gap between the two screen samples (default 400); 0 skips sampling and reports running/exited only",
132
+ description: "status: explicitly sample twice with this gap (fallback default 400); 0 reports running/exited only; default prefers bus changes",
133
133
  },
134
134
  {
135
135
  name: "sinceLast",
136
136
  type: "boolean",
137
137
  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",
138
+ description: "status: classify changes since the previous bus observation (default when bus available); first observation is unknown unless a prompt is recognized",
139
+ },
140
+ {
141
+ name: "fresh",
142
+ type: "boolean",
143
+ flags: ["--fresh"],
144
+ description: "bypass the short-lived session/pane/tab listing cache for this call",
145
+ },
146
+ {
147
+ name: "serveAddress",
148
+ type: "string",
149
+ flags: ["--serve"],
150
+ description: "use an existing serve endpoint (e.g. 127.0.0.1:9419 through an SSH tunnel); preferred for frequent status calls; uses ZSWARM_SERVE_TOKEN",
139
151
  },
140
152
  {
141
153
  name: "limit",
@@ -483,7 +495,7 @@ export function cliUsage() {
483
495
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
484
496
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
485
497
  }
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", "");
498
+ 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, ZSWARM_CACHE_TTL_MS", "");
487
499
  return lines.join("\n");
488
500
  }
489
501
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
@@ -30,14 +30,27 @@ export declare function identityCacheKey(zellijPath: string, ssh?: {
30
30
  options: string[];
31
31
  mode?: string;
32
32
  remoteBin?: string;
33
+ /** SSH client binary (`SshTarget.ssh`). */
34
+ ssh?: string;
35
+ sshBin?: string;
36
+ remoteShell?: string;
33
37
  } | null): string;
34
38
  /**
35
39
  * Confirm the resolved binary is Zellij. Only verified identities are cached.
36
40
  * Transport timeouts leave the cache empty so a later call can retry.
37
41
  * Returns false when verification is unresolved; only true is cached.
42
+ * In-flight probes are never shared — each caller supplies its own timeout.
38
43
  */
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>;
44
+ export declare function ensureZellijIdentity(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string, signal?: AbortSignal): Promise<boolean>;
45
+ export declare function ensureZellijCapabilities(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string, signal?: AbortSignal): Promise<boolean>;
46
+ /**
47
+ * Identity and capability are independent reads. Run them together under one
48
+ * remaining budget; do not share in-flight work with another caller.
49
+ */
50
+ export declare function ensureZellijProbes(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string, signal?: AbortSignal): Promise<{
51
+ identity: boolean;
52
+ capabilities: boolean;
53
+ }>;
41
54
  /** Test helper: drop cached identity/capability probes. */
42
55
  export declare function resetZellijIdentityCache(): void;
43
56
  export declare function resolveZellijBinary(env?: NodeJS.ProcessEnv): string;
@@ -98,26 +98,32 @@ export function isZellijVersionOutput(stdout, stderr = "") {
98
98
  */
99
99
  export function identityCacheKey(zellijPath, ssh) {
100
100
  if (!ssh)
101
- return zellijPath;
102
- return [
101
+ return JSON.stringify([zellijPath]);
102
+ return JSON.stringify([
103
103
  zellijPath,
104
104
  ssh.host,
105
105
  ssh.remoteBin ?? "",
106
106
  ssh.mode ?? "ssh",
107
- ssh.options.join("\0"),
108
- ].join("|");
107
+ ssh.options,
108
+ ssh.sshBin ?? ssh.ssh ?? "",
109
+ ssh.remoteShell ?? "",
110
+ ]);
109
111
  }
110
112
  /**
111
113
  * Confirm the resolved binary is Zellij. Only verified identities are cached.
112
114
  * Transport timeouts leave the cache empty so a later call can retry.
113
115
  * Returns false when verification is unresolved; only true is cached.
116
+ * In-flight probes are never shared — each caller supplies its own timeout.
114
117
  */
115
- export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath) {
118
+ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath, signal) {
116
119
  if (identityCache.has(cacheKey))
117
120
  return true;
121
+ if (signal?.aborted)
122
+ return false;
118
123
  assertZellijBinaryPath(zellijPath);
119
124
  const result = await exec(["--version"], {
120
125
  timeoutMs: Math.min(timeoutMs, 5_000),
126
+ signal,
121
127
  });
122
128
  const text = `${result.stdout}\n${result.stderr}`;
123
129
  if (/usage:\s*zswarm/i.test(text) || /unknown arg:/i.test(text)) {
@@ -127,6 +133,8 @@ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000,
127
133
  throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
128
134
  }
129
135
  if (result.code === 0) {
136
+ if (signal?.aborted)
137
+ return false;
130
138
  if (isZellijVersionOutput(result.stdout, result.stderr)) {
131
139
  identityCache.add(cacheKey);
132
140
  return true;
@@ -142,11 +150,14 @@ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000,
142
150
  * Returns false when the transport cannot verify support.
143
151
  */
144
152
  const capabilityCache = new Set();
145
- export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath) {
153
+ export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath, signal) {
146
154
  if (capabilityCache.has(cacheKey))
147
155
  return true;
156
+ if (signal?.aborted)
157
+ return false;
148
158
  const result = await exec(["list-sessions", "--help"], {
149
159
  timeoutMs: Math.min(timeoutMs, 5_000),
160
+ signal,
150
161
  });
151
162
  const text = `${result.stdout}\n${result.stderr}`;
152
163
  if (/usage:\s*zswarm/i.test(text)) {
@@ -159,6 +170,8 @@ export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_0
159
170
  if (result.code === 0 &&
160
171
  /--no-formatting/i.test(text) &&
161
172
  /list-sessions/i.test(text)) {
173
+ if (signal?.aborted)
174
+ return false;
162
175
  capabilityCache.add(cacheKey);
163
176
  return true;
164
177
  }
@@ -168,6 +181,25 @@ export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_0
168
181
  // Soft: leave unresolved on transport failure.
169
182
  return false;
170
183
  }
184
+ /**
185
+ * Identity and capability are independent reads. Run them together under one
186
+ * remaining budget; do not share in-flight work with another caller.
187
+ */
188
+ export async function ensureZellijProbes(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath, signal) {
189
+ if (signal?.aborted)
190
+ return { identity: false, capabilities: false };
191
+ const probeTimeout = Math.min(timeoutMs, 5_000);
192
+ // Settle both so a wrong-bin throw does not leave the other SSH probe running.
193
+ const settled = await Promise.allSettled([
194
+ ensureZellijIdentity(exec, zellijPath, probeTimeout, cacheKey, signal),
195
+ ensureZellijCapabilities(exec, zellijPath, probeTimeout, cacheKey, signal),
196
+ ]);
197
+ if (settled[0].status === "rejected")
198
+ throw settled[0].reason;
199
+ if (settled[1].status === "rejected")
200
+ throw settled[1].reason;
201
+ return { identity: settled[0].value, capabilities: settled[1].value };
202
+ }
171
203
  /** Test helper: drop cached identity/capability probes. */
172
204
  export function resetZellijIdentityCache() {
173
205
  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;