@zswarm/core 0.4.2 → 0.6.0
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/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/ops/delivery.d.ts +6 -2
- package/dist/ops/delivery.js +7 -3
- package/dist/ops/dispatch.js +33 -2
- package/dist/ops/panes.d.ts +2 -1
- package/dist/ops/panes.js +15 -3
- package/dist/ops/signals.d.ts +1 -1
- package/dist/ops/signals.js +24 -11
- package/dist/schema.js +13 -6
- package/dist/state.d.ts +16 -0
- package/dist/state.js +50 -0
- package/dist/zellij/args.d.ts +2 -0
- package/dist/zellij/args.js +4 -0
- package/dist/zellij/client.d.ts +30 -2
- package/dist/zellij/client.js +434 -8
- package/dist/zellij/panes.d.ts +1 -1
- package/dist/zellij/panes.js +8 -3
- package/dist/zellij/server-identity.d.ts +95 -0
- package/dist/zellij/server-identity.js +405 -0
- package/dist/zellij/session.d.ts +3 -1
- package/package.json +1 -1
package/dist/zellij/client.js
CHANGED
|
@@ -1,13 +1,60 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { ListingCache, executorIdentity, listingTtl, routingEnvironment, sharedListings } from "./cache.js";
|
|
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
|
+
import { buildClosePaneArgs, buildDumpArgs, buildDumpLayoutArgs, buildFocusPaneArgs, buildLaunchPluginArgs, buildListPanesArgs, buildListTabsArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildRenamePaneArgs, buildRenameSessionArgs, buildRenameTabArgs, buildSendEnterArgs, buildSendKeysArgs, buildStackPanesArgs, buildWriteCharsArgs, changedPayload, scrollbackPayload, waitPayload, } from "./args.js";
|
|
4
4
|
import { DEFAULT_BUS_TIMEOUT_MS, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, } from "./bus.js";
|
|
5
5
|
import { parseTabList, resolveTab } from "./tabs.js";
|
|
6
6
|
import { createSshExec } from "../exec.js";
|
|
7
7
|
import { originSuffix } from "../ops/delivery.js";
|
|
8
8
|
import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijProbes, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, zellijExecDetails, zellijMissingError, } from "./binary.js";
|
|
9
9
|
import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
|
|
10
|
+
import { assignServersToSessions, findAncestorServer, liveServers, matchServerToSession, paneMatchesId, readProcessTable, } from "./server-identity.js";
|
|
10
11
|
import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, resolveSelfSession, sessionFromEnv, sessionFromList, } from "./session.js";
|
|
12
|
+
/** How long one process-table snapshot is reused, for the whole process. */
|
|
13
|
+
const PROCESS_TABLE_TTL_MS = 2_000;
|
|
14
|
+
/** How long a resolved self session is trusted across clients. */
|
|
15
|
+
const SELF_RESOLUTION_TTL_MS = 30_000;
|
|
16
|
+
/**
|
|
17
|
+
* Process-wide caches. MCP/serve processes are long-lived but create a client
|
|
18
|
+
* per dispatch, so caches kept inside a client never survive to the next call.
|
|
19
|
+
*/
|
|
20
|
+
let sharedProcessTable = null;
|
|
21
|
+
let sharedProcessTableInFlight = null;
|
|
22
|
+
const selfResolutionCache = new Map();
|
|
23
|
+
/** Test helper: forget every cached self resolution (and its process table). */
|
|
24
|
+
export function resetSelfResolutionCache() {
|
|
25
|
+
selfResolutionCache.clear();
|
|
26
|
+
sharedProcessTable = null;
|
|
27
|
+
sharedProcessTableInFlight = null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Drop cached self resolutions that name `session` (as the env name a pane
|
|
31
|
+
* carries or as the resolved live name); a renamed or closed session must not
|
|
32
|
+
* be served from cache.
|
|
33
|
+
*/
|
|
34
|
+
function invalidateSelfResolutionCache(session) {
|
|
35
|
+
for (const [key, entry] of selfResolutionCache) {
|
|
36
|
+
if (entry.env === session || entry.name === session)
|
|
37
|
+
selfResolutionCache.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function sharedProcessTableSnapshot(platform) {
|
|
41
|
+
const at = Date.now();
|
|
42
|
+
if (sharedProcessTable && at - sharedProcessTable.at <= PROCESS_TABLE_TTL_MS) {
|
|
43
|
+
return sharedProcessTable.rows;
|
|
44
|
+
}
|
|
45
|
+
// Collapse concurrent readers onto one spawn so N clients do not each pay.
|
|
46
|
+
if (!sharedProcessTableInFlight) {
|
|
47
|
+
sharedProcessTableInFlight = readProcessTable({ platform })
|
|
48
|
+
.then((rows) => {
|
|
49
|
+
sharedProcessTable = { at: Date.now(), rows };
|
|
50
|
+
return rows;
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
sharedProcessTableInFlight = null;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return sharedProcessTableInFlight;
|
|
57
|
+
}
|
|
11
58
|
/** Zellij wrapper with bounded caches of completed discovery observations. */
|
|
12
59
|
export function createZellijClient(options = {}) {
|
|
13
60
|
const env = options.env ?? process.env;
|
|
@@ -28,9 +75,28 @@ export function createZellijClient(options = {}) {
|
|
|
28
75
|
(sshExec ? sshExec : defaultExec(zellijPath, env));
|
|
29
76
|
const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
|
|
30
77
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
78
|
+
const platform = options.platform ?? process.platform;
|
|
31
79
|
const selfPaneId = resolveSelfPaneId(env);
|
|
32
80
|
// Over SSH the target is another machine's Zellij: no pane there is ours.
|
|
33
|
-
const
|
|
81
|
+
const staticSelfSession = ssh ? null : resolveSelfSession(env);
|
|
82
|
+
// The live name of this pane's session once resolved through the process
|
|
83
|
+
// table (the env name can be stale after a rename).
|
|
84
|
+
let selfResolved = null;
|
|
85
|
+
let selfFailureAt = 0;
|
|
86
|
+
// Explicit names seen by this client (arg/env_zswarm -> source label), used
|
|
87
|
+
// to diagnose a failed Zellij call on the failure path only. Bounded so a
|
|
88
|
+
// long-lived MCP process cannot grow it without limit.
|
|
89
|
+
const explicitSessions = new Map();
|
|
90
|
+
function rememberExplicitSession(session, source) {
|
|
91
|
+
explicitSessions.delete(session);
|
|
92
|
+
explicitSessions.set(session, source);
|
|
93
|
+
if (explicitSessions.size > 256) {
|
|
94
|
+
const oldest = explicitSessions.keys().next().value;
|
|
95
|
+
if (oldest !== undefined)
|
|
96
|
+
explicitSessions.delete(oldest);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const aliases = options.aliases ?? null;
|
|
34
100
|
const probeKey = JSON.stringify([identityCacheKey(zellijPath, ssh), routingEnvironment(env)]);
|
|
35
101
|
const now = options.now ?? Date.now;
|
|
36
102
|
const cache = options.cache instanceof ListingCache ? options.cache : sharedListings;
|
|
@@ -42,6 +108,11 @@ export function createZellijClient(options = {}) {
|
|
|
42
108
|
const ipc = readTransport().ipc;
|
|
43
109
|
return ttl > 0 && (ipc?.requested?.toLowerCase() !== "auto" || ipc.status === "resolved");
|
|
44
110
|
};
|
|
111
|
+
async function processTable() {
|
|
112
|
+
if (options.processTable)
|
|
113
|
+
return await options.processTable();
|
|
114
|
+
return sharedProcessTableSnapshot(platform);
|
|
115
|
+
}
|
|
45
116
|
function invalidateListings(session) {
|
|
46
117
|
cache.invalidate(scopeFor(session));
|
|
47
118
|
cache.invalidate(scopeFor());
|
|
@@ -135,7 +206,8 @@ export function createZellijClient(options = {}) {
|
|
|
135
206
|
}
|
|
136
207
|
const mutations = new Set([
|
|
137
208
|
"new-pane", "new-tab", "close-pane", "rename-pane", "rename-tab-by-id",
|
|
138
|
-
"focus-pane-id", "stack-panes", "launch-or-focus-plugin",
|
|
209
|
+
"rename-session", "focus-pane-id", "stack-panes", "launch-or-focus-plugin",
|
|
210
|
+
"paste", "send-keys", "write-chars",
|
|
139
211
|
]);
|
|
140
212
|
async function run(args, label, callTimeoutMs = timeoutMs) {
|
|
141
213
|
const action = args[args.indexOf("action") + 1];
|
|
@@ -191,14 +263,358 @@ export function createZellijClient(options = {}) {
|
|
|
191
263
|
}
|
|
192
264
|
invalidateListings(name);
|
|
193
265
|
}
|
|
266
|
+
/** A clear failure for a session name that is not (and does not map to) a live one. */
|
|
267
|
+
function sessionNotLiveError(candidate, source, live) {
|
|
268
|
+
const liveList = live.length > 0 ? live.join(", ") : "none";
|
|
269
|
+
const stale = source === "env_zellij"
|
|
270
|
+
? " (this pane's ZELLIJ_SESSION_NAME is stale)"
|
|
271
|
+
: "";
|
|
272
|
+
return new ZellijError("session_not_live", `session "${candidate}" is not a live Zellij session (renamed or closed?); live sessions: ${liveList}; pass session=<name>${stale}`, { requested: candidate, source, live });
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Match a server process to the live session whose panes it owns. Lists the
|
|
276
|
+
* panes of every live session under the caller's remaining budget.
|
|
277
|
+
*/
|
|
278
|
+
async function matchViaServer(candidate, isSelf, live, table, ancestor, budget) {
|
|
279
|
+
let server = ancestor;
|
|
280
|
+
if (!server) {
|
|
281
|
+
const byCreation = liveServers(table).filter((item) => item.creationName === candidate);
|
|
282
|
+
if (byCreation.length !== 1)
|
|
283
|
+
return null;
|
|
284
|
+
server = byCreation[0];
|
|
285
|
+
}
|
|
286
|
+
const withPanes = [];
|
|
287
|
+
for (const session of live) {
|
|
288
|
+
try {
|
|
289
|
+
withPanes.push({
|
|
290
|
+
name: session.name,
|
|
291
|
+
panes: await listPanes(session.name, budget()),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// A session that closed mid-resolution cannot be the answer.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// The deterministic global assignment resolves generic-command ties via
|
|
299
|
+
// creation-name pins and single-candidate propagation. In the self case a
|
|
300
|
+
// pinned session is only trusted when it actually holds our pane; else
|
|
301
|
+
// fall back to the pane-id-filtered single match.
|
|
302
|
+
let match = null;
|
|
303
|
+
const pinned = assignServersToSessions(table, withPanes).get(server.pid);
|
|
304
|
+
if (pinned) {
|
|
305
|
+
const pinnedSession = withPanes.find((session) => session.name === pinned);
|
|
306
|
+
if (!isSelf ||
|
|
307
|
+
!selfPaneId ||
|
|
308
|
+
pinnedSession?.panes.some((pane) => paneMatchesId(pane, selfPaneId))) {
|
|
309
|
+
match = pinned;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!match) {
|
|
313
|
+
match = matchServerToSession(server.pid, table, withPanes, {
|
|
314
|
+
...(isSelf && selfPaneId ? { paneId: selfPaneId } : {}),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (!match)
|
|
318
|
+
return null;
|
|
319
|
+
if (aliases) {
|
|
320
|
+
const entry = {
|
|
321
|
+
current: match,
|
|
322
|
+
serverPid: server.pid,
|
|
323
|
+
creation: server.creationName,
|
|
324
|
+
at: now(),
|
|
325
|
+
};
|
|
326
|
+
aliases.recordSessionAlias(candidate, entry);
|
|
327
|
+
if (server.creationName && server.creationName !== candidate) {
|
|
328
|
+
aliases.recordSessionAlias(server.creationName, entry);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { session: match, source: "renamed", requested: candidate };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Session resolution beyond the environment. An explicit name is trusted
|
|
335
|
+
* without a listing (callers that name their session do not pay a round
|
|
336
|
+
* trip to confirm it), except that a remembered rename is applied from the
|
|
337
|
+
* alias store; a live server pid locally proves the alias still leads to a
|
|
338
|
+
* live session. A pane's own env name gets the full treatment: the process
|
|
339
|
+
* table disambiguates a reused name and the server behind a renamed one.
|
|
340
|
+
*/
|
|
341
|
+
async function resolveCandidate(fromEnv, callTimeoutMs) {
|
|
342
|
+
const candidate = fromEnv.session;
|
|
343
|
+
const source = fromEnv.source;
|
|
344
|
+
const isSelf = source === "env_zellij";
|
|
345
|
+
if (!isSelf) {
|
|
346
|
+
if (aliases) {
|
|
347
|
+
const entry = aliases.readSessionAliases()[candidate];
|
|
348
|
+
if (entry?.serverPid != null) {
|
|
349
|
+
const table = await processTable();
|
|
350
|
+
if (liveServers(table).some((server) => server.pid === entry.serverPid)) {
|
|
351
|
+
return { session: entry.current, source: "alias", requested: candidate };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return fromEnv;
|
|
356
|
+
}
|
|
357
|
+
const deadline = Date.now() + callTimeoutMs;
|
|
358
|
+
const budget = () => Math.max(1, deadline - Date.now());
|
|
359
|
+
const sessions = await listSessions(budget());
|
|
360
|
+
const live = sessions.filter((session) => !session.exited);
|
|
361
|
+
const liveNames = live.map((session) => session.name);
|
|
362
|
+
if (liveNames.includes(candidate)) {
|
|
363
|
+
// Windows pays one PowerShell CIM call (often 1-2 s) for the process
|
|
364
|
+
// table, so a live env name is trusted there; the reuse check stays
|
|
365
|
+
// POSIX-only and the table is read only when the name is not live.
|
|
366
|
+
if (platform === "win32")
|
|
367
|
+
return fromEnv;
|
|
368
|
+
const table = await processTable();
|
|
369
|
+
const ancestor = findAncestorServer(table, process.pid);
|
|
370
|
+
// A live session may have been created with this name after our session
|
|
371
|
+
// moved off it; another server carrying the name is what tells us so.
|
|
372
|
+
const reused = liveServers(table).some((server) => server.creationName === candidate && server.pid !== ancestor?.pid);
|
|
373
|
+
if (!reused)
|
|
374
|
+
return fromEnv;
|
|
375
|
+
const viaServer = await matchViaServer(candidate, isSelf, live, table, ancestor, budget);
|
|
376
|
+
if (viaServer)
|
|
377
|
+
return viaServer;
|
|
378
|
+
invalidateSelfResolutionCache(candidate);
|
|
379
|
+
throw sessionNotLiveError(candidate, source, liveNames);
|
|
380
|
+
}
|
|
381
|
+
if (aliases) {
|
|
382
|
+
const entry = aliases.readSessionAliases()[candidate];
|
|
383
|
+
if (entry && liveNames.includes(entry.current)) {
|
|
384
|
+
const table = await processTable();
|
|
385
|
+
if (entry.serverPid !== null &&
|
|
386
|
+
liveServers(table).some((server) => server.pid === entry.serverPid)) {
|
|
387
|
+
return { session: entry.current, source: "alias", requested: candidate };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const table = await processTable();
|
|
392
|
+
const ancestor = findAncestorServer(table, process.pid);
|
|
393
|
+
const viaServer = await matchViaServer(candidate, isSelf, live, table, ancestor, budget);
|
|
394
|
+
if (viaServer)
|
|
395
|
+
return viaServer;
|
|
396
|
+
invalidateSelfResolutionCache(candidate);
|
|
397
|
+
throw sessionNotLiveError(candidate, source, liveNames);
|
|
398
|
+
}
|
|
194
399
|
async function resolveSession(explicit, callTimeoutMs = timeoutMs) {
|
|
195
|
-
|
|
196
|
-
|
|
400
|
+
const fromEnv = sessionFromEnv(env, explicit);
|
|
401
|
+
if (!fromEnv)
|
|
402
|
+
return sessionFromList(await listSessions(callTimeoutMs));
|
|
403
|
+
// Over SSH the process table describes the wrong machine; an explicit or
|
|
404
|
+
// env name is all this transport can trust.
|
|
405
|
+
if (ssh)
|
|
406
|
+
return fromEnv;
|
|
407
|
+
if (fromEnv.source === "arg" || fromEnv.source === "env_zswarm") {
|
|
408
|
+
rememberExplicitSession(fromEnv.session, fromEnv.source);
|
|
409
|
+
}
|
|
410
|
+
const resolved = await resolveCandidate(fromEnv, callTimeoutMs);
|
|
411
|
+
if (fromEnv.source === "env_zellij") {
|
|
412
|
+
selfResolved = resolved.session;
|
|
413
|
+
}
|
|
414
|
+
else if (fromEnv.source === "arg" || fromEnv.source === "env_zswarm") {
|
|
415
|
+
rememberExplicitSession(resolved.session, fromEnv.source);
|
|
416
|
+
}
|
|
417
|
+
return resolved;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Cache key for a self resolution: env name + pane id + the pane's ancestor
|
|
421
|
+
* server pid when known, so a reused name cannot be served from another
|
|
422
|
+
* pane's entry. On win32 the process table costs one PowerShell CIM call
|
|
423
|
+
* (often 1-2 s) and the fast path must not pay it, so the pid is left out.
|
|
424
|
+
*/
|
|
425
|
+
async function selfResolutionKey(candidate) {
|
|
426
|
+
const pane = selfPaneId ?? "";
|
|
427
|
+
let pid = null;
|
|
428
|
+
if (platform !== "win32") {
|
|
429
|
+
try {
|
|
430
|
+
const table = await processTable();
|
|
431
|
+
pid = findAncestorServer(table, process.pid)?.pid ?? null;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
pid = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return JSON.stringify([candidate, pane, pid]);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The live session this process runs in, resolved through its server
|
|
441
|
+
* process; null when there is no pane env or resolution fails. Cached per
|
|
442
|
+
* client and, for long-lived processes (MCP/serve), across clients for 30 s.
|
|
443
|
+
*/
|
|
444
|
+
async function resolveSelf() {
|
|
445
|
+
if (ssh)
|
|
446
|
+
return null;
|
|
447
|
+
if (selfResolved)
|
|
448
|
+
return selfResolved;
|
|
449
|
+
if (selfFailureAt && now() - selfFailureAt < PROCESS_TABLE_TTL_MS)
|
|
450
|
+
return null;
|
|
451
|
+
const fromEnv = sessionFromEnv(env, null);
|
|
452
|
+
if (!fromEnv || fromEnv.source !== "env_zellij")
|
|
453
|
+
return null;
|
|
454
|
+
const key = await selfResolutionKey(fromEnv.session);
|
|
455
|
+
const cached = selfResolutionCache.get(key);
|
|
456
|
+
if (cached && Date.now() - cached.at <= SELF_RESOLUTION_TTL_MS) {
|
|
457
|
+
selfResolved = cached.name;
|
|
458
|
+
return cached.name;
|
|
459
|
+
}
|
|
460
|
+
if (cached)
|
|
461
|
+
selfResolutionCache.delete(key);
|
|
462
|
+
try {
|
|
463
|
+
const resolved = await resolveCandidate(fromEnv, timeoutMs);
|
|
464
|
+
selfResolved = resolved.session;
|
|
465
|
+
selfResolutionCache.set(key, {
|
|
466
|
+
env: fromEnv.session,
|
|
467
|
+
name: resolved.session,
|
|
468
|
+
at: Date.now(),
|
|
469
|
+
});
|
|
470
|
+
return selfResolved;
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
selfFailureAt = now();
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Rename the session itself. Refuses a name that is already live. */
|
|
478
|
+
async function renameSession(input) {
|
|
479
|
+
const was = input.session.trim();
|
|
480
|
+
const name = input.name.trim();
|
|
481
|
+
if (!was)
|
|
482
|
+
throw new ZellijError("missing_session", "session required");
|
|
483
|
+
if (!name)
|
|
484
|
+
throw new ZellijError("missing_name", "name required");
|
|
485
|
+
const sessions = await listSessions(input.callTimeoutMs ?? timeoutMs, { fresh: true });
|
|
486
|
+
if (sessions.some((session) => !session.exited && session.name === name)) {
|
|
487
|
+
throw new ZellijError("name_taken", `session "${name}" is already live; pick another name or close it first`, { name });
|
|
488
|
+
}
|
|
489
|
+
// The server process keeps its pid across the rename; remember its
|
|
490
|
+
// identity before the old name is gone.
|
|
491
|
+
let serverPid = null;
|
|
492
|
+
let creation = null;
|
|
493
|
+
if (!ssh) {
|
|
494
|
+
const table = await processTable();
|
|
495
|
+
const byCreation = liveServers(table).find((server) => server.creationName === was);
|
|
496
|
+
if (byCreation) {
|
|
497
|
+
serverPid = byCreation.pid;
|
|
498
|
+
creation = byCreation.creationName;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const remembered = aliases?.readSessionAliases();
|
|
502
|
+
const entry = remembered
|
|
503
|
+
? (remembered[was] ??
|
|
504
|
+
Object.values(remembered).find((item) => item.current === was))
|
|
505
|
+
: undefined;
|
|
506
|
+
if (entry?.serverPid != null) {
|
|
507
|
+
serverPid = entry.serverPid;
|
|
508
|
+
creation = entry.creation;
|
|
509
|
+
}
|
|
510
|
+
else if (was === (selfResolved ?? staticSelfSession)) {
|
|
511
|
+
const ancestor = findAncestorServer(table, process.pid);
|
|
512
|
+
if (ancestor) {
|
|
513
|
+
serverPid = ancestor.pid;
|
|
514
|
+
creation = ancestor.creationName;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
invalidateListings(was);
|
|
520
|
+
invalidateListings(name);
|
|
521
|
+
try {
|
|
522
|
+
await run(buildRenameSessionArgs(was, name), "zellij action rename-session", input.callTimeoutMs ?? timeoutMs);
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
invalidateListings(was);
|
|
526
|
+
invalidateListings(name);
|
|
527
|
+
}
|
|
528
|
+
if (aliases) {
|
|
529
|
+
aliases.recordSessionAlias(was, { current: name, serverPid, creation, at: now() });
|
|
530
|
+
}
|
|
531
|
+
if (was === selfResolved || was === staticSelfSession)
|
|
532
|
+
selfResolved = name;
|
|
533
|
+
// A rename invalidates every cached self resolution of the old name, for
|
|
534
|
+
// this client and for other clients in the process.
|
|
535
|
+
invalidateSelfResolutionCache(was);
|
|
536
|
+
invalidateSelfResolutionCache(name);
|
|
537
|
+
return { session: name, was };
|
|
538
|
+
}
|
|
539
|
+
/** Raw list-panes call: shared by the normal path and the failure diagnosis. */
|
|
540
|
+
async function fetchPanes(session, remaining) {
|
|
541
|
+
const result = await run(buildListPanesArgs(session), "zellij action list-panes", remaining());
|
|
542
|
+
return parsePaneList(result.stdout, result.stderr);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* A trusted explicit name that just failed a Zellij call may be an old name
|
|
546
|
+
* whose rename zswarm has not recorded yet. Diagnose once, on the failure
|
|
547
|
+
* path only (passing calls keep their exact Zellij call counts):
|
|
548
|
+
* the session is live -> keep the original error; renamed -> record the
|
|
549
|
+
* alias and throw session_renamed; gone -> session_not_live + live list.
|
|
550
|
+
*/
|
|
551
|
+
async function diagnoseFailedExplicitSession(session, err) {
|
|
552
|
+
if (!(err instanceof ZellijError) || err.code !== "zellij_failed")
|
|
553
|
+
return err;
|
|
554
|
+
// A timeout is not evidence of a dead name: Zellij fails fast for a
|
|
555
|
+
// missing session, and diagnosing a timeout only doubles the budget that
|
|
556
|
+
// callers are already sharing (status deadline tests pin this).
|
|
557
|
+
if (/timed out/i.test(err.message))
|
|
558
|
+
return err;
|
|
559
|
+
const source = explicitSessions.get(session);
|
|
560
|
+
if (ssh || !source)
|
|
561
|
+
return err;
|
|
562
|
+
try {
|
|
563
|
+
const deadline = Date.now() + Math.min(timeoutMs, 5_000);
|
|
564
|
+
const budget = () => Math.max(1, deadline - Date.now());
|
|
565
|
+
const listed = await listSessions(budget(), { fresh: true });
|
|
566
|
+
const live = listed.filter((item) => !item.exited);
|
|
567
|
+
const liveNames = live.map((item) => item.name);
|
|
568
|
+
if (liveNames.includes(session))
|
|
569
|
+
return err;
|
|
570
|
+
invalidateSelfResolutionCache(session);
|
|
571
|
+
const table = await processTable();
|
|
572
|
+
const byCreation = liveServers(table).filter((server) => server.creationName === session);
|
|
573
|
+
if (byCreation.length !== 1)
|
|
574
|
+
return sessionNotLiveError(session, source, liveNames);
|
|
575
|
+
const server = byCreation[0];
|
|
576
|
+
const withPanes = [];
|
|
577
|
+
for (const item of live) {
|
|
578
|
+
try {
|
|
579
|
+
withPanes.push({ name: item.name, panes: await fetchPanes(item.name, budget) });
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
// A session that closed mid-diagnosis cannot be the answer.
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Prefer the deterministic global assignment; fall back to the
|
|
586
|
+
// single-match test for a server the assignment could not pin.
|
|
587
|
+
const match = assignServersToSessions(table, withPanes).get(server.pid) ??
|
|
588
|
+
matchServerToSession(server.pid, table, withPanes);
|
|
589
|
+
if (!match)
|
|
590
|
+
return sessionNotLiveError(session, source, liveNames);
|
|
591
|
+
if (aliases) {
|
|
592
|
+
const entry = {
|
|
593
|
+
current: match,
|
|
594
|
+
serverPid: server.pid,
|
|
595
|
+
creation: server.creationName,
|
|
596
|
+
at: now(),
|
|
597
|
+
};
|
|
598
|
+
aliases.recordSessionAlias(session, entry);
|
|
599
|
+
if (server.creationName && server.creationName !== session) {
|
|
600
|
+
aliases.recordSessionAlias(server.creationName, entry);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return new ZellijError("session_renamed", `session "${session}" was renamed to "${match}"; use session=${match} (zswarm now maps the old name)`, { requested: session, current: match, live: liveNames });
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Diagnosis is best-effort; the original failure is still the answer.
|
|
607
|
+
return err;
|
|
608
|
+
}
|
|
197
609
|
}
|
|
198
610
|
async function listPanes(session, callTimeoutMs = timeoutMs, opts = {}) {
|
|
199
611
|
return listing("list-panes", session, callTimeoutMs, opts.fresh === true, async (remaining) => {
|
|
200
|
-
|
|
201
|
-
|
|
612
|
+
try {
|
|
613
|
+
return await fetchPanes(session, remaining);
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
throw await diagnoseFailedExplicitSession(session, err);
|
|
617
|
+
}
|
|
202
618
|
});
|
|
203
619
|
}
|
|
204
620
|
async function injectPane(input) {
|
|
@@ -371,7 +787,14 @@ export function createZellijClient(options = {}) {
|
|
|
371
787
|
return {
|
|
372
788
|
zellijPath,
|
|
373
789
|
selfPaneId,
|
|
374
|
-
|
|
790
|
+
/**
|
|
791
|
+
* The caller's session: the resolved live name once resolveSelf (or a
|
|
792
|
+
* self resolveSession) has run, else the raw ZELLIJ_SESSION_NAME. A getter
|
|
793
|
+
* so a rename resolution is visible to the self-pane guard.
|
|
794
|
+
*/
|
|
795
|
+
get selfSession() {
|
|
796
|
+
return ssh ? null : (selfResolved ?? staticSelfSession);
|
|
797
|
+
},
|
|
375
798
|
contextKey,
|
|
376
799
|
invalidateListings,
|
|
377
800
|
observeManifest,
|
|
@@ -383,6 +806,7 @@ export function createZellijClient(options = {}) {
|
|
|
383
806
|
listSessions,
|
|
384
807
|
deleteSession,
|
|
385
808
|
resolveSession,
|
|
809
|
+
resolveSelf,
|
|
386
810
|
listPanes,
|
|
387
811
|
resolvePane,
|
|
388
812
|
injectPane,
|
|
@@ -394,6 +818,7 @@ export function createZellijClient(options = {}) {
|
|
|
394
818
|
newTab,
|
|
395
819
|
renamePane,
|
|
396
820
|
renameTab,
|
|
821
|
+
renameSession,
|
|
397
822
|
focusPane,
|
|
398
823
|
listTabs,
|
|
399
824
|
dumpLayout,
|
|
@@ -415,5 +840,6 @@ export function createZellijClient(options = {}) {
|
|
|
415
840
|
buildClosePaneArgs,
|
|
416
841
|
buildNewPaneArgs,
|
|
417
842
|
buildNewTabArgs,
|
|
843
|
+
buildRenameSessionArgs,
|
|
418
844
|
};
|
|
419
845
|
}
|
package/dist/zellij/panes.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type ZellijPane = {
|
|
|
16
16
|
};
|
|
17
17
|
export declare function normalizePaneId(raw: string, isPlugin?: boolean): string;
|
|
18
18
|
/** Parse `list-panes --json` output. */
|
|
19
|
-
export declare function parsePaneList(stdout: string): ZellijPane[];
|
|
19
|
+
export declare function parsePaneList(stdout: string, stderr?: string): ZellijPane[];
|
|
20
20
|
/**
|
|
21
21
|
* Find a pane by typed id, bare number, exact title, command, then partial
|
|
22
22
|
* title. Ambiguity is an error rather than a guess.
|
package/dist/zellij/panes.js
CHANGED
|
@@ -38,17 +38,22 @@ function parsePaneRow(row) {
|
|
|
38
38
|
: null,
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
/** First 200 characters of what Zellij printed, for an actionable failure. */
|
|
42
|
+
function paneListFailure(message, stdout, stderr) {
|
|
43
|
+
const detail = `${stdout}\n${stderr}`.trim().slice(0, 200);
|
|
44
|
+
return detail ? `${message}: ${detail}` : message;
|
|
45
|
+
}
|
|
41
46
|
/** Parse `list-panes --json` output. */
|
|
42
|
-
export function parsePaneList(stdout) {
|
|
47
|
+
export function parsePaneList(stdout, stderr = "") {
|
|
43
48
|
let parsed;
|
|
44
49
|
try {
|
|
45
50
|
parsed = JSON.parse(stdout);
|
|
46
51
|
}
|
|
47
52
|
catch {
|
|
48
|
-
throw new ZellijError("zellij_failed", "list-panes returned non-JSON output");
|
|
53
|
+
throw new ZellijError("zellij_failed", paneListFailure("list-panes returned non-JSON output", stdout, stderr));
|
|
49
54
|
}
|
|
50
55
|
if (!Array.isArray(parsed)) {
|
|
51
|
-
throw new ZellijError("zellij_failed", "list-panes JSON was not an array");
|
|
56
|
+
throw new ZellijError("zellij_failed", paneListFailure("list-panes JSON was not an array", stdout, stderr));
|
|
52
57
|
}
|
|
53
58
|
const panes = [];
|
|
54
59
|
for (const row of parsed) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ZellijPane } from "./panes.js";
|
|
2
|
+
/**
|
|
3
|
+
* Identify a Zellij session through the server process that owns it.
|
|
4
|
+
*
|
|
5
|
+
* Zellij exports ZELLIJ_SESSION_NAME once, when a pane starts, and never
|
|
6
|
+
* updates it after `rename-session`. Worse, a renamed session's server process
|
|
7
|
+
* keeps its *creation* name on its command line forever. This module bridges
|
|
8
|
+
* the two: it reads the process table, finds the `zellij --server <path>`
|
|
9
|
+
* ancestor of the caller (or any live server), and matches a server to the
|
|
10
|
+
* session whose panes are its descendants.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is pure and never throws: a failed read is an empty table.
|
|
13
|
+
*/
|
|
14
|
+
export type ProcInfo = {
|
|
15
|
+
pid: number;
|
|
16
|
+
ppid: number;
|
|
17
|
+
/** Full command line, args joined with spaces. */
|
|
18
|
+
args: string;
|
|
19
|
+
};
|
|
20
|
+
export type ServerInfo = {
|
|
21
|
+
pid: number;
|
|
22
|
+
creationName: string;
|
|
23
|
+
socketPath: string;
|
|
24
|
+
};
|
|
25
|
+
/** One row from a pane listing plus its session name. */
|
|
26
|
+
export type SessionPanes = {
|
|
27
|
+
name: string;
|
|
28
|
+
panes: ZellijPane[];
|
|
29
|
+
};
|
|
30
|
+
export type ProcessRunner = (file: string, args: string[], options: {
|
|
31
|
+
timeout: number;
|
|
32
|
+
windowsHide?: boolean;
|
|
33
|
+
}) => Promise<{
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr?: string;
|
|
36
|
+
}>;
|
|
37
|
+
export type ReadProcessTableOptions = {
|
|
38
|
+
/** Override process.platform (tests). */
|
|
39
|
+
platform?: NodeJS.Platform;
|
|
40
|
+
/** Injected execFile (tests); defaults to node:child_process. */
|
|
41
|
+
runner?: ProcessRunner;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Snapshot the local process table. Never throws: a failure returns [].
|
|
45
|
+
* Linux reads /proc; macOS/other POSIX shells out to `ps`; Windows asks
|
|
46
|
+
* PowerShell for Win32_Process. Both external reads are bounded at 5s.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readProcessTable(opts?: ReadProcessTableOptions): Promise<ProcInfo[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Parse the argv of a `zellij --server <socketPath>` server process.
|
|
51
|
+
* Returns null for anything that is not a Zellij server. The creation name is
|
|
52
|
+
* the last path segment of the socket path (the name the session had when its
|
|
53
|
+
* server started; renames do not touch it).
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseServerArgs(args: string): {
|
|
56
|
+
socketPath: string;
|
|
57
|
+
creationName: string;
|
|
58
|
+
} | null;
|
|
59
|
+
/**
|
|
60
|
+
* Walk parents from `pid` (up to 64 hops, stopping on a cycle or a missing
|
|
61
|
+
* row) and return the first Zellij server found, or null.
|
|
62
|
+
*/
|
|
63
|
+
export declare function findAncestorServer(table: ProcInfo[], pid: number): ServerInfo | null;
|
|
64
|
+
/** Every Zellij server currently present in the table. */
|
|
65
|
+
export declare function liveServers(table: ProcInfo[]): ServerInfo[];
|
|
66
|
+
/** True when a pane row is the requested (non-plugin) pane id. */
|
|
67
|
+
export declare function paneMatchesId(pane: ZellijPane, paneId: string): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Pick the one live session whose panes belong to `serverPid`.
|
|
70
|
+
*
|
|
71
|
+
* The server's direct children are its panes' processes. A session matches
|
|
72
|
+
* when it has exactly as many non-plugin, non-exited, non-held panes as the
|
|
73
|
+
* server has direct children, and the panes can be assigned one-to-one to
|
|
74
|
+
* distinct children whose subtree argv (child plus descendants) contains the
|
|
75
|
+
* pane's command. With `opts.paneId`, the session must also contain that
|
|
76
|
+
* pane. Zero or several matches return null — this never guesses.
|
|
77
|
+
*/
|
|
78
|
+
export declare function matchServerToSession(serverPid: number, table: ProcInfo[], sessions: SessionPanes[], opts?: {
|
|
79
|
+
paneId?: string;
|
|
80
|
+
}): string | null;
|
|
81
|
+
/**
|
|
82
|
+
* Pair live servers with live sessions using forced assignments only:
|
|
83
|
+
*
|
|
84
|
+
* 1. A server whose creation name is a live session name is pinned to it when
|
|
85
|
+
* that session fits. A creation name shared by two fitting servers (name
|
|
86
|
+
* reuse) is a guess, so neither is pinned and the session stays unassigned.
|
|
87
|
+
* 2. Repeat until stable: an unpinned server with exactly one unpinned fitting
|
|
88
|
+
* session is pinned to it, and an unpinned session fitted by exactly one
|
|
89
|
+
* unpinned server pins that pair. Nothing else: no backtracking, no
|
|
90
|
+
* picking among ties.
|
|
91
|
+
*
|
|
92
|
+
* Returns only the pinned pairs. Servers and sessions left ambiguous are
|
|
93
|
+
* absent; the caller still has the pane-id-filtered single-match test.
|
|
94
|
+
*/
|
|
95
|
+
export declare function assignServersToSessions(table: ProcInfo[], sessions: SessionPanes[]): Map<number, string>;
|