@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/dist/keys.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ZellijError } from "./errors.js";
|
|
2
|
+
/** Named keys Zellij accepts verbatim, keyed by their lowercase spelling. */
|
|
3
|
+
const NAMED_KEYS = {
|
|
4
|
+
enter: "Enter",
|
|
5
|
+
return: "Enter",
|
|
6
|
+
esc: "Esc",
|
|
7
|
+
escape: "Esc",
|
|
8
|
+
tab: "Tab",
|
|
9
|
+
backspace: "Backspace",
|
|
10
|
+
bs: "Backspace",
|
|
11
|
+
delete: "Delete",
|
|
12
|
+
del: "Delete",
|
|
13
|
+
insert: "Insert",
|
|
14
|
+
ins: "Insert",
|
|
15
|
+
space: "Space",
|
|
16
|
+
up: "Up",
|
|
17
|
+
down: "Down",
|
|
18
|
+
left: "Left",
|
|
19
|
+
right: "Right",
|
|
20
|
+
home: "Home",
|
|
21
|
+
end: "End",
|
|
22
|
+
pageup: "PageUp",
|
|
23
|
+
pgup: "PageUp",
|
|
24
|
+
pagedown: "PageDown",
|
|
25
|
+
pgdn: "PageDown",
|
|
26
|
+
};
|
|
27
|
+
const MODIFIERS = {
|
|
28
|
+
ctrl: "Ctrl",
|
|
29
|
+
control: "Ctrl",
|
|
30
|
+
c: "Ctrl",
|
|
31
|
+
alt: "Alt",
|
|
32
|
+
meta: "Alt",
|
|
33
|
+
opt: "Alt",
|
|
34
|
+
option: "Alt",
|
|
35
|
+
shift: "Shift",
|
|
36
|
+
super: "Super",
|
|
37
|
+
cmd: "Super",
|
|
38
|
+
win: "Super",
|
|
39
|
+
};
|
|
40
|
+
/** Emission order; Zellij prints modifiers this way in its own docs. */
|
|
41
|
+
const MODIFIER_ORDER = ["Ctrl", "Alt", "Shift", "Super"];
|
|
42
|
+
function canonicalKey(raw) {
|
|
43
|
+
const key = raw.trim();
|
|
44
|
+
if (!key)
|
|
45
|
+
throw new ZellijError("bad_key", "empty key");
|
|
46
|
+
if (key.length === 1)
|
|
47
|
+
return key;
|
|
48
|
+
const named = NAMED_KEYS[key.toLowerCase()];
|
|
49
|
+
if (named)
|
|
50
|
+
return named;
|
|
51
|
+
if (/^f([1-9]|1[0-2])$/i.test(key))
|
|
52
|
+
return `F${key.slice(1)}`;
|
|
53
|
+
throw new ZellijError("bad_key", `unknown key "${raw}"; use a single character, F1-F12, or one of ${Object.values(NAMED_KEYS)
|
|
54
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
55
|
+
.join("/")}`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Normalize one key spec into Zellij's `send-keys` spelling.
|
|
59
|
+
* Accepts `ctrl+c`, `Ctrl-C`, `^C`, `esc`, `Alt Shift b`, `f1`.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeKey(raw) {
|
|
62
|
+
const spec = raw.trim();
|
|
63
|
+
if (!spec)
|
|
64
|
+
throw new ZellijError("bad_key", "empty key");
|
|
65
|
+
if (/^\^[A-Za-z]$/.test(spec)) {
|
|
66
|
+
return `Ctrl ${spec.slice(1).toLowerCase()}`;
|
|
67
|
+
}
|
|
68
|
+
// Split on `+`, whitespace, or a `-` that separates a known modifier.
|
|
69
|
+
const parts = spec
|
|
70
|
+
.replace(/\b(ctrl|control|alt|meta|opt|option|shift|super|cmd|win)-/gi, "$1 ")
|
|
71
|
+
.split(/[+\s]+/)
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
if (parts.length === 0)
|
|
74
|
+
throw new ZellijError("bad_key", "empty key");
|
|
75
|
+
const mods = [];
|
|
76
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
77
|
+
const mod = MODIFIERS[parts[i].toLowerCase()];
|
|
78
|
+
if (!mod) {
|
|
79
|
+
throw new ZellijError("bad_key", `unknown modifier "${parts[i]}" in "${raw}"`);
|
|
80
|
+
}
|
|
81
|
+
if (!mods.includes(mod))
|
|
82
|
+
mods.push(mod);
|
|
83
|
+
}
|
|
84
|
+
const key = canonicalKey(parts[parts.length - 1]);
|
|
85
|
+
if (mods.length === 0)
|
|
86
|
+
return key;
|
|
87
|
+
const ordered = MODIFIER_ORDER.filter((m) => mods.includes(m));
|
|
88
|
+
// A lone letter with a modifier is lowercase in Zellij's key syntax.
|
|
89
|
+
const base = key.length === 1 ? key.toLowerCase() : key;
|
|
90
|
+
return `${ordered.join(" ")} ${base}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Normalize a key list. Arrays hold one spec per entry; a string is a single
|
|
94
|
+
* spec unless it is comma separated (`"Esc,Enter"`), since `"Ctrl c"` is one key.
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeKeys(input) {
|
|
97
|
+
const specs = [];
|
|
98
|
+
if (Array.isArray(input)) {
|
|
99
|
+
for (const entry of input) {
|
|
100
|
+
if (typeof entry !== "string") {
|
|
101
|
+
throw new ZellijError("bad_key", "keys entries must be strings");
|
|
102
|
+
}
|
|
103
|
+
specs.push(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (typeof input === "string") {
|
|
107
|
+
specs.push(...(input.includes(",") ? input.split(",") : [input]));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
throw new ZellijError("bad_key", "keys must be a string or string array");
|
|
111
|
+
}
|
|
112
|
+
const keys = specs.map((s) => s.trim()).filter(Boolean).map(normalizeKey);
|
|
113
|
+
if (keys.length === 0)
|
|
114
|
+
throw new ZellijError("bad_key", "no keys given");
|
|
115
|
+
return keys;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Split a command line into argv. Arrays pass through; strings honour single
|
|
119
|
+
* and double quotes. Zellij runs the command directly — there is no shell.
|
|
120
|
+
*/
|
|
121
|
+
export function tokenizeCommand(input) {
|
|
122
|
+
if (input === undefined || input === null || input === "")
|
|
123
|
+
return [];
|
|
124
|
+
if (Array.isArray(input)) {
|
|
125
|
+
return input.map((part) => {
|
|
126
|
+
if (typeof part !== "string") {
|
|
127
|
+
throw new ZellijError("bad_command", "command entries must be strings");
|
|
128
|
+
}
|
|
129
|
+
return part;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (typeof input !== "string") {
|
|
133
|
+
throw new ZellijError("bad_command", "command must be a string or array");
|
|
134
|
+
}
|
|
135
|
+
const argv = [];
|
|
136
|
+
let current = "";
|
|
137
|
+
let quote = null;
|
|
138
|
+
let started = false;
|
|
139
|
+
for (const ch of input) {
|
|
140
|
+
if (quote) {
|
|
141
|
+
if (ch === quote)
|
|
142
|
+
quote = null;
|
|
143
|
+
else
|
|
144
|
+
current += ch;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === '"' || ch === "'") {
|
|
148
|
+
quote = ch;
|
|
149
|
+
started = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (/\s/.test(ch)) {
|
|
153
|
+
if (started)
|
|
154
|
+
argv.push(current);
|
|
155
|
+
current = "";
|
|
156
|
+
started = false;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
current += ch;
|
|
160
|
+
started = true;
|
|
161
|
+
}
|
|
162
|
+
if (quote) {
|
|
163
|
+
throw new ZellijError("bad_command", `unbalanced ${quote} in command`);
|
|
164
|
+
}
|
|
165
|
+
if (started)
|
|
166
|
+
argv.push(current);
|
|
167
|
+
return argv;
|
|
168
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Policy } from "../policy.js";
|
|
2
|
+
import type { StateStore } from "../state.js";
|
|
3
|
+
import type { ZellijClient } from "../zellij/client.js";
|
|
4
|
+
import type { ZellijPane } from "../zellij/panes.js";
|
|
5
|
+
import type { Clock, OpsResult } from "./types.js";
|
|
6
|
+
export type Skipped = {
|
|
7
|
+
id: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
12
|
+
* every terminal pane — then drop the ones it must not write to.
|
|
13
|
+
*/
|
|
14
|
+
export declare function selectTargets(client: ZellijClient, panes: ZellijPane[], args: Record<string, unknown>): {
|
|
15
|
+
targets: ZellijPane[];
|
|
16
|
+
skipped: Skipped[];
|
|
17
|
+
};
|
|
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>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { assertPaneAllowed } from "../policy.js";
|
|
3
|
+
import { deliverTo } from "./delivery.js";
|
|
4
|
+
import { isTrue, optionalString } from "./util.js";
|
|
5
|
+
/**
|
|
6
|
+
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
7
|
+
* every terminal pane — then drop the ones it must not write to.
|
|
8
|
+
*/
|
|
9
|
+
export function selectTargets(client, panes, args) {
|
|
10
|
+
const skipped = [];
|
|
11
|
+
const list = optionalString(args.to);
|
|
12
|
+
const tab = optionalString(args.tab);
|
|
13
|
+
const group = optionalString(args.group);
|
|
14
|
+
let candidates;
|
|
15
|
+
if (list) {
|
|
16
|
+
candidates = [];
|
|
17
|
+
for (const key of list.split(",").map((k) => k.trim()).filter(Boolean)) {
|
|
18
|
+
try {
|
|
19
|
+
candidates.push(client.resolvePane(panes, key));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
skipped.push({
|
|
23
|
+
id: key,
|
|
24
|
+
reason: err instanceof ZellijError ? err.code : "peer_not_found",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (tab) {
|
|
30
|
+
const wanted = tab.toLowerCase();
|
|
31
|
+
candidates = panes.filter((p) => (p.tabName ?? "").toLowerCase() === wanted);
|
|
32
|
+
}
|
|
33
|
+
else if (isTrue(args.all)) {
|
|
34
|
+
candidates = panes.slice();
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
throw new ZellijError("missing_target", "broadcast needs to, tab, or all");
|
|
38
|
+
}
|
|
39
|
+
// `group` narrows whatever the selector produced, by title or command.
|
|
40
|
+
if (group) {
|
|
41
|
+
const needle = group.toLowerCase();
|
|
42
|
+
candidates = candidates.filter((p) => p.title.toLowerCase().includes(needle) ||
|
|
43
|
+
(p.command ?? "").toLowerCase().includes(needle));
|
|
44
|
+
}
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const targets = [];
|
|
47
|
+
for (const pane of candidates) {
|
|
48
|
+
if (seen.has(pane.id))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(pane.id);
|
|
51
|
+
if (pane.isPlugin) {
|
|
52
|
+
skipped.push({ id: pane.id, reason: "pane_is_plugin" });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (pane.id === client.selfPaneId && !isTrue(args.allowSelf)) {
|
|
56
|
+
skipped.push({ id: pane.id, reason: "self_target" });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (pane.exited && !isTrue(args.force)) {
|
|
60
|
+
skipped.push({ id: pane.id, reason: "pane_exited" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
targets.push(pane);
|
|
64
|
+
}
|
|
65
|
+
return { targets, skipped };
|
|
66
|
+
}
|
|
67
|
+
/** Send one body to many panes, reporting per-pane outcomes. */
|
|
68
|
+
export async function broadcast(client, state, args, clock, policy) {
|
|
69
|
+
const body = String(args.body ?? args.text ?? "");
|
|
70
|
+
if (!body.trim())
|
|
71
|
+
throw new ZellijError("missing_body", "body required");
|
|
72
|
+
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
73
|
+
const panes = await client.listPanes(session);
|
|
74
|
+
const { targets: selected, skipped } = selectTargets(client, panes, args);
|
|
75
|
+
// Policy narrows the selection rather than failing the whole broadcast.
|
|
76
|
+
const targets = selected.filter((pane) => {
|
|
77
|
+
if (!policy)
|
|
78
|
+
return true;
|
|
79
|
+
try {
|
|
80
|
+
assertPaneAllowed(policy, pane, "broadcast");
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
skipped.push({ id: pane.id, reason: "policy_denied" });
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
if (targets.length === 0) {
|
|
89
|
+
throw new ZellijError("no_targets", `nothing to broadcast to${skipped.length > 0 ? ` (skipped ${skipped.map((s) => `${s.id}:${s.reason}`).join(", ")})` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
const results = [];
|
|
92
|
+
for (const pane of targets) {
|
|
93
|
+
results.push(await deliverTo(client, state, args, {
|
|
94
|
+
session,
|
|
95
|
+
pane,
|
|
96
|
+
body,
|
|
97
|
+
op: "broadcast",
|
|
98
|
+
at: clock.now(),
|
|
99
|
+
clock,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
const delivered = results.filter((r) => r.ok);
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
data: {
|
|
106
|
+
session,
|
|
107
|
+
from: (typeof args.from === "string" && args.from.trim()) || "swarm",
|
|
108
|
+
delivered: delivered.map((r) => r.to),
|
|
109
|
+
failed: results.filter((r) => !r.ok),
|
|
110
|
+
skipped,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Policy } from "../policy.js";
|
|
2
|
+
import type { StateStore } from "../state.js";
|
|
3
|
+
import { type BusChanged, type BusSnapshot, type BusWait } from "../zellij/bus.js";
|
|
4
|
+
import type { WaitRequest } from "../zellij/args.js";
|
|
5
|
+
import type { ZellijClient } from "../zellij/client.js";
|
|
6
|
+
import type { Clock, OpsResult } from "./types.js";
|
|
7
|
+
export type BusPlan = {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
/** Why the bus is off, or how it was turned on. */
|
|
10
|
+
reason: string;
|
|
11
|
+
plugin: string | null;
|
|
12
|
+
url: string | null;
|
|
13
|
+
configKey: string;
|
|
14
|
+
installed: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** Tests share a module instance; this drops what a previous case cached. */
|
|
17
|
+
export declare function resetBusCache(): void;
|
|
18
|
+
export declare function planBus(client: ZellijClient, state: StateStore, env?: NodeJS.ProcessEnv): BusPlan;
|
|
19
|
+
/**
|
|
20
|
+
* One pipe, one answer — or null, and the caller polls instead.
|
|
21
|
+
*
|
|
22
|
+
* Two things go wrong in practice. A stale instance under the same key eats the
|
|
23
|
+
* message and replies with nothing, so each attempt rotates to a new key. And a
|
|
24
|
+
* freshly launched instance replies before Zellij has pushed it a manifest, so
|
|
25
|
+
* a not-ready answer is retried once rather than reported as an empty session.
|
|
26
|
+
*/
|
|
27
|
+
export declare function busSnapshot(client: ZellijClient, state: StateStore, session: string, clock: Clock, env?: NodeJS.ProcessEnv, payload?: string): Promise<{
|
|
28
|
+
snapshot: BusSnapshot;
|
|
29
|
+
configKey: string;
|
|
30
|
+
} | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Read several pane screens in one pipe, or null to make the caller poll.
|
|
33
|
+
*
|
|
34
|
+
* Measured on this tree: the pipe costs ~0.055s fixed plus ~0.014s per pane,
|
|
35
|
+
* `dump-screen` ~0.050s per pane. So this is a loss for one pane and a win from
|
|
36
|
+
* two upward — callers must not route single-pane reads here.
|
|
37
|
+
*
|
|
38
|
+
* The text comes back as viewport lines padded to the terminal width, where
|
|
39
|
+
* `dump-screen` returns them ragged. `normalizeScreen` erases exactly that
|
|
40
|
+
* difference, and every caller here already normalizes before comparing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function busScreens(client: ZellijClient, state: StateStore, session: string, paneIds: string[], clock: Clock, env?: NodeJS.ProcessEnv): Promise<Map<string, string> | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Hand the whole wait to the plugin, which holds the pipe until it resolves.
|
|
45
|
+
* One process covers a minute-long wait instead of one per poll, and the plugin
|
|
46
|
+
* can poll far tighter than a process spawn allows.
|
|
47
|
+
*
|
|
48
|
+
* Returns null for anything it will not serve — a regex needle, a cold
|
|
49
|
+
* instance, no reply — and the caller runs its own loop.
|
|
50
|
+
*/
|
|
51
|
+
export declare function busWait(client: ZellijClient, state: StateStore, session: string, request: WaitRequest, env?: NodeJS.ProcessEnv): Promise<BusWait | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Screens plus "did this move since you last asked". Answers the same question
|
|
54
|
+
* `status` answers by sampling twice, without the gap between samples — but
|
|
55
|
+
* relative to the previous call rather than a fixed window.
|
|
56
|
+
*/
|
|
57
|
+
export declare function busChanged(client: ZellijClient, state: StateStore, session: string, paneIds: string[], env?: NodeJS.ProcessEnv): Promise<BusChanged | null>;
|
|
58
|
+
/** Report on the bus, install it, or forget it. */
|
|
59
|
+
export declare function busOp(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, env?: NodeJS.ProcessEnv, policy?: Policy): Promise<OpsResult>;
|
package/dist/ops/bus.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { ZellijError } from "../errors.js";
|
|
3
|
+
import { DEFAULT_BUS_KEY, busPluginUrl, nextConfigKey, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, resolveBusPlugin, scrollbackToScreen, } from "../zellij/bus.js";
|
|
4
|
+
import { isTrue } from "./util.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether to ask the event-bus plugin, and what to do when it does not answer.
|
|
7
|
+
*
|
|
8
|
+
* The bus is off until `bus --install` has walked the plugin's permission
|
|
9
|
+
* prompt, because a pipe to a plugin that was never approved costs a process
|
|
10
|
+
* and a timeout on every call and then falls back anyway.
|
|
11
|
+
*/
|
|
12
|
+
/** Config keys to try before giving up; each one is a fresh pipe destination. */
|
|
13
|
+
const MAX_KEY_ATTEMPTS = 3;
|
|
14
|
+
/** A just-launched instance answers before Zellij has pushed it anything. */
|
|
15
|
+
const COLD_RETRY_MS = 200;
|
|
16
|
+
/** Set once a pipe has failed, so one dead plugin costs one timeout, not many. */
|
|
17
|
+
let processDisabled = null;
|
|
18
|
+
/** The key that actually answered, per session, for the life of this process. */
|
|
19
|
+
const answeredWith = new Map();
|
|
20
|
+
/** Tests share a module instance; this drops what a previous case cached. */
|
|
21
|
+
export function resetBusCache() {
|
|
22
|
+
processDisabled = null;
|
|
23
|
+
answeredWith.clear();
|
|
24
|
+
}
|
|
25
|
+
function affirmative(raw) {
|
|
26
|
+
const v = (raw ?? "").trim().toLowerCase();
|
|
27
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
28
|
+
}
|
|
29
|
+
function negative(raw) {
|
|
30
|
+
const v = (raw ?? "").trim().toLowerCase();
|
|
31
|
+
return v === "0" || v === "false" || v === "no" || v === "off";
|
|
32
|
+
}
|
|
33
|
+
export function planBus(client, state, env = process.env) {
|
|
34
|
+
const marker = state.readBus();
|
|
35
|
+
// A marker can outlive the wasm it names — an upgrade renames the artifact,
|
|
36
|
+
// so a remembered path that no longer exists must not shadow the current one.
|
|
37
|
+
const remembered = marker && existsSync(marker.plugin) ? marker : null;
|
|
38
|
+
const plugin = remembered?.plugin ?? resolveBusPlugin(env);
|
|
39
|
+
const configKey = remembered?.configKey ?? DEFAULT_BUS_KEY;
|
|
40
|
+
const base = {
|
|
41
|
+
enabled: false,
|
|
42
|
+
reason: "",
|
|
43
|
+
plugin,
|
|
44
|
+
url: plugin ? busPluginUrl(plugin) : null,
|
|
45
|
+
configKey,
|
|
46
|
+
installed: marker !== null,
|
|
47
|
+
};
|
|
48
|
+
if (negative(env.ZSWARM_BUS))
|
|
49
|
+
return { ...base, reason: "ZSWARM_BUS=0" };
|
|
50
|
+
if (client.remote) {
|
|
51
|
+
return { ...base, reason: "remote session: a file: plugin url is local-only" };
|
|
52
|
+
}
|
|
53
|
+
if (!plugin) {
|
|
54
|
+
return {
|
|
55
|
+
...base,
|
|
56
|
+
reason: "no plugin wasm found; build plugin/zswarm-events or set ZSWARM_BUS_PLUGIN",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (processDisabled)
|
|
60
|
+
return { ...base, reason: processDisabled };
|
|
61
|
+
if (affirmative(env.ZSWARM_BUS)) {
|
|
62
|
+
return { ...base, enabled: true, reason: "ZSWARM_BUS=1" };
|
|
63
|
+
}
|
|
64
|
+
if (marker)
|
|
65
|
+
return { ...base, enabled: true, reason: "installed" };
|
|
66
|
+
return { ...base, reason: 'not installed; run zswarm({op:"bus", install:true})' };
|
|
67
|
+
}
|
|
68
|
+
async function askOnce(client, session, url, configKey, payload) {
|
|
69
|
+
const result = await client.pipePlugin({ session, url, configKey, payload });
|
|
70
|
+
return parseBusReply(result.stdout);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* One pipe, one answer — or null, and the caller polls instead.
|
|
74
|
+
*
|
|
75
|
+
* Two things go wrong in practice. A stale instance under the same key eats the
|
|
76
|
+
* message and replies with nothing, so each attempt rotates to a new key. And a
|
|
77
|
+
* freshly launched instance replies before Zellij has pushed it a manifest, so
|
|
78
|
+
* a not-ready answer is retried once rather than reported as an empty session.
|
|
79
|
+
*/
|
|
80
|
+
export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status") {
|
|
81
|
+
const plan = planBus(client, state, env);
|
|
82
|
+
const pluginPath = plan.plugin;
|
|
83
|
+
if (!plan.enabled || !plan.url || !pluginPath)
|
|
84
|
+
return null;
|
|
85
|
+
let configKey = answeredWith.get(session) ?? plan.configKey;
|
|
86
|
+
for (let attempt = 0; attempt < MAX_KEY_ATTEMPTS; attempt++) {
|
|
87
|
+
let snapshot = await askOnce(client, session, plan.url, configKey, payload);
|
|
88
|
+
if (snapshot && !snapshot.ready) {
|
|
89
|
+
await clock.sleep(COLD_RETRY_MS);
|
|
90
|
+
snapshot =
|
|
91
|
+
(await askOnce(client, session, plan.url, configKey, payload)) ??
|
|
92
|
+
snapshot;
|
|
93
|
+
}
|
|
94
|
+
if (snapshot) {
|
|
95
|
+
answeredWith.set(session, configKey);
|
|
96
|
+
if (plan.installed && configKey !== plan.configKey) {
|
|
97
|
+
// Remember the rotation, so the next process starts on a live key.
|
|
98
|
+
state.writeBus({
|
|
99
|
+
plugin: pluginPath,
|
|
100
|
+
configKey,
|
|
101
|
+
installedAt: clock.now(),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return { snapshot, configKey };
|
|
105
|
+
}
|
|
106
|
+
configKey = nextConfigKey(configKey);
|
|
107
|
+
}
|
|
108
|
+
processDisabled = "plugin did not answer; using zellij polling for this run";
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Read several pane screens in one pipe, or null to make the caller poll.
|
|
113
|
+
*
|
|
114
|
+
* Measured on this tree: the pipe costs ~0.055s fixed plus ~0.014s per pane,
|
|
115
|
+
* `dump-screen` ~0.050s per pane. So this is a loss for one pane and a win from
|
|
116
|
+
* two upward — callers must not route single-pane reads here.
|
|
117
|
+
*
|
|
118
|
+
* The text comes back as viewport lines padded to the terminal width, where
|
|
119
|
+
* `dump-screen` returns them ragged. `normalizeScreen` erases exactly that
|
|
120
|
+
* difference, and every caller here already normalizes before comparing.
|
|
121
|
+
*/
|
|
122
|
+
export async function busScreens(client, state, session, paneIds, clock, env = process.env) {
|
|
123
|
+
if (paneIds.length < 2)
|
|
124
|
+
return null;
|
|
125
|
+
const plan = planBus(client, state, env);
|
|
126
|
+
if (!plan.enabled || !plan.url)
|
|
127
|
+
return null;
|
|
128
|
+
const configKey = answeredWith.get(session) ?? plan.configKey;
|
|
129
|
+
const reply = await client.scrollbackPlugin({
|
|
130
|
+
session,
|
|
131
|
+
url: plan.url,
|
|
132
|
+
configKey,
|
|
133
|
+
panes: paneIds,
|
|
134
|
+
});
|
|
135
|
+
const parsed = parseScrollbackReply(reply.stdout);
|
|
136
|
+
if (!parsed || !parsed.ready)
|
|
137
|
+
return null;
|
|
138
|
+
const screens = new Map();
|
|
139
|
+
for (const pane of parsed.panes) {
|
|
140
|
+
screens.set(pane.id, scrollbackToScreen(pane));
|
|
141
|
+
}
|
|
142
|
+
// A pane that vanished mid-read is normal; a wholesale miss is not, and
|
|
143
|
+
// silently returning fewer screens would read as "those panes are quiet".
|
|
144
|
+
return screens.size === paneIds.length ? screens : null;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Hand the whole wait to the plugin, which holds the pipe until it resolves.
|
|
148
|
+
* One process covers a minute-long wait instead of one per poll, and the plugin
|
|
149
|
+
* can poll far tighter than a process spawn allows.
|
|
150
|
+
*
|
|
151
|
+
* Returns null for anything it will not serve — a regex needle, a cold
|
|
152
|
+
* instance, no reply — and the caller runs its own loop.
|
|
153
|
+
*/
|
|
154
|
+
export async function busWait(client, state, session, request, env = process.env) {
|
|
155
|
+
const plan = planBus(client, state, env);
|
|
156
|
+
if (!plan.enabled || !plan.url)
|
|
157
|
+
return null;
|
|
158
|
+
const configKey = answeredWith.get(session) ?? plan.configKey;
|
|
159
|
+
const reply = await client.waitPlugin({
|
|
160
|
+
session,
|
|
161
|
+
url: plan.url,
|
|
162
|
+
configKey,
|
|
163
|
+
...request,
|
|
164
|
+
});
|
|
165
|
+
const parsed = parseWaitReply(reply.stdout);
|
|
166
|
+
// `gone` means the pane vanished mid-wait; the polling path reports that
|
|
167
|
+
// with its own error, so let it.
|
|
168
|
+
return parsed && parsed.reason !== "gone" ? parsed : null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Screens plus "did this move since you last asked". Answers the same question
|
|
172
|
+
* `status` answers by sampling twice, without the gap between samples — but
|
|
173
|
+
* relative to the previous call rather than a fixed window.
|
|
174
|
+
*/
|
|
175
|
+
export async function busChanged(client, state, session, paneIds, env = process.env) {
|
|
176
|
+
if (paneIds.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
const plan = planBus(client, state, env);
|
|
179
|
+
if (!plan.enabled || !plan.url)
|
|
180
|
+
return null;
|
|
181
|
+
const configKey = answeredWith.get(session) ?? plan.configKey;
|
|
182
|
+
const reply = await client.changedPlugin({
|
|
183
|
+
session,
|
|
184
|
+
url: plan.url,
|
|
185
|
+
configKey,
|
|
186
|
+
panes: paneIds,
|
|
187
|
+
});
|
|
188
|
+
const parsed = parseChangedReply(reply.stdout);
|
|
189
|
+
if (!parsed || !parsed.ready)
|
|
190
|
+
return null;
|
|
191
|
+
return parsed.panes.length === paneIds.length ? parsed : null;
|
|
192
|
+
}
|
|
193
|
+
/** Report on the bus, install it, or forget it. */
|
|
194
|
+
export async function busOp(client, state, args, clock, env = process.env, policy) {
|
|
195
|
+
if (isTrue(args.clear)) {
|
|
196
|
+
state.clearBus();
|
|
197
|
+
resetBusCache();
|
|
198
|
+
return { ok: true, data: { installed: false, cleared: true } };
|
|
199
|
+
}
|
|
200
|
+
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
201
|
+
if (isTrue(args.install)) {
|
|
202
|
+
// Installing opens a pane, which a read-only crew is not allowed to do.
|
|
203
|
+
if (policy?.readOnly) {
|
|
204
|
+
throw new ZellijError("policy_denied", 'op "bus" with install opens a pane; denied by ZSWARM_READONLY');
|
|
205
|
+
}
|
|
206
|
+
const plugin = resolveBusPlugin(env);
|
|
207
|
+
if (!plugin) {
|
|
208
|
+
throw new ZellijError("bus_missing", "no plugin wasm found; build plugin/zswarm-events or set ZSWARM_BUS_PLUGIN");
|
|
209
|
+
}
|
|
210
|
+
const previous = state.readBus();
|
|
211
|
+
const force = isTrue(args.force);
|
|
212
|
+
// Launching again would add a second instance, so an install that is
|
|
213
|
+
// already answering is a no-op unless the caller asked for a fresh one.
|
|
214
|
+
if (previous && !force) {
|
|
215
|
+
const live = await busSnapshot(client, state, session, clock, env);
|
|
216
|
+
if (live?.snapshot.ready) {
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
data: {
|
|
220
|
+
session,
|
|
221
|
+
installed: true,
|
|
222
|
+
plugin: previous.plugin,
|
|
223
|
+
url: busPluginUrl(previous.plugin),
|
|
224
|
+
configKey: live.configKey,
|
|
225
|
+
ready: true,
|
|
226
|
+
panes: live.snapshot.panes.length,
|
|
227
|
+
note: "already installed; --force reloads under a fresh key",
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// A reload needs a key Zellij has not already bound an instance to.
|
|
233
|
+
const configKey = force && previous
|
|
234
|
+
? nextConfigKey(previous.configKey)
|
|
235
|
+
: (previous?.configKey ?? DEFAULT_BUS_KEY);
|
|
236
|
+
const url = busPluginUrl(plugin);
|
|
237
|
+
const launched = await client.launchPlugin({
|
|
238
|
+
session,
|
|
239
|
+
url,
|
|
240
|
+
configKey,
|
|
241
|
+
floating: true,
|
|
242
|
+
skipCache: force,
|
|
243
|
+
});
|
|
244
|
+
state.writeBus({ plugin, configKey, installedAt: clock.now() });
|
|
245
|
+
resetBusCache();
|
|
246
|
+
const probe = await busSnapshot(client, state, session, clock, env);
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
data: {
|
|
250
|
+
session,
|
|
251
|
+
installed: true,
|
|
252
|
+
plugin,
|
|
253
|
+
url,
|
|
254
|
+
configKey,
|
|
255
|
+
pane: launched.paneId,
|
|
256
|
+
ready: probe?.snapshot.ready ?? false,
|
|
257
|
+
panes: probe?.snapshot.panes.length ?? null,
|
|
258
|
+
note: probe
|
|
259
|
+
? "bus answering; list and status use it now, and this pane can be closed"
|
|
260
|
+
: "approve the plugin's permission prompt in the new pane, then re-run",
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const plan = planBus(client, state, env);
|
|
265
|
+
const probe = plan.enabled
|
|
266
|
+
? await busSnapshot(client, state, session, clock, env)
|
|
267
|
+
: null;
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
data: {
|
|
271
|
+
session,
|
|
272
|
+
enabled: plan.enabled,
|
|
273
|
+
reason: plan.reason,
|
|
274
|
+
installed: plan.installed,
|
|
275
|
+
plugin: plan.plugin,
|
|
276
|
+
url: plan.url,
|
|
277
|
+
configKey: probe?.configKey ?? plan.configKey,
|
|
278
|
+
ready: probe?.snapshot.ready ?? false,
|
|
279
|
+
paneUpdates: probe?.snapshot.paneUpdates ?? null,
|
|
280
|
+
tabUpdates: probe?.snapshot.tabUpdates ?? null,
|
|
281
|
+
panes: probe?.snapshot.panes.length ?? null,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StateStore } from "../state.js";
|
|
2
|
+
import type { ZellijClient } from "../zellij/client.js";
|
|
3
|
+
import type { ZellijPane } from "../zellij/panes.js";
|
|
4
|
+
import type { Clock } from "./types.js";
|
|
5
|
+
export type Submitted = boolean | "unverified";
|
|
6
|
+
export type DeliveryResult = {
|
|
7
|
+
to: string;
|
|
8
|
+
ok: boolean;
|
|
9
|
+
delivery?: string;
|
|
10
|
+
submitted?: Submitted;
|
|
11
|
+
error?: {
|
|
12
|
+
code: string;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type SubmitMode = "auto" | "double-enter" | "none";
|
|
17
|
+
export declare function senderLabel(args: Record<string, unknown>): string;
|
|
18
|
+
export declare function bodyText(client: ZellijClient, args: Record<string, unknown>, body: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Caller `submit=` always wins. With none given, the pane's harness picks
|
|
21
|
+
* auto vs double-enter (codex needs two Enters; the rest submit on one).
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveSubmitMode(args: Record<string, unknown>, pane: {
|
|
24
|
+
command?: string | null;
|
|
25
|
+
title?: string | null;
|
|
26
|
+
}): SubmitMode;
|
|
27
|
+
/** True when the paste still sits in the composer (bottom of the screen). */
|
|
28
|
+
export declare function composerHolds(screen: string, body: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Compare the pane before vs after the paste.
|
|
31
|
+
* Composer clearing (pasted text left the bottom input region) is submission.
|
|
32
|
+
* `[Pasted text` in scrollback is not evidence.
|
|
33
|
+
*/
|
|
34
|
+
export declare function classifySubmit(before: string, after: string, body: string): Submitted;
|
|
35
|
+
/** Paste a body into one pane and record the attempt. */
|
|
36
|
+
export declare function deliverTo(client: ZellijClient, state: StateStore, args: Record<string, unknown>, input: {
|
|
37
|
+
session: string;
|
|
38
|
+
pane: ZellijPane;
|
|
39
|
+
body: string;
|
|
40
|
+
op: string;
|
|
41
|
+
at: number;
|
|
42
|
+
clock: Clock;
|
|
43
|
+
}): Promise<DeliveryResult>;
|