@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.
@@ -0,0 +1,405 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ const WINDOWS_PROCESS_SCRIPT = 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.CommandLine)" }';
4
+ /** Cap on how far an ancestor walk goes; guards against malformed tables. */
5
+ const MAX_ANCESTOR_HOPS = 64;
6
+ const defaultRunner = (file, args, options) => new Promise((resolve, reject) => {
7
+ execFile(file, args, {
8
+ timeout: options.timeout,
9
+ windowsHide: options.windowsHide,
10
+ maxBuffer: 8 * 1024 * 1024,
11
+ encoding: "utf8",
12
+ }, (err, stdout, stderr) => {
13
+ if (err)
14
+ reject(err);
15
+ else
16
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
17
+ });
18
+ });
19
+ function parsePsOutput(stdout) {
20
+ const rows = [];
21
+ for (const line of stdout.split(/\r?\n/)) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed)
24
+ continue;
25
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);
26
+ if (!match)
27
+ continue;
28
+ rows.push({
29
+ pid: Number(match[1]),
30
+ ppid: Number(match[2]),
31
+ args: match[3].trim(),
32
+ });
33
+ }
34
+ return rows;
35
+ }
36
+ function parseWindowsOutput(stdout) {
37
+ const rows = [];
38
+ for (const line of stdout.split(/\r?\n/)) {
39
+ if (!line.trim())
40
+ continue;
41
+ const parts = line.split("\t");
42
+ if (parts.length < 3)
43
+ continue;
44
+ const pid = Number(parts[0]);
45
+ const ppid = Number(parts[1]);
46
+ if (!Number.isInteger(pid) || !Number.isInteger(ppid))
47
+ continue;
48
+ rows.push({
49
+ pid,
50
+ ppid,
51
+ args: parts.slice(2).join("\t").trim(),
52
+ });
53
+ }
54
+ return rows;
55
+ }
56
+ /** Linux: /proc/<pid>/stat for the parent and cmdline for the command. */
57
+ function readProcTable() {
58
+ const rows = [];
59
+ let entries;
60
+ try {
61
+ entries = readdirSync("/proc");
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ for (const name of entries) {
67
+ if (!/^\d+$/.test(name))
68
+ continue;
69
+ try {
70
+ const stat = readFileSync(`/proc/${name}/stat`, "utf8");
71
+ // comm may contain spaces and parentheses: parse from the LAST ')'.
72
+ const close = stat.lastIndexOf(")");
73
+ if (close < 0)
74
+ continue;
75
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
76
+ // state is fields[0]; ppid is fields[1].
77
+ const ppid = Number(fields[1]);
78
+ if (!Number.isInteger(ppid))
79
+ continue;
80
+ let args = "";
81
+ try {
82
+ args = readFileSync(`/proc/${name}/cmdline`, "utf8")
83
+ .replace(/\0/g, " ")
84
+ .trim();
85
+ }
86
+ catch {
87
+ // Kernel threads have no cmdline; the ppid chain still matters.
88
+ }
89
+ rows.push({ pid: Number(name), ppid, args });
90
+ }
91
+ catch {
92
+ // Vanished between readdir and read.
93
+ }
94
+ }
95
+ return rows;
96
+ }
97
+ /**
98
+ * Snapshot the local process table. Never throws: a failure returns [].
99
+ * Linux reads /proc; macOS/other POSIX shells out to `ps`; Windows asks
100
+ * PowerShell for Win32_Process. Both external reads are bounded at 5s.
101
+ */
102
+ export async function readProcessTable(opts = {}) {
103
+ const platform = opts.platform ?? process.platform;
104
+ const runner = opts.runner ?? defaultRunner;
105
+ try {
106
+ if (platform === "linux")
107
+ return readProcTable();
108
+ if (platform === "win32") {
109
+ const result = await runner("powershell", ["-NoProfile", "-Command", WINDOWS_PROCESS_SCRIPT], { timeout: 5_000, windowsHide: true });
110
+ return parseWindowsOutput(result.stdout);
111
+ }
112
+ const result = await runner("ps", ["-axo", "pid=,ppid=,command="], {
113
+ timeout: 5_000,
114
+ });
115
+ return parsePsOutput(result.stdout);
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
121
+ /** Strip one layer of matching surrounding quotes from a single token. */
122
+ function unquoteToken(token) {
123
+ for (const quote of ['"', "'"]) {
124
+ if (token.length >= 2 && token.startsWith(quote) && token.endsWith(quote)) {
125
+ return token.slice(1, -1);
126
+ }
127
+ }
128
+ return token;
129
+ }
130
+ /**
131
+ * Whitespace-split a command line, dropping one layer of surrounding quotes
132
+ * per token. `Prog "a b" c` and `Prog a b c` normalize the same way; this is
133
+ * the comparison form used for server args versus Zellij's pane_command.
134
+ */
135
+ function commandTokens(args) {
136
+ return String(args)
137
+ .trim()
138
+ .split(/\s+/)
139
+ .filter(Boolean)
140
+ .map(unquoteToken);
141
+ }
142
+ function normalizeCommand(args) {
143
+ return commandTokens(args).join(" ");
144
+ }
145
+ /**
146
+ * Parse the argv of a `zellij --server <socketPath>` server process.
147
+ * Returns null for anything that is not a Zellij server. The creation name is
148
+ * the last path segment of the socket path (the name the session had when its
149
+ * server started; renames do not touch it).
150
+ */
151
+ export function parseServerArgs(args) {
152
+ const tokens = commandTokens(args);
153
+ const argv0 = tokens[0];
154
+ if (!argv0)
155
+ return null;
156
+ const base = argv0.replace(/\\/g, "/").split("/").pop() ?? "";
157
+ if (!/^zellij(\.exe)?$/i.test(base))
158
+ return null;
159
+ const index = tokens.indexOf("--server");
160
+ if (index < 0 || index + 1 >= tokens.length)
161
+ return null;
162
+ const socketPath = tokens[index + 1];
163
+ if (!socketPath)
164
+ return null;
165
+ const segments = socketPath.split(/[\\/]/).filter(Boolean);
166
+ const creationName = segments[segments.length - 1] ?? "";
167
+ if (!creationName)
168
+ return null;
169
+ return { socketPath, creationName };
170
+ }
171
+ /**
172
+ * Walk parents from `pid` (up to 64 hops, stopping on a cycle or a missing
173
+ * row) and return the first Zellij server found, or null.
174
+ */
175
+ export function findAncestorServer(table, pid) {
176
+ const byPid = new Map(table.map((proc) => [proc.pid, proc]));
177
+ const seen = new Set();
178
+ let current = pid;
179
+ for (let hops = 0; hops < MAX_ANCESTOR_HOPS; hops++) {
180
+ if (seen.has(current))
181
+ return null;
182
+ seen.add(current);
183
+ const proc = byPid.get(current);
184
+ if (!proc)
185
+ return null;
186
+ const parsed = parseServerArgs(proc.args);
187
+ if (parsed) {
188
+ return { pid: proc.pid, creationName: parsed.creationName, socketPath: parsed.socketPath };
189
+ }
190
+ if (!Number.isInteger(proc.ppid) || proc.ppid <= 0)
191
+ return null;
192
+ current = proc.ppid;
193
+ }
194
+ return null;
195
+ }
196
+ /** Every Zellij server currently present in the table. */
197
+ export function liveServers(table) {
198
+ const servers = [];
199
+ for (const proc of table) {
200
+ const parsed = parseServerArgs(proc.args);
201
+ if (!parsed)
202
+ continue;
203
+ servers.push({ pid: proc.pid, creationName: parsed.creationName, socketPath: parsed.socketPath });
204
+ }
205
+ return servers;
206
+ }
207
+ /** True when a pane row is the requested (non-plugin) pane id. */
208
+ export function paneMatchesId(pane, paneId) {
209
+ const key = paneId.trim().toLowerCase();
210
+ if (!key || pane.isPlugin)
211
+ return false;
212
+ return (pane.id.toLowerCase() === key ||
213
+ String(pane.numericId) === key ||
214
+ `terminal_${pane.numericId}` === key);
215
+ }
216
+ /** The live panes of a session that can take one of a server's children. */
217
+ function paneCommandsFor(session) {
218
+ const commands = [];
219
+ for (const pane of session.panes) {
220
+ // A held pane ("exited, press Enter to re-run") has no running process,
221
+ // so it cannot take one of the server's children.
222
+ if (pane.isPlugin || pane.exited || pane.held)
223
+ continue;
224
+ commands.push(pane.command && pane.command.trim() ? normalizeCommand(pane.command) : null);
225
+ }
226
+ return commands;
227
+ }
228
+ /** Does this session's live panes fit these server children one-to-one? */
229
+ function sessionMatchesChildSets(session, childSets) {
230
+ return hasPerfectMatching(paneCommandsFor(session), childSets);
231
+ }
232
+ /**
233
+ * The direct child of `serverPid` on `pid`'s parent chain, or null when `pid`
234
+ * is not below the server. Every pane's shell is a direct child, so this maps
235
+ * each process to the pane that owns it.
236
+ */
237
+ function owningChild(byPid, serverPid, pid) {
238
+ let current = pid;
239
+ let last = null;
240
+ for (let hops = 0; hops < MAX_ANCESTOR_HOPS; hops++) {
241
+ if (current === serverPid)
242
+ return last;
243
+ last = current;
244
+ const parent = byPid.get(current);
245
+ if (!parent || parent.ppid === current)
246
+ return null;
247
+ current = parent.ppid;
248
+ }
249
+ return null;
250
+ }
251
+ /**
252
+ * Candidate commands for each direct child of the server: the child's own argv
253
+ * plus every descendant's (Zellij's `pane_command` may be the pane's shell or
254
+ * a foreground process below it). One set per child, in child order.
255
+ */
256
+ function childCommandSets(serverPid, table) {
257
+ const byPid = new Map(table.map((proc) => [proc.pid, proc]));
258
+ const childIndex = new Map();
259
+ let order = 0;
260
+ for (const proc of table) {
261
+ if (proc.pid === serverPid || proc.ppid !== serverPid)
262
+ continue;
263
+ childIndex.set(proc.pid, order++);
264
+ }
265
+ const sets = Array.from({ length: childIndex.size }, () => new Set());
266
+ for (const proc of table) {
267
+ const child = owningChild(byPid, serverPid, proc.pid);
268
+ if (child === null)
269
+ continue;
270
+ const index = childIndex.get(child);
271
+ if (index === undefined)
272
+ continue;
273
+ sets[index].add(normalizeCommand(proc.args));
274
+ }
275
+ return sets;
276
+ }
277
+ /**
278
+ * Can every pane take a distinct direct child (augmenting paths; pane counts
279
+ * are small)? An empty pane command accepts any child, and equal commands
280
+ * still need different children — duplicates are counted, not collapsed.
281
+ */
282
+ function hasPerfectMatching(paneCommands, childSets) {
283
+ if (paneCommands.length === 0 || paneCommands.length !== childSets.length)
284
+ return false;
285
+ const childTakenBy = new Array(childSets.length).fill(-1);
286
+ const augment = (pane, seen) => {
287
+ const command = paneCommands[pane];
288
+ for (let child = 0; child < childSets.length; child++) {
289
+ if (seen[child])
290
+ continue;
291
+ if (command !== null && !childSets[child].has(command))
292
+ continue;
293
+ seen[child] = true;
294
+ const owner = childTakenBy[child];
295
+ if (owner === -1 || augment(owner, seen)) {
296
+ childTakenBy[child] = pane;
297
+ return true;
298
+ }
299
+ }
300
+ return false;
301
+ };
302
+ for (let pane = 0; pane < paneCommands.length; pane++) {
303
+ if (!augment(pane, new Array(childSets.length).fill(false)))
304
+ return false;
305
+ }
306
+ return true;
307
+ }
308
+ /**
309
+ * Pick the one live session whose panes belong to `serverPid`.
310
+ *
311
+ * The server's direct children are its panes' processes. A session matches
312
+ * when it has exactly as many non-plugin, non-exited, non-held panes as the
313
+ * server has direct children, and the panes can be assigned one-to-one to
314
+ * distinct children whose subtree argv (child plus descendants) contains the
315
+ * pane's command. With `opts.paneId`, the session must also contain that
316
+ * pane. Zero or several matches return null — this never guesses.
317
+ */
318
+ export function matchServerToSession(serverPid, table, sessions, opts = {}) {
319
+ const childSets = childCommandSets(serverPid, table);
320
+ const matches = [];
321
+ for (const session of sessions) {
322
+ if (opts.paneId && !session.panes.some((pane) => paneMatchesId(pane, opts.paneId))) {
323
+ continue;
324
+ }
325
+ if (!sessionMatchesChildSets(session, childSets))
326
+ continue;
327
+ matches.push(session.name);
328
+ }
329
+ return matches.length === 1 ? matches[0] : null;
330
+ }
331
+ /**
332
+ * Pair live servers with live sessions using forced assignments only:
333
+ *
334
+ * 1. A server whose creation name is a live session name is pinned to it when
335
+ * that session fits. A creation name shared by two fitting servers (name
336
+ * reuse) is a guess, so neither is pinned and the session stays unassigned.
337
+ * 2. Repeat until stable: an unpinned server with exactly one unpinned fitting
338
+ * session is pinned to it, and an unpinned session fitted by exactly one
339
+ * unpinned server pins that pair. Nothing else: no backtracking, no
340
+ * picking among ties.
341
+ *
342
+ * Returns only the pinned pairs. Servers and sessions left ambiguous are
343
+ * absent; the caller still has the pane-id-filtered single-match test.
344
+ */
345
+ export function assignServersToSessions(table, sessions) {
346
+ const servers = liveServers(table);
347
+ const childSetsByServer = new Map();
348
+ for (const server of servers) {
349
+ childSetsByServer.set(server.pid, childCommandSets(server.pid, table));
350
+ }
351
+ const fits = (serverPid, session) => sessionMatchesChildSets(session, childSetsByServer.get(serverPid) ?? []);
352
+ const assigned = new Map();
353
+ const takenSessions = new Set();
354
+ // A reused name fitted by two servers cannot be told apart; never assign it.
355
+ const contestedSessions = new Set();
356
+ // 1. Creation-name pins.
357
+ const serversByCreation = new Map();
358
+ for (const server of servers) {
359
+ const group = serversByCreation.get(server.creationName) ?? [];
360
+ group.push(server);
361
+ serversByCreation.set(server.creationName, group);
362
+ }
363
+ const sessionByName = new Map(sessions.map((session) => [session.name, session]));
364
+ for (const [creationName, group] of serversByCreation) {
365
+ const session = sessionByName.get(creationName);
366
+ if (!session)
367
+ continue;
368
+ const fitting = group.filter((server) => fits(server.pid, session));
369
+ if (fitting.length === 1) {
370
+ assigned.set(fitting[0].pid, creationName);
371
+ takenSessions.add(creationName);
372
+ }
373
+ else if (fitting.length > 1) {
374
+ contestedSessions.add(creationName);
375
+ }
376
+ }
377
+ // 2. Propagation of single remaining candidates.
378
+ let changed = true;
379
+ while (changed) {
380
+ changed = false;
381
+ for (const server of servers) {
382
+ if (assigned.has(server.pid))
383
+ continue;
384
+ const candidates = sessions.filter((session) => !takenSessions.has(session.name) &&
385
+ !contestedSessions.has(session.name) &&
386
+ fits(server.pid, session));
387
+ if (candidates.length === 1) {
388
+ assigned.set(server.pid, candidates[0].name);
389
+ takenSessions.add(candidates[0].name);
390
+ changed = true;
391
+ }
392
+ }
393
+ for (const session of sessions) {
394
+ if (takenSessions.has(session.name) || contestedSessions.has(session.name))
395
+ continue;
396
+ const candidates = servers.filter((server) => !assigned.has(server.pid) && fits(server.pid, session));
397
+ if (candidates.length === 1) {
398
+ assigned.set(candidates[0].pid, session.name);
399
+ takenSessions.add(session.name);
400
+ changed = true;
401
+ }
402
+ }
403
+ }
404
+ return assigned;
405
+ }
@@ -1,6 +1,8 @@
1
1
  export type ZellijSessionResolve = {
2
2
  session: string;
3
- source: "arg" | "env_zswarm" | "env_zellij" | "sole_live";
3
+ source: "arg" | "env_zswarm" | "env_zellij" | "sole_live" | "alias" | "renamed";
4
+ /** The name the caller asked for, when resolution changed it (alias/renamed). */
5
+ requested?: string;
4
6
  };
5
7
  /** One row from `zellij list-sessions --no-formatting` (not `--short`). */
6
8
  export type ZellijSession = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {