@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/errors.d.ts +5 -0
  4. package/dist/errors.js +9 -0
  5. package/dist/exec.d.ts +32 -0
  6. package/dist/exec.js +118 -0
  7. package/dist/git.d.ts +51 -0
  8. package/dist/git.js +190 -0
  9. package/dist/harness.d.ts +21 -0
  10. package/dist/harness.js +98 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.js +22 -0
  13. package/dist/keys.d.ts +15 -0
  14. package/dist/keys.js +168 -0
  15. package/dist/ops/broadcast.d.ts +19 -0
  16. package/dist/ops/broadcast.js +113 -0
  17. package/dist/ops/bus.d.ts +59 -0
  18. package/dist/ops/bus.js +284 -0
  19. package/dist/ops/delivery.d.ts +43 -0
  20. package/dist/ops/delivery.js +191 -0
  21. package/dist/ops/dispatch.d.ts +4 -0
  22. package/dist/ops/dispatch.js +331 -0
  23. package/dist/ops/guards.d.ts +15 -0
  24. package/dist/ops/guards.js +36 -0
  25. package/dist/ops/log.d.ts +4 -0
  26. package/dist/ops/log.js +26 -0
  27. package/dist/ops/panes.d.ts +13 -0
  28. package/dist/ops/panes.js +111 -0
  29. package/dist/ops/review.d.ts +9 -0
  30. package/dist/ops/review.js +108 -0
  31. package/dist/ops/signals.d.ts +10 -0
  32. package/dist/ops/signals.js +86 -0
  33. package/dist/ops/spawn.d.ts +5 -0
  34. package/dist/ops/spawn.js +98 -0
  35. package/dist/ops/status.d.ts +36 -0
  36. package/dist/ops/status.js +192 -0
  37. package/dist/ops/tail.d.ts +19 -0
  38. package/dist/ops/tail.js +59 -0
  39. package/dist/ops/types.d.ts +26 -0
  40. package/dist/ops/types.js +1 -0
  41. package/dist/ops/util.d.ts +48 -0
  42. package/dist/ops/util.js +85 -0
  43. package/dist/ops/wait.d.ts +19 -0
  44. package/dist/ops/wait.js +138 -0
  45. package/dist/ops/worktree.d.ts +21 -0
  46. package/dist/ops/worktree.js +144 -0
  47. package/dist/policy.d.ts +15 -0
  48. package/dist/policy.js +75 -0
  49. package/dist/schema.d.ts +26 -0
  50. package/dist/schema.js +489 -0
  51. package/dist/state.d.ts +52 -0
  52. package/dist/state.js +150 -0
  53. package/dist/zellij/args.d.ts +93 -0
  54. package/dist/zellij/args.js +261 -0
  55. package/dist/zellij/binary.d.ts +17 -0
  56. package/dist/zellij/binary.js +96 -0
  57. package/dist/zellij/bus.d.ts +91 -0
  58. package/dist/zellij/bus.js +279 -0
  59. package/dist/zellij/client.d.ts +162 -0
  60. package/dist/zellij/client.js +246 -0
  61. package/dist/zellij/panes.d.ts +21 -0
  62. package/dist/zellij/panes.js +110 -0
  63. package/dist/zellij/session.d.ts +14 -0
  64. package/dist/zellij/session.js +44 -0
  65. package/dist/zellij/tabs.d.ts +14 -0
  66. package/dist/zellij/tabs.js +56 -0
  67. package/package.json +34 -0
