@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.
@@ -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 selfSession = ssh ? null : resolveSelfSession(env);
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", "paste", "send-keys", "write-chars",
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
- return (sessionFromEnv(env, explicit) ??
196
- sessionFromList(await listSessions(callTimeoutMs)));
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
- const result = await run(buildListPanesArgs(session), "zellij action list-panes", remaining());
201
- return parsePaneList(result.stdout);
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
- selfSession,
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
  }
@@ -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.
@@ -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>;