@zswarm/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/exec.d.ts +11 -0
- package/dist/exec.js +64 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/ops/bus.d.ts +1 -0
- package/dist/ops/bus.js +29 -2
- package/dist/ops/dispatch.d.ts +1 -1
- package/dist/ops/dispatch.js +24 -1
- package/dist/ops/serve.d.ts +39 -0
- package/dist/ops/serve.js +197 -0
- package/dist/schema.d.ts +1 -2
- package/dist/schema.js +32 -5
- package/dist/zellij/binary.d.ts +2 -0
- package/dist/zellij/binary.js +14 -2
- package/dist/zellij/ipc.d.ts +51 -0
- package/dist/zellij/ipc.js +200 -0
- package/package.json +2 -2
package/dist/exec.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type RemoteShell } from "./zellij/ipc.js";
|
|
1
2
|
export type ExecResult = {
|
|
2
3
|
code: number;
|
|
3
4
|
stdout: string;
|
|
@@ -23,7 +24,17 @@ export type SshTarget = {
|
|
|
23
24
|
host: string;
|
|
24
25
|
remoteBin: string;
|
|
25
26
|
options: string[];
|
|
27
|
+
/** Concrete IPC temp, or `auto` to read `--server` off a live zellij process. */
|
|
28
|
+
tmp?: string;
|
|
29
|
+
/** `interactive` runs the CLI in the Windows desktop session (schtasks /IT). */
|
|
30
|
+
mode?: "ssh" | "interactive";
|
|
31
|
+
remoteShell?: "cmd" | "sh";
|
|
26
32
|
};
|
|
33
|
+
export declare function quoteRemoteArg(arg: string, shell: RemoteShell): string;
|
|
34
|
+
/**
|
|
35
|
+
* The string `ssh host <this>` runs. Tests assert this rather than spawning ssh.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildSshRemoteCommand(target: SshTarget, args: string[], tmp: string | undefined, timeoutMs: number, socketDir?: string): string;
|
|
27
38
|
/**
|
|
28
39
|
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
29
40
|
* command string, because the remote login shell re-parses it.
|
package/dist/exec.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { cmdQuote, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
|
|
2
3
|
export const NOT_FOUND_EXIT = 127;
|
|
3
4
|
/** POSIX single-quoting, for building a command line the remote shell parses. */
|
|
4
5
|
export function shellQuote(arg) {
|
|
@@ -8,14 +9,75 @@ export function shellQuote(arg) {
|
|
|
8
9
|
return arg;
|
|
9
10
|
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
10
11
|
}
|
|
12
|
+
export function quoteRemoteArg(arg, shell) {
|
|
13
|
+
return shell === "cmd" ? cmdQuote(arg) : shellQuote(arg);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The string `ssh host <this>` runs. Tests assert this rather than spawning ssh.
|
|
17
|
+
*/
|
|
18
|
+
export function buildSshRemoteCommand(target, args, tmp, timeoutMs, socketDir) {
|
|
19
|
+
const shell = inferRemoteShell({
|
|
20
|
+
explicit: target.remoteShell,
|
|
21
|
+
tmp,
|
|
22
|
+
remoteBin: target.remoteBin,
|
|
23
|
+
mode: target.mode,
|
|
24
|
+
});
|
|
25
|
+
const zellij = [target.remoteBin, ...args]
|
|
26
|
+
.map((arg) => quoteRemoteArg(arg, shell))
|
|
27
|
+
.join(" ");
|
|
28
|
+
const withTmp = tmp ? wrapWithTmpEnv(zellij, tmp, shell, socketDir) : zellij;
|
|
29
|
+
if (target.mode === "interactive") {
|
|
30
|
+
return windowsInteractiveRemote(withTmp, timeoutMs);
|
|
31
|
+
}
|
|
32
|
+
// Windows OpenSSH often logs into PowerShell; `set "TEMP=…"` only works in cmd.
|
|
33
|
+
if (shell === "cmd") {
|
|
34
|
+
return `cmd.exe /c ${cmdQuote(withTmp)}`;
|
|
35
|
+
}
|
|
36
|
+
return withTmp;
|
|
37
|
+
}
|
|
38
|
+
async function discoverRemoteIpc(runner, target, timeoutMs) {
|
|
39
|
+
const shell = inferRemoteShell({
|
|
40
|
+
explicit: target.remoteShell,
|
|
41
|
+
remoteBin: target.remoteBin,
|
|
42
|
+
mode: target.mode,
|
|
43
|
+
});
|
|
44
|
+
// `auto` has no tmp yet, so a Windows host with remoteBin=zellij still looks
|
|
45
|
+
// like Unix. Try both listings; pickIpcDirs ignores lines without --server.
|
|
46
|
+
const probes = shell === "cmd"
|
|
47
|
+
? [windowsDiscoverRemote(), unixDiscoverRemote()]
|
|
48
|
+
: [unixDiscoverRemote(), windowsDiscoverRemote()];
|
|
49
|
+
for (const probe of probes) {
|
|
50
|
+
const result = await runner([...target.options, target.host, probe], {
|
|
51
|
+
timeoutMs,
|
|
52
|
+
});
|
|
53
|
+
const dirs = pickIpcDirs(parseZellijServerPaths(result.stdout));
|
|
54
|
+
if (dirs)
|
|
55
|
+
return dirs;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
11
59
|
/**
|
|
12
60
|
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
13
61
|
* command string, because the remote login shell re-parses it.
|
|
14
62
|
*/
|
|
15
63
|
export function createSshExec(target, env) {
|
|
16
64
|
const runner = createExec(target.ssh, env);
|
|
17
|
-
|
|
18
|
-
|
|
65
|
+
let cached;
|
|
66
|
+
async function resolveIpc(timeoutMs) {
|
|
67
|
+
const requested = target.tmp?.trim();
|
|
68
|
+
if (!requested)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (requested.toLowerCase() !== "auto") {
|
|
71
|
+
return { tmp: requested, socketDir: "" };
|
|
72
|
+
}
|
|
73
|
+
if (cached !== undefined)
|
|
74
|
+
return cached ?? undefined;
|
|
75
|
+
cached = (await discoverRemoteIpc(runner, target, timeoutMs)) ?? null;
|
|
76
|
+
return cached ?? undefined;
|
|
77
|
+
}
|
|
78
|
+
return async (args, options) => {
|
|
79
|
+
const ipc = await resolveIpc(options.timeoutMs);
|
|
80
|
+
const remote = buildSshRemoteCommand(target, args, ipc?.tmp, options.timeoutMs, ipc?.socketDir);
|
|
19
81
|
return runner([...target.options, target.host, remote], options);
|
|
20
82
|
};
|
|
21
83
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ export { createZellijClient, type ZellijClient, type ZellijClientOptions, } from
|
|
|
11
11
|
export { createStateStore, defaultStateDir, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
12
12
|
export { dispatchZswarm } from "./ops/dispatch.js";
|
|
13
13
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy, } from "./policy.js";
|
|
14
|
-
export { createSshExec, shellQuote, type SshTarget } from "./exec.js";
|
|
14
|
+
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type SshTarget, } from "./exec.js";
|
|
15
15
|
export { resolveSshTarget } from "./zellij/binary.js";
|
|
16
|
+
export { applyIpcTmpEnv, cmdQuote, concreteTmp, encodePowerShellCommand, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, pickIpcTmp, socketDirFromServerPath, tmpFromServerPath, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
|
|
17
|
+
export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, parseListenAddress, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
|
|
16
18
|
export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
|
|
17
19
|
export { selectTargets } from "./ops/broadcast.js";
|
|
18
20
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
package/dist/index.js
CHANGED
|
@@ -11,8 +11,10 @@ export { createZellijClient, } from "./zellij/client.js";
|
|
|
11
11
|
export { createStateStore, defaultStateDir, } from "./state.js";
|
|
12
12
|
export { dispatchZswarm } from "./ops/dispatch.js";
|
|
13
13
|
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./policy.js";
|
|
14
|
-
export { createSshExec, shellQuote } from "./exec.js";
|
|
14
|
+
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
|
|
15
15
|
export { resolveSshTarget } from "./zellij/binary.js";
|
|
16
|
+
export { applyIpcTmpEnv, cmdQuote, concreteTmp, encodePowerShellCommand, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, pickIpcTmp, socketDirFromServerPath, tmpFromServerPath, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
|
|
17
|
+
export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, parseListenAddress, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
|
|
16
18
|
export { parseTabList, resolveTab } from "./zellij/tabs.js";
|
|
17
19
|
export { selectTargets } from "./ops/broadcast.js";
|
|
18
20
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
package/dist/ops/bus.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare function planBus(client: ZellijClient, state: StateStore, env?: N
|
|
|
23
23
|
* message and replies with nothing, so each attempt rotates to a new key. And a
|
|
24
24
|
* freshly launched instance replies before Zellij has pushed it a manifest, so
|
|
25
25
|
* a not-ready answer is retried once rather than reported as an empty session.
|
|
26
|
+
* If it is still cold after that, one same-title rename nudges Zellij to push.
|
|
26
27
|
*/
|
|
27
28
|
export declare function busSnapshot(client: ZellijClient, state: StateStore, session: string, clock: Clock, env?: NodeJS.ProcessEnv, payload?: string): Promise<{
|
|
28
29
|
snapshot: BusSnapshot;
|
package/dist/ops/bus.js
CHANGED
|
@@ -53,7 +53,7 @@ export function planBus(client, state, env = process.env) {
|
|
|
53
53
|
if (!plugin) {
|
|
54
54
|
return {
|
|
55
55
|
...base,
|
|
56
|
-
reason: "no plugin wasm found; build
|
|
56
|
+
reason: "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN",
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
if (processDisabled)
|
|
@@ -69,6 +69,19 @@ async function askOnce(client, session, url, configKey, payload) {
|
|
|
69
69
|
const result = await client.pipePlugin({ session, url, configKey, payload });
|
|
70
70
|
return parseBusReply(result.stdout);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Zellij pushes a pane manifest on change, never on subscribe — there is no
|
|
74
|
+
* get_pane_manifest(). A quiet session leaves a freshly launched instance
|
|
75
|
+
* blind (ready:false, paneUpdates:0) until something moves. Renaming a pane
|
|
76
|
+
* to the title it already has is an invisible change that forces the push.
|
|
77
|
+
*/
|
|
78
|
+
async function nudgeManifest(client, session) {
|
|
79
|
+
const panes = await client.listPanes(session);
|
|
80
|
+
const pane = panes.find((p) => !p.isPlugin && !p.exited && p.title.trim());
|
|
81
|
+
if (!pane)
|
|
82
|
+
return;
|
|
83
|
+
await client.renamePane({ session, paneId: pane.id, name: pane.title });
|
|
84
|
+
}
|
|
72
85
|
/**
|
|
73
86
|
* One pipe, one answer — or null, and the caller polls instead.
|
|
74
87
|
*
|
|
@@ -76,6 +89,7 @@ async function askOnce(client, session, url, configKey, payload) {
|
|
|
76
89
|
* message and replies with nothing, so each attempt rotates to a new key. And a
|
|
77
90
|
* freshly launched instance replies before Zellij has pushed it a manifest, so
|
|
78
91
|
* a not-ready answer is retried once rather than reported as an empty session.
|
|
92
|
+
* If it is still cold after that, one same-title rename nudges Zellij to push.
|
|
79
93
|
*/
|
|
80
94
|
export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status") {
|
|
81
95
|
const plan = planBus(client, state, env);
|
|
@@ -83,6 +97,7 @@ export async function busSnapshot(client, state, session, clock, env = process.e
|
|
|
83
97
|
if (!plan.enabled || !plan.url || !pluginPath)
|
|
84
98
|
return null;
|
|
85
99
|
let configKey = answeredWith.get(session) ?? plan.configKey;
|
|
100
|
+
let nudged = false;
|
|
86
101
|
for (let attempt = 0; attempt < MAX_KEY_ATTEMPTS; attempt++) {
|
|
87
102
|
let snapshot = await askOnce(client, session, plan.url, configKey, payload);
|
|
88
103
|
if (snapshot && !snapshot.ready) {
|
|
@@ -91,6 +106,18 @@ export async function busSnapshot(client, state, session, clock, env = process.e
|
|
|
91
106
|
(await askOnce(client, session, plan.url, configKey, payload)) ??
|
|
92
107
|
snapshot;
|
|
93
108
|
}
|
|
109
|
+
if (snapshot && !snapshot.ready && !nudged) {
|
|
110
|
+
nudged = true;
|
|
111
|
+
try {
|
|
112
|
+
await nudgeManifest(client, session);
|
|
113
|
+
snapshot =
|
|
114
|
+
(await askOnce(client, session, plan.url, configKey, payload)) ??
|
|
115
|
+
snapshot;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A failed nudge must not fail the op — the caller polls instead.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
94
121
|
if (snapshot) {
|
|
95
122
|
answeredWith.set(session, configKey);
|
|
96
123
|
if (plan.installed && configKey !== plan.configKey) {
|
|
@@ -205,7 +232,7 @@ export async function busOp(client, state, args, clock, env = process.env, polic
|
|
|
205
232
|
}
|
|
206
233
|
const plugin = resolveBusPlugin(env);
|
|
207
234
|
if (!plugin) {
|
|
208
|
-
throw new ZellijError("bus_missing", "no plugin wasm found; build
|
|
235
|
+
throw new ZellijError("bus_missing", "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN");
|
|
209
236
|
}
|
|
210
237
|
const previous = state.readBus();
|
|
211
238
|
const force = isTrue(args.force);
|
package/dist/ops/dispatch.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type ZellijClient } from "../zellij/client.js";
|
|
2
2
|
import type { DispatchDeps, OpsResult } from "./types.js";
|
|
3
3
|
/** Shared MCP/CLI dispatch for zswarm ops. */
|
|
4
|
-
export declare function dispatchZswarm(args: Record<string, unknown>,
|
|
4
|
+
export declare function dispatchZswarm(args: Record<string, unknown>, injected?: ZellijClient, deps?: DispatchDeps): Promise<OpsResult>;
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -20,6 +20,7 @@ import { OP_NAMES } from "../schema.js";
|
|
|
20
20
|
import { dumpMaxChars, fail, isTrue, isVerbose, paneViewBus, paneViewFull, paneViewSlim, truncateDumpText, } from "./util.js";
|
|
21
21
|
import { waitForPane } from "./wait.js";
|
|
22
22
|
import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
|
|
23
|
+
import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, } from "./serve.js";
|
|
23
24
|
/** Same default `wait` applies: match when a needle is given, else idle. */
|
|
24
25
|
function waitMode(args) {
|
|
25
26
|
const requested = typeof args.for === "string" ? args.for.trim() : "";
|
|
@@ -57,7 +58,7 @@ async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
|
57
58
|
return { session, panes, pane };
|
|
58
59
|
}
|
|
59
60
|
/** Shared MCP/CLI dispatch for zswarm ops. */
|
|
60
|
-
export async function dispatchZswarm(args,
|
|
61
|
+
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
61
62
|
const op = String(args.op ?? "");
|
|
62
63
|
const verbose = isVerbose(args);
|
|
63
64
|
const clock = {
|
|
@@ -71,9 +72,31 @@ export async function dispatchZswarm(args, client = createZellijClient(), deps =
|
|
|
71
72
|
let stateStore = deps.state ?? null;
|
|
72
73
|
const state = () => (stateStore ??= createStateStore());
|
|
73
74
|
const policy = deps.policy ?? loadPolicy(deps.env);
|
|
75
|
+
const env = deps.env ?? process.env;
|
|
74
76
|
try {
|
|
75
77
|
// Policy gates the op before anything touches the session.
|
|
76
78
|
assertOpAllowed(policy, op);
|
|
79
|
+
if (op === "serve") {
|
|
80
|
+
if (isTrue(args.clear)) {
|
|
81
|
+
const cleared = await uninstallServeLogon({
|
|
82
|
+
platform: process.platform,
|
|
83
|
+
});
|
|
84
|
+
return { ok: true, data: cleared };
|
|
85
|
+
}
|
|
86
|
+
if (isTrue(args.install)) {
|
|
87
|
+
const installed = await installServeLogon({
|
|
88
|
+
listen: typeof args.listen === "string" ? args.listen : undefined,
|
|
89
|
+
});
|
|
90
|
+
return { ok: true, data: { ...installed, running: true } };
|
|
91
|
+
}
|
|
92
|
+
throw new ZellijError("usage", "serve --listen is CLI-only; MCP/dispatch would hang the tool call. Run `zswarm serve --listen` in the session that owns Zellij");
|
|
93
|
+
}
|
|
94
|
+
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
95
|
+
// just because the host env has ZSWARM_SERVE set.
|
|
96
|
+
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
97
|
+
return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args));
|
|
98
|
+
}
|
|
99
|
+
const client = injected ?? createZellijClient({ env: deps.env });
|
|
77
100
|
switch (op) {
|
|
78
101
|
case "sessions": {
|
|
79
102
|
const sessions = await client.listSessions();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { OpsResult } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
|
|
3
|
+
export declare const SERVE_TASK_NAME = "zswarm-serve";
|
|
4
|
+
export declare function parseListenAddress(raw: string | undefined): {
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
/** Client-side wait: the op's own timeout plus slack, capped at ten minutes. */
|
|
10
|
+
export declare function serveCallTimeout(args: Record<string, unknown>): number;
|
|
11
|
+
/** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
|
|
12
|
+
export declare function serveChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
13
|
+
export type ServeDispatch = (args: Record<string, unknown>) => Promise<OpsResult>;
|
|
14
|
+
export declare function startServe(listen: string | undefined, dispatch: ServeDispatch): Promise<{
|
|
15
|
+
label: string;
|
|
16
|
+
close: () => Promise<void>;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function parseServeTarget(raw: string): {
|
|
19
|
+
host: string;
|
|
20
|
+
port: number;
|
|
21
|
+
};
|
|
22
|
+
export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number): Promise<OpsResult>;
|
|
23
|
+
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
|
|
24
|
+
export declare function installServeLogon(input: {
|
|
25
|
+
listen?: string;
|
|
26
|
+
execPath?: string;
|
|
27
|
+
scriptPath?: string;
|
|
28
|
+
platform?: NodeJS.Platform;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
task: string;
|
|
31
|
+
listen: string;
|
|
32
|
+
command: string;
|
|
33
|
+
}>;
|
|
34
|
+
export declare function uninstallServeLogon(input?: {
|
|
35
|
+
platform?: NodeJS.Platform;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
task: string;
|
|
38
|
+
cleared: true;
|
|
39
|
+
}>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createServer, connect } from "node:net";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { ZellijError } from "../errors.js";
|
|
4
|
+
import { encodePowerShellCommand } from "../zellij/ipc.js";
|
|
5
|
+
export const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
|
|
6
|
+
export const SERVE_TASK_NAME = "zswarm-serve";
|
|
7
|
+
export function parseListenAddress(raw) {
|
|
8
|
+
const text = (raw ?? DEFAULT_SERVE_LISTEN).trim() || DEFAULT_SERVE_LISTEN;
|
|
9
|
+
const stripped = text.replace(/^tcp:\/\//i, "");
|
|
10
|
+
const hostPort = stripped.includes("]")
|
|
11
|
+
? stripped
|
|
12
|
+
: stripped.includes(":")
|
|
13
|
+
? stripped
|
|
14
|
+
: `127.0.0.1:${stripped}`;
|
|
15
|
+
const colon = hostPort.lastIndexOf(":");
|
|
16
|
+
const host = colon === -1 ? "127.0.0.1" : hostPort.slice(0, colon) || "127.0.0.1";
|
|
17
|
+
const port = Number(colon === -1 ? hostPort : hostPort.slice(colon + 1));
|
|
18
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
19
|
+
throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
|
|
20
|
+
}
|
|
21
|
+
return { host, port, label: `${host}:${port}` };
|
|
22
|
+
}
|
|
23
|
+
/** Client-side wait: the op's own timeout plus slack, capped at ten minutes. */
|
|
24
|
+
export function serveCallTimeout(args) {
|
|
25
|
+
const requested = Number(args.timeoutMs);
|
|
26
|
+
const base = Number.isFinite(requested) && requested > 0 ? requested : 60_000;
|
|
27
|
+
return Math.min(Math.max(base + 5_000, 15_000), 10 * 60_000);
|
|
28
|
+
}
|
|
29
|
+
/** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
|
|
30
|
+
export function serveChildEnv(env) {
|
|
31
|
+
const out = { ...env };
|
|
32
|
+
delete out.ZSWARM_SERVE;
|
|
33
|
+
delete out.ZSWARM_SSH;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export function startServe(listen, dispatch) {
|
|
37
|
+
const { host, port } = parseListenAddress(listen);
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const server = createServer((socket) => {
|
|
40
|
+
let buf = "";
|
|
41
|
+
socket.setEncoding("utf8");
|
|
42
|
+
socket.on("data", (chunk) => {
|
|
43
|
+
buf += chunk;
|
|
44
|
+
void drain();
|
|
45
|
+
});
|
|
46
|
+
async function drain() {
|
|
47
|
+
let nl = buf.indexOf("\n");
|
|
48
|
+
while (nl !== -1) {
|
|
49
|
+
const line = buf.slice(0, nl).trim();
|
|
50
|
+
buf = buf.slice(nl + 1);
|
|
51
|
+
if (line) {
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
const args = JSON.parse(line);
|
|
55
|
+
result = await dispatch(args);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
result = { ok: false, error: { code: "bad_arg", message } };
|
|
60
|
+
}
|
|
61
|
+
socket.write(`${JSON.stringify(result)}\n`);
|
|
62
|
+
}
|
|
63
|
+
nl = buf.indexOf("\n");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
server.on("error", reject);
|
|
68
|
+
server.listen(port, host, () => {
|
|
69
|
+
const addr = server.address();
|
|
70
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
71
|
+
resolve({
|
|
72
|
+
label: `${host}:${actualPort}`,
|
|
73
|
+
close: () => new Promise((done, fail) => {
|
|
74
|
+
server.close((err) => (err ? fail(err) : done()));
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function parseServeTarget(raw) {
|
|
81
|
+
return parseListenAddress(raw);
|
|
82
|
+
}
|
|
83
|
+
export function callServe(target, args, timeoutMs = 15_000) {
|
|
84
|
+
const { host, port } = parseServeTarget(target);
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const socket = connect({ host, port });
|
|
87
|
+
let buf = "";
|
|
88
|
+
let settled = false;
|
|
89
|
+
const finish = (result) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
return;
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
socket.destroy();
|
|
95
|
+
resolve(result);
|
|
96
|
+
};
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
finish({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: {
|
|
101
|
+
code: "timeout",
|
|
102
|
+
message: `zswarm serve at ${host}:${port} timed out after ${timeoutMs}ms`,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
socket.setEncoding("utf8");
|
|
107
|
+
socket.on("error", (err) => {
|
|
108
|
+
finish({
|
|
109
|
+
ok: false,
|
|
110
|
+
error: {
|
|
111
|
+
code: "serve_unreachable",
|
|
112
|
+
message: `cannot reach zswarm serve at ${host}:${port}: ${err.message}`,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
socket.on("data", (chunk) => {
|
|
117
|
+
buf += chunk;
|
|
118
|
+
const nl = buf.indexOf("\n");
|
|
119
|
+
if (nl === -1)
|
|
120
|
+
return;
|
|
121
|
+
const line = buf.slice(0, nl).trim();
|
|
122
|
+
try {
|
|
123
|
+
finish(JSON.parse(line));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
finish({
|
|
127
|
+
ok: false,
|
|
128
|
+
error: { code: "failed", message: `serve returned non-JSON: ${line}` },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
socket.on("connect", () => {
|
|
133
|
+
socket.write(`${JSON.stringify(args)}\n`);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
export function serveLogonCommand(execPath, scriptPath, listen) {
|
|
138
|
+
return `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
|
|
139
|
+
}
|
|
140
|
+
function runPowerShell(script) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
execFile("powershell.exe", [
|
|
143
|
+
"-NoProfile",
|
|
144
|
+
"-ExecutionPolicy",
|
|
145
|
+
"Bypass",
|
|
146
|
+
"-EncodedCommand",
|
|
147
|
+
encodePowerShellCommand(script),
|
|
148
|
+
], { windowsHide: true, timeout: 20_000 }, (error, stdout, stderr) => {
|
|
149
|
+
const code = error && typeof error.code === "number"
|
|
150
|
+
? error.code
|
|
151
|
+
: error
|
|
152
|
+
? 1
|
|
153
|
+
: 0;
|
|
154
|
+
resolve({
|
|
155
|
+
code,
|
|
156
|
+
stdout: String(stdout ?? ""),
|
|
157
|
+
stderr: String(stderr ?? ""),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
export async function installServeLogon(input) {
|
|
163
|
+
if ((input.platform ?? process.platform) !== "win32") {
|
|
164
|
+
throw new ZellijError("usage", "serve --install registers a Windows logon task; on Unix start `zswarm serve --listen` in the session that owns Zellij");
|
|
165
|
+
}
|
|
166
|
+
const { label } = parseListenAddress(input.listen);
|
|
167
|
+
const execPath = input.execPath ?? process.execPath;
|
|
168
|
+
const scriptPath = input.scriptPath ?? process.argv[1];
|
|
169
|
+
if (!scriptPath) {
|
|
170
|
+
throw new ZellijError("usage", "cannot resolve the zswarm script path for the logon task");
|
|
171
|
+
}
|
|
172
|
+
const command = serveLogonCommand(execPath, scriptPath, label);
|
|
173
|
+
const commandB64 = Buffer.from(command, "utf8").toString("base64");
|
|
174
|
+
const script = [
|
|
175
|
+
"$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
|
176
|
+
commandB64 +
|
|
177
|
+
"'))",
|
|
178
|
+
"$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument \"/c $cmd\"",
|
|
179
|
+
"$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited",
|
|
180
|
+
"$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME",
|
|
181
|
+
`Register-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Action $action -Principal $principal -Trigger $trigger -Force | Out-Null`,
|
|
182
|
+
`Start-ScheduledTask -TaskName '${SERVE_TASK_NAME}'`,
|
|
183
|
+
].join("\n");
|
|
184
|
+
const result = await runPowerShell(script);
|
|
185
|
+
if (result.code !== 0) {
|
|
186
|
+
throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
187
|
+
}
|
|
188
|
+
return { task: SERVE_TASK_NAME, listen: label, command };
|
|
189
|
+
}
|
|
190
|
+
export async function uninstallServeLogon(input = {}) {
|
|
191
|
+
if ((input.platform ?? process.platform) !== "win32") {
|
|
192
|
+
throw new ZellijError("usage", "serve --clear is Windows-only");
|
|
193
|
+
}
|
|
194
|
+
const script = `Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue`;
|
|
195
|
+
await runPowerShell(script);
|
|
196
|
+
return { task: SERVE_TASK_NAME, cleared: true };
|
|
197
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* One description of the zswarm surface. The MCP tool schema, the CLI flags,
|
|
3
3
|
* and the CLI help text are all generated from it, so they cannot drift apart.
|
|
4
4
|
*/
|
|
5
|
-
export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus"];
|
|
5
|
+
export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve"];
|
|
6
6
|
export type OpName = (typeof OP_NAMES)[number];
|
|
7
7
|
/** Ops that address an existing pane through `to`. */
|
|
8
8
|
export declare const TARGET_OPS: readonly OpName[];
|
|
@@ -22,5 +22,4 @@ export declare const PARAMS: readonly ParamSpec[];
|
|
|
22
22
|
export declare function mcpInputSchema(): Record<string, unknown>;
|
|
23
23
|
export declare const MCP_TOOL_DESCRIPTION: string;
|
|
24
24
|
export declare function cliUsage(): string;
|
|
25
|
-
/** Turn argv (without the op) into dispatch args, driven by PARAMS. */
|
|
26
25
|
export declare function parseCliArgv(argv: string[]): Record<string, unknown>;
|
package/dist/schema.js
CHANGED
|
@@ -30,6 +30,7 @@ export const OP_NAMES = [
|
|
|
30
30
|
"diff",
|
|
31
31
|
"checkpoint",
|
|
32
32
|
"bus",
|
|
33
|
+
"serve",
|
|
33
34
|
];
|
|
34
35
|
/** Ops that address an existing pane through `to`. */
|
|
35
36
|
export const TARGET_OPS = [
|
|
@@ -89,13 +90,13 @@ export const PARAMS = [
|
|
|
89
90
|
name: "clear",
|
|
90
91
|
type: "boolean",
|
|
91
92
|
flags: ["--clear"],
|
|
92
|
-
description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin",
|
|
93
|
+
description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: unregister the Windows logon task",
|
|
93
94
|
},
|
|
94
95
|
{
|
|
95
96
|
name: "install",
|
|
96
97
|
type: "boolean",
|
|
97
98
|
flags: ["--install"],
|
|
98
|
-
description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it",
|
|
99
|
+
description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register a Windows logon task that listens for remote zswarm",
|
|
99
100
|
},
|
|
100
101
|
{
|
|
101
102
|
name: "reset",
|
|
@@ -383,6 +384,12 @@ export const PARAMS = [
|
|
|
383
384
|
flags: ["--force"],
|
|
384
385
|
description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus: reinstall under a fresh key",
|
|
385
386
|
},
|
|
387
|
+
{
|
|
388
|
+
name: "listen",
|
|
389
|
+
type: "string",
|
|
390
|
+
flags: ["--listen"],
|
|
391
|
+
description: "serve: bind address (default 127.0.0.1:9419). Reach it from another machine with ZSWARM_SERVE after an SSH tunnel",
|
|
392
|
+
},
|
|
386
393
|
{
|
|
387
394
|
name: "verbose",
|
|
388
395
|
type: "boolean",
|
|
@@ -424,7 +431,8 @@ export function mcpInputSchema() {
|
|
|
424
431
|
}
|
|
425
432
|
export const MCP_TOOL_DESCRIPTION = `zSwarm Zellij pane coordination (op=${OP_NAMES.join("|")}). ` +
|
|
426
433
|
"List panes, send text into a CLI pane (paste+Enter), block until a pane goes idle or prints a match, " +
|
|
427
|
-
"send raw keys, open or close panes, and give a peer its own git worktree.
|
|
434
|
+
"send raw keys, open or close panes, and give a peer its own git worktree. " +
|
|
435
|
+
"Same host as Zellij, or ZSWARM_SSH / ZSWARM_SERVE for a remote crew.";
|
|
428
436
|
const FLAG_INDEX = new Map(PARAMS.flatMap((p) => p.flags.map((flag) => [flag, p])));
|
|
429
437
|
export function cliUsage() {
|
|
430
438
|
const lines = [
|
|
@@ -439,12 +447,31 @@ export function cliUsage() {
|
|
|
439
447
|
const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
|
|
440
448
|
lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
|
|
441
449
|
}
|
|
442
|
-
lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once, then list
|
|
450
|
+
lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once, then list and status use it now.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE", "");
|
|
443
451
|
return lines.join("\n");
|
|
444
452
|
}
|
|
445
453
|
/** Turn argv (without the op) into dispatch args, driven by PARAMS. */
|
|
454
|
+
/**
|
|
455
|
+
* Pull the op out of argv, tolerating flags before it.
|
|
456
|
+
*
|
|
457
|
+
* The op normally comes first. When it does not — `zswarm --session crew list`
|
|
458
|
+
* — the first token is a flag, and taking argv[0] blindly reported the flag as
|
|
459
|
+
* an unknown op. Only positions that already errored are affected: if argv[0]
|
|
460
|
+
* is a real op it wins, so `send reviewer list` still sends the word "list".
|
|
461
|
+
*/
|
|
462
|
+
function extractOp(argv) {
|
|
463
|
+
const first = argv[0];
|
|
464
|
+
if (first && !first.startsWith("-")) {
|
|
465
|
+
return { op: first, rest: argv.slice(1) };
|
|
466
|
+
}
|
|
467
|
+
const names = OP_NAMES;
|
|
468
|
+
const at = argv.findIndex((token) => names.includes(token));
|
|
469
|
+
if (at === -1)
|
|
470
|
+
return { op: first ?? "", rest: argv.slice(1) };
|
|
471
|
+
return { op: argv[at], rest: [...argv.slice(0, at), ...argv.slice(at + 1)] };
|
|
472
|
+
}
|
|
446
473
|
export function parseCliArgv(argv) {
|
|
447
|
-
const
|
|
474
|
+
const { op, rest } = extractOp(argv);
|
|
448
475
|
if (!op)
|
|
449
476
|
throw new ZellijError("usage", "no op given");
|
|
450
477
|
const out = { op };
|
package/dist/zellij/binary.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare function sanitizeZellijEnv(env: NodeJS.ProcessEnv): NodeJS.Proces
|
|
|
11
11
|
/**
|
|
12
12
|
* Remote crew: `ZSWARM_SSH=user@host` routes every zellij call over ssh.
|
|
13
13
|
* `ZSWARM_SSH_OPTS` is split on whitespace; `BatchMode` keeps it non-interactive.
|
|
14
|
+
* `ZSWARM_TMP` points at the interactive session's temp (or `auto` to discover).
|
|
15
|
+
* `ZSWARM_SSH_MODE=interactive` runs each call in the Windows desktop session.
|
|
14
16
|
*/
|
|
15
17
|
export declare function resolveSshTarget(env?: NodeJS.ProcessEnv): SshTarget | null;
|
|
16
18
|
/** Runner bound to the resolved zellij binary and a cleaned environment. */
|
package/dist/zellij/binary.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { delimiter, join } from "node:path";
|
|
4
4
|
import { createExec, createSshExec, NOT_FOUND_EXIT, } from "../exec.js";
|
|
5
|
+
import { applyIpcTmpEnv } from "./ipc.js";
|
|
5
6
|
export { createSshExec };
|
|
6
7
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
7
8
|
export { NOT_FOUND_EXIT };
|
|
@@ -54,9 +55,10 @@ export function resolveZellijBinary(env = process.env) {
|
|
|
54
55
|
return process.platform === "win32" ? "zellij.exe" : "zellij";
|
|
55
56
|
}
|
|
56
57
|
export function sanitizeZellijEnv(env) {
|
|
58
|
+
const withTmp = applyIpcTmpEnv(env);
|
|
57
59
|
const out = {};
|
|
58
|
-
for (const key of Object.keys(
|
|
59
|
-
const value =
|
|
60
|
+
for (const key of Object.keys(withTmp)) {
|
|
61
|
+
const value = withTmp[key];
|
|
60
62
|
if (typeof value === "string")
|
|
61
63
|
out[key] = value;
|
|
62
64
|
}
|
|
@@ -74,6 +76,8 @@ export function sanitizeZellijEnv(env) {
|
|
|
74
76
|
/**
|
|
75
77
|
* Remote crew: `ZSWARM_SSH=user@host` routes every zellij call over ssh.
|
|
76
78
|
* `ZSWARM_SSH_OPTS` is split on whitespace; `BatchMode` keeps it non-interactive.
|
|
79
|
+
* `ZSWARM_TMP` points at the interactive session's temp (or `auto` to discover).
|
|
80
|
+
* `ZSWARM_SSH_MODE=interactive` runs each call in the Windows desktop session.
|
|
77
81
|
*/
|
|
78
82
|
export function resolveSshTarget(env = process.env) {
|
|
79
83
|
const host = env.ZSWARM_SSH?.trim();
|
|
@@ -83,11 +87,19 @@ export function resolveSshTarget(env = process.env) {
|
|
|
83
87
|
if (!options.some((o) => o.startsWith("BatchMode"))) {
|
|
84
88
|
options.unshift("-o", "BatchMode=yes");
|
|
85
89
|
}
|
|
90
|
+
const shellRaw = (env.ZSWARM_REMOTE_SHELL ?? "").trim().toLowerCase();
|
|
91
|
+
const modeRaw = (env.ZSWARM_SSH_MODE ?? "").trim().toLowerCase();
|
|
92
|
+
const tmpRaw = env.ZSWARM_TMP?.trim();
|
|
93
|
+
const interactive = modeRaw === "interactive";
|
|
86
94
|
return {
|
|
87
95
|
ssh: env.ZSWARM_SSH_BIN?.trim() || "ssh",
|
|
88
96
|
host,
|
|
89
97
|
remoteBin: env.ZSWARM_REMOTE_BIN?.trim() || "zellij",
|
|
90
98
|
options,
|
|
99
|
+
// Interactive tasks do not inherit the desktop TEMP; discover it unless set.
|
|
100
|
+
tmp: tmpRaw || (interactive ? "auto" : undefined),
|
|
101
|
+
mode: interactive ? "interactive" : "ssh",
|
|
102
|
+
remoteShell: shellRaw === "cmd" || shellRaw === "sh" ? shellRaw : undefined,
|
|
91
103
|
};
|
|
92
104
|
}
|
|
93
105
|
/** Runner bound to the resolved zellij binary and a cleaned environment. */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zellij keeps its session sockets under TEMP (Windows) / TMPDIR (Unix).
|
|
3
|
+
* An SSH login on Windows is a different session from the interactive desktop,
|
|
4
|
+
* so the CLI looks in the wrong directory unless we point it at the live one.
|
|
5
|
+
* Windows pane attach still uses named pipes in that desktop session — TEMP
|
|
6
|
+
* is enough to list sessions; `interactive` or `serve` is what can write.
|
|
7
|
+
*/
|
|
8
|
+
/** Drive letter plus rest, or a UNC / posix path. */
|
|
9
|
+
export declare function looksLikeWindowsPath(path: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* `--server C:\Temp\zellij\contract_version_1\crew` → `C:\Temp`.
|
|
12
|
+
* The session file sits in `…/zellij[-uid]/contract_version_N/<name>`; TEMP is
|
|
13
|
+
* the parent of that `zellij` directory.
|
|
14
|
+
*/
|
|
15
|
+
export declare function tmpFromServerPath(serverPath: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Directory Zellij treats as `ZELLIJ_SOCKET_DIR` — the `zellij` / `zellij-<uid>`
|
|
18
|
+
* folder that contains `contract_version_N`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function socketDirFromServerPath(serverPath: string): string | null;
|
|
21
|
+
export type IpcDirs = {
|
|
22
|
+
tmp: string;
|
|
23
|
+
socketDir: string;
|
|
24
|
+
};
|
|
25
|
+
/** Pull every `--server <path>` out of process command lines. */
|
|
26
|
+
export declare function parseZellijServerPaths(commandLines: string): string[];
|
|
27
|
+
/** Majority vote so a stray SSH-session server does not win. */
|
|
28
|
+
export declare function pickIpcDirs(serverPaths: string[]): IpcDirs | null;
|
|
29
|
+
export declare function pickIpcTmp(serverPaths: string[]): string | null;
|
|
30
|
+
export declare function concreteTmp(raw: string | undefined): string | undefined;
|
|
31
|
+
export type RemoteShell = "cmd" | "sh";
|
|
32
|
+
export declare function inferRemoteShell(input: {
|
|
33
|
+
explicit?: string;
|
|
34
|
+
tmp?: string;
|
|
35
|
+
remoteBin?: string;
|
|
36
|
+
mode?: string;
|
|
37
|
+
}): RemoteShell;
|
|
38
|
+
/** cmd.exe quoting: double quotes, doubled inner quotes. */
|
|
39
|
+
export declare function cmdQuote(arg: string): string;
|
|
40
|
+
export declare function wrapWithTmpEnv(command: string, tmp: string, shell: RemoteShell, socketDir?: string): string;
|
|
41
|
+
/** UTF-16LE base64, which `powershell -EncodedCommand` expects. */
|
|
42
|
+
export declare function encodePowerShellCommand(script: string): string;
|
|
43
|
+
export declare function windowsDiscoverRemote(): string;
|
|
44
|
+
export declare function unixDiscoverRemote(): string;
|
|
45
|
+
/**
|
|
46
|
+
* Run `innerCmd` (a cmd.exe command line) in the interactive Windows session
|
|
47
|
+
* via a one-shot `schtasks /IT` job. SSH itself stays in session 0; the task
|
|
48
|
+
* is what can see the desktop's named pipes.
|
|
49
|
+
*/
|
|
50
|
+
export declare function windowsInteractiveRemote(innerCmd: string, timeoutMs: number): string;
|
|
51
|
+
export declare function applyIpcTmpEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zellij keeps its session sockets under TEMP (Windows) / TMPDIR (Unix).
|
|
3
|
+
* An SSH login on Windows is a different session from the interactive desktop,
|
|
4
|
+
* so the CLI looks in the wrong directory unless we point it at the live one.
|
|
5
|
+
* Windows pane attach still uses named pipes in that desktop session — TEMP
|
|
6
|
+
* is enough to list sessions; `interactive` or `serve` is what can write.
|
|
7
|
+
*/
|
|
8
|
+
/** `…/zellij/contract_version_N/…` or Unix `…/zellij-<uid>/contract_version_N/…`. */
|
|
9
|
+
const ZELLIJ_DIR = /[\\/](zellij(?:-\d+)?)[\\/]contract_version_[^\\/]+[\\/]/i;
|
|
10
|
+
const SERVER_FLAG = /(?:^|\s)--server(?:\s+|=)(\S+)/;
|
|
11
|
+
/** Drive letter plus rest, or a UNC / posix path. */
|
|
12
|
+
export function looksLikeWindowsPath(path) {
|
|
13
|
+
return /^[A-Za-z]:[\\/]/.test(path) || path.includes("\\");
|
|
14
|
+
}
|
|
15
|
+
function stripQuotes(path) {
|
|
16
|
+
return path.trim().replace(/^["']|["']$/g, "");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `--server C:\Temp\zellij\contract_version_1\crew` → `C:\Temp`.
|
|
20
|
+
* The session file sits in `…/zellij[-uid]/contract_version_N/<name>`; TEMP is
|
|
21
|
+
* the parent of that `zellij` directory.
|
|
22
|
+
*/
|
|
23
|
+
export function tmpFromServerPath(serverPath) {
|
|
24
|
+
const trimmed = stripQuotes(serverPath);
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
return null;
|
|
27
|
+
const match = ZELLIJ_DIR.exec(trimmed);
|
|
28
|
+
if (!match || match.index <= 0)
|
|
29
|
+
return null;
|
|
30
|
+
return trimmed.slice(0, match.index);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Directory Zellij treats as `ZELLIJ_SOCKET_DIR` — the `zellij` / `zellij-<uid>`
|
|
34
|
+
* folder that contains `contract_version_N`.
|
|
35
|
+
*/
|
|
36
|
+
export function socketDirFromServerPath(serverPath) {
|
|
37
|
+
const trimmed = stripQuotes(serverPath);
|
|
38
|
+
if (!trimmed)
|
|
39
|
+
return null;
|
|
40
|
+
const match = ZELLIJ_DIR.exec(trimmed);
|
|
41
|
+
if (!match || match.index < 0)
|
|
42
|
+
return null;
|
|
43
|
+
const name = match[1];
|
|
44
|
+
if (!name)
|
|
45
|
+
return null;
|
|
46
|
+
return trimmed.slice(0, match.index + 1 + name.length);
|
|
47
|
+
}
|
|
48
|
+
/** Pull every `--server <path>` out of process command lines. */
|
|
49
|
+
export function parseZellijServerPaths(commandLines) {
|
|
50
|
+
const found = [];
|
|
51
|
+
for (const line of commandLines.split(/\r?\n/)) {
|
|
52
|
+
const match = SERVER_FLAG.exec(line);
|
|
53
|
+
if (match?.[1])
|
|
54
|
+
found.push(stripQuotes(match[1]));
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
/** Majority vote so a stray SSH-session server does not win. */
|
|
59
|
+
export function pickIpcDirs(serverPaths) {
|
|
60
|
+
const counts = new Map();
|
|
61
|
+
for (const server of serverPaths) {
|
|
62
|
+
const tmp = tmpFromServerPath(server);
|
|
63
|
+
const socketDir = socketDirFromServerPath(server);
|
|
64
|
+
if (!tmp || !socketDir)
|
|
65
|
+
continue;
|
|
66
|
+
const prev = counts.get(tmp);
|
|
67
|
+
if (prev)
|
|
68
|
+
prev.count += 1;
|
|
69
|
+
else
|
|
70
|
+
counts.set(tmp, { count: 1, socketDir });
|
|
71
|
+
}
|
|
72
|
+
let best = null;
|
|
73
|
+
let bestCount = 0;
|
|
74
|
+
for (const [tmp, { count, socketDir }] of counts) {
|
|
75
|
+
if (count > bestCount) {
|
|
76
|
+
best = { tmp, socketDir };
|
|
77
|
+
bestCount = count;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
export function pickIpcTmp(serverPaths) {
|
|
83
|
+
return pickIpcDirs(serverPaths)?.tmp ?? null;
|
|
84
|
+
}
|
|
85
|
+
export function concreteTmp(raw) {
|
|
86
|
+
const trimmed = raw?.trim();
|
|
87
|
+
if (!trimmed || trimmed.toLowerCase() === "auto")
|
|
88
|
+
return undefined;
|
|
89
|
+
return trimmed;
|
|
90
|
+
}
|
|
91
|
+
export function inferRemoteShell(input) {
|
|
92
|
+
const explicit = input.explicit?.trim().toLowerCase();
|
|
93
|
+
if (explicit === "cmd" || explicit === "sh")
|
|
94
|
+
return explicit;
|
|
95
|
+
if (input.mode === "interactive")
|
|
96
|
+
return "cmd";
|
|
97
|
+
if (input.remoteBin && /\.exe$/i.test(input.remoteBin))
|
|
98
|
+
return "cmd";
|
|
99
|
+
if (input.tmp && looksLikeWindowsPath(input.tmp))
|
|
100
|
+
return "cmd";
|
|
101
|
+
return "sh";
|
|
102
|
+
}
|
|
103
|
+
/** cmd.exe quoting: double quotes, doubled inner quotes. */
|
|
104
|
+
export function cmdQuote(arg) {
|
|
105
|
+
if (arg === "")
|
|
106
|
+
return '""';
|
|
107
|
+
if (/^[A-Za-z0-9_@%+=:,.\\/-]+$/.test(arg))
|
|
108
|
+
return arg;
|
|
109
|
+
return `"${arg.replace(/"/g, '""')}"`;
|
|
110
|
+
}
|
|
111
|
+
function defaultSocketDir(tmp, shell) {
|
|
112
|
+
if (shell !== "cmd")
|
|
113
|
+
return undefined;
|
|
114
|
+
return tmp.replace(/[\\/]+$/, "") + "\\zellij";
|
|
115
|
+
}
|
|
116
|
+
export function wrapWithTmpEnv(command, tmp, shell, socketDir) {
|
|
117
|
+
const sock = socketDir || defaultSocketDir(tmp, shell);
|
|
118
|
+
if (shell === "cmd") {
|
|
119
|
+
let prefix = `set "TEMP=${tmp}" && set "TMP=${tmp}"`;
|
|
120
|
+
if (sock)
|
|
121
|
+
prefix += ` && set "ZELLIJ_SOCKET_DIR=${sock}"`;
|
|
122
|
+
return `${prefix} && ${command}`;
|
|
123
|
+
}
|
|
124
|
+
const q = tmp.replace(/'/g, `'\\''`);
|
|
125
|
+
let prefix = `TMPDIR='${q}' TEMP='${q}' TMP='${q}'`;
|
|
126
|
+
if (sock) {
|
|
127
|
+
const sq = sock.replace(/'/g, `'\\''`);
|
|
128
|
+
prefix += ` ZELLIJ_SOCKET_DIR='${sq}'`;
|
|
129
|
+
}
|
|
130
|
+
return `${prefix} ${command}`;
|
|
131
|
+
}
|
|
132
|
+
const WINDOWS_DISCOVER_PS = "Get-CimInstance Win32_Process -Filter \"Name='zellij.exe'\" | ForEach-Object { $_.CommandLine }";
|
|
133
|
+
/** UTF-16LE base64, which `powershell -EncodedCommand` expects. */
|
|
134
|
+
export function encodePowerShellCommand(script) {
|
|
135
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
136
|
+
}
|
|
137
|
+
export function windowsDiscoverRemote() {
|
|
138
|
+
return `powershell.exe -NoProfile -EncodedCommand ${encodePowerShellCommand(WINDOWS_DISCOVER_PS)}`;
|
|
139
|
+
}
|
|
140
|
+
export function unixDiscoverRemote() {
|
|
141
|
+
return "ps ax -o args=";
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Run `innerCmd` (a cmd.exe command line) in the interactive Windows session
|
|
145
|
+
* via a one-shot `schtasks /IT` job. SSH itself stays in session 0; the task
|
|
146
|
+
* is what can see the desktop's named pipes.
|
|
147
|
+
*/
|
|
148
|
+
export function windowsInteractiveRemote(innerCmd, timeoutMs) {
|
|
149
|
+
const innerB64 = Buffer.from(innerCmd, "utf8").toString("base64");
|
|
150
|
+
const waitMs = Math.max(1000, timeoutMs - 2000);
|
|
151
|
+
const script = [
|
|
152
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
153
|
+
"$inner = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
|
154
|
+
innerB64 +
|
|
155
|
+
"'))",
|
|
156
|
+
"$id = [guid]::NewGuid().ToString('n').Substring(0,8)",
|
|
157
|
+
"$task = \"zswarm-$id\"",
|
|
158
|
+
"$dir = Join-Path $env:LOCALAPPDATA 'Temp'",
|
|
159
|
+
"New-Item -ItemType Directory -Force -Path $dir | Out-Null",
|
|
160
|
+
"$bat = Join-Path $dir \"zswarm-$id.bat\"",
|
|
161
|
+
"$out = Join-Path $dir \"zswarm-$id.out\"",
|
|
162
|
+
"$err = Join-Path $dir \"zswarm-$id.err\"",
|
|
163
|
+
"$code = Join-Path $dir \"zswarm-$id.code\"",
|
|
164
|
+
"@(",
|
|
165
|
+
" '@echo off'",
|
|
166
|
+
" \"$inner > `\"$out`\" 2> `\"$err`\"\"",
|
|
167
|
+
" \"echo %ERRORLEVEL% > `\"$code`\"\"",
|
|
168
|
+
") | Set-Content -Path $bat -Encoding ASCII",
|
|
169
|
+
"try {",
|
|
170
|
+
" $create = & schtasks.exe /Create /TN $task /TR $bat /SC ONCE /ST 00:00 /IT /F /RL LIMITED 2>&1",
|
|
171
|
+
" if ($LASTEXITCODE -ne 0) {",
|
|
172
|
+
" [Console]::Error.Write(($create | Out-String).Trim())",
|
|
173
|
+
" exit 1",
|
|
174
|
+
" }",
|
|
175
|
+
" # schtasks /Run ignores /IT and stays in session 0. Start-ScheduledTask honours it.",
|
|
176
|
+
" Start-ScheduledTask -TaskName $task",
|
|
177
|
+
" $deadline = (Get-Date).AddMilliseconds(" + waitMs + ")",
|
|
178
|
+
" do { Start-Sleep -Milliseconds 200 } while (-not (Test-Path $code) -and (Get-Date) -lt $deadline)",
|
|
179
|
+
" if (Test-Path $out) { [Console]::Out.Write([IO.File]::ReadAllText($out)) }",
|
|
180
|
+
" if (Test-Path $err) { [Console]::Error.Write([IO.File]::ReadAllText($err)) }",
|
|
181
|
+
" if (Test-Path $code) { exit [int](((Get-Content $code -TotalCount 1) | Out-String).Trim()) }",
|
|
182
|
+
" [Console]::Error.Write('zswarm: interactive task produced no exit code; is a desktop session logged on?')",
|
|
183
|
+
" exit 1",
|
|
184
|
+
"} finally {",
|
|
185
|
+
" & schtasks.exe /Delete /TN $task /F 2>&1 | Out-Null",
|
|
186
|
+
" Remove-Item $bat,$out,$err,$code -Force -ErrorAction SilentlyContinue",
|
|
187
|
+
"}",
|
|
188
|
+
].join("\n");
|
|
189
|
+
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}`;
|
|
190
|
+
}
|
|
191
|
+
export function applyIpcTmpEnv(env) {
|
|
192
|
+
const tmp = concreteTmp(env.ZSWARM_TMP);
|
|
193
|
+
if (!tmp)
|
|
194
|
+
return env;
|
|
195
|
+
const out = { ...env, TEMP: tmp, TMP: tmp, TMPDIR: tmp };
|
|
196
|
+
const sock = defaultSocketDir(tmp, looksLikeWindowsPath(tmp) ? "cmd" : "sh");
|
|
197
|
+
if (sock)
|
|
198
|
+
out.ZELLIJ_SOCKET_DIR = sock;
|
|
199
|
+
return out;
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zswarm/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "zSwarm Zellij client and shared ops dispatch",
|
|
6
6
|
"exports": {
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsc -p tsconfig.json",
|
|
32
|
-
"test": "pnpm run build && node --test
|
|
32
|
+
"test": "pnpm run build && node --test"
|
|
33
33
|
}
|
|
34
34
|
}
|