@@ -0,0 +1,59 @@
1
+ import { DEFAULT_DUMP_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, truncateDumpText, } from "./util.js";
2
+ /**
3
+ * What is new in `cur` given the previously seen screen. The viewport scrolls,
4
+ * so the old bottom shows up as the new top: find the longest suffix of the
5
+ * previous screen that still heads the current one, and return the rest.
6
+ */
7
+ export function diffScreens(prev, cur) {
8
+ if (prev === null)
9
+ return { text: cur, reset: true };
10
+ if (cur === prev)
11
+ return { text: "", reset: false };
12
+ if (cur.startsWith(prev))
13
+ return { text: cur.slice(prev.length), reset: false };
14
+ const prevLines = prev.split("\n");
15
+ const curLines = cur.split("\n");
16
+ for (let skip = 1; skip < prevLines.length; skip++) {
17
+ const overlap = prevLines.slice(skip);
18
+ if (overlap.length > curLines.length)
19
+ continue;
20
+ if (curLines.slice(0, overlap.length).join("\n") === overlap.join("\n")) {
21
+ return { text: curLines.slice(overlap.length).join("\n"), reset: false };
22
+ }
23
+ }
24
+ // Nothing lines up — the pane redrew itself.
25
+ return { text: cur, reset: true };
26
+ }
27
+ export function cursorKey(session, paneId) {
28
+ return `${session}:${paneId}`;
29
+ }
30
+ /** Read only what a pane printed since the last tail. */
31
+ export async function tailPane(client, state, args, target) {
32
+ const { session, pane } = target;
33
+ const key = cursorKey(session, pane.id);
34
+ if (isTrue(args.reset))
35
+ state.clearCursor(key);
36
+ const dumped = await client.dumpPane({
37
+ session,
38
+ paneId: pane.id,
39
+ full: isTrue(args.full),
40
+ });
41
+ const screen = normalizeScreen(dumped.text);
42
+ const previous = state.readCursor(key);
43
+ const diff = diffScreens(previous, screen);
44
+ state.writeCursor(key, screen);
45
+ const max = dumpMaxChars(args, DEFAULT_DUMP_MAX_CHARS);
46
+ const clipped = truncateDumpText(diff.text, max);
47
+ return {
48
+ ok: true,
49
+ data: {
50
+ session,
51
+ to: pane.id,
52
+ text: clipped.text,
53
+ reset: diff.reset,
54
+ fresh: diff.text.length > 0,
55
+ truncated: clipped.truncated,
56
+ chars: clipped.chars,
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,26 @@
1
+ export type OpsResult = {
2
+ ok: true;
3
+ data: unknown;
4
+ } | {
5
+ ok: false;
6
+ error: {
7
+ code: string;
8
+ message: string;
9
+ };
10
+ };
11
+ import type { GitClient } from "../git.js";
12
+ import type { Policy } from "../policy.js";
13
+ import type { StateStore } from "../state.js";
14
+ /** Injectable clock, git, state, and policy so timing/IO ops stay testable. */
15
+ export type DispatchDeps = {
16
+ now?: () => number;
17
+ sleep?: (ms: number) => Promise<void>;
18
+ git?: GitClient;
19
+ state?: StateStore;
20
+ policy?: Policy;
21
+ env?: NodeJS.ProcessEnv;
22
+ };
23
+ export type Clock = {
24
+ now: () => number;
25
+ sleep: (ms: number) => Promise<void>;
26
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ import type { ZellijPane } from "../zellij/panes.js";
2
+ import type { OpsResult } from "./types.js";
3
+ /** Default dump text budget (characters). Override with max=; max=0 disables. */
4
+ export declare const DEFAULT_DUMP_MAX_CHARS = 8000;
5
+ /** `wait` returns a short tail by default — it is called in loops. */
6
+ export declare const DEFAULT_WAIT_MAX_CHARS = 2000;
7
+ export declare function fail(err: unknown): OpsResult;
8
+ export declare function isTrue(value: unknown): boolean;
9
+ export declare function isVerbose(args: Record<string, unknown>): boolean;
10
+ export declare function optionalString(value: unknown): string | null;
11
+ export declare function paneViewSlim(p: ZellijPane): {
12
+ id: string;
13
+ title: string;
14
+ command: string | null;
15
+ tab: string | null;
16
+ };
17
+ /**
18
+ * The event-bus view. Zellij's pane manifest carries no command, so the key is
19
+ * left out rather than reported as null — absent means unknown, not none.
20
+ */
21
+ export declare function paneViewBus(p: ZellijPane): {
22
+ id: string;
23
+ title: string;
24
+ tab: string | null;
25
+ };
26
+ export declare function paneViewFull(p: ZellijPane): {
27
+ cwd: string | null;
28
+ focused: boolean;
29
+ exited: boolean;
30
+ floating: boolean;
31
+ id: string;
32
+ title: string;
33
+ command: string | null;
34
+ tab: string | null;
35
+ };
36
+ /** Truncate dump text; default keeps the tail (recent output). */
37
+ export declare function truncateDumpText(text: string, maxChars: number, keep?: "tail" | "head"): {
38
+ text: string;
39
+ truncated: boolean;
40
+ chars: number;
41
+ };
42
+ /** Screen text with trailing blanks removed, so idle detection ignores padding. */
43
+ export declare function normalizeScreen(text: string): string;
44
+ export declare function numberArg(args: Record<string, unknown>, key: string, fallback: number, limits: {
45
+ min: number;
46
+ max: number;
47
+ }): number;
48
+ export declare function dumpMaxChars(args: Record<string, unknown>, fallback?: number): number;
@@ -0,0 +1,85 @@
1
+ import { ZellijError } from "../errors.js";
2
+ /** Default dump text budget (characters). Override with max=; max=0 disables. */
3
+ export const DEFAULT_DUMP_MAX_CHARS = 8_000;
4
+ /** `wait` returns a short tail by default — it is called in loops. */
5
+ export const DEFAULT_WAIT_MAX_CHARS = 2_000;
6
+ export function fail(err) {
7
+ if (err instanceof ZellijError) {
8
+ return { ok: false, error: { code: err.code, message: err.message } };
9
+ }
10
+ const message = err instanceof Error ? err.message : String(err);
11
+ return { ok: false, error: { code: "failed", message } };
12
+ }
13
+ export function isTrue(value) {
14
+ return value === true || value === "true";
15
+ }
16
+ export function isVerbose(args) {
17
+ return isTrue(args.verbose);
18
+ }
19
+ export function optionalString(value) {
20
+ return typeof value === "string" && value.trim() ? value.trim() : null;
21
+ }
22
+ export function paneViewSlim(p) {
23
+ return {
24
+ id: p.id,
25
+ title: p.title,
26
+ command: p.command ?? null,
27
+ tab: p.tabName ?? null,
28
+ };
29
+ }
30
+ /**
31
+ * The event-bus view. Zellij's pane manifest carries no command, so the key is
32
+ * left out rather than reported as null — absent means unknown, not none.
33
+ */
34
+ export function paneViewBus(p) {
35
+ return { id: p.id, title: p.title, tab: p.tabName ?? null };
36
+ }
37
+ export function paneViewFull(p) {
38
+ return {
39
+ ...paneViewSlim(p),
40
+ cwd: p.cwd ?? null,
41
+ focused: p.focused,
42
+ exited: p.exited,
43
+ floating: p.floating,
44
+ };
45
+ }
46
+ /** Truncate dump text; default keeps the tail (recent output). */
47
+ export function truncateDumpText(text, maxChars, keep = "tail") {
48
+ const chars = text.length;
49
+ if (maxChars <= 0 || chars <= maxChars) {
50
+ return { text, truncated: false, chars };
51
+ }
52
+ if (keep === "head") {
53
+ return { text: text.slice(0, maxChars), truncated: true, chars };
54
+ }
55
+ return { text: text.slice(chars - maxChars), truncated: true, chars };
56
+ }
57
+ /** Screen text with trailing blanks removed, so idle detection ignores padding. */
58
+ export function normalizeScreen(text) {
59
+ return text
60
+ .replace(/\r/g, "")
61
+ .split("\n")
62
+ .map((line) => line.replace(/\s+$/, ""))
63
+ .join("\n")
64
+ .replace(/\n+$/, "");
65
+ }
66
+ export function numberArg(args, key, fallback, limits) {
67
+ const raw = args[key];
68
+ if (raw === undefined || raw === null || raw === "")
69
+ return fallback;
70
+ const n = Number(raw);
71
+ if (!Number.isFinite(n)) {
72
+ throw new ZellijError("bad_arg", `${key} must be a number`);
73
+ }
74
+ return Math.min(limits.max, Math.max(limits.min, Math.floor(n)));
75
+ }
76
+ export function dumpMaxChars(args, fallback = DEFAULT_DUMP_MAX_CHARS) {
77
+ if (args.max === undefined || args.max === null || args.max === "") {
78
+ return fallback;
79
+ }
80
+ const n = Number(args.max);
81
+ if (!Number.isFinite(n) || n < 0) {
82
+ throw new ZellijError("bad_max", "max must be a non-negative number");
83
+ }
84
+ return Math.floor(n);
85
+ }
@@ -0,0 +1,19 @@
1
+ import type { ZellijClient } from "../zellij/client.js";
2
+ import type { BusWait } from "../zellij/bus.js";
3
+ import type { ZellijPane } from "../zellij/panes.js";
4
+ import type { Clock, OpsResult } from "./types.js";
5
+ export declare function buildMatcher(args: Record<string, unknown>): ((text: string) => boolean) | null;
6
+ /** Held-pipe wait answer, shaped by `parseWaitReply` in `zellij/bus.ts`. */
7
+ export type { BusWait };
8
+ /**
9
+ * Poll a pane's screen until it goes quiet, prints a match, or the deadline
10
+ * passes — so callers stop re-dumping in a loop of their own.
11
+ *
12
+ * `waitViaBus`, when provided, is one held pipe covering the whole wait.
13
+ * Null (plugin missing or silent) falls back to dump-screen polling. The
14
+ * pipe's own timeout belongs to the transport; this op does not cap it.
15
+ */
16
+ export declare function waitForPane(client: ZellijClient, target: {
17
+ session: string;
18
+ pane: ZellijPane;
19
+ }, args: Record<string, unknown>, clock: Clock, waitViaBus?: () => Promise<BusWait | null>): Promise<OpsResult>;
@@ -0,0 +1,138 @@
1
+ import { ZellijError } from "../errors.js";
2
+ import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, truncateDumpText, } from "./util.js";
3
+ const WAIT_DEFAULTS = {
4
+ idleMs: 2_000,
5
+ pollMs: 150,
6
+ timeoutMs: 60_000,
7
+ };
8
+ const WAIT_LIMITS = {
9
+ idleMs: { min: 200, max: 600_000 },
10
+ pollMs: { min: 50, max: 30_000 },
11
+ timeoutMs: { min: 1_000, max: 900_000 },
12
+ };
13
+ export function buildMatcher(args) {
14
+ const match = optionalString(args.match);
15
+ if (!match)
16
+ return null;
17
+ if (isTrue(args.regex)) {
18
+ try {
19
+ const re = new RegExp(match, isTrue(args.ignoreCase) ? "im" : "m");
20
+ return (text) => re.test(text);
21
+ }
22
+ catch (err) {
23
+ throw new ZellijError("bad_match", `invalid regex: ${err instanceof Error ? err.message : String(err)}`);
24
+ }
25
+ }
26
+ if (isTrue(args.ignoreCase)) {
27
+ const needle = match.toLowerCase();
28
+ return (text) => text.toLowerCase().includes(needle);
29
+ }
30
+ return (text) => text.includes(match);
31
+ }
32
+ function waitResult(reason, ctx) {
33
+ const max = dumpMaxChars(ctx.args, DEFAULT_WAIT_MAX_CHARS);
34
+ const clipped = truncateDumpText(ctx.text, max);
35
+ return {
36
+ ok: true,
37
+ data: {
38
+ session: ctx.session,
39
+ to: ctx.pane.id,
40
+ reason,
41
+ elapsedMs: ctx.at - ctx.started,
42
+ polls: ctx.polls,
43
+ changes: ctx.changes,
44
+ idleMs: ctx.idleMs,
45
+ text: clipped.text,
46
+ truncated: clipped.truncated,
47
+ chars: clipped.chars,
48
+ },
49
+ };
50
+ }
51
+ /**
52
+ * Poll a pane's screen until it goes quiet, prints a match, or the deadline
53
+ * passes — so callers stop re-dumping in a loop of their own.
54
+ *
55
+ * `waitViaBus`, when provided, is one held pipe covering the whole wait.
56
+ * Null (plugin missing or silent) falls back to dump-screen polling. The
57
+ * pipe's own timeout belongs to the transport; this op does not cap it.
58
+ */
59
+ export async function waitForPane(client, target, args, clock, waitViaBus) {
60
+ const { session, pane } = target;
61
+ const matcher = buildMatcher(args);
62
+ const requested = optionalString(args.for) ?? (matcher ? "match" : "idle");
63
+ if (!["idle", "match", "either"].includes(requested)) {
64
+ throw new ZellijError("bad_arg", "for must be idle|match|either");
65
+ }
66
+ if (requested !== "idle" && !matcher) {
67
+ throw new ZellijError("missing_match", `for=${requested} needs match=`);
68
+ }
69
+ const wantMatch = requested !== "idle";
70
+ const wantIdle = requested !== "match";
71
+ const idleMs = numberArg(args, "idleMs", WAIT_DEFAULTS.idleMs, WAIT_LIMITS.idleMs);
72
+ const pollMs = numberArg(args, "pollMs", WAIT_DEFAULTS.pollMs, WAIT_LIMITS.pollMs);
73
+ const timeoutMs = numberArg(args, "timeoutMs", WAIT_DEFAULTS.timeoutMs, WAIT_LIMITS.timeoutMs);
74
+ const started = clock.now();
75
+ if (waitViaBus) {
76
+ const reply = await waitViaBus();
77
+ // `gone` is filtered upstream: a pane that vanished mid-wait is reported by
78
+ // the polling path, which has the error message for it.
79
+ if (reply && reply.reason !== "gone") {
80
+ const at = clock.now();
81
+ return waitResult(reply.reason, {
82
+ session,
83
+ pane,
84
+ text: reply.screen,
85
+ args,
86
+ started,
87
+ at,
88
+ polls: 1,
89
+ changes: 0,
90
+ idleMs,
91
+ });
92
+ }
93
+ }
94
+ let previous = null;
95
+ let lastChangeAt = started;
96
+ let polls = 0;
97
+ let changes = 0;
98
+ for (;;) {
99
+ const dumped = await client.dumpPane({
100
+ session,
101
+ paneId: pane.id,
102
+ full: isTrue(args.full),
103
+ });
104
+ polls++;
105
+ const text = dumped.text;
106
+ const screen = normalizeScreen(text);
107
+ const at = clock.now();
108
+ const ctx = {
109
+ session,
110
+ pane,
111
+ text,
112
+ args,
113
+ started,
114
+ at,
115
+ polls,
116
+ changes,
117
+ idleMs,
118
+ };
119
+ if (wantMatch && matcher && matcher(screen))
120
+ return waitResult("match", ctx);
121
+ if (previous === null) {
122
+ previous = screen;
123
+ lastChangeAt = at;
124
+ }
125
+ else if (screen !== previous) {
126
+ previous = screen;
127
+ lastChangeAt = at;
128
+ changes++;
129
+ ctx.changes = changes;
130
+ }
131
+ else if (wantIdle && at - lastChangeAt >= idleMs) {
132
+ return waitResult("idle", ctx);
133
+ }
134
+ if (at - started >= timeoutMs)
135
+ return waitResult("timeout", ctx);
136
+ await clock.sleep(pollMs);
137
+ }
138
+ }
@@ -0,0 +1,21 @@
1
+ import { type GitClient } from "../git.js";
2
+ import type { ZellijClient } from "../zellij/client.js";
3
+ import type { OpsResult } from "./types.js";
4
+ export type PeerWorktree = {
5
+ path: string;
6
+ branch: string;
7
+ root: string;
8
+ created: boolean;
9
+ };
10
+ /**
11
+ * Give a peer its own branch and working directory. Reuses the worktree when
12
+ * one already sits at the target path, so spawn stays idempotent.
13
+ */
14
+ export declare function ensurePeerWorktree(git: GitClient, args: Record<string, unknown>, env?: NodeJS.ProcessEnv): Promise<PeerWorktree>;
15
+ /** List the repo's worktrees, annotated with the panes working in each. */
16
+ export declare function listPeerWorktrees(git: GitClient, client: ZellijClient, args: Record<string, unknown>): Promise<OpsResult>;
17
+ /**
18
+ * Remove a worktree. Refuses the main worktree, a worktree a pane is still
19
+ * working in, and one with uncommitted changes — `force` overrides the last two.
20
+ */
21
+ export declare function removePeerWorktree(git: GitClient, client: ZellijClient, args: Record<string, unknown>, env?: NodeJS.ProcessEnv): Promise<OpsResult>;
@@ -0,0 +1,144 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ import { ZellijError } from "../errors.js";
3
+ import { defaultWorktreeRoot, normalizeRepoPath, worktreeDirName, } from "../git.js";
4
+ import { isTrue, optionalString } from "./util.js";
5
+ function startDir(args) {
6
+ return optionalString(args.cwd) ?? process.cwd();
7
+ }
8
+ function worktreeRootFor(args, repoRoot, env) {
9
+ const explicit = optionalString(args.worktreeRoot) ??
10
+ (env.ZSWARM_WORKTREE_ROOT?.trim() || null);
11
+ if (!explicit)
12
+ return defaultWorktreeRoot(repoRoot);
13
+ return isAbsolute(explicit) ? explicit : join(repoRoot, explicit);
14
+ }
15
+ function findByPath(trees, path) {
16
+ const wanted = normalizeRepoPath(path);
17
+ return trees.find((t) => normalizeRepoPath(t.path) === wanted) ?? null;
18
+ }
19
+ /** Panes whose cwd sits inside a worktree — the agents that would lose the floor. */
20
+ function panesIn(panes, path) {
21
+ const prefix = `${normalizeRepoPath(path)}/`;
22
+ return panes
23
+ .filter((p) => {
24
+ if (!p.cwd)
25
+ return false;
26
+ const cwd = `${normalizeRepoPath(p.cwd)}/`;
27
+ return cwd === prefix || cwd.startsWith(prefix);
28
+ })
29
+ .map((p) => p.id);
30
+ }
31
+ /**
32
+ * Give a peer its own branch and working directory. Reuses the worktree when
33
+ * one already sits at the target path, so spawn stays idempotent.
34
+ */
35
+ export async function ensurePeerWorktree(git, args, env = process.env) {
36
+ const branch = optionalString(args.worktree);
37
+ if (!branch) {
38
+ throw new ZellijError("bad_arg", "worktree must be a branch name");
39
+ }
40
+ const repoRoot = await git.repoRoot(startDir(args));
41
+ const path = join(worktreeRootFor(args, repoRoot, env), worktreeDirName(branch));
42
+ const existing = findByPath(await git.listWorktrees(repoRoot), path);
43
+ if (existing) {
44
+ if (existing.branch && existing.branch !== branch) {
45
+ throw new ZellijError("worktree_conflict", `${path} already holds branch ${existing.branch}, not ${branch}`);
46
+ }
47
+ return { path: existing.path, branch, root: repoRoot, created: false };
48
+ }
49
+ await git.addWorktree({
50
+ root: repoRoot,
51
+ path,
52
+ branch,
53
+ baseRef: optionalString(args.baseRef),
54
+ createBranch: !(await git.branchExists(repoRoot, branch)),
55
+ });
56
+ return { path, branch, root: repoRoot, created: true };
57
+ }
58
+ /** List the repo's worktrees, annotated with the panes working in each. */
59
+ export async function listPeerWorktrees(git, client, args) {
60
+ const repoRoot = await git.repoRoot(startDir(args));
61
+ const trees = await git.listWorktrees(repoRoot);
62
+ let panes = [];
63
+ try {
64
+ const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
65
+ panes = await client.listPanes(session);
66
+ }
67
+ catch {
68
+ // Worktrees are useful to list even with no live Zellij session.
69
+ }
70
+ return {
71
+ ok: true,
72
+ data: {
73
+ repo: repoRoot,
74
+ worktrees: trees.map((t) => ({
75
+ path: t.path,
76
+ branch: t.branch,
77
+ main: normalizeRepoPath(t.path) === normalizeRepoPath(repoRoot),
78
+ detached: t.detached,
79
+ locked: t.locked,
80
+ panes: panesIn(panes, t.path),
81
+ })),
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * Remove a worktree. Refuses the main worktree, a worktree a pane is still
87
+ * working in, and one with uncommitted changes — `force` overrides the last two.
88
+ */
89
+ export async function removePeerWorktree(git, client, args, env = process.env) {
90
+ const repoRoot = await git.repoRoot(startDir(args));
91
+ const trees = await git.listWorktrees(repoRoot);
92
+ const force = isTrue(args.force);
93
+ const wantedPath = optionalString(args.path);
94
+ const wantedBranch = optionalString(args.branch) ?? optionalString(args.worktree);
95
+ if (!wantedPath && !wantedBranch) {
96
+ throw new ZellijError("missing_target", "path or branch required");
97
+ }
98
+ let target = null;
99
+ if (wantedPath) {
100
+ const abs = isAbsolute(wantedPath)
101
+ ? wantedPath
102
+ : join(worktreeRootFor(args, repoRoot, env), wantedPath);
103
+ target = findByPath(trees, abs) ?? findByPath(trees, wantedPath);
104
+ }
105
+ else if (wantedBranch) {
106
+ const matches = trees.filter((t) => t.branch === wantedBranch);
107
+ if (matches.length > 1) {
108
+ throw new ZellijError("worktree_ambiguous", `multiple worktrees on branch ${wantedBranch}; pass path=`);
109
+ }
110
+ target = matches[0] ?? null;
111
+ }
112
+ if (!target) {
113
+ throw new ZellijError("worktree_not_found", `no worktree for ${wantedPath ?? wantedBranch}`);
114
+ }
115
+ if (normalizeRepoPath(target.path) === normalizeRepoPath(repoRoot)) {
116
+ throw new ZellijError("worktree_is_main", "refusing to remove the main worktree");
117
+ }
118
+ let occupants = [];
119
+ try {
120
+ const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
121
+ occupants = panesIn(await client.listPanes(session), target.path);
122
+ }
123
+ catch {
124
+ // No live session means nobody is working in it.
125
+ }
126
+ if (occupants.length > 0 && !force) {
127
+ throw new ZellijError("worktree_busy", `${occupants.join(", ")} still working in ${target.path}; close the pane or pass force=true`);
128
+ }
129
+ const dirty = await git.isDirty(target.path);
130
+ if (dirty && !force) {
131
+ throw new ZellijError("worktree_dirty", `${target.path} has uncommitted changes; commit them or pass force=true`);
132
+ }
133
+ await git.removeWorktree({ root: repoRoot, path: target.path, force });
134
+ return {
135
+ ok: true,
136
+ data: {
137
+ repo: repoRoot,
138
+ removed: target.path,
139
+ branch: target.branch,
140
+ wasDirty: dirty,
141
+ evicted: occupants,
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,15 @@
1
+ export type Policy = {
2
+ readOnly: boolean;
3
+ allowPanes: string[] | null;
4
+ denyPanes: string[];
5
+ allowSpawn: boolean;
6
+ allowClose: boolean;
7
+ allowWorktreeRemove: boolean;
8
+ };
9
+ export declare function loadPolicy(env?: NodeJS.ProcessEnv): Policy;
10
+ export declare function isWriteOp(op: string): boolean;
11
+ export declare function assertOpAllowed(policy: Policy, op: string): void;
12
+ export declare function assertPaneAllowed(policy: Policy, pane: {
13
+ id: string;
14
+ title: string;
15
+ }, op: string): void;
package/dist/policy.js ADDED
@@ -0,0 +1,75 @@
1
+ import { ZellijError } from "./errors.js";
2
+ const WRITE_OPS = new Set([
3
+ "send",
4
+ "broadcast",
5
+ "keys",
6
+ "interrupt",
7
+ "spawn",
8
+ "close",
9
+ "unworktree",
10
+ ]);
11
+ /** Explicit disable tokens for ZSWARM_ALLOW_* switches. */
12
+ function isDisabled(raw) {
13
+ const v = (raw ?? "").trim().toLowerCase();
14
+ return v === "0" || v === "false" || v === "no";
15
+ }
16
+ /** Affirmative tokens for ZSWARM_READONLY. */
17
+ function isEnabled(raw) {
18
+ const v = (raw ?? "").trim().toLowerCase();
19
+ return v === "1" || v === "true" || v === "yes";
20
+ }
21
+ function parseList(raw) {
22
+ const text = (raw ?? "").trim();
23
+ if (!text)
24
+ return [];
25
+ return text
26
+ .split(",")
27
+ .map((s) => s.trim())
28
+ .filter(Boolean);
29
+ }
30
+ export function loadPolicy(env = process.env) {
31
+ const allowRaw = (env.ZSWARM_ALLOW_PANES ?? "").trim();
32
+ return {
33
+ readOnly: isEnabled(env.ZSWARM_READONLY),
34
+ allowPanes: allowRaw ? parseList(allowRaw) : null,
35
+ denyPanes: parseList(env.ZSWARM_DENY_PANES),
36
+ allowSpawn: !isDisabled(env.ZSWARM_ALLOW_SPAWN),
37
+ allowClose: !isDisabled(env.ZSWARM_ALLOW_CLOSE),
38
+ allowWorktreeRemove: !isDisabled(env.ZSWARM_ALLOW_WORKTREE_REMOVE),
39
+ };
40
+ }
41
+ export function isWriteOp(op) {
42
+ return WRITE_OPS.has(op);
43
+ }
44
+ function deny(envVar, detail) {
45
+ throw new ZellijError("policy_denied", `${detail}; denied by ${envVar}`);
46
+ }
47
+ export function assertOpAllowed(policy, op) {
48
+ if (policy.readOnly && isWriteOp(op)) {
49
+ deny("ZSWARM_READONLY", `op "${op}" is a write`);
50
+ }
51
+ if (op === "spawn" && !policy.allowSpawn) {
52
+ deny("ZSWARM_ALLOW_SPAWN", `op "spawn" disabled`);
53
+ }
54
+ if (op === "close" && !policy.allowClose) {
55
+ deny("ZSWARM_ALLOW_CLOSE", `op "close" disabled`);
56
+ }
57
+ if (op === "unworktree" && !policy.allowWorktreeRemove) {
58
+ deny("ZSWARM_ALLOW_WORKTREE_REMOVE", `op "unworktree" disabled`);
59
+ }
60
+ }
61
+ function paneMatches(entry, pane) {
62
+ if (pane.id === entry)
63
+ return true;
64
+ const needle = entry.toLowerCase();
65
+ return pane.title.toLowerCase().includes(needle);
66
+ }
67
+ export function assertPaneAllowed(policy, pane, op) {
68
+ if (policy.denyPanes.some((entry) => paneMatches(entry, pane))) {
69
+ deny("ZSWARM_DENY_PANES", `op "${op}" on pane ${pane.id} ("${pane.title}")`);
70
+ }
71
+ if (policy.allowPanes !== null &&
72
+ !policy.allowPanes.some((entry) => paneMatches(entry, pane))) {
73
+ deny("ZSWARM_ALLOW_PANES", `op "${op}" on pane ${pane.id} ("${pane.title}") not in allowlist`);
74
+ }
75
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One description of the zswarm surface. The MCP tool schema, the CLI flags,
3
+ * and the CLI help text are all generated from it, so they cannot drift apart.
4
+ */
5
+ export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus"];
6
+ export type OpName = (typeof OP_NAMES)[number];
7
+ /** Ops that address an existing pane through `to`. */
8
+ export declare const TARGET_OPS: readonly OpName[];
9
+ export type ParamType = "string" | "number" | "boolean" | "stringOrArray";
10
+ export type ParamSpec = {
11
+ name: string;
12
+ type: ParamType;
13
+ /** CLI flags; empty means the parameter is reachable through MCP only. */
14
+ flags: string[];
15
+ /** Repeatable flags collect into an array (`--key a --key b`). */
16
+ repeat?: boolean;
17
+ values?: readonly string[];
18
+ description: string;
19
+ };
20
+ export declare const PARAMS: readonly ParamSpec[];
21
+ /** JSON Schema for the single `zswarm` MCP tool. */
22
+ export declare function mcpInputSchema(): Record<string, unknown>;
23
+ export declare const MCP_TOOL_DESCRIPTION: string;
24
+ export declare function cliUsage(): string;
25
+ /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
26
+ export declare function parseCliArgv(argv: string[]): Record<string, unknown>;