@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
|
@@ -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
|
+
}
|
package/dist/zellij/session.d.ts
CHANGED
|
@@ -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 = {
|