@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/exec.d.ts +36 -2
- package/dist/exec.js +186 -34
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ops/broadcast.js +1 -1
- package/dist/ops/bus.js +31 -14
- package/dist/ops/dispatch.js +57 -20
- package/dist/ops/panes.js +4 -4
- package/dist/ops/routing.js +1 -1
- package/dist/ops/spawn.js +4 -4
- package/dist/ops/status.js +18 -9
- package/dist/ops/worktree.js +2 -2
- package/dist/schema.js +15 -3
- package/dist/zellij/binary.d.ts +15 -2
- package/dist/zellij/binary.js +38 -6
- 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 +105 -31
- package/package.json +1 -1
package/dist/exec.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RemoteShell } from "./zellij/ipc.js";
|
|
1
|
+
import { type IpcDirs, type RemoteShell } from "./zellij/ipc.js";
|
|
2
2
|
export type ExecResult = {
|
|
3
3
|
code: number;
|
|
4
4
|
stdout: string;
|
|
@@ -49,10 +49,44 @@ export type IpcDiscoveryState = {
|
|
|
49
49
|
};
|
|
50
50
|
export type SshExecFn = ExecFn & {
|
|
51
51
|
ipcState: IpcDiscoveryState;
|
|
52
|
+
/**
|
|
53
|
+
* Resolve `ZSWARM_TMP=auto` once under this caller's budget before parallel
|
|
54
|
+
* identity/capability probes. Independent of other callers' in-flight work.
|
|
55
|
+
*/
|
|
56
|
+
prepareIpc: (timeoutMs: number, signal?: AbortSignal) => Promise<IpcDirs | undefined>;
|
|
52
57
|
};
|
|
58
|
+
/** Positive `auto` IPC hits only; failures/cancels never enter this map. */
|
|
59
|
+
export declare const DEFAULT_IPC_DISCOVERY_TTL_MS = 15000;
|
|
60
|
+
export declare const DEFAULT_IPC_DISCOVERY_CACHE_LIMIT = 512;
|
|
61
|
+
/** Fully scoped key so hosts / SSH bins / remote shells / users never collide. */
|
|
62
|
+
export declare function ipcDiscoveryCacheKey(target: SshTarget, env?: NodeJS.ProcessEnv): string;
|
|
63
|
+
/**
|
|
64
|
+
* Probe list for `ZSWARM_TMP=auto`. Known Windows (cmd / interactive / .exe)
|
|
65
|
+
* skips the Unix `ps` fallback; explicit `sh` skips PowerShell. Unknown
|
|
66
|
+
* `zellij` still tries both — a Windows host with remoteBin=zellij looks Unix.
|
|
67
|
+
*/
|
|
68
|
+
export declare function ipcDiscoveryProbes(target: SshTarget): string[];
|
|
69
|
+
export declare function resetIpcDiscoveryCache(): void;
|
|
70
|
+
/** Test helper: shrink the positive-hit TTL without exposing the map. */
|
|
71
|
+
export declare function setIpcDiscoveryTtlMs(ttlMs: number): void;
|
|
72
|
+
export declare function peekIpcDiscoveryCache(key: string): IpcDirs | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Canonical SSH + resolved IPC namespace. Listing caches should hash this
|
|
75
|
+
* (or `ipcState.tmp` / `socketDir` after a refreshed read) so a warmed
|
|
76
|
+
* socket context matches across `createSshExec` instances.
|
|
77
|
+
*/
|
|
78
|
+
export declare function sshIpcContextKey(target: SshTarget, env?: NodeJS.ProcessEnv, ipc?: {
|
|
79
|
+
tmp?: string;
|
|
80
|
+
socketDir?: string;
|
|
81
|
+
} | null): string;
|
|
82
|
+
/** Test helper: shrink the positive-hit map so eviction is observable. */
|
|
83
|
+
export declare function setIpcDiscoveryCacheLimit(limit: number): void;
|
|
84
|
+
export declare function ipcDiscoveryCacheSize(): number;
|
|
53
85
|
/**
|
|
54
86
|
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
55
87
|
* command string, because the remote login shell re-parses it.
|
|
56
88
|
*/
|
|
57
|
-
export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv
|
|
89
|
+
export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv, options?: {
|
|
90
|
+
pinIpc?: boolean;
|
|
91
|
+
}): SshExecFn;
|
|
58
92
|
export declare function createExec(binPath: string, env: NodeJS.ProcessEnv): ExecFn;
|
package/dist/exec.js
CHANGED
|
@@ -35,17 +35,104 @@ export function buildSshRemoteCommand(target, args, tmp, timeoutMs, socketDir) {
|
|
|
35
35
|
}
|
|
36
36
|
return withTmp;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
/** Positive `auto` IPC hits only; failures/cancels never enter this map. */
|
|
39
|
+
export const DEFAULT_IPC_DISCOVERY_TTL_MS = 15_000;
|
|
40
|
+
export const DEFAULT_IPC_DISCOVERY_CACHE_LIMIT = 512;
|
|
41
|
+
const ipcDiscoveryCache = new Map();
|
|
42
|
+
let ipcDiscoveryTtlMs = DEFAULT_IPC_DISCOVERY_TTL_MS;
|
|
43
|
+
let ipcDiscoveryCacheLimit = DEFAULT_IPC_DISCOVERY_CACHE_LIMIT;
|
|
44
|
+
const IPC_USER_ENV = [
|
|
45
|
+
"USER",
|
|
46
|
+
"USERNAME",
|
|
47
|
+
"LOGNAME",
|
|
48
|
+
"HOME",
|
|
49
|
+
"USERPROFILE",
|
|
50
|
+
"USERDOMAIN",
|
|
51
|
+
"SSH_AUTH_SOCK",
|
|
52
|
+
"PATH",
|
|
53
|
+
"Path",
|
|
54
|
+
"XDG_CONFIG_HOME",
|
|
55
|
+
];
|
|
56
|
+
/** Fully scoped key so hosts / SSH bins / remote shells / users never collide. */
|
|
57
|
+
export function ipcDiscoveryCacheKey(target, env = {}) {
|
|
58
|
+
const requested = target.tmp?.trim() ?? "";
|
|
59
|
+
const user = IPC_USER_ENV.map((name) => env[name] ?? "");
|
|
60
|
+
return JSON.stringify([
|
|
61
|
+
target.ssh,
|
|
62
|
+
target.host,
|
|
63
|
+
target.options,
|
|
64
|
+
target.remoteBin,
|
|
65
|
+
target.remoteShell ?? "",
|
|
66
|
+
target.mode ?? "ssh",
|
|
67
|
+
requested.toLowerCase() === "auto" ? "auto" : requested,
|
|
68
|
+
user,
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Probe list for `ZSWARM_TMP=auto`. Known Windows (cmd / interactive / .exe)
|
|
73
|
+
* skips the Unix `ps` fallback; explicit `sh` skips PowerShell. Unknown
|
|
74
|
+
* `zellij` still tries both — a Windows host with remoteBin=zellij looks Unix.
|
|
75
|
+
*/
|
|
76
|
+
export function ipcDiscoveryProbes(target) {
|
|
39
77
|
const shell = inferRemoteShell({
|
|
40
78
|
explicit: target.remoteShell,
|
|
41
79
|
remoteBin: target.remoteBin,
|
|
42
80
|
mode: target.mode,
|
|
43
81
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
const windows = windowsDiscoverRemote();
|
|
83
|
+
const unix = unixDiscoverRemote();
|
|
84
|
+
if (shell === "cmd")
|
|
85
|
+
return [windows];
|
|
86
|
+
if (target.remoteShell === "sh")
|
|
87
|
+
return [unix];
|
|
88
|
+
return [unix, windows];
|
|
89
|
+
}
|
|
90
|
+
export function resetIpcDiscoveryCache() {
|
|
91
|
+
ipcDiscoveryCache.clear();
|
|
92
|
+
ipcDiscoveryTtlMs = DEFAULT_IPC_DISCOVERY_TTL_MS;
|
|
93
|
+
ipcDiscoveryCacheLimit = DEFAULT_IPC_DISCOVERY_CACHE_LIMIT;
|
|
94
|
+
}
|
|
95
|
+
/** Test helper: shrink the positive-hit TTL without exposing the map. */
|
|
96
|
+
export function setIpcDiscoveryTtlMs(ttlMs) {
|
|
97
|
+
ipcDiscoveryTtlMs = Math.max(0, ttlMs);
|
|
98
|
+
}
|
|
99
|
+
export function peekIpcDiscoveryCache(key) {
|
|
100
|
+
const entry = ipcDiscoveryCache.get(key);
|
|
101
|
+
if (!entry)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (entry.expiresAt <= Date.now()) {
|
|
104
|
+
ipcDiscoveryCache.delete(key);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return { tmp: entry.dirs.tmp, socketDir: entry.dirs.socketDir };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Canonical SSH + resolved IPC namespace. Listing caches should hash this
|
|
111
|
+
* (or `ipcState.tmp` / `socketDir` after a refreshed read) so a warmed
|
|
112
|
+
* socket context matches across `createSshExec` instances.
|
|
113
|
+
*/
|
|
114
|
+
export function sshIpcContextKey(target, env = {}, ipc) {
|
|
115
|
+
return JSON.stringify([ipcDiscoveryCacheKey(target, env), ipc?.tmp ?? "", ipc?.socketDir ?? ""]);
|
|
116
|
+
}
|
|
117
|
+
/** Test helper: shrink the positive-hit map so eviction is observable. */
|
|
118
|
+
export function setIpcDiscoveryCacheLimit(limit) {
|
|
119
|
+
ipcDiscoveryCacheLimit = Math.max(1, limit);
|
|
120
|
+
}
|
|
121
|
+
export function ipcDiscoveryCacheSize() {
|
|
122
|
+
return ipcDiscoveryCache.size;
|
|
123
|
+
}
|
|
124
|
+
function storeIpcDiscovery(key, dirs) {
|
|
125
|
+
ipcDiscoveryCache.delete(key);
|
|
126
|
+
ipcDiscoveryCache.set(key, {
|
|
127
|
+
dirs: { ...dirs },
|
|
128
|
+
expiresAt: Date.now() + ipcDiscoveryTtlMs,
|
|
129
|
+
});
|
|
130
|
+
while (ipcDiscoveryCache.size > ipcDiscoveryCacheLimit) {
|
|
131
|
+
ipcDiscoveryCache.delete(ipcDiscoveryCache.keys().next().value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function discoverRemoteIpc(runner, target, remaining, signal) {
|
|
135
|
+
const probes = ipcDiscoveryProbes(target);
|
|
49
136
|
for (const probe of probes) {
|
|
50
137
|
if (signal?.aborted)
|
|
51
138
|
return undefined;
|
|
@@ -56,6 +143,10 @@ async function discoverRemoteIpc(runner, target, remaining, signal) {
|
|
|
56
143
|
timeoutMs: left,
|
|
57
144
|
signal,
|
|
58
145
|
});
|
|
146
|
+
if (result.code !== 0)
|
|
147
|
+
continue;
|
|
148
|
+
if (remaining() <= 0)
|
|
149
|
+
return undefined;
|
|
59
150
|
const dirs = pickIpcDirs(parseZellijServerPaths(result.stdout));
|
|
60
151
|
if (dirs)
|
|
61
152
|
return dirs;
|
|
@@ -66,22 +157,63 @@ async function discoverRemoteIpc(runner, target, remaining, signal) {
|
|
|
66
157
|
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
67
158
|
* command string, because the remote login shell re-parses it.
|
|
68
159
|
*/
|
|
69
|
-
export function createSshExec(target, env) {
|
|
160
|
+
export function createSshExec(target, env, options = {}) {
|
|
70
161
|
const runner = createExec(target.ssh, env);
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
|
|
162
|
+
const cacheKey = ipcDiscoveryCacheKey(target, env);
|
|
163
|
+
let autoFailed = false;
|
|
164
|
+
let pinnedIpc;
|
|
74
165
|
const ipcState = {
|
|
75
|
-
requested:
|
|
76
|
-
status:
|
|
77
|
-
? "none"
|
|
78
|
-
: requestedTmp.toLowerCase() === "auto"
|
|
79
|
-
? "none"
|
|
80
|
-
: "skipped",
|
|
81
|
-
...(requestedTmp && requestedTmp.toLowerCase() !== "auto"
|
|
82
|
-
? { tmp: requestedTmp, socketDir: "" }
|
|
83
|
-
: {}),
|
|
166
|
+
requested: null,
|
|
167
|
+
status: "none",
|
|
84
168
|
};
|
|
169
|
+
function adopt(dirs) {
|
|
170
|
+
if (options.pinIpc) {
|
|
171
|
+
pinnedIpc ??= { ...dirs };
|
|
172
|
+
dirs = pinnedIpc;
|
|
173
|
+
}
|
|
174
|
+
autoFailed = false;
|
|
175
|
+
ipcState.status = "resolved";
|
|
176
|
+
ipcState.tmp = dirs.tmp;
|
|
177
|
+
ipcState.socketDir = dirs.socketDir;
|
|
178
|
+
return { ...dirs };
|
|
179
|
+
}
|
|
180
|
+
/** Seed from a positive hit; unpinned execs drop resolved dirs after TTL. */
|
|
181
|
+
function refreshIpcView() {
|
|
182
|
+
const requested = target.tmp?.trim() || null;
|
|
183
|
+
ipcState.requested = requested;
|
|
184
|
+
if (!requested) {
|
|
185
|
+
ipcState.status = "none";
|
|
186
|
+
delete ipcState.tmp;
|
|
187
|
+
delete ipcState.socketDir;
|
|
188
|
+
return ipcState;
|
|
189
|
+
}
|
|
190
|
+
if (requested.toLowerCase() !== "auto") {
|
|
191
|
+
ipcState.status = "skipped";
|
|
192
|
+
ipcState.tmp = requested;
|
|
193
|
+
ipcState.socketDir = "";
|
|
194
|
+
return ipcState;
|
|
195
|
+
}
|
|
196
|
+
// A high-level client pins its namespace so pane lookup and the following
|
|
197
|
+
// write cannot switch sockets when another caller refreshes discovery.
|
|
198
|
+
if (pinnedIpc)
|
|
199
|
+
return ipcState;
|
|
200
|
+
// Diagnostics describe this exec's last attempt, not a sibling's later
|
|
201
|
+
// success. A subsequent actual invocation may adopt a positive cache hit.
|
|
202
|
+
if (autoFailed)
|
|
203
|
+
return ipcState;
|
|
204
|
+
const cached = peekIpcDiscoveryCache(cacheKey);
|
|
205
|
+
if (cached) {
|
|
206
|
+
adopt(cached);
|
|
207
|
+
return ipcState;
|
|
208
|
+
}
|
|
209
|
+
if (ipcState.status === "resolved") {
|
|
210
|
+
ipcState.status = "none";
|
|
211
|
+
delete ipcState.tmp;
|
|
212
|
+
delete ipcState.socketDir;
|
|
213
|
+
}
|
|
214
|
+
return ipcState;
|
|
215
|
+
}
|
|
216
|
+
refreshIpcView();
|
|
85
217
|
async function resolveIpc(remaining, signal) {
|
|
86
218
|
const requested = target.tmp?.trim();
|
|
87
219
|
if (!requested) {
|
|
@@ -96,40 +228,50 @@ export function createSshExec(target, env) {
|
|
|
96
228
|
ipcState.socketDir = "";
|
|
97
229
|
return { tmp: requested, socketDir: "" };
|
|
98
230
|
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return cached;
|
|
104
|
-
}
|
|
231
|
+
if (pinnedIpc)
|
|
232
|
+
return adopt(pinnedIpc);
|
|
233
|
+
const cached = peekIpcDiscoveryCache(cacheKey);
|
|
234
|
+
if (cached)
|
|
235
|
+
return adopt(cached);
|
|
105
236
|
// Do not re-probe after a failed/expired/cancelled attempt on this exec.
|
|
106
|
-
|
|
237
|
+
// A later createSshExec with the same key still probes — failures are not
|
|
238
|
+
// stored, and in-flight work is never shared across callers' budgets.
|
|
239
|
+
if (autoFailed)
|
|
107
240
|
return undefined;
|
|
108
241
|
if (signal?.aborted) {
|
|
242
|
+
autoFailed = true;
|
|
109
243
|
ipcState.status = "cancelled";
|
|
110
244
|
return undefined;
|
|
111
245
|
}
|
|
112
246
|
if (remaining() <= 0) {
|
|
247
|
+
autoFailed = true;
|
|
113
248
|
ipcState.status = "expired";
|
|
114
249
|
return undefined;
|
|
115
250
|
}
|
|
116
|
-
autoAttempted = true;
|
|
117
251
|
const dirs = await discoverRemoteIpc(runner, target, remaining, signal);
|
|
252
|
+
// Another probe on this client may have pinned a namespace while we were
|
|
253
|
+
// waiting. Its first positive result wins, even if this probe failed.
|
|
254
|
+
if (pinnedIpc)
|
|
255
|
+
return adopt(pinnedIpc);
|
|
118
256
|
if (signal?.aborted) {
|
|
257
|
+
autoFailed = true;
|
|
119
258
|
ipcState.status = "cancelled";
|
|
120
259
|
return undefined;
|
|
121
260
|
}
|
|
122
261
|
if (dirs) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
262
|
+
if (remaining() <= 0) {
|
|
263
|
+
autoFailed = true;
|
|
264
|
+
ipcState.status = "expired";
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
storeIpcDiscovery(cacheKey, dirs);
|
|
268
|
+
return adopt(dirs);
|
|
128
269
|
}
|
|
270
|
+
autoFailed = true;
|
|
129
271
|
ipcState.status = remaining() <= 0 ? "expired" : "failed";
|
|
130
272
|
return undefined;
|
|
131
273
|
}
|
|
132
|
-
const exec = async (args, options) => {
|
|
274
|
+
const exec = (async (args, options) => {
|
|
133
275
|
const deadline = Date.now() + options.timeoutMs;
|
|
134
276
|
const remaining = () => Math.max(0, deadline - Date.now());
|
|
135
277
|
const ipc = await resolveIpc(remaining, options.signal);
|
|
@@ -156,8 +298,18 @@ export function createSshExec(target, env) {
|
|
|
156
298
|
...options,
|
|
157
299
|
timeoutMs: left,
|
|
158
300
|
});
|
|
301
|
+
});
|
|
302
|
+
Object.defineProperty(exec, "ipcState", {
|
|
303
|
+
enumerable: true,
|
|
304
|
+
configurable: true,
|
|
305
|
+
get() {
|
|
306
|
+
return { ...refreshIpcView() };
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
exec.prepareIpc = (timeoutMs, signal) => {
|
|
310
|
+
const deadline = Date.now() + timeoutMs;
|
|
311
|
+
return resolveIpc(() => Math.max(0, deadline - Date.now()), signal);
|
|
159
312
|
};
|
|
160
|
-
exec.ipcState = ipcState;
|
|
161
313
|
return exec;
|
|
162
314
|
}
|
|
163
315
|
/** Read stdout until the answer is in hand, then stop caring about the child. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ZellijError } from "./errors.js";
|
|
2
|
+
export { ListingCache } from "./zellij/cache.js";
|
|
2
3
|
export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecResult, } from "./exec.js";
|
|
3
4
|
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
|
|
4
5
|
export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ZellijError } from "./errors.js";
|
|
2
|
+
export { ListingCache } from "./zellij/cache.js";
|
|
2
3
|
export { createExec, NOT_FOUND_EXIT, } from "./exec.js";
|
|
3
4
|
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, } from "./git.js";
|
|
4
5
|
export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, } from "./zellij/binary.js";
|
package/dist/ops/broadcast.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function broadcast(client, state, args, clock, policy, env = proces
|
|
|
70
70
|
if (!body.trim())
|
|
71
71
|
throw new ZellijError("missing_body", "body required");
|
|
72
72
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
73
|
-
const panes = await client.listPanes(session);
|
|
73
|
+
const panes = await client.listPanes(session, undefined, { fresh: true });
|
|
74
74
|
const labeled = withSenderLabel(args, {
|
|
75
75
|
env,
|
|
76
76
|
selfTitle: selfPaneTitle(client, panes),
|
package/dist/ops/bus.js
CHANGED
|
@@ -19,15 +19,18 @@ const COLD_RETRY_MS = 200;
|
|
|
19
19
|
* copies.
|
|
20
20
|
*/
|
|
21
21
|
const INSTALL_IN_FLIGHT_MS = 10_000;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/** Back off failed pipes briefly, isolated to their transport/session/plugin. */
|
|
23
|
+
const busFailures = new Map();
|
|
24
24
|
/** The key that actually answered, per session, for the life of this process. */
|
|
25
25
|
const answeredWith = new Map();
|
|
26
26
|
/** Tests share a module instance; this drops what a previous case cached. */
|
|
27
27
|
export function resetBusCache() {
|
|
28
|
-
|
|
28
|
+
busFailures.clear();
|
|
29
29
|
answeredWith.clear();
|
|
30
30
|
}
|
|
31
|
+
function busScope(client, session, plan) {
|
|
32
|
+
return JSON.stringify([client.contextKey(), session, plan.url, plan.configKey]);
|
|
33
|
+
}
|
|
31
34
|
function affirmative(raw) {
|
|
32
35
|
const v = (raw ?? "").trim().toLowerCase();
|
|
33
36
|
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
@@ -62,8 +65,12 @@ export function planBus(client, state, env = process.env, session = "") {
|
|
|
62
65
|
reason: "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN",
|
|
63
66
|
};
|
|
64
67
|
}
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
const scope = busScope(client, session, base);
|
|
69
|
+
const failed = busFailures.get(scope);
|
|
70
|
+
if (failed && Date.now() < failed.until)
|
|
71
|
+
return { ...base, reason: failed.reason };
|
|
72
|
+
if (failed)
|
|
73
|
+
busFailures.delete(scope);
|
|
67
74
|
if (affirmative(env.ZSWARM_BUS)) {
|
|
68
75
|
return { ...base, enabled: true, reason: "ZSWARM_BUS=1" };
|
|
69
76
|
}
|
|
@@ -89,7 +96,7 @@ async function nudgeManifest(client, session, env, remaining) {
|
|
|
89
96
|
const listBudget = Math.min(DEFAULT_TIMEOUT_MS, remaining());
|
|
90
97
|
if (listBudget <= 0)
|
|
91
98
|
return;
|
|
92
|
-
const panes = await client.listPanes(session, listBudget);
|
|
99
|
+
const panes = await client.listPanes(session, listBudget, { fresh: true });
|
|
93
100
|
const pane = panes.find((p) => !p.isPlugin && !p.exited && p.title.trim());
|
|
94
101
|
if (!pane)
|
|
95
102
|
return;
|
|
@@ -127,7 +134,8 @@ export async function busSnapshot(client, state, session, clock, env = process.e
|
|
|
127
134
|
const url = plan.url;
|
|
128
135
|
if (!plan.enabled || !url || !pluginPath)
|
|
129
136
|
return null;
|
|
130
|
-
const
|
|
137
|
+
const scope = busScope(client, session, plan);
|
|
138
|
+
const configKey = answeredWith.get(scope) ?? plan.configKey;
|
|
131
139
|
const ask = async () => {
|
|
132
140
|
const budget = Math.min(DEFAULT_BUS_TIMEOUT_MS, remaining());
|
|
133
141
|
if (budget <= 0)
|
|
@@ -155,10 +163,18 @@ export async function busSnapshot(client, state, session, clock, env = process.e
|
|
|
155
163
|
if (remaining() <= 0 && !snapshot?.ready)
|
|
156
164
|
return null;
|
|
157
165
|
if (snapshot) {
|
|
158
|
-
answeredWith.set(
|
|
166
|
+
answeredWith.set(scope, configKey);
|
|
167
|
+
if (answeredWith.size > 256)
|
|
168
|
+
answeredWith.delete(answeredWith.keys().next().value);
|
|
169
|
+
if (snapshot.ready)
|
|
170
|
+
client.observeManifest(session, JSON.stringify([
|
|
171
|
+
scope, snapshot.paneUpdates, snapshot.tabUpdates, snapshot.panes, snapshot.tabs, snapshot.tabIds,
|
|
172
|
+
]));
|
|
159
173
|
return { snapshot, configKey };
|
|
160
174
|
}
|
|
161
|
-
|
|
175
|
+
busFailures.set(scope, { reason: "plugin did not answer; using zellij polling for 5s", until: Date.now() + 5000 });
|
|
176
|
+
if (busFailures.size > 256)
|
|
177
|
+
busFailures.delete(busFailures.keys().next().value);
|
|
162
178
|
return null;
|
|
163
179
|
}
|
|
164
180
|
/**
|
|
@@ -178,7 +194,7 @@ export async function busScreens(client, state, session, paneIds, clock, env = p
|
|
|
178
194
|
const plan = planBus(client, state, env, session);
|
|
179
195
|
if (!plan.enabled || !plan.url)
|
|
180
196
|
return null;
|
|
181
|
-
const configKey = answeredWith.get(session) ?? plan.configKey;
|
|
197
|
+
const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
|
|
182
198
|
const reply = await client.scrollbackPlugin({
|
|
183
199
|
session,
|
|
184
200
|
url: plan.url,
|
|
@@ -209,7 +225,7 @@ export async function busWait(client, state, session, request, env = process.env
|
|
|
209
225
|
const plan = planBus(client, state, env, session);
|
|
210
226
|
if (!plan.enabled || !plan.url)
|
|
211
227
|
return null;
|
|
212
|
-
const configKey = answeredWith.get(session) ?? plan.configKey;
|
|
228
|
+
const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
|
|
213
229
|
const reply = await client.waitPlugin({
|
|
214
230
|
session,
|
|
215
231
|
url: plan.url,
|
|
@@ -232,7 +248,7 @@ export async function busChanged(client, state, session, paneIds, env = process.
|
|
|
232
248
|
const plan = planBus(client, state, env, session);
|
|
233
249
|
if (!plan.enabled || !plan.url)
|
|
234
250
|
return null;
|
|
235
|
-
const configKey = answeredWith.get(session) ?? plan.configKey;
|
|
251
|
+
const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
|
|
236
252
|
const reply = await client.changedPlugin({
|
|
237
253
|
session,
|
|
238
254
|
url: plan.url,
|
|
@@ -243,7 +259,8 @@ export async function busChanged(client, state, session, paneIds, env = process.
|
|
|
243
259
|
const parsed = parseChangedReply(reply.stdout);
|
|
244
260
|
if (!parsed || !parsed.ready)
|
|
245
261
|
return null;
|
|
246
|
-
|
|
262
|
+
const received = new Set([...parsed.panes.map((pane) => pane.id), ...parsed.missing]);
|
|
263
|
+
return paneIds.every((id) => received.has(id)) ? parsed : null;
|
|
247
264
|
}
|
|
248
265
|
function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
|
|
249
266
|
return {
|
|
@@ -261,7 +278,7 @@ function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
|
|
|
261
278
|
};
|
|
262
279
|
}
|
|
263
280
|
async function listBusPluginPanes(client, session, pluginPath) {
|
|
264
|
-
const panes = await client.listPanes(session);
|
|
281
|
+
const panes = await client.listPanes(session, undefined, { fresh: true });
|
|
265
282
|
return panes.filter((pane) => isBusPluginPane(pane, pluginPath));
|
|
266
283
|
}
|
|
267
284
|
/** Close every zswarm-bus plugin pane in this session. Best-effort per pane. */
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -6,7 +6,7 @@ import { assertOpAllowed, assertPaneAllowed, loadPolicy, } from "../policy.js";
|
|
|
6
6
|
import { createStateStore } from "../state.js";
|
|
7
7
|
import { busToPanes } from "../zellij/bus.js";
|
|
8
8
|
import { broadcast } from "./broadcast.js";
|
|
9
|
-
import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
|
|
9
|
+
import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
|
|
10
10
|
import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
|
|
11
11
|
import { peerCheckpoint, peerDiff } from "./review.js";
|
|
12
12
|
import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
|
|
@@ -31,12 +31,18 @@ import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, }
|
|
|
31
31
|
export function resolveInvocationEnv(args, base) {
|
|
32
32
|
const local = isTrue(args.local);
|
|
33
33
|
const sshDest = typeof args.ssh === "string" && args.ssh.trim() ? args.ssh.trim() : null;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const serveAddress = optionalString(args.serveAddress);
|
|
35
|
+
if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(serveAddress)) > 1) {
|
|
36
|
+
throw new ZellijError("usage", "choose only one of --local, --ssh, or --serve");
|
|
36
37
|
}
|
|
37
|
-
if (!local && !sshDest)
|
|
38
|
+
if (!local && !sshDest && !serveAddress)
|
|
38
39
|
return base;
|
|
39
40
|
const env = { ...base };
|
|
41
|
+
if (serveAddress) {
|
|
42
|
+
env.ZSWARM_SERVE = serveAddress;
|
|
43
|
+
delete env.ZSWARM_SSH;
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
40
46
|
if (local) {
|
|
41
47
|
delete env.ZSWARM_SSH;
|
|
42
48
|
delete env.ZSWARM_SSH_OPTS;
|
|
@@ -107,9 +113,10 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
107
113
|
}
|
|
108
114
|
const until = Math.min(budget.deadline, clock.now() + observeMs);
|
|
109
115
|
let pane;
|
|
116
|
+
let fresh = isTrue(args.fresh) || Boolean(op);
|
|
110
117
|
for (;;) {
|
|
111
118
|
const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
|
|
112
|
-
panes ??= await client.listPanes(session, left);
|
|
119
|
+
panes ??= await client.listPanes(session, left, { fresh });
|
|
113
120
|
throwIfAborted(signal);
|
|
114
121
|
try {
|
|
115
122
|
pane = client.resolvePane(panes, to);
|
|
@@ -122,6 +129,7 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
|
|
|
122
129
|
if (clock.now() >= until)
|
|
123
130
|
throw err;
|
|
124
131
|
panes = null;
|
|
132
|
+
fresh = true;
|
|
125
133
|
}
|
|
126
134
|
}
|
|
127
135
|
if (policy && op)
|
|
@@ -181,9 +189,11 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
181
189
|
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
182
190
|
// just because the host env has ZSWARM_SERVE set.
|
|
183
191
|
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
184
|
-
|
|
192
|
+
const request = { ...attachKnownSender(args, env) };
|
|
193
|
+
delete request.serveAddress; // Routing is consumed here, never forwarded back into a tunnel.
|
|
194
|
+
return await callServe(env.ZSWARM_SERVE.trim(), request, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
185
195
|
}
|
|
186
|
-
const baseClient = injected ?? createZellijClient({ env, signal });
|
|
196
|
+
const baseClient = injected ?? createZellijClient({ env, signal, cache: isTrue(args.fresh) ? false : undefined });
|
|
187
197
|
const client = {
|
|
188
198
|
...baseClient,
|
|
189
199
|
get transport() { return baseClient.transport; },
|
|
@@ -224,9 +234,10 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
224
234
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
225
235
|
// The plugin's manifest carries no cwd or floating flag, so anything
|
|
226
236
|
// verbose has to go the polling route.
|
|
227
|
-
const
|
|
237
|
+
const observedBus = verbose
|
|
228
238
|
? null
|
|
229
239
|
: await busSnapshot(client, state(), session, clock, env);
|
|
240
|
+
const bus = observedBus?.snapshot.ready ? observedBus : null;
|
|
230
241
|
const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
|
|
231
242
|
const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
|
|
232
243
|
.filter((p) => !p.isPlugin)
|
|
@@ -391,24 +402,39 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
391
402
|
throwIfAborted(signal);
|
|
392
403
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
|
|
393
404
|
throwIfAborted(signal);
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
405
|
+
// Once the session is known, verbose metadata and the bus manifest
|
|
406
|
+
// are independent reads. Both consume this operation's remaining time.
|
|
407
|
+
const readBus = () => remaining() <= 0 ? Promise.resolve(null)
|
|
408
|
+
: busSnapshot(client, state(), session, clock, env, "status", { deadlineAt, signal });
|
|
409
|
+
let observedBus;
|
|
410
|
+
let detailedPanes;
|
|
411
|
+
if (verbose) {
|
|
412
|
+
const reads = await Promise.allSettled([
|
|
413
|
+
readBus(), client.listPanes(session, setupBudget(), { fresh: true }),
|
|
414
|
+
]);
|
|
415
|
+
throwIfAborted(signal);
|
|
416
|
+
if (reads[1].status === "rejected")
|
|
417
|
+
throw reads[1].reason;
|
|
418
|
+
if (reads[0].status === "rejected")
|
|
419
|
+
throw reads[0].reason;
|
|
420
|
+
observedBus = reads[0].value;
|
|
421
|
+
detailedPanes = reads[1].value;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
observedBus = await readBus();
|
|
425
|
+
}
|
|
426
|
+
const bus = observedBus?.snapshot.ready ? observedBus : null;
|
|
401
427
|
throwIfAborted(signal);
|
|
402
428
|
// Always hand peerStatus the resolved session (and panes) so it does
|
|
403
429
|
// not spend the overall budget resolving again.
|
|
404
|
-
const panes = bus
|
|
430
|
+
const panes = detailedPanes ?? (bus
|
|
405
431
|
? busToPanes(bus.snapshot)
|
|
406
|
-
: await client.listPanes(session, setupBudget());
|
|
432
|
+
: await client.listPanes(session, setupBudget()));
|
|
407
433
|
throwIfAborted(signal);
|
|
408
434
|
let supplied = {
|
|
409
435
|
session,
|
|
410
436
|
panes,
|
|
411
|
-
source: bus ? "plugin" : "zellij",
|
|
437
|
+
source: bus && !verbose ? "plugin" : "zellij",
|
|
412
438
|
};
|
|
413
439
|
if (bus) {
|
|
414
440
|
supplied = {
|
|
@@ -433,13 +459,24 @@ async function dispatchOperation(args, injected, deps, context, env) {
|
|
|
433
459
|
catch {
|
|
434
460
|
// Command-shaped `to` needs the polled list; re-fetch without bus.
|
|
435
461
|
const polled = await client.listPanes(session, setupBudget());
|
|
436
|
-
supplied = { session, panes: polled, source: "zellij" };
|
|
462
|
+
supplied = { ...supplied, session, panes: polled, source: "zellij" };
|
|
437
463
|
}
|
|
438
464
|
}
|
|
439
|
-
|
|
465
|
+
const result = await peerStatus(client, args, clock, supplied, {
|
|
440
466
|
deadlineAt,
|
|
441
467
|
signal,
|
|
442
468
|
});
|
|
469
|
+
if (result.ok && result.data && typeof result.data === "object") {
|
|
470
|
+
const data = result.data;
|
|
471
|
+
data.polling = {
|
|
472
|
+
busAvailable: Boolean(bus),
|
|
473
|
+
...(bus ? {} : { reason: planBus(client, state(), env, session).reason }),
|
|
474
|
+
...(client.remote ? {
|
|
475
|
+
recommendation: "For frequent status calls, run zswarm serve beside Zellij and use --serve 127.0.0.1:9419 through an SSH tunnel; direct SSH cannot use the local event bus.",
|
|
476
|
+
} : {}),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return result;
|
|
443
480
|
}
|
|
444
481
|
case "bus":
|
|
445
482
|
return await busOp(client, state(), args, clock, env, policy);
|