@zswarm/core 0.1.1 → 0.1.3
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 +13 -0
- package/dist/exec.js +68 -4
- package/dist/git.js +44 -20
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/ops/dispatch.d.ts +1 -1
- package/dist/ops/dispatch.js +44 -2
- package/dist/ops/serve.d.ts +49 -0
- package/dist/ops/serve.js +280 -0
- package/dist/ops/worktree.js +9 -2
- package/dist/policy.js +6 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +28 -8
- package/dist/state.js +53 -18
- 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 +206 -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;
|
|
@@ -6,6 +7,8 @@ export type ExecResult = {
|
|
|
6
7
|
export type ExecOptions = {
|
|
7
8
|
timeoutMs: number;
|
|
8
9
|
cwd?: string;
|
|
10
|
+
/** Overlay on the runner's env (e.g. GIT_INDEX_FILE for a scratch index). */
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
9
12
|
/**
|
|
10
13
|
* Stop as soon as the accumulated stdout satisfies this, instead of waiting
|
|
11
14
|
* for the process to exit. `zellij pipe` answers in milliseconds but stays
|
|
@@ -23,7 +26,17 @@ export type SshTarget = {
|
|
|
23
26
|
host: string;
|
|
24
27
|
remoteBin: string;
|
|
25
28
|
options: string[];
|
|
29
|
+
/** Concrete IPC temp, or `auto` to read `--server` off a live zellij process. */
|
|
30
|
+
tmp?: string;
|
|
31
|
+
/** `interactive` runs the CLI in the Windows desktop session (schtasks /IT). */
|
|
32
|
+
mode?: "ssh" | "interactive";
|
|
33
|
+
remoteShell?: "cmd" | "sh";
|
|
26
34
|
};
|
|
35
|
+
export declare function quoteRemoteArg(arg: string, shell: RemoteShell): string;
|
|
36
|
+
/**
|
|
37
|
+
* The string `ssh host <this>` runs. Tests assert this rather than spawning ssh.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildSshRemoteCommand(target: SshTarget, args: string[], tmp: string | undefined, timeoutMs: number, socketDir?: string): string;
|
|
27
40
|
/**
|
|
28
41
|
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
29
42
|
* 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,77 @@ 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)
|
|
74
|
+
return cached;
|
|
75
|
+
const dirs = await discoverRemoteIpc(runner, target, timeoutMs);
|
|
76
|
+
if (dirs)
|
|
77
|
+
cached = dirs;
|
|
78
|
+
return dirs;
|
|
79
|
+
}
|
|
80
|
+
return async (args, options) => {
|
|
81
|
+
const ipc = await resolveIpc(options.timeoutMs);
|
|
82
|
+
const remote = buildSshRemoteCommand(target, args, ipc?.tmp, options.timeoutMs, ipc?.socketDir);
|
|
19
83
|
return runner([...target.options, target.host, remote], options);
|
|
20
84
|
};
|
|
21
85
|
}
|
|
@@ -23,7 +87,7 @@ export function createSshExec(target, env) {
|
|
|
23
87
|
function runUntil(binPath, args, env, options) {
|
|
24
88
|
return new Promise((resolve) => {
|
|
25
89
|
const child = spawn(binPath, args, {
|
|
26
|
-
env,
|
|
90
|
+
env: options.env ? { ...env, ...options.env } : env,
|
|
27
91
|
cwd: options.cwd,
|
|
28
92
|
windowsHide: true,
|
|
29
93
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -74,7 +138,7 @@ function runToExit(binPath, args, env, options) {
|
|
|
74
138
|
cwd: options.cwd,
|
|
75
139
|
maxBuffer: 8 * 1024 * 1024,
|
|
76
140
|
windowsHide: true,
|
|
77
|
-
env,
|
|
141
|
+
env: options.env ? { ...env, ...options.env } : env,
|
|
78
142
|
}, (error, stdout, stderr) => {
|
|
79
143
|
const failure = error;
|
|
80
144
|
if (failure && typeof failure.code === "string") {
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { copyFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
2
4
|
import { ZellijError } from "./errors.js";
|
|
3
5
|
import { createExec, NOT_FOUND_EXIT } from "./exec.js";
|
|
4
6
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
@@ -77,8 +79,8 @@ export function createGitClient(options = {}) {
|
|
|
77
79
|
const gitPath = options.gitPath ?? env.ZSWARM_GIT_BIN?.trim() ?? "git";
|
|
78
80
|
const exec = options.exec ?? createExec(gitPath, env);
|
|
79
81
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
80
|
-
async function run(args, cwd, label) {
|
|
81
|
-
const result = await exec(args, { timeoutMs, cwd });
|
|
82
|
+
async function run(args, cwd, label, extraEnv) {
|
|
83
|
+
const result = await exec(args, { timeoutMs, cwd, env: extraEnv });
|
|
82
84
|
if (result.code === NOT_FOUND_EXIT) {
|
|
83
85
|
throw new ZellijError("git_missing", `git not found (${gitPath}); install git or set ZSWARM_GIT_BIN`);
|
|
84
86
|
}
|
|
@@ -136,32 +138,54 @@ export function createGitClient(options = {}) {
|
|
|
136
138
|
return result.stdout.trim().length > 0;
|
|
137
139
|
}
|
|
138
140
|
/**
|
|
139
|
-
* Stage untracked paths as intent-to-add so they appear
|
|
140
|
-
*
|
|
141
|
+
* Stage untracked paths as intent-to-add in a scratch index so they appear
|
|
142
|
+
* in `git diff` without mutating the real `.git/index`.
|
|
141
143
|
*/
|
|
142
|
-
async function
|
|
143
|
-
await run(["
|
|
144
|
+
async function withScratchIndex(path, fn) {
|
|
145
|
+
const located = await run(["rev-parse", "--git-path", "index"], path, "git rev-parse --git-path index");
|
|
146
|
+
const indexRel = located.stdout.trim();
|
|
147
|
+
const indexAbs = isAbsolute(indexRel) ? indexRel : join(path, indexRel);
|
|
148
|
+
const scratch = `${indexAbs}.zswarm-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
149
|
+
try {
|
|
150
|
+
copyFileSync(indexAbs, scratch);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// No index yet (empty repo / new worktree).
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
return await fn({ GIT_INDEX_FILE: scratch });
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
rmSync(scratch, { force: true });
|
|
160
|
+
}
|
|
144
161
|
}
|
|
145
|
-
|
|
162
|
+
async function intentAddUntracked(path, indexEnv) {
|
|
163
|
+
await run(["add", "-A", "--intent-to-add"], path, "git add --intent-to-add", indexEnv);
|
|
164
|
+
}
|
|
165
|
+
/** `git diff --stat HEAD`, including untracked via a scratch intent-to-add. */
|
|
146
166
|
async function diffStat(path) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
167
|
+
return withScratchIndex(path, async (indexEnv) => {
|
|
168
|
+
await intentAddUntracked(path, indexEnv);
|
|
169
|
+
const result = await run(["diff", "--stat", "HEAD"], path, "git diff --stat", indexEnv);
|
|
170
|
+
return result.stdout;
|
|
171
|
+
});
|
|
150
172
|
}
|
|
151
173
|
/**
|
|
152
|
-
* `git diff HEAD`, including untracked via
|
|
174
|
+
* `git diff HEAD`, including untracked via a scratch intent-to-add.
|
|
153
175
|
* When `maxChars > 0` and the patch is longer, keep the head and set
|
|
154
176
|
* `truncated`. `chars` is always the full patch length before truncating.
|
|
155
177
|
*/
|
|
156
178
|
async function diffPatch(path, maxChars) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
return withScratchIndex(path, async (indexEnv) => {
|
|
180
|
+
await intentAddUntracked(path, indexEnv);
|
|
181
|
+
const result = await run(["diff", "HEAD"], path, "git diff", indexEnv);
|
|
182
|
+
const full = result.stdout;
|
|
183
|
+
const chars = full.length;
|
|
184
|
+
if (maxChars <= 0 || chars <= maxChars) {
|
|
185
|
+
return { patch: full, truncated: false, chars };
|
|
186
|
+
}
|
|
187
|
+
return { patch: full.slice(0, maxChars), truncated: true, chars };
|
|
188
|
+
});
|
|
165
189
|
}
|
|
166
190
|
/** `git add -A` then `git commit -m`; returns the new short HEAD sha. */
|
|
167
191
|
async function commitAll(path, message) {
|
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, isLoopbackHost, parseListenAddress, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, 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, isLoopbackHost, parseListenAddress, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, 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/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
|
@@ -17,9 +17,10 @@ import { spawnPane } from "./spawn.js";
|
|
|
17
17
|
import { peerStatus } from "./status.js";
|
|
18
18
|
import { tailPane } from "./tail.js";
|
|
19
19
|
import { OP_NAMES } from "../schema.js";
|
|
20
|
-
import { dumpMaxChars, fail, isTrue, isVerbose, paneViewBus, paneViewFull, paneViewSlim, truncateDumpText, } from "./util.js";
|
|
20
|
+
import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, 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() : "";
|
|
@@ -28,6 +29,24 @@ function waitMode(args) {
|
|
|
28
29
|
}
|
|
29
30
|
return typeof args.match === "string" && args.match ? "match" : "idle";
|
|
30
31
|
}
|
|
32
|
+
const SSH_GIT_OPS = new Set([
|
|
33
|
+
"worktrees",
|
|
34
|
+
"unworktree",
|
|
35
|
+
"diff",
|
|
36
|
+
"checkpoint",
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* SSH only forwards Zellij. Git worktrees, diffs, and checkpoints stay on this
|
|
40
|
+
* machine, so mixing them with a remote pane cwd is how you delete the wrong tree.
|
|
41
|
+
*/
|
|
42
|
+
function assertSshGitAllowed(env, op, args) {
|
|
43
|
+
if (!env.ZSWARM_SSH?.trim() || env.ZSWARM_SERVE?.trim())
|
|
44
|
+
return;
|
|
45
|
+
const worktreeSpawn = op === "spawn" && optionalString(args.worktree);
|
|
46
|
+
if (!worktreeSpawn && !SSH_GIT_OPS.has(op))
|
|
47
|
+
return;
|
|
48
|
+
throw new ZellijError("ssh_git_unsupported", `${op} uses local git; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
|
|
49
|
+
}
|
|
31
50
|
async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
32
51
|
const to = String(args.to ?? "").trim();
|
|
33
52
|
if (!to)
|
|
@@ -57,7 +76,7 @@ async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
|
57
76
|
return { session, panes, pane };
|
|
58
77
|
}
|
|
59
78
|
/** Shared MCP/CLI dispatch for zswarm ops. */
|
|
60
|
-
export async function dispatchZswarm(args,
|
|
79
|
+
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
61
80
|
const op = String(args.op ?? "");
|
|
62
81
|
const verbose = isVerbose(args);
|
|
63
82
|
const clock = {
|
|
@@ -71,9 +90,32 @@ export async function dispatchZswarm(args, client = createZellijClient(), deps =
|
|
|
71
90
|
let stateStore = deps.state ?? null;
|
|
72
91
|
const state = () => (stateStore ??= createStateStore());
|
|
73
92
|
const policy = deps.policy ?? loadPolicy(deps.env);
|
|
93
|
+
const env = deps.env ?? process.env;
|
|
74
94
|
try {
|
|
75
95
|
// Policy gates the op before anything touches the session.
|
|
76
96
|
assertOpAllowed(policy, op);
|
|
97
|
+
assertSshGitAllowed(env, op, args);
|
|
98
|
+
if (op === "serve") {
|
|
99
|
+
if (isTrue(args.clear)) {
|
|
100
|
+
const cleared = await uninstallServeLogon({
|
|
101
|
+
platform: process.platform,
|
|
102
|
+
});
|
|
103
|
+
return { ok: true, data: cleared };
|
|
104
|
+
}
|
|
105
|
+
if (isTrue(args.install)) {
|
|
106
|
+
const installed = await installServeLogon({
|
|
107
|
+
listen: typeof args.listen === "string" ? args.listen : undefined,
|
|
108
|
+
});
|
|
109
|
+
return { ok: true, data: { ...installed, running: true } };
|
|
110
|
+
}
|
|
111
|
+
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");
|
|
112
|
+
}
|
|
113
|
+
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
114
|
+
// just because the host env has ZSWARM_SERVE set.
|
|
115
|
+
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
116
|
+
return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
117
|
+
}
|
|
118
|
+
const client = injected ?? createZellijClient({ env: deps.env });
|
|
77
119
|
switch (op) {
|
|
78
120
|
case "sessions": {
|
|
79
121
|
const sessions = await client.listSessions();
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
/** One JSONL request; a client that never sends newline cannot grow forever. */
|
|
5
|
+
export declare const SERVE_MAX_REQUEST_BYTES: number;
|
|
6
|
+
/** Wait/await allow 15 minutes; this is that plus slack. */
|
|
7
|
+
export declare const SERVE_CALL_TIMEOUT_CAP_MS: number;
|
|
8
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
9
|
+
export declare function formatListenLabel(host: string, port: number): string;
|
|
10
|
+
export declare function parseListenAddress(raw: string | undefined): {
|
|
11
|
+
host: string;
|
|
12
|
+
port: number;
|
|
13
|
+
label: string;
|
|
14
|
+
};
|
|
15
|
+
/** Client-side wait: the op's own timeout plus slack. */
|
|
16
|
+
export declare function serveCallTimeout(args: Record<string, unknown>): number;
|
|
17
|
+
/** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
|
|
18
|
+
export declare function serveChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
19
|
+
export type ServeDispatch = (args: Record<string, unknown>) => Promise<OpsResult>;
|
|
20
|
+
export type StartServeOptions = {
|
|
21
|
+
token?: string;
|
|
22
|
+
maxRequestBytes?: number;
|
|
23
|
+
};
|
|
24
|
+
export declare function startServe(listen: string | undefined, dispatch: ServeDispatch, options?: StartServeOptions): Promise<{
|
|
25
|
+
label: string;
|
|
26
|
+
close: () => Promise<void>;
|
|
27
|
+
}>;
|
|
28
|
+
export declare function parseServeTarget(raw: string): {
|
|
29
|
+
host: string;
|
|
30
|
+
port: number;
|
|
31
|
+
};
|
|
32
|
+
export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
|
|
33
|
+
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
|
|
34
|
+
export declare function installServeLogon(input: {
|
|
35
|
+
listen?: string;
|
|
36
|
+
execPath?: string;
|
|
37
|
+
scriptPath?: string;
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
task: string;
|
|
41
|
+
listen: string;
|
|
42
|
+
command: string;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function uninstallServeLogon(input?: {
|
|
45
|
+
platform?: NodeJS.Platform;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
task: string;
|
|
48
|
+
cleared: true;
|
|
49
|
+
}>;
|
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
/** One JSONL request; a client that never sends newline cannot grow forever. */
|
|
8
|
+
export const SERVE_MAX_REQUEST_BYTES = 1024 * 1024;
|
|
9
|
+
/** Wait/await allow 15 minutes; this is that plus slack. */
|
|
10
|
+
export const SERVE_CALL_TIMEOUT_CAP_MS = 16 * 60_000;
|
|
11
|
+
const SERVE_TOKEN_FIELD = "serveToken";
|
|
12
|
+
export function isLoopbackHost(host) {
|
|
13
|
+
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
14
|
+
return (h === "127.0.0.1" ||
|
|
15
|
+
h === "localhost" ||
|
|
16
|
+
h === "::1" ||
|
|
17
|
+
h === "0:0:0:0:0:0:0:1");
|
|
18
|
+
}
|
|
19
|
+
export function formatListenLabel(host, port) {
|
|
20
|
+
return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
|
|
21
|
+
}
|
|
22
|
+
export function parseListenAddress(raw) {
|
|
23
|
+
const text = (raw ?? DEFAULT_SERVE_LISTEN).trim() || DEFAULT_SERVE_LISTEN;
|
|
24
|
+
const stripped = text.replace(/^tcp:\/\//i, "");
|
|
25
|
+
let host;
|
|
26
|
+
let port;
|
|
27
|
+
if (stripped.startsWith("[")) {
|
|
28
|
+
const end = stripped.indexOf("]");
|
|
29
|
+
if (end === -1) {
|
|
30
|
+
throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
|
|
31
|
+
}
|
|
32
|
+
host = stripped.slice(1, end);
|
|
33
|
+
const rest = stripped.slice(end + 1);
|
|
34
|
+
if (!rest.startsWith(":")) {
|
|
35
|
+
throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
|
|
36
|
+
}
|
|
37
|
+
port = Number(rest.slice(1));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const hostPort = stripped.includes(":")
|
|
41
|
+
? stripped
|
|
42
|
+
: `127.0.0.1:${stripped}`;
|
|
43
|
+
const colon = hostPort.lastIndexOf(":");
|
|
44
|
+
host = colon === -1 ? "127.0.0.1" : hostPort.slice(0, colon) || "127.0.0.1";
|
|
45
|
+
port = Number(colon === -1 ? hostPort : hostPort.slice(colon + 1));
|
|
46
|
+
}
|
|
47
|
+
if (!host || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
48
|
+
throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
|
|
49
|
+
}
|
|
50
|
+
return { host, port, label: formatListenLabel(host, port) };
|
|
51
|
+
}
|
|
52
|
+
/** Client-side wait: the op's own timeout plus slack. */
|
|
53
|
+
export function serveCallTimeout(args) {
|
|
54
|
+
const requested = Number(args.timeoutMs);
|
|
55
|
+
const base = Number.isFinite(requested) && requested > 0 ? requested : 60_000;
|
|
56
|
+
return Math.min(Math.max(base + 5_000, 15_000), SERVE_CALL_TIMEOUT_CAP_MS);
|
|
57
|
+
}
|
|
58
|
+
/** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
|
|
59
|
+
export function serveChildEnv(env) {
|
|
60
|
+
const out = { ...env };
|
|
61
|
+
delete out.ZSWARM_SERVE;
|
|
62
|
+
delete out.ZSWARM_SSH;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function unauthorized() {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: {
|
|
69
|
+
code: "serve_unauthorized",
|
|
70
|
+
message: "zswarm serve rejected the request (missing or wrong ZSWARM_SERVE_TOKEN)",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function takeServeToken(args) {
|
|
75
|
+
const token = typeof args[SERVE_TOKEN_FIELD] === "string"
|
|
76
|
+
? args[SERVE_TOKEN_FIELD]
|
|
77
|
+
: undefined;
|
|
78
|
+
const request = { ...args };
|
|
79
|
+
delete request[SERVE_TOKEN_FIELD];
|
|
80
|
+
return { token, request };
|
|
81
|
+
}
|
|
82
|
+
export function startServe(listen, dispatch, options = {}) {
|
|
83
|
+
const { host, port } = parseListenAddress(listen);
|
|
84
|
+
const token = options.token?.trim() || undefined;
|
|
85
|
+
if (!token && !isLoopbackHost(host)) {
|
|
86
|
+
return Promise.reject(new ZellijError("serve_auth", `listening on ${host} requires ZSWARM_SERVE_TOKEN; loopback can run without one`));
|
|
87
|
+
}
|
|
88
|
+
const maxRequestBytes = options.maxRequestBytes ?? SERVE_MAX_REQUEST_BYTES;
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const server = createServer((socket) => {
|
|
91
|
+
let buf = "";
|
|
92
|
+
socket.setEncoding("utf8");
|
|
93
|
+
socket.on("data", (chunk) => {
|
|
94
|
+
buf += chunk;
|
|
95
|
+
if (buf.length > maxRequestBytes) {
|
|
96
|
+
socket.write(`${JSON.stringify({
|
|
97
|
+
ok: false,
|
|
98
|
+
error: {
|
|
99
|
+
code: "bad_arg",
|
|
100
|
+
message: `serve request exceeded ${maxRequestBytes} bytes`,
|
|
101
|
+
},
|
|
102
|
+
})}\n`);
|
|
103
|
+
socket.destroy();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
void drain();
|
|
107
|
+
});
|
|
108
|
+
async function drain() {
|
|
109
|
+
let nl = buf.indexOf("\n");
|
|
110
|
+
while (nl !== -1) {
|
|
111
|
+
const line = buf.slice(0, nl).trim();
|
|
112
|
+
buf = buf.slice(nl + 1);
|
|
113
|
+
if (line) {
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(line);
|
|
117
|
+
const taken = takeServeToken(parsed);
|
|
118
|
+
if (token && taken.token !== token) {
|
|
119
|
+
result = unauthorized();
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
result = await dispatch(taken.request);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
127
|
+
result = { ok: false, error: { code: "bad_arg", message } };
|
|
128
|
+
}
|
|
129
|
+
socket.write(`${JSON.stringify(result)}\n`);
|
|
130
|
+
}
|
|
131
|
+
nl = buf.indexOf("\n");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
server.on("error", reject);
|
|
136
|
+
server.listen(port, host, () => {
|
|
137
|
+
const addr = server.address();
|
|
138
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
139
|
+
resolve({
|
|
140
|
+
label: formatListenLabel(host, actualPort),
|
|
141
|
+
close: () => new Promise((done, fail) => {
|
|
142
|
+
server.close((err) => (err ? fail(err) : done()));
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export function parseServeTarget(raw) {
|
|
149
|
+
return parseListenAddress(raw);
|
|
150
|
+
}
|
|
151
|
+
export function callServe(target, args, timeoutMs = 15_000, token) {
|
|
152
|
+
const { host, port } = parseServeTarget(target);
|
|
153
|
+
const payload = token && token.trim()
|
|
154
|
+
? { ...args, [SERVE_TOKEN_FIELD]: token.trim() }
|
|
155
|
+
: args;
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
const socket = connect({ host, port });
|
|
158
|
+
let buf = "";
|
|
159
|
+
let settled = false;
|
|
160
|
+
const finish = (result) => {
|
|
161
|
+
if (settled)
|
|
162
|
+
return;
|
|
163
|
+
settled = true;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
socket.destroy();
|
|
166
|
+
resolve(result);
|
|
167
|
+
};
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
finish({
|
|
170
|
+
ok: false,
|
|
171
|
+
error: {
|
|
172
|
+
code: "timeout",
|
|
173
|
+
message: `zswarm serve at ${host}:${port} timed out after ${timeoutMs}ms`,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
socket.setEncoding("utf8");
|
|
178
|
+
socket.on("error", (err) => {
|
|
179
|
+
finish({
|
|
180
|
+
ok: false,
|
|
181
|
+
error: {
|
|
182
|
+
code: "serve_unreachable",
|
|
183
|
+
message: `cannot reach zswarm serve at ${host}:${port}: ${err.message}`,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
socket.on("data", (chunk) => {
|
|
188
|
+
buf += chunk;
|
|
189
|
+
const nl = buf.indexOf("\n");
|
|
190
|
+
if (nl === -1)
|
|
191
|
+
return;
|
|
192
|
+
const line = buf.slice(0, nl).trim();
|
|
193
|
+
try {
|
|
194
|
+
finish(JSON.parse(line));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
finish({
|
|
198
|
+
ok: false,
|
|
199
|
+
error: { code: "failed", message: `serve returned non-JSON: ${line}` },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
socket.on("connect", () => {
|
|
204
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function serveLogonCommand(execPath, scriptPath, listen) {
|
|
209
|
+
return `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
|
|
210
|
+
}
|
|
211
|
+
function runPowerShell(script) {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
execFile("powershell.exe", [
|
|
214
|
+
"-NoProfile",
|
|
215
|
+
"-ExecutionPolicy",
|
|
216
|
+
"Bypass",
|
|
217
|
+
"-EncodedCommand",
|
|
218
|
+
encodePowerShellCommand(script),
|
|
219
|
+
], { windowsHide: true, timeout: 20_000 }, (error, stdout, stderr) => {
|
|
220
|
+
const code = error && typeof error.code === "number"
|
|
221
|
+
? error.code
|
|
222
|
+
: error
|
|
223
|
+
? 1
|
|
224
|
+
: 0;
|
|
225
|
+
resolve({
|
|
226
|
+
code,
|
|
227
|
+
stdout: String(stdout ?? ""),
|
|
228
|
+
stderr: String(stderr ?? ""),
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
export async function installServeLogon(input) {
|
|
234
|
+
if ((input.platform ?? process.platform) !== "win32") {
|
|
235
|
+
throw new ZellijError("usage", "serve --install registers a Windows logon task; on Unix start `zswarm serve --listen` in the session that owns Zellij");
|
|
236
|
+
}
|
|
237
|
+
const { label } = parseListenAddress(input.listen);
|
|
238
|
+
const execPath = input.execPath ?? process.execPath;
|
|
239
|
+
const scriptPath = input.scriptPath ?? process.argv[1];
|
|
240
|
+
if (!scriptPath) {
|
|
241
|
+
throw new ZellijError("usage", "cannot resolve the zswarm script path for the logon task");
|
|
242
|
+
}
|
|
243
|
+
const command = serveLogonCommand(execPath, scriptPath, label);
|
|
244
|
+
const commandB64 = Buffer.from(command, "utf8").toString("base64");
|
|
245
|
+
const script = [
|
|
246
|
+
"$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
|
247
|
+
commandB64 +
|
|
248
|
+
"'))",
|
|
249
|
+
"$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument \"/c $cmd\"",
|
|
250
|
+
"$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited",
|
|
251
|
+
"$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME",
|
|
252
|
+
`Register-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Action $action -Principal $principal -Trigger $trigger -Force | Out-Null`,
|
|
253
|
+
`Start-ScheduledTask -TaskName '${SERVE_TASK_NAME}'`,
|
|
254
|
+
].join("\n");
|
|
255
|
+
const result = await runPowerShell(script);
|
|
256
|
+
if (result.code !== 0) {
|
|
257
|
+
throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
258
|
+
}
|
|
259
|
+
return { task: SERVE_TASK_NAME, listen: label, command };
|
|
260
|
+
}
|
|
261
|
+
export async function uninstallServeLogon(input = {}) {
|
|
262
|
+
if ((input.platform ?? process.platform) !== "win32") {
|
|
263
|
+
throw new ZellijError("usage", "serve --clear is Windows-only");
|
|
264
|
+
}
|
|
265
|
+
const script = [
|
|
266
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
267
|
+
"try {",
|
|
268
|
+
` Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false`,
|
|
269
|
+
"} catch {",
|
|
270
|
+
" if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { exit 0 }",
|
|
271
|
+
" [Console]::Error.Write(($_ | Out-String).Trim())",
|
|
272
|
+
" exit 1",
|
|
273
|
+
"}",
|
|
274
|
+
].join("\n");
|
|
275
|
+
const result = await runPowerShell(script);
|
|
276
|
+
if (result.code !== 0) {
|
|
277
|
+
throw new ZellijError("zellij_failed", `serve --clear failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
278
|
+
}
|
|
279
|
+
return { task: SERVE_TASK_NAME, cleared: true };
|
|
280
|
+
}
|
package/dist/ops/worktree.js
CHANGED
|
@@ -120,8 +120,15 @@ export async function removePeerWorktree(git, client, args, env = process.env) {
|
|
|
120
120
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
121
121
|
occupants = panesIn(await client.listPanes(session), target.path);
|
|
122
122
|
}
|
|
123
|
-
catch {
|
|
124
|
-
// No live session means nobody is working in it.
|
|
123
|
+
catch (err) {
|
|
124
|
+
// No live session means nobody is working in it. Any other failure must
|
|
125
|
+
// not look like "empty" — that is how a clean busy worktree gets deleted.
|
|
126
|
+
if (err instanceof ZellijError && err.code === "zellij_no_session") {
|
|
127
|
+
occupants = [];
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
125
132
|
}
|
|
126
133
|
if (occupants.length > 0 && !force) {
|
|
127
134
|
throw new ZellijError("worktree_busy", `${occupants.join(", ")} still working in ${target.path}; close the pane or pass force=true`);
|
package/dist/policy.js
CHANGED
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[];
|
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,7 +447,7 @@ 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 (+ ZSWARM_SERVE_TOKEN off loopback).", "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, ZSWARM_SERVE_TOKEN", "");
|
|
443
451
|
return lines.join("\n");
|
|
444
452
|
}
|
|
445
453
|
/** Turn argv (without the op) into dispatch args, driven by PARAMS. */
|
|
@@ -457,10 +465,22 @@ function extractOp(argv) {
|
|
|
457
465
|
return { op: first, rest: argv.slice(1) };
|
|
458
466
|
}
|
|
459
467
|
const names = OP_NAMES;
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
468
|
+
for (let i = 0; i < argv.length; i++) {
|
|
469
|
+
const token = argv[i];
|
|
470
|
+
if (token.startsWith("-")) {
|
|
471
|
+
const param = FLAG_INDEX.get(token);
|
|
472
|
+
if (param && param.type !== "boolean")
|
|
473
|
+
i += 1;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (names.includes(token)) {
|
|
477
|
+
return {
|
|
478
|
+
op: token,
|
|
479
|
+
rest: [...argv.slice(0, i), ...argv.slice(i + 1)],
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return { op: first ?? "", rest: argv.slice(1) };
|
|
464
484
|
}
|
|
465
485
|
export function parseCliArgv(argv) {
|
|
466
486
|
const { op, rest } = extractOp(argv);
|
package/dist/state.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const LOG_FILE = "log.jsonl";
|
|
5
5
|
const SIGNALS_FILE = "signals.json";
|
|
6
|
+
const SIGNALS_LOCK = "signals.lock";
|
|
6
7
|
const CURSORS_FILE = "cursors.json";
|
|
7
8
|
const BUS_FILE = "bus.json";
|
|
8
9
|
/** Keeps the log bounded without needing a rotation daemon. */
|
|
9
10
|
const LOG_TAIL_BYTES = 512 * 1024;
|
|
11
|
+
const LOCK_WAIT_MS = 5_000;
|
|
12
|
+
function sleepSync(ms) {
|
|
13
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
14
|
+
}
|
|
10
15
|
export function defaultStateDir(env = process.env) {
|
|
11
16
|
const explicit = env.ZSWARM_STATE_DIR?.trim();
|
|
12
17
|
if (explicit)
|
|
@@ -72,29 +77,59 @@ export function createStateStore(options = {}) {
|
|
|
72
77
|
}
|
|
73
78
|
return out;
|
|
74
79
|
}
|
|
80
|
+
function withSignalsLock(fn) {
|
|
81
|
+
ensureDir();
|
|
82
|
+
const lockPath = join(dir, SIGNALS_LOCK);
|
|
83
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
84
|
+
while (true) {
|
|
85
|
+
try {
|
|
86
|
+
const fd = openSync(lockPath, "wx");
|
|
87
|
+
try {
|
|
88
|
+
return fn();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
rmSync(lockPath, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const code = err.code;
|
|
97
|
+
if (code !== "EEXIST")
|
|
98
|
+
throw err;
|
|
99
|
+
if (Date.now() >= deadline) {
|
|
100
|
+
throw new Error(`timed out waiting for ${SIGNALS_LOCK}`);
|
|
101
|
+
}
|
|
102
|
+
sleepSync(10);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
75
106
|
function readSignals() {
|
|
76
107
|
return readJson(SIGNALS_FILE, {});
|
|
77
108
|
}
|
|
78
109
|
function postSignal(channel, payload, at) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
110
|
+
return withSignalsLock(() => {
|
|
111
|
+
const all = readSignals();
|
|
112
|
+
const prev = all[channel];
|
|
113
|
+
const next = {
|
|
114
|
+
count: (prev?.count ?? 0) + 1,
|
|
115
|
+
at,
|
|
116
|
+
last: payload,
|
|
117
|
+
};
|
|
118
|
+
all[channel] = next;
|
|
119
|
+
writeJson(SIGNALS_FILE, all);
|
|
120
|
+
return next;
|
|
121
|
+
});
|
|
89
122
|
}
|
|
90
123
|
function clearSignal(channel) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
124
|
+
withSignalsLock(() => {
|
|
125
|
+
if (channel === null) {
|
|
126
|
+
writeJson(SIGNALS_FILE, {});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const all = readSignals();
|
|
130
|
+
delete all[channel];
|
|
131
|
+
writeJson(SIGNALS_FILE, all);
|
|
132
|
+
});
|
|
98
133
|
}
|
|
99
134
|
function readCursor(key) {
|
|
100
135
|
const all = readJson(CURSORS_FILE, {});
|
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,206 @@
|
|
|
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
|
+
const captured = match?.[1] || match?.[2] || match?.[3];
|
|
54
|
+
if (captured)
|
|
55
|
+
found.push(stripQuotes(captured));
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
/** Majority vote so a stray SSH-session server does not win. */
|
|
60
|
+
export function pickIpcDirs(serverPaths) {
|
|
61
|
+
const counts = new Map();
|
|
62
|
+
for (const server of serverPaths) {
|
|
63
|
+
const tmp = tmpFromServerPath(server);
|
|
64
|
+
const socketDir = socketDirFromServerPath(server);
|
|
65
|
+
if (!tmp || !socketDir)
|
|
66
|
+
continue;
|
|
67
|
+
const prev = counts.get(tmp);
|
|
68
|
+
if (prev)
|
|
69
|
+
prev.count += 1;
|
|
70
|
+
else
|
|
71
|
+
counts.set(tmp, { count: 1, socketDir });
|
|
72
|
+
}
|
|
73
|
+
let best = null;
|
|
74
|
+
let bestCount = 0;
|
|
75
|
+
for (const [tmp, { count, socketDir }] of counts) {
|
|
76
|
+
if (count > bestCount) {
|
|
77
|
+
best = { tmp, socketDir };
|
|
78
|
+
bestCount = count;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return best;
|
|
82
|
+
}
|
|
83
|
+
export function pickIpcTmp(serverPaths) {
|
|
84
|
+
return pickIpcDirs(serverPaths)?.tmp ?? null;
|
|
85
|
+
}
|
|
86
|
+
export function concreteTmp(raw) {
|
|
87
|
+
const trimmed = raw?.trim();
|
|
88
|
+
if (!trimmed || trimmed.toLowerCase() === "auto")
|
|
89
|
+
return undefined;
|
|
90
|
+
return trimmed;
|
|
91
|
+
}
|
|
92
|
+
export function inferRemoteShell(input) {
|
|
93
|
+
const explicit = input.explicit?.trim().toLowerCase();
|
|
94
|
+
if (explicit === "cmd" || explicit === "sh")
|
|
95
|
+
return explicit;
|
|
96
|
+
if (input.mode === "interactive")
|
|
97
|
+
return "cmd";
|
|
98
|
+
if (input.remoteBin && /\.exe$/i.test(input.remoteBin))
|
|
99
|
+
return "cmd";
|
|
100
|
+
if (input.tmp && looksLikeWindowsPath(input.tmp))
|
|
101
|
+
return "cmd";
|
|
102
|
+
return "sh";
|
|
103
|
+
}
|
|
104
|
+
/** cmd.exe quoting: double quotes, doubled inner quotes. */
|
|
105
|
+
export function cmdQuote(arg) {
|
|
106
|
+
if (arg === "")
|
|
107
|
+
return '""';
|
|
108
|
+
// cmd.exe expands %VAR% even inside quotes; double percents to keep literals.
|
|
109
|
+
const escaped = arg.replace(/%/g, "%%");
|
|
110
|
+
if (/^[A-Za-z0-9_@+=:,.\\/-]+$/.test(escaped.replace(/%%/g, ""))) {
|
|
111
|
+
return escaped;
|
|
112
|
+
}
|
|
113
|
+
return `"${escaped.replace(/"/g, '""')}"`;
|
|
114
|
+
}
|
|
115
|
+
function defaultSocketDir(tmp, shell) {
|
|
116
|
+
if (shell !== "cmd")
|
|
117
|
+
return undefined;
|
|
118
|
+
return tmp.replace(/[\\/]+$/, "") + "\\zellij";
|
|
119
|
+
}
|
|
120
|
+
export function wrapWithTmpEnv(command, tmp, shell, socketDir) {
|
|
121
|
+
const sock = socketDir || defaultSocketDir(tmp, shell);
|
|
122
|
+
if (shell === "cmd") {
|
|
123
|
+
let prefix = `set "TEMP=${tmp}" && set "TMP=${tmp}"`;
|
|
124
|
+
if (sock)
|
|
125
|
+
prefix += ` && set "ZELLIJ_SOCKET_DIR=${sock}"`;
|
|
126
|
+
return `${prefix} && ${command}`;
|
|
127
|
+
}
|
|
128
|
+
const q = tmp.replace(/'/g, `'\\''`);
|
|
129
|
+
let prefix = `TMPDIR='${q}' TEMP='${q}' TMP='${q}'`;
|
|
130
|
+
if (sock) {
|
|
131
|
+
const sq = sock.replace(/'/g, `'\\''`);
|
|
132
|
+
prefix += ` ZELLIJ_SOCKET_DIR='${sq}'`;
|
|
133
|
+
}
|
|
134
|
+
return `${prefix} ${command}`;
|
|
135
|
+
}
|
|
136
|
+
const WINDOWS_DISCOVER_PS = "Get-CimInstance Win32_Process -Filter \"Name='zellij.exe'\" | ForEach-Object { $_.CommandLine }";
|
|
137
|
+
/** UTF-16LE base64, which `powershell -EncodedCommand` expects. */
|
|
138
|
+
export function encodePowerShellCommand(script) {
|
|
139
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
140
|
+
}
|
|
141
|
+
export function windowsDiscoverRemote() {
|
|
142
|
+
return `powershell.exe -NoProfile -EncodedCommand ${encodePowerShellCommand(WINDOWS_DISCOVER_PS)}`;
|
|
143
|
+
}
|
|
144
|
+
export function unixDiscoverRemote() {
|
|
145
|
+
return "ps ax -o args=";
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Run `innerCmd` (a cmd.exe command line) in the interactive Windows session
|
|
149
|
+
* via a one-shot `schtasks /IT` job. SSH itself stays in session 0; the task
|
|
150
|
+
* is what can see the desktop's named pipes.
|
|
151
|
+
*/
|
|
152
|
+
export function windowsInteractiveRemote(innerCmd, timeoutMs) {
|
|
153
|
+
const innerB64 = Buffer.from(innerCmd, "utf8").toString("base64");
|
|
154
|
+
const waitMs = Math.max(1000, timeoutMs - 2000);
|
|
155
|
+
const script = [
|
|
156
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
157
|
+
"$inner = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
|
158
|
+
innerB64 +
|
|
159
|
+
"'))",
|
|
160
|
+
"$id = [guid]::NewGuid().ToString('n').Substring(0,8)",
|
|
161
|
+
"$task = \"zswarm-$id\"",
|
|
162
|
+
"$dir = Join-Path $env:LOCALAPPDATA 'Temp'",
|
|
163
|
+
"New-Item -ItemType Directory -Force -Path $dir | Out-Null",
|
|
164
|
+
"$bat = Join-Path $dir \"zswarm-$id.bat\"",
|
|
165
|
+
"$out = Join-Path $dir \"zswarm-$id.out\"",
|
|
166
|
+
"$err = Join-Path $dir \"zswarm-$id.err\"",
|
|
167
|
+
"$code = Join-Path $dir \"zswarm-$id.code\"",
|
|
168
|
+
"$lines = @(",
|
|
169
|
+
" '@echo off'",
|
|
170
|
+
" 'chcp 65001 >nul'",
|
|
171
|
+
" \"$inner > `\"$out`\" 2> `\"$err`\"\"",
|
|
172
|
+
" \"echo %ERRORLEVEL% > `\"$code`\"\"",
|
|
173
|
+
")",
|
|
174
|
+
"[IO.File]::WriteAllLines($bat, $lines, [Text.UTF8Encoding]::new($false))",
|
|
175
|
+
"try {",
|
|
176
|
+
" $create = & schtasks.exe /Create /TN $task /TR $bat /SC ONCE /ST 00:00 /IT /F /RL LIMITED 2>&1",
|
|
177
|
+
" if ($LASTEXITCODE -ne 0) {",
|
|
178
|
+
" [Console]::Error.Write(($create | Out-String).Trim())",
|
|
179
|
+
" exit 1",
|
|
180
|
+
" }",
|
|
181
|
+
" # schtasks /Run ignores /IT and stays in session 0. Start-ScheduledTask honours it.",
|
|
182
|
+
" Start-ScheduledTask -TaskName $task",
|
|
183
|
+
" $deadline = (Get-Date).AddMilliseconds(" + waitMs + ")",
|
|
184
|
+
" do { Start-Sleep -Milliseconds 200 } while (-not (Test-Path $code) -and (Get-Date) -lt $deadline)",
|
|
185
|
+
" if (Test-Path $out) { [Console]::Out.Write([IO.File]::ReadAllText($out)) }",
|
|
186
|
+
" if (Test-Path $err) { [Console]::Error.Write([IO.File]::ReadAllText($err)) }",
|
|
187
|
+
" if (Test-Path $code) { exit [int](((Get-Content $code -TotalCount 1) | Out-String).Trim()) }",
|
|
188
|
+
" [Console]::Error.Write('zswarm: interactive task produced no exit code; is a desktop session logged on?')",
|
|
189
|
+
" exit 1",
|
|
190
|
+
"} finally {",
|
|
191
|
+
" & schtasks.exe /Delete /TN $task /F 2>&1 | Out-Null",
|
|
192
|
+
" Remove-Item $bat,$out,$err,$code -Force -ErrorAction SilentlyContinue",
|
|
193
|
+
"}",
|
|
194
|
+
].join("\n");
|
|
195
|
+
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}`;
|
|
196
|
+
}
|
|
197
|
+
export function applyIpcTmpEnv(env) {
|
|
198
|
+
const tmp = concreteTmp(env.ZSWARM_TMP);
|
|
199
|
+
if (!tmp)
|
|
200
|
+
return env;
|
|
201
|
+
const out = { ...env, TEMP: tmp, TMP: tmp, TMPDIR: tmp };
|
|
202
|
+
const sock = defaultSocketDir(tmp, looksLikeWindowsPath(tmp) ? "cmd" : "sh");
|
|
203
|
+
if (sock)
|
|
204
|
+
out.ZELLIJ_SOCKET_DIR = sock;
|
|
205
|
+
return out;
|
|
206
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zswarm/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "zSwarm Zellij client and shared ops dispatch",
|
|
6
6
|
"exports": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@zswarm/wasm": "0.1.
|
|
28
|
+
"@zswarm/wasm": "0.1.1"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsc -p tsconfig.json",
|