@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/README.md +8 -0
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +4 -1
- package/dist/exec.d.ts +44 -2
- package/dist/exec.js +198 -46
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/ops/broadcast.js +1 -1
- package/dist/ops/bus.js +38 -14
- package/dist/ops/dispatch.js +94 -23
- package/dist/ops/doctor.d.ts +80 -0
- package/dist/ops/doctor.js +1377 -0
- package/dist/ops/panes.js +4 -4
- package/dist/ops/routing.js +3 -2
- package/dist/ops/serve-bind.d.ts +86 -0
- package/dist/ops/serve-bind.js +354 -0
- package/dist/ops/serve-install.d.ts +88 -0
- package/dist/ops/serve-install.js +1171 -0
- package/dist/ops/serve-tunnel.d.ts +109 -0
- package/dist/ops/serve-tunnel.js +1179 -0
- package/dist/ops/serve.d.ts +89 -21
- package/dist/ops/serve.js +622 -156
- package/dist/ops/spawn.js +4 -4
- package/dist/ops/status.js +18 -9
- package/dist/ops/types.d.ts +49 -0
- package/dist/ops/util.js +8 -1
- package/dist/ops/worktree.js +2 -2
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +21 -8
- package/dist/state.js +158 -38
- package/dist/zellij/binary.d.ts +22 -2
- package/dist/zellij/binary.js +58 -13
- package/dist/zellij/bus.js +1 -1
- package/dist/zellij/cache.d.ts +24 -0
- package/dist/zellij/cache.js +90 -0
- package/dist/zellij/client.d.ts +18 -4
- package/dist/zellij/client.js +108 -34
- package/package.json +1 -1
package/dist/zellij/binary.js
CHANGED
|
@@ -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
|
|
108
|
-
|
|
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
|
|
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
|
|
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();
|
package/dist/zellij/bus.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/zellij/client.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/zellij/client.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
},
|