@zswarm/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +118 -0
- package/dist/git.d.ts +51 -0
- package/dist/git.js +190 -0
- package/dist/harness.d.ts +21 -0
- package/dist/harness.js +98 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/keys.d.ts +15 -0
- package/dist/keys.js +168 -0
- package/dist/ops/broadcast.d.ts +19 -0
- package/dist/ops/broadcast.js +113 -0
- package/dist/ops/bus.d.ts +59 -0
- package/dist/ops/bus.js +284 -0
- package/dist/ops/delivery.d.ts +43 -0
- package/dist/ops/delivery.js +191 -0
- package/dist/ops/dispatch.d.ts +4 -0
- package/dist/ops/dispatch.js +331 -0
- package/dist/ops/guards.d.ts +15 -0
- package/dist/ops/guards.js +36 -0
- package/dist/ops/log.d.ts +4 -0
- package/dist/ops/log.js +26 -0
- package/dist/ops/panes.d.ts +13 -0
- package/dist/ops/panes.js +111 -0
- package/dist/ops/review.d.ts +9 -0
- package/dist/ops/review.js +108 -0
- package/dist/ops/signals.d.ts +10 -0
- package/dist/ops/signals.js +86 -0
- package/dist/ops/spawn.d.ts +5 -0
- package/dist/ops/spawn.js +98 -0
- package/dist/ops/status.d.ts +36 -0
- package/dist/ops/status.js +192 -0
- package/dist/ops/tail.d.ts +19 -0
- package/dist/ops/tail.js +59 -0
- package/dist/ops/types.d.ts +26 -0
- package/dist/ops/types.js +1 -0
- package/dist/ops/util.d.ts +48 -0
- package/dist/ops/util.js +85 -0
- package/dist/ops/wait.d.ts +19 -0
- package/dist/ops/wait.js +138 -0
- package/dist/ops/worktree.d.ts +21 -0
- package/dist/ops/worktree.js +144 -0
- package/dist/policy.d.ts +15 -0
- package/dist/policy.js +75 -0
- package/dist/schema.d.ts +26 -0
- package/dist/schema.js +489 -0
- package/dist/state.d.ts +52 -0
- package/dist/state.js +150 -0
- package/dist/zellij/args.d.ts +93 -0
- package/dist/zellij/args.js +261 -0
- package/dist/zellij/binary.d.ts +17 -0
- package/dist/zellij/binary.js +96 -0
- package/dist/zellij/bus.d.ts +91 -0
- package/dist/zellij/bus.js +279 -0
- package/dist/zellij/client.d.ts +162 -0
- package/dist/zellij/client.js +246 -0
- package/dist/zellij/panes.d.ts +21 -0
- package/dist/zellij/panes.js +110 -0
- package/dist/zellij/session.d.ts +14 -0
- package/dist/zellij/session.js +44 -0
- package/dist/zellij/tabs.d.ts +14 -0
- package/dist/zellij/tabs.js +56 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zSwarm contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @zswarm/core
|
|
2
|
+
|
|
3
|
+
Zellij client and the shared `dispatchZswarm` ops layer. Everything else is a
|
|
4
|
+
thin shell over it. One table in `src/schema.ts` generates the MCP tool schema,
|
|
5
|
+
the CLI flags, and the CLI help, so they cannot drift.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @zswarm/core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
From a checkout of this repo:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install ./packages/core
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Zellij >= 0.42 and Node >= 20.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { dispatchZswarm } from "@zswarm/core";
|
|
25
|
+
|
|
26
|
+
const result = await dispatchZswarm({ op: "list" });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`dispatchZswarm` builds a Zellij client unless you pass one. The result is
|
|
30
|
+
`{ ok, data }` or `{ ok: false, error }`.
|
|
31
|
+
|
|
32
|
+
Ops, env, and the event bus: the [zswarm README](../../README.md).
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/exec.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ExecResult = {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
};
|
|
6
|
+
export type ExecOptions = {
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Stop as soon as the accumulated stdout satisfies this, instead of waiting
|
|
11
|
+
* for the process to exit. `zellij pipe` answers in milliseconds but stays
|
|
12
|
+
* resident when it has no terminal, so waiting for exit means waiting for the
|
|
13
|
+
* timeout.
|
|
14
|
+
*/
|
|
15
|
+
until?: (stdout: string) => boolean;
|
|
16
|
+
};
|
|
17
|
+
export type ExecFn = (args: string[], options: ExecOptions) => Promise<ExecResult>;
|
|
18
|
+
export declare const NOT_FOUND_EXIT = 127;
|
|
19
|
+
/** POSIX single-quoting, for building a command line the remote shell parses. */
|
|
20
|
+
export declare function shellQuote(arg: string): string;
|
|
21
|
+
export type SshTarget = {
|
|
22
|
+
ssh: string;
|
|
23
|
+
host: string;
|
|
24
|
+
remoteBin: string;
|
|
25
|
+
options: string[];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
29
|
+
* command string, because the remote login shell re-parses it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv): ExecFn;
|
|
32
|
+
export declare function createExec(binPath: string, env: NodeJS.ProcessEnv): ExecFn;
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
export const NOT_FOUND_EXIT = 127;
|
|
3
|
+
/** POSIX single-quoting, for building a command line the remote shell parses. */
|
|
4
|
+
export function shellQuote(arg) {
|
|
5
|
+
if (arg === "")
|
|
6
|
+
return "''";
|
|
7
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg))
|
|
8
|
+
return arg;
|
|
9
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Run the remote zellij over ssh. The whole invocation is quoted into a single
|
|
13
|
+
* command string, because the remote login shell re-parses it.
|
|
14
|
+
*/
|
|
15
|
+
export function createSshExec(target, env) {
|
|
16
|
+
const runner = createExec(target.ssh, env);
|
|
17
|
+
return (args, options) => {
|
|
18
|
+
const remote = [target.remoteBin, ...args].map(shellQuote).join(" ");
|
|
19
|
+
return runner([...target.options, target.host, remote], options);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Read stdout until the answer is in hand, then stop caring about the child. */
|
|
23
|
+
function runUntil(binPath, args, env, options) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const child = spawn(binPath, args, {
|
|
26
|
+
env,
|
|
27
|
+
cwd: options.cwd,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
let settled = false;
|
|
34
|
+
const finish = (result) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
child.kill();
|
|
40
|
+
resolve(result);
|
|
41
|
+
};
|
|
42
|
+
const timer = setTimeout(() => finish({
|
|
43
|
+
code: -1,
|
|
44
|
+
stdout,
|
|
45
|
+
stderr: `${binPath} timed out after ${options.timeoutMs}ms`,
|
|
46
|
+
}), options.timeoutMs);
|
|
47
|
+
child.on("error", (err) => {
|
|
48
|
+
const missing = err.code === "ENOENT" || err.code === "ENOTDIR";
|
|
49
|
+
finish({
|
|
50
|
+
code: missing ? NOT_FOUND_EXIT : 1,
|
|
51
|
+
stdout: "",
|
|
52
|
+
stderr: `${err.code ?? "error"}: ${err.message} (bin=${binPath})`,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
child.stdout.on("data", (chunk) => {
|
|
56
|
+
stdout += chunk.toString();
|
|
57
|
+
if (options.until(stdout))
|
|
58
|
+
finish({ code: 0, stdout, stderr });
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on("data", (chunk) => {
|
|
61
|
+
stderr += chunk.toString();
|
|
62
|
+
});
|
|
63
|
+
child.on("close", (code) => finish({ code: code ?? 0, stdout, stderr }));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* execFile-backed runner for a fixed binary. Never rejects — callers branch on
|
|
68
|
+
* `code`, and a missing binary surfaces as NOT_FOUND_EXIT.
|
|
69
|
+
*/
|
|
70
|
+
function runToExit(binPath, args, env, options) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
execFile(binPath, args, {
|
|
73
|
+
timeout: options.timeoutMs,
|
|
74
|
+
cwd: options.cwd,
|
|
75
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
env,
|
|
78
|
+
}, (error, stdout, stderr) => {
|
|
79
|
+
const failure = error;
|
|
80
|
+
if (failure && typeof failure.code === "string") {
|
|
81
|
+
const missing = failure.code === "ENOENT" || failure.code === "ENOTDIR";
|
|
82
|
+
resolve({
|
|
83
|
+
code: missing ? NOT_FOUND_EXIT : 1,
|
|
84
|
+
stdout: "",
|
|
85
|
+
stderr: `${failure.code}: ${failure.message} (bin=${binPath})`,
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (failure &&
|
|
90
|
+
(failure.killed === true || typeof failure.signal === "string")) {
|
|
91
|
+
resolve({
|
|
92
|
+
code: -1,
|
|
93
|
+
stdout: String(stdout ?? ""),
|
|
94
|
+
stderr: `${binPath} timed out after ${options.timeoutMs}ms`,
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const code = failure && typeof failure.code === "number"
|
|
99
|
+
? failure.code
|
|
100
|
+
: failure
|
|
101
|
+
? 1
|
|
102
|
+
: 0;
|
|
103
|
+
resolve({
|
|
104
|
+
code,
|
|
105
|
+
stdout: String(stdout ?? ""),
|
|
106
|
+
stderr: String(stderr ?? ""),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function createExec(binPath, env) {
|
|
112
|
+
return (args, options) => {
|
|
113
|
+
const until = options.until;
|
|
114
|
+
return until
|
|
115
|
+
? runUntil(binPath, args, env, { ...options, until })
|
|
116
|
+
: runToExit(binPath, args, env, options);
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
export type Worktree = {
|
|
3
|
+
path: string;
|
|
4
|
+
head: string | null;
|
|
5
|
+
branch: string | null;
|
|
6
|
+
bare: boolean;
|
|
7
|
+
detached: boolean;
|
|
8
|
+
locked: boolean;
|
|
9
|
+
prunable: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** Compare paths across separators and case, so pane cwds match git output. */
|
|
12
|
+
export declare function normalizeRepoPath(input: string): string;
|
|
13
|
+
/** Branch names may contain `/`; directory names may not. */
|
|
14
|
+
export declare function worktreeDirName(branch: string): string;
|
|
15
|
+
/** Sibling of the repo, matching the common `<repo>-worktrees` convention. */
|
|
16
|
+
export declare function defaultWorktreeRoot(repoRoot: string): string;
|
|
17
|
+
export declare function parseWorktreeList(stdout: string): Worktree[];
|
|
18
|
+
export type GitClientOptions = {
|
|
19
|
+
exec?: ExecFn;
|
|
20
|
+
gitPath?: string;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
};
|
|
24
|
+
export declare function createGitClient(options?: GitClientOptions): {
|
|
25
|
+
gitPath: string;
|
|
26
|
+
repoRoot: (cwd: string) => Promise<string>;
|
|
27
|
+
branchExists: (root: string, branch: string) => Promise<boolean>;
|
|
28
|
+
listWorktrees: (root: string) => Promise<Worktree[]>;
|
|
29
|
+
addWorktree: (input: {
|
|
30
|
+
root: string;
|
|
31
|
+
path: string;
|
|
32
|
+
branch: string;
|
|
33
|
+
baseRef?: string | null;
|
|
34
|
+
createBranch: boolean;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
removeWorktree: (input: {
|
|
37
|
+
root: string;
|
|
38
|
+
path: string;
|
|
39
|
+
force?: boolean;
|
|
40
|
+
}) => Promise<void>;
|
|
41
|
+
isDirty: (path: string) => Promise<boolean>;
|
|
42
|
+
diffStat: (path: string) => Promise<string>;
|
|
43
|
+
diffPatch: (path: string, maxChars: number) => Promise<{
|
|
44
|
+
patch: string;
|
|
45
|
+
truncated: boolean;
|
|
46
|
+
chars: number;
|
|
47
|
+
}>;
|
|
48
|
+
commitAll: (path: string, message: string) => Promise<string>;
|
|
49
|
+
headSha: (path: string) => Promise<string>;
|
|
50
|
+
};
|
|
51
|
+
export type GitClient = ReturnType<typeof createGitClient>;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { ZellijError } from "./errors.js";
|
|
3
|
+
import { createExec, NOT_FOUND_EXIT } from "./exec.js";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
5
|
+
/** Compare paths across separators and case, so pane cwds match git output. */
|
|
6
|
+
export function normalizeRepoPath(input) {
|
|
7
|
+
return input
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/\\/g, "/")
|
|
10
|
+
.replace(/\/+$/, "")
|
|
11
|
+
.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
/** Branch names may contain `/`; directory names may not. */
|
|
14
|
+
export function worktreeDirName(branch) {
|
|
15
|
+
const cleaned = branch
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^refs\/heads\//, "")
|
|
18
|
+
.replace(/[\\/]+/g, "-")
|
|
19
|
+
.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "");
|
|
21
|
+
if (!cleaned) {
|
|
22
|
+
throw new ZellijError("bad_branch", `branch "${branch}" has no usable name`);
|
|
23
|
+
}
|
|
24
|
+
return cleaned;
|
|
25
|
+
}
|
|
26
|
+
/** Sibling of the repo, matching the common `<repo>-worktrees` convention. */
|
|
27
|
+
export function defaultWorktreeRoot(repoRoot) {
|
|
28
|
+
return join(dirname(repoRoot), `${basename(repoRoot)}-worktrees`);
|
|
29
|
+
}
|
|
30
|
+
export function parseWorktreeList(stdout) {
|
|
31
|
+
const trees = [];
|
|
32
|
+
let current = null;
|
|
33
|
+
const push = () => {
|
|
34
|
+
if (current)
|
|
35
|
+
trees.push(current);
|
|
36
|
+
current = null;
|
|
37
|
+
};
|
|
38
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
39
|
+
const line = raw.trim();
|
|
40
|
+
if (!line) {
|
|
41
|
+
push();
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (line.startsWith("worktree ")) {
|
|
45
|
+
push();
|
|
46
|
+
current = {
|
|
47
|
+
path: line.slice("worktree ".length),
|
|
48
|
+
head: null,
|
|
49
|
+
branch: null,
|
|
50
|
+
bare: false,
|
|
51
|
+
detached: false,
|
|
52
|
+
locked: false,
|
|
53
|
+
prunable: false,
|
|
54
|
+
};
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!current)
|
|
58
|
+
continue;
|
|
59
|
+
if (line.startsWith("HEAD "))
|
|
60
|
+
current.head = line.slice("HEAD ".length);
|
|
61
|
+
else if (line.startsWith("branch "))
|
|
62
|
+
current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
63
|
+
else if (line === "bare")
|
|
64
|
+
current.bare = true;
|
|
65
|
+
else if (line === "detached")
|
|
66
|
+
current.detached = true;
|
|
67
|
+
else if (line.startsWith("locked"))
|
|
68
|
+
current.locked = true;
|
|
69
|
+
else if (line.startsWith("prunable"))
|
|
70
|
+
current.prunable = true;
|
|
71
|
+
}
|
|
72
|
+
push();
|
|
73
|
+
return trees;
|
|
74
|
+
}
|
|
75
|
+
export function createGitClient(options = {}) {
|
|
76
|
+
const env = options.env ?? process.env;
|
|
77
|
+
const gitPath = options.gitPath ?? env.ZSWARM_GIT_BIN?.trim() ?? "git";
|
|
78
|
+
const exec = options.exec ?? createExec(gitPath, env);
|
|
79
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
80
|
+
async function run(args, cwd, label) {
|
|
81
|
+
const result = await exec(args, { timeoutMs, cwd });
|
|
82
|
+
if (result.code === NOT_FOUND_EXIT) {
|
|
83
|
+
throw new ZellijError("git_missing", `git not found (${gitPath}); install git or set ZSWARM_GIT_BIN`);
|
|
84
|
+
}
|
|
85
|
+
if (result.code !== 0) {
|
|
86
|
+
throw new ZellijError("git_failed", `${label} failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
/** Top level of the working tree containing `cwd`. */
|
|
91
|
+
async function repoRoot(cwd) {
|
|
92
|
+
const result = await exec(["rev-parse", "--show-toplevel"], {
|
|
93
|
+
timeoutMs,
|
|
94
|
+
cwd,
|
|
95
|
+
});
|
|
96
|
+
if (result.code === NOT_FOUND_EXIT) {
|
|
97
|
+
throw new ZellijError("git_missing", `git not found (${gitPath}); install git or set ZSWARM_GIT_BIN`);
|
|
98
|
+
}
|
|
99
|
+
if (result.code !== 0) {
|
|
100
|
+
throw new ZellijError("not_a_repo", `${cwd} is not inside a git repository`);
|
|
101
|
+
}
|
|
102
|
+
return result.stdout.trim();
|
|
103
|
+
}
|
|
104
|
+
async function branchExists(root, branch) {
|
|
105
|
+
const result = await exec(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { timeoutMs, cwd: root });
|
|
106
|
+
return result.code === 0;
|
|
107
|
+
}
|
|
108
|
+
async function listWorktrees(root) {
|
|
109
|
+
const result = await run(["worktree", "list", "--porcelain"], root, "git worktree list");
|
|
110
|
+
return parseWorktreeList(result.stdout);
|
|
111
|
+
}
|
|
112
|
+
async function addWorktree(input) {
|
|
113
|
+
const args = ["worktree", "add"];
|
|
114
|
+
if (input.createBranch)
|
|
115
|
+
args.push("-b", input.branch);
|
|
116
|
+
args.push(input.path);
|
|
117
|
+
if (input.createBranch) {
|
|
118
|
+
if (input.baseRef)
|
|
119
|
+
args.push(input.baseRef);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
args.push(input.branch);
|
|
123
|
+
}
|
|
124
|
+
await run(args, input.root, "git worktree add");
|
|
125
|
+
}
|
|
126
|
+
async function removeWorktree(input) {
|
|
127
|
+
const args = ["worktree", "remove"];
|
|
128
|
+
if (input.force)
|
|
129
|
+
args.push("--force");
|
|
130
|
+
args.push(input.path);
|
|
131
|
+
await run(args, input.root, "git worktree remove");
|
|
132
|
+
}
|
|
133
|
+
/** Uncommitted changes, including untracked files. */
|
|
134
|
+
async function isDirty(path) {
|
|
135
|
+
const result = await run(["status", "--porcelain"], path, "git status");
|
|
136
|
+
return result.stdout.trim().length > 0;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Stage untracked paths as intent-to-add so they appear in `git diff`
|
|
140
|
+
* (without copying blob contents into the object database).
|
|
141
|
+
*/
|
|
142
|
+
async function intentAddUntracked(path) {
|
|
143
|
+
await run(["add", "-A", "--intent-to-add"], path, "git add --intent-to-add");
|
|
144
|
+
}
|
|
145
|
+
/** `git diff --stat HEAD`, including untracked via `--intent-to-add`. */
|
|
146
|
+
async function diffStat(path) {
|
|
147
|
+
await intentAddUntracked(path);
|
|
148
|
+
const result = await run(["diff", "--stat", "HEAD"], path, "git diff --stat");
|
|
149
|
+
return result.stdout;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `git diff HEAD`, including untracked via `--intent-to-add`.
|
|
153
|
+
* When `maxChars > 0` and the patch is longer, keep the head and set
|
|
154
|
+
* `truncated`. `chars` is always the full patch length before truncating.
|
|
155
|
+
*/
|
|
156
|
+
async function diffPatch(path, maxChars) {
|
|
157
|
+
await intentAddUntracked(path);
|
|
158
|
+
const result = await run(["diff", "HEAD"], path, "git diff");
|
|
159
|
+
const full = result.stdout;
|
|
160
|
+
const chars = full.length;
|
|
161
|
+
if (maxChars <= 0 || chars <= maxChars) {
|
|
162
|
+
return { patch: full, truncated: false, chars };
|
|
163
|
+
}
|
|
164
|
+
return { patch: full.slice(0, maxChars), truncated: true, chars };
|
|
165
|
+
}
|
|
166
|
+
/** `git add -A` then `git commit -m`; returns the new short HEAD sha. */
|
|
167
|
+
async function commitAll(path, message) {
|
|
168
|
+
await run(["add", "-A"], path, "git add");
|
|
169
|
+
await run(["commit", "-m", message], path, "git commit");
|
|
170
|
+
return headSha(path);
|
|
171
|
+
}
|
|
172
|
+
/** Short sha of HEAD. */
|
|
173
|
+
async function headSha(path) {
|
|
174
|
+
const result = await run(["rev-parse", "--short", "HEAD"], path, "git rev-parse");
|
|
175
|
+
return result.stdout.trim();
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
gitPath,
|
|
179
|
+
repoRoot,
|
|
180
|
+
branchExists,
|
|
181
|
+
listWorktrees,
|
|
182
|
+
addWorktree,
|
|
183
|
+
removeWorktree,
|
|
184
|
+
isDirty,
|
|
185
|
+
diffStat,
|
|
186
|
+
diffPatch,
|
|
187
|
+
commitAll,
|
|
188
|
+
headSha,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type SubmitStrategy = "auto" | "double-enter";
|
|
2
|
+
export type HarnessProfile = {
|
|
3
|
+
/** Stable id: "codex" | "cursor" | "opencode" | "gemini" | "pi" | "unknown" */
|
|
4
|
+
name: string;
|
|
5
|
+
/** What `send` should do when the caller did not pass submit= explicitly. */
|
|
6
|
+
submit: SubmitStrategy;
|
|
7
|
+
/**
|
|
8
|
+
* Approval-prompt UI this harness pins on screen. `status` matches these
|
|
9
|
+
* against the trailing lines; a hit means the pane wants input, not time.
|
|
10
|
+
*/
|
|
11
|
+
waiting: readonly RegExp[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Identify the harness from a pane's command and title, case-insensitively.
|
|
15
|
+
* The command is the executable that spawned the pane, so it wins; the title
|
|
16
|
+
* is the fallback for panes launched from a generic shell. Never throws.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveHarness(pane: {
|
|
19
|
+
command?: string | null;
|
|
20
|
+
title?: string | null;
|
|
21
|
+
}): HarnessProfile;
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Command and title needles observed on real panes in one live session. */
|
|
2
|
+
const PROFILES = [
|
|
3
|
+
{ name: "codex", command: ["codex"], title: ["agent-codex"] },
|
|
4
|
+
{ name: "cursor", command: ["cursor-agent"], title: ["agent-cursor"] },
|
|
5
|
+
{ name: "opencode", command: ["opencode"], title: ["agent-opencode"] },
|
|
6
|
+
{ name: "gemini", command: ["agy", "gemini"], title: ["agent-gemini"] },
|
|
7
|
+
{ name: "pi", command: ["pi"], title: ["agent-pi"] },
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Only codex needs a second Enter to submit: its composer keeps the pasted
|
|
11
|
+
* message (`› [zswarm from=...]`) until an extra Enter lands — verified live
|
|
12
|
+
* on all five harnesses. The other four submit on the paste itself.
|
|
13
|
+
*/
|
|
14
|
+
const SUBMIT = {
|
|
15
|
+
codex: "double-enter",
|
|
16
|
+
cursor: "auto",
|
|
17
|
+
opencode: "auto",
|
|
18
|
+
gemini: "auto",
|
|
19
|
+
pi: "auto",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Prompt shapes any CLI can show, so every profile carries these as a base.
|
|
23
|
+
* They are named UI, never guesses: nothing here matches on a bare "?", the
|
|
24
|
+
* word "confirm", or a lone "y".
|
|
25
|
+
*/
|
|
26
|
+
const GENERIC_WAITING = [
|
|
27
|
+
/\(y\/n\)/i,
|
|
28
|
+
/\[y\/n\]/i,
|
|
29
|
+
/press enter to continue/i,
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Per-harness approval UI observed in this live session. Only gemini and
|
|
33
|
+
* opencode showed full-screen prompts worth naming; codex, cursor, and pi
|
|
34
|
+
* show only the generic shapes so far, and unknown panes get the generic set
|
|
35
|
+
* and nothing else. Mislabelling a working pane as waiting makes a dispatcher
|
|
36
|
+
* type into it, so unobserved prompts stay undetected.
|
|
37
|
+
*/
|
|
38
|
+
const WAITING = {
|
|
39
|
+
codex: GENERIC_WAITING,
|
|
40
|
+
cursor: GENERIC_WAITING,
|
|
41
|
+
opencode: [...GENERIC_WAITING, /Permission required/i, /Allow once/i],
|
|
42
|
+
gemini: [
|
|
43
|
+
...GENERIC_WAITING,
|
|
44
|
+
/Do you want to proceed\?/i,
|
|
45
|
+
/Accept this file edit\?/i,
|
|
46
|
+
],
|
|
47
|
+
pi: GENERIC_WAITING,
|
|
48
|
+
};
|
|
49
|
+
const UNKNOWN = {
|
|
50
|
+
name: "unknown",
|
|
51
|
+
submit: "auto",
|
|
52
|
+
waiting: GENERIC_WAITING,
|
|
53
|
+
};
|
|
54
|
+
function escapeRegExp(text) {
|
|
55
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Word-boundary match, never a bare `includes`: a raw `pi` substring appears
|
|
59
|
+
* in plenty of real paths (`pip.exe`, `spike/`, `pipeline`), so it would
|
|
60
|
+
* misidentify nearly every pane. `\b` still finds `pi.cmd`, `agent-pi`, and a
|
|
61
|
+
* bare `pi`, but requires the needle to stand alone.
|
|
62
|
+
*/
|
|
63
|
+
function hasWord(haystack, needle) {
|
|
64
|
+
return new RegExp(`\\b${escapeRegExp(needle)}\\b`, "i").test(haystack);
|
|
65
|
+
}
|
|
66
|
+
function matchCommand(command) {
|
|
67
|
+
if (!command)
|
|
68
|
+
return null;
|
|
69
|
+
for (const profile of PROFILES) {
|
|
70
|
+
for (const needle of profile.command) {
|
|
71
|
+
if (hasWord(command, needle))
|
|
72
|
+
return profile.name;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function matchTitle(title) {
|
|
78
|
+
if (!title)
|
|
79
|
+
return null;
|
|
80
|
+
for (const profile of PROFILES) {
|
|
81
|
+
for (const needle of profile.title) {
|
|
82
|
+
if (hasWord(title, needle))
|
|
83
|
+
return profile.name;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Identify the harness from a pane's command and title, case-insensitively.
|
|
90
|
+
* The command is the executable that spawned the pane, so it wins; the title
|
|
91
|
+
* is the fallback for panes launched from a generic shell. Never throws.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveHarness(pane) {
|
|
94
|
+
const name = matchCommand(pane.command) ?? matchTitle(pane.title) ?? "unknown";
|
|
95
|
+
if (name === "unknown")
|
|
96
|
+
return UNKNOWN;
|
|
97
|
+
return { name, submit: SUBMIT[name], waiting: WAITING[name] };
|
|
98
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { ZellijError } from "./errors.js";
|
|
2
|
+
export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecResult, } from "./exec.js";
|
|
3
|
+
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
|
|
4
|
+
export { expandHomePath, resolveZellijBinary, sanitizeZellijEnv, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
|
|
5
|
+
export { normalizePaneId, parsePaneList, resolvePane, type ZellijPane, } from "./zellij/panes.js";
|
|
6
|
+
export { parseSessionList, resolveSelfPaneId, type ZellijSessionResolve, } from "./zellij/session.js";
|
|
7
|
+
export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, } from "./zellij/args.js";
|
|
8
|
+
export { busPluginUrl, busToPanes, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
|
|
9
|
+
export { busSnapshot, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
|
|
10
|
+
export { createZellijClient, type ZellijClient, type ZellijClientOptions, } from "./zellij/client.js";
|
|
11
|
+
export { createStateStore, defaultStateDir, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
|
|
12
|
+
export { dispatchZswarm } from "./ops/dispatch.js";
|
|
13
|
+
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy, } from "./policy.js";
|
|
14
|
+
export { createSshExec, shellQuote, type SshTarget } from "./exec.js";
|
|
15
|
+
export { resolveSshTarget } from "./zellij/binary.js";
|
|
16
|
+
export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
|
|
17
|
+
export { selectTargets } from "./ops/broadcast.js";
|
|
18
|
+
export { diffScreens, cursorKey } from "./ops/tail.js";
|
|
19
|
+
export { classify, lastLine, type PeerState } from "./ops/status.js";
|
|
20
|
+
export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
21
|
+
export type { DispatchDeps, OpsResult } from "./ops/types.js";
|
|
22
|
+
export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
|
|
23
|
+
export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, type OpName, type ParamSpec, type ParamType, } from "./schema.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { ZellijError } from "./errors.js";
|
|
2
|
+
export { createExec, NOT_FOUND_EXIT, } from "./exec.js";
|
|
3
|
+
export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, } from "./git.js";
|
|
4
|
+
export { expandHomePath, resolveZellijBinary, sanitizeZellijEnv, } from "./zellij/binary.js";
|
|
5
|
+
export { normalizePaneId, parsePaneList, resolvePane, } from "./zellij/panes.js";
|
|
6
|
+
export { parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
|
|
7
|
+
export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, } from "./zellij/args.js";
|
|
8
|
+
export { busPluginUrl, busToPanes, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, } from "./zellij/bus.js";
|
|
9
|
+
export { busSnapshot, planBus, resetBusCache, } from "./ops/bus.js";
|
|
10
|
+
export { createZellijClient, } from "./zellij/client.js";
|
|
11
|
+
export { createStateStore, defaultStateDir, } from "./state.js";
|
|
12
|
+
export { dispatchZswarm } from "./ops/dispatch.js";
|
|
13
|
+
export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./policy.js";
|
|
14
|
+
export { createSshExec, shellQuote } from "./exec.js";
|
|
15
|
+
export { resolveSshTarget } from "./zellij/binary.js";
|
|
16
|
+
export { parseTabList, resolveTab } from "./zellij/tabs.js";
|
|
17
|
+
export { selectTargets } from "./ops/broadcast.js";
|
|
18
|
+
export { diffScreens, cursorKey } from "./ops/tail.js";
|
|
19
|
+
export { classify, lastLine } from "./ops/status.js";
|
|
20
|
+
export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
|
|
21
|
+
export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
|
|
22
|
+
export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize one key spec into Zellij's `send-keys` spelling.
|
|
3
|
+
* Accepts `ctrl+c`, `Ctrl-C`, `^C`, `esc`, `Alt Shift b`, `f1`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function normalizeKey(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a key list. Arrays hold one spec per entry; a string is a single
|
|
8
|
+
* spec unless it is comma separated (`"Esc,Enter"`), since `"Ctrl c"` is one key.
|
|
9
|
+
*/
|
|
10
|
+
export declare function normalizeKeys(input: unknown): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Split a command line into argv. Arrays pass through; strings honour single
|
|
13
|
+
* and double quotes. Zellij runs the command directly — there is no shell.
|
|
14
|
+
*/
|
|
15
|
+
export declare function tokenizeCommand(input: unknown): string[];
|