@zswarm/core 0.1.2 → 0.1.4
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 +2 -0
- package/dist/exec.js +8 -6
- package/dist/git.js +44 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ops/broadcast.d.ts +1 -1
- package/dist/ops/broadcast.js +9 -5
- package/dist/ops/delivery.d.ts +31 -1
- package/dist/ops/delivery.js +56 -2
- package/dist/ops/dispatch.js +39 -10
- package/dist/ops/panes.d.ts +4 -3
- package/dist/ops/panes.js +14 -4
- package/dist/ops/serve.d.ts +22 -4
- package/dist/ops/serve.js +185 -35
- package/dist/ops/worktree.js +9 -2
- package/dist/policy.js +6 -0
- package/dist/schema.js +18 -6
- package/dist/state.js +136 -24
- package/dist/zellij/binary.d.ts +3 -1
- package/dist/zellij/binary.js +34 -2
- package/dist/zellij/client.js +15 -2
- package/dist/zellij/ipc.js +14 -8
- package/dist/zellij/session.d.ts +2 -0
- package/dist/zellij/session.js +4 -0
- package/package.json +2 -2
package/dist/exec.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type ExecResult = {
|
|
|
7
7
|
export type ExecOptions = {
|
|
8
8
|
timeoutMs: number;
|
|
9
9
|
cwd?: string;
|
|
10
|
+
/** Overlay on the runner's env (e.g. GIT_INDEX_FILE for a scratch index). */
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
10
12
|
/**
|
|
11
13
|
* Stop as soon as the accumulated stdout satisfies this, instead of waiting
|
|
12
14
|
* for the process to exit. `zellij pipe` answers in milliseconds but stays
|
package/dist/exec.js
CHANGED
|
@@ -70,10 +70,12 @@ export function createSshExec(target, env) {
|
|
|
70
70
|
if (requested.toLowerCase() !== "auto") {
|
|
71
71
|
return { tmp: requested, socketDir: "" };
|
|
72
72
|
}
|
|
73
|
-
if (cached
|
|
74
|
-
return cached
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
if (cached)
|
|
74
|
+
return cached;
|
|
75
|
+
const dirs = await discoverRemoteIpc(runner, target, timeoutMs);
|
|
76
|
+
if (dirs)
|
|
77
|
+
cached = dirs;
|
|
78
|
+
return dirs;
|
|
77
79
|
}
|
|
78
80
|
return async (args, options) => {
|
|
79
81
|
const ipc = await resolveIpc(options.timeoutMs);
|
|
@@ -85,7 +87,7 @@ export function createSshExec(target, env) {
|
|
|
85
87
|
function runUntil(binPath, args, env, options) {
|
|
86
88
|
return new Promise((resolve) => {
|
|
87
89
|
const child = spawn(binPath, args, {
|
|
88
|
-
env,
|
|
90
|
+
env: options.env ? { ...env, ...options.env } : env,
|
|
89
91
|
cwd: options.cwd,
|
|
90
92
|
windowsHide: true,
|
|
91
93
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -136,7 +138,7 @@ function runToExit(binPath, args, env, options) {
|
|
|
136
138
|
cwd: options.cwd,
|
|
137
139
|
maxBuffer: 8 * 1024 * 1024,
|
|
138
140
|
windowsHide: true,
|
|
139
|
-
env,
|
|
141
|
+
env: options.env ? { ...env, ...options.env } : env,
|
|
140
142
|
}, (error, stdout, stderr) => {
|
|
141
143
|
const failure = error;
|
|
142
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
|
@@ -14,7 +14,7 @@ export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy,
|
|
|
14
14
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type SshTarget, } from "./exec.js";
|
|
15
15
|
export { resolveSshTarget } from "./zellij/binary.js";
|
|
16
16
|
export { applyIpcTmpEnv, cmdQuote, concreteTmp, encodePowerShellCommand, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, pickIpcTmp, socketDirFromServerPath, tmpFromServerPath, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
|
|
17
|
-
export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, parseListenAddress, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
|
|
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";
|
|
18
18
|
export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
|
|
19
19
|
export { selectTargets } from "./ops/broadcast.js";
|
|
20
20
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./po
|
|
|
14
14
|
export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
|
|
15
15
|
export { resolveSshTarget } from "./zellij/binary.js";
|
|
16
16
|
export { applyIpcTmpEnv, cmdQuote, concreteTmp, encodePowerShellCommand, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, pickIpcTmp, socketDirFromServerPath, tmpFromServerPath, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
|
|
17
|
-
export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, parseListenAddress, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
|
|
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";
|
|
18
18
|
export { parseTabList, resolveTab } from "./zellij/tabs.js";
|
|
19
19
|
export { selectTargets } from "./ops/broadcast.js";
|
|
20
20
|
export { diffScreens, cursorKey } from "./ops/tail.js";
|
package/dist/ops/broadcast.d.ts
CHANGED
|
@@ -16,4 +16,4 @@ export declare function selectTargets(client: ZellijClient, panes: ZellijPane[],
|
|
|
16
16
|
skipped: Skipped[];
|
|
17
17
|
};
|
|
18
18
|
/** Send one body to many panes, reporting per-pane outcomes. */
|
|
19
|
-
export declare function broadcast(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, policy?: Policy): Promise<OpsResult>;
|
|
19
|
+
export declare function broadcast(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, policy?: Policy, env?: NodeJS.ProcessEnv): Promise<OpsResult>;
|
package/dist/ops/broadcast.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { assertPaneAllowed } from "../policy.js";
|
|
3
|
-
import { deliverTo } from "./delivery.js";
|
|
3
|
+
import { deliverTo, selfPaneTitle, withSenderLabel } from "./delivery.js";
|
|
4
4
|
import { isTrue, optionalString } from "./util.js";
|
|
5
5
|
/**
|
|
6
6
|
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
@@ -65,13 +65,17 @@ export function selectTargets(client, panes, args) {
|
|
|
65
65
|
return { targets, skipped };
|
|
66
66
|
}
|
|
67
67
|
/** Send one body to many panes, reporting per-pane outcomes. */
|
|
68
|
-
export async function broadcast(client, state, args, clock, policy) {
|
|
68
|
+
export async function broadcast(client, state, args, clock, policy, env = process.env) {
|
|
69
69
|
const body = String(args.body ?? args.text ?? "");
|
|
70
70
|
if (!body.trim())
|
|
71
71
|
throw new ZellijError("missing_body", "body required");
|
|
72
72
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
73
73
|
const panes = await client.listPanes(session);
|
|
74
|
-
const
|
|
74
|
+
const labeled = withSenderLabel(args, {
|
|
75
|
+
env,
|
|
76
|
+
selfTitle: selfPaneTitle(client, panes),
|
|
77
|
+
});
|
|
78
|
+
const { targets: selected, skipped } = selectTargets(client, panes, labeled);
|
|
75
79
|
// Policy narrows the selection rather than failing the whole broadcast.
|
|
76
80
|
const targets = selected.filter((pane) => {
|
|
77
81
|
if (!policy)
|
|
@@ -90,7 +94,7 @@ export async function broadcast(client, state, args, clock, policy) {
|
|
|
90
94
|
}
|
|
91
95
|
const results = [];
|
|
92
96
|
for (const pane of targets) {
|
|
93
|
-
results.push(await deliverTo(client, state,
|
|
97
|
+
results.push(await deliverTo(client, state, labeled, {
|
|
94
98
|
session,
|
|
95
99
|
pane,
|
|
96
100
|
body,
|
|
@@ -104,7 +108,7 @@ export async function broadcast(client, state, args, clock, policy) {
|
|
|
104
108
|
ok: true,
|
|
105
109
|
data: {
|
|
106
110
|
session,
|
|
107
|
-
from:
|
|
111
|
+
from: labeled.from,
|
|
108
112
|
delivered: delivered.map((r) => r.to),
|
|
109
113
|
failed: results.filter((r) => !r.ok),
|
|
110
114
|
skipped,
|
package/dist/ops/delivery.d.ts
CHANGED
|
@@ -14,7 +14,37 @@ export type DeliveryResult = {
|
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
16
|
export type SubmitMode = "auto" | "double-enter" | "none";
|
|
17
|
-
export
|
|
17
|
+
export type SenderSource = "arg" | "env" | "title" | "default";
|
|
18
|
+
export type SenderLabel = {
|
|
19
|
+
label: string;
|
|
20
|
+
source: SenderSource;
|
|
21
|
+
};
|
|
22
|
+
/** Drop characters that would break `[zswarm from=…]` or wrap the prefix. */
|
|
23
|
+
export declare function sanitizeSenderLabel(raw: string): string;
|
|
24
|
+
export declare function selfPaneTitle(client: Pick<ZellijClient, "selfPaneId">, panes: ZellijPane[]): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Label for `[zswarm from=…]`. Explicit `from` wins, then `ZSWARM_FROM`,
|
|
27
|
+
* then the sending pane's title when we know that pane, else `swarm`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveSender(args: Record<string, unknown>, opts?: {
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
selfTitle?: string | null;
|
|
32
|
+
}): SenderLabel;
|
|
33
|
+
export declare function senderLabel(args: Record<string, unknown>, opts?: {
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
selfTitle?: string | null;
|
|
36
|
+
}): string;
|
|
37
|
+
/** Stamp `from` so deliverTo and the op result use the same resolved label. */
|
|
38
|
+
export declare function withSenderLabel(args: Record<string, unknown>, opts?: {
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
selfTitle?: string | null;
|
|
41
|
+
}): Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Before forwarding over `ZSWARM_SERVE`, keep a caller-chosen label and
|
|
44
|
+
* leave `from` unset when it would default, so the Zellij-side process can
|
|
45
|
+
* still fill in its own pane title.
|
|
46
|
+
*/
|
|
47
|
+
export declare function attachKnownSender(args: Record<string, unknown>, env: NodeJS.ProcessEnv): Record<string, unknown>;
|
|
18
48
|
export declare function bodyText(client: ZellijClient, args: Record<string, unknown>, body: string): string;
|
|
19
49
|
/**
|
|
20
50
|
* Caller `submit=` always wins. With none given, the pane's harness picks
|
package/dist/ops/delivery.js
CHANGED
|
@@ -2,8 +2,62 @@ import { resolveHarness } from "../harness.js";
|
|
|
2
2
|
import { isTrue, numberArg } from "./util.js";
|
|
3
3
|
/** Cursor / Claude leave this in scrollback after a successful submit. */
|
|
4
4
|
const PASTE_MARKER = "[Pasted text";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const SENDER_LABEL_MAX = 48;
|
|
6
|
+
/** Drop characters that would break `[zswarm from=…]` or wrap the prefix. */
|
|
7
|
+
export function sanitizeSenderLabel(raw) {
|
|
8
|
+
const cleaned = raw.replace(/[\r\n[\]]+/g, " ").replace(/\s+/g, " ").trim();
|
|
9
|
+
if (!cleaned)
|
|
10
|
+
return "";
|
|
11
|
+
return cleaned.length <= SENDER_LABEL_MAX
|
|
12
|
+
? cleaned
|
|
13
|
+
: cleaned.slice(0, SENDER_LABEL_MAX).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
export function selfPaneTitle(client, panes) {
|
|
16
|
+
const id = client.selfPaneId;
|
|
17
|
+
if (!id)
|
|
18
|
+
return null;
|
|
19
|
+
const title = panes.find((p) => p.id === id)?.title;
|
|
20
|
+
return title?.trim() ? title : null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Label for `[zswarm from=…]`. Explicit `from` wins, then `ZSWARM_FROM`,
|
|
24
|
+
* then the sending pane's title when we know that pane, else `swarm`.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveSender(args, opts = {}) {
|
|
27
|
+
if (typeof args.from === "string") {
|
|
28
|
+
const explicit = sanitizeSenderLabel(args.from);
|
|
29
|
+
if (explicit)
|
|
30
|
+
return { label: explicit, source: "arg" };
|
|
31
|
+
}
|
|
32
|
+
const envFrom = sanitizeSenderLabel(opts.env?.ZSWARM_FROM ?? "");
|
|
33
|
+
if (envFrom)
|
|
34
|
+
return { label: envFrom, source: "env" };
|
|
35
|
+
const title = sanitizeSenderLabel(opts.selfTitle ?? "");
|
|
36
|
+
if (title)
|
|
37
|
+
return { label: title, source: "title" };
|
|
38
|
+
return { label: "swarm", source: "default" };
|
|
39
|
+
}
|
|
40
|
+
export function senderLabel(args, opts = {}) {
|
|
41
|
+
return resolveSender(args, opts).label;
|
|
42
|
+
}
|
|
43
|
+
/** Stamp `from` so deliverTo and the op result use the same resolved label. */
|
|
44
|
+
export function withSenderLabel(args, opts = {}) {
|
|
45
|
+
return { ...args, from: senderLabel(args, opts) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Before forwarding over `ZSWARM_SERVE`, keep a caller-chosen label and
|
|
49
|
+
* leave `from` unset when it would default, so the Zellij-side process can
|
|
50
|
+
* still fill in its own pane title.
|
|
51
|
+
*/
|
|
52
|
+
export function attachKnownSender(args, env) {
|
|
53
|
+
const op = String(args.op ?? "");
|
|
54
|
+
if (op !== "send" && op !== "broadcast")
|
|
55
|
+
return args;
|
|
56
|
+
const sender = resolveSender(args, { env });
|
|
57
|
+
if (sender.source === "arg" || sender.source === "env") {
|
|
58
|
+
return { ...args, from: sender.label };
|
|
59
|
+
}
|
|
60
|
+
return args;
|
|
7
61
|
}
|
|
8
62
|
export function bodyText(client, args, body) {
|
|
9
63
|
return isTrue(args.raw) ? body : client.formatPeerMessage(senderLabel(args), body);
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -9,7 +9,7 @@ import { broadcast } from "./broadcast.js";
|
|
|
9
9
|
import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
|
|
10
10
|
import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
|
|
11
11
|
import { peerCheckpoint, peerDiff } from "./review.js";
|
|
12
|
-
import { deliverTo } from "./delivery.js";
|
|
12
|
+
import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
|
|
13
13
|
import { assertNotPlugin, assertNotSelf, assertPaneExpects, assertWritable, } from "./guards.js";
|
|
14
14
|
import { readDeliveryLog } from "./log.js";
|
|
15
15
|
import { awaitSignal, listSignals, postSignal } from "./signals.js";
|
|
@@ -17,7 +17,7 @@ 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
23
|
import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, } from "./serve.js";
|
|
@@ -29,6 +29,30 @@ function waitMode(args) {
|
|
|
29
29
|
}
|
|
30
30
|
return typeof args.match === "string" && args.match ? "match" : "idle";
|
|
31
31
|
}
|
|
32
|
+
const SSH_GIT_OPS = new Set([
|
|
33
|
+
"worktrees",
|
|
34
|
+
"unworktree",
|
|
35
|
+
"diff",
|
|
36
|
+
"checkpoint",
|
|
37
|
+
]);
|
|
38
|
+
const SSH_LOCAL_BARRIER_OPS = new Set(["signal", "signals", "await"]);
|
|
39
|
+
/**
|
|
40
|
+
* SSH only forwards Zellij. Git worktrees, diffs, and checkpoints stay on this
|
|
41
|
+
* machine, so mixing them with a remote pane cwd is how you delete the wrong tree.
|
|
42
|
+
* Client-local signal barriers cannot rendezvous with a remote crew either.
|
|
43
|
+
*/
|
|
44
|
+
function assertSshGitAllowed(env, op, args) {
|
|
45
|
+
if (!env.ZSWARM_SSH?.trim() || env.ZSWARM_SERVE?.trim())
|
|
46
|
+
return;
|
|
47
|
+
const worktreeSpawn = op === "spawn" && optionalString(args.worktree);
|
|
48
|
+
if (!worktreeSpawn && !SSH_GIT_OPS.has(op) && !SSH_LOCAL_BARRIER_OPS.has(op)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const why = SSH_LOCAL_BARRIER_OPS.has(op)
|
|
52
|
+
? "is a client-local barrier"
|
|
53
|
+
: "uses local git";
|
|
54
|
+
throw new ZellijError("ssh_git_unsupported", `${op} ${why}; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
|
|
55
|
+
}
|
|
32
56
|
async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
33
57
|
const to = String(args.to ?? "").trim();
|
|
34
58
|
if (!to)
|
|
@@ -76,6 +100,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
76
100
|
try {
|
|
77
101
|
// Policy gates the op before anything touches the session.
|
|
78
102
|
assertOpAllowed(policy, op);
|
|
103
|
+
assertSshGitAllowed(env, op, args);
|
|
79
104
|
if (op === "serve") {
|
|
80
105
|
if (isTrue(args.clear)) {
|
|
81
106
|
const cleared = await uninstallServeLogon({
|
|
@@ -94,7 +119,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
94
119
|
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
95
120
|
// just because the host env has ZSWARM_SERVE set.
|
|
96
121
|
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
97
|
-
return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args));
|
|
122
|
+
return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
98
123
|
}
|
|
99
124
|
const client = injected ?? createZellijClient({ env: deps.env });
|
|
100
125
|
switch (op) {
|
|
@@ -127,7 +152,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
127
152
|
const body = String(args.body ?? args.text ?? "");
|
|
128
153
|
if (!body.trim())
|
|
129
154
|
throw new ZellijError("missing_body", "body required");
|
|
130
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
155
|
+
const { session, pane, panes } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
131
156
|
assertWritable(client, pane, args, "send");
|
|
132
157
|
// A pane that dropped back to a shell will *run* the message. There is
|
|
133
158
|
// no reliable way to tell an agent from a prompt, so the caller names
|
|
@@ -137,7 +162,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
137
162
|
const screen = await client.dumpPane({ session, paneId: pane.id });
|
|
138
163
|
assertPaneExpects(screen.text, expect, pane.id);
|
|
139
164
|
}
|
|
140
|
-
const
|
|
165
|
+
const labeled = withSenderLabel(args, {
|
|
166
|
+
env,
|
|
167
|
+
selfTitle: selfPaneTitle(client, panes),
|
|
168
|
+
});
|
|
169
|
+
const result = await deliverTo(client, state(), labeled, {
|
|
141
170
|
session,
|
|
142
171
|
pane,
|
|
143
172
|
body,
|
|
@@ -152,7 +181,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
152
181
|
delivery: result.delivery,
|
|
153
182
|
session,
|
|
154
183
|
to: result.to,
|
|
155
|
-
from:
|
|
184
|
+
from: labeled.from,
|
|
156
185
|
submitted: result.submitted,
|
|
157
186
|
};
|
|
158
187
|
if (verbose)
|
|
@@ -160,7 +189,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
160
189
|
return { ok: true, data };
|
|
161
190
|
}
|
|
162
191
|
case "broadcast":
|
|
163
|
-
return await broadcast(client, state(), args, clock, policy);
|
|
192
|
+
return await broadcast(client, state(), args, clock, policy, env);
|
|
164
193
|
case "keys":
|
|
165
194
|
case "interrupt": {
|
|
166
195
|
const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
@@ -307,15 +336,15 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
307
336
|
case "log":
|
|
308
337
|
return readDeliveryLog(state(), args);
|
|
309
338
|
case "rename":
|
|
310
|
-
return await renameTarget(client, args);
|
|
339
|
+
return await renameTarget(client, args, policy);
|
|
311
340
|
case "focus":
|
|
312
|
-
return await focusTarget(client, args);
|
|
341
|
+
return await focusTarget(client, args, policy);
|
|
313
342
|
case "tabs":
|
|
314
343
|
return await listTabsOp(client, args);
|
|
315
344
|
case "layout":
|
|
316
345
|
return await dumpLayoutOp(client, args);
|
|
317
346
|
case "stack":
|
|
318
|
-
return await stackTargets(client, args);
|
|
347
|
+
return await stackTargets(client, args, policy);
|
|
319
348
|
case "diff":
|
|
320
349
|
return await peerDiff(git(), args);
|
|
321
350
|
case "checkpoint":
|
package/dist/ops/panes.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { type Policy } from "../policy.js";
|
|
1
2
|
import type { ZellijClient } from "../zellij/client.js";
|
|
2
3
|
import type { OpsResult } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* Give a pane (or its tab) a stable name, so peers can be addressed as
|
|
5
6
|
* `reviewer` instead of whatever id Zellij handed out.
|
|
6
7
|
*/
|
|
7
|
-
export declare function renameTarget(client: ZellijClient, args: Record<string, unknown
|
|
8
|
-
export declare function focusTarget(client: ZellijClient, args: Record<string, unknown
|
|
8
|
+
export declare function renameTarget(client: ZellijClient, args: Record<string, unknown>, policy?: Policy): Promise<OpsResult>;
|
|
9
|
+
export declare function focusTarget(client: ZellijClient, args: Record<string, unknown>, policy?: Policy): Promise<OpsResult>;
|
|
9
10
|
export declare function listTabsOp(client: ZellijClient, args: Record<string, unknown>): Promise<OpsResult>;
|
|
10
11
|
/** The session's current layout as KDL — a spawnable description of the crew. */
|
|
11
12
|
export declare function dumpLayoutOp(client: ZellijClient, args: Record<string, unknown>): Promise<OpsResult>;
|
|
12
13
|
/** Collapse several peers into one stack so a big crew stays readable. */
|
|
13
|
-
export declare function stackTargets(client: ZellijClient, args: Record<string, unknown
|
|
14
|
+
export declare function stackTargets(client: ZellijClient, args: Record<string, unknown>, policy?: Policy): Promise<OpsResult>;
|
package/dist/ops/panes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { assertPaneAllowed } from "../policy.js";
|
|
2
3
|
import { dumpMaxChars, optionalString, truncateDumpText } from "./util.js";
|
|
3
4
|
async function session(client, args) {
|
|
4
5
|
const resolved = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
@@ -8,7 +9,7 @@ async function session(client, args) {
|
|
|
8
9
|
* Give a pane (or its tab) a stable name, so peers can be addressed as
|
|
9
10
|
* `reviewer` instead of whatever id Zellij handed out.
|
|
10
11
|
*/
|
|
11
|
-
export async function renameTarget(client, args) {
|
|
12
|
+
export async function renameTarget(client, args, policy) {
|
|
12
13
|
const name = optionalString(args.name);
|
|
13
14
|
if (!name)
|
|
14
15
|
throw new ZellijError("missing_name", "name required");
|
|
@@ -30,18 +31,22 @@ export async function renameTarget(client, args) {
|
|
|
30
31
|
if (!to)
|
|
31
32
|
throw new ZellijError("missing_peer", "to (or tab) required");
|
|
32
33
|
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
34
|
+
if (policy)
|
|
35
|
+
assertPaneAllowed(policy, pane, "rename");
|
|
33
36
|
await client.renamePane({ session: sess, paneId: pane.id, name });
|
|
34
37
|
return {
|
|
35
38
|
ok: true,
|
|
36
39
|
data: { session: sess, to: pane.id, was: pane.title, name },
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
|
-
export async function focusTarget(client, args) {
|
|
42
|
+
export async function focusTarget(client, args, policy) {
|
|
40
43
|
const to = optionalString(args.to);
|
|
41
44
|
if (!to)
|
|
42
45
|
throw new ZellijError("missing_peer", "to required");
|
|
43
46
|
const sess = await session(client, args);
|
|
44
47
|
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
48
|
+
if (policy)
|
|
49
|
+
assertPaneAllowed(policy, pane, "focus");
|
|
45
50
|
// Zellij exits non-zero when asked to focus the already-focused pane.
|
|
46
51
|
if (pane.focused) {
|
|
47
52
|
return {
|
|
@@ -94,7 +99,7 @@ export async function dumpLayoutOp(client, args) {
|
|
|
94
99
|
};
|
|
95
100
|
}
|
|
96
101
|
/** Collapse several peers into one stack so a big crew stays readable. */
|
|
97
|
-
export async function stackTargets(client, args) {
|
|
102
|
+
export async function stackTargets(client, args, policy) {
|
|
98
103
|
const list = optionalString(args.to);
|
|
99
104
|
if (!list)
|
|
100
105
|
throw new ZellijError("missing_peer", "to (comma list) required");
|
|
@@ -104,7 +109,12 @@ export async function stackTargets(client, args) {
|
|
|
104
109
|
.split(",")
|
|
105
110
|
.map((k) => k.trim())
|
|
106
111
|
.filter(Boolean)
|
|
107
|
-
.map((key) =>
|
|
112
|
+
.map((key) => {
|
|
113
|
+
const pane = client.resolvePane(panes, key);
|
|
114
|
+
if (policy)
|
|
115
|
+
assertPaneAllowed(policy, pane, "stack");
|
|
116
|
+
return pane.id;
|
|
117
|
+
});
|
|
108
118
|
const unique = [...new Set(ids)];
|
|
109
119
|
const stacked = await client.stackPanes({ session: sess, paneIds: unique });
|
|
110
120
|
return { ok: true, data: { session: sess, stacked: stacked.paneIds } };
|
package/dist/ops/serve.d.ts
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import type { OpsResult } from "./types.js";
|
|
2
2
|
export declare const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
|
|
3
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
|
+
/** Drop a socket that never finishes a JSONL line. */
|
|
7
|
+
export declare const SERVE_IDLE_TIMEOUT_MS = 30000;
|
|
8
|
+
/** Concurrent TCP clients, including in-flight wait ops. */
|
|
9
|
+
export declare const SERVE_MAX_CONNECTIONS = 32;
|
|
10
|
+
/** Wait/await allow 15 minutes; this is that plus slack. */
|
|
11
|
+
export declare const SERVE_CALL_TIMEOUT_CAP_MS: number;
|
|
12
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
13
|
+
export declare function formatListenLabel(host: string, port: number): string;
|
|
4
14
|
export declare function parseListenAddress(raw: string | undefined): {
|
|
5
15
|
host: string;
|
|
6
16
|
port: number;
|
|
7
17
|
label: string;
|
|
8
18
|
};
|
|
9
|
-
/** Client-side wait: the op's own timeout plus slack
|
|
19
|
+
/** Client-side wait: the op's own timeout plus slack. */
|
|
10
20
|
export declare function serveCallTimeout(args: Record<string, unknown>): number;
|
|
11
21
|
/** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
|
|
12
22
|
export declare function serveChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
13
23
|
export type ServeDispatch = (args: Record<string, unknown>) => Promise<OpsResult>;
|
|
14
|
-
export
|
|
24
|
+
export type StartServeOptions = {
|
|
25
|
+
token?: string;
|
|
26
|
+
maxRequestBytes?: number;
|
|
27
|
+
idleTimeoutMs?: number;
|
|
28
|
+
maxConnections?: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function startServe(listen: string | undefined, dispatch: ServeDispatch, options?: StartServeOptions): Promise<{
|
|
15
31
|
label: string;
|
|
16
32
|
close: () => Promise<void>;
|
|
17
33
|
}>;
|
|
@@ -19,13 +35,15 @@ export declare function parseServeTarget(raw: string): {
|
|
|
19
35
|
host: string;
|
|
20
36
|
port: number;
|
|
21
37
|
};
|
|
22
|
-
export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number): Promise<OpsResult>;
|
|
23
|
-
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
|
|
38
|
+
export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
|
|
39
|
+
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
|
|
24
40
|
export declare function installServeLogon(input: {
|
|
25
41
|
listen?: string;
|
|
26
42
|
execPath?: string;
|
|
27
43
|
scriptPath?: string;
|
|
28
44
|
platform?: NodeJS.Platform;
|
|
45
|
+
token?: string;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
29
47
|
}): Promise<{
|
|
30
48
|
task: string;
|
|
31
49
|
listen: string;
|