@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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { dumpMaxChars, optionalString, truncateDumpText } from "./util.js";
|
|
3
|
+
async function session(client, args) {
|
|
4
|
+
const resolved = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
5
|
+
return resolved.session;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Give a pane (or its tab) a stable name, so peers can be addressed as
|
|
9
|
+
* `reviewer` instead of whatever id Zellij handed out.
|
|
10
|
+
*/
|
|
11
|
+
export async function renameTarget(client, args) {
|
|
12
|
+
const name = optionalString(args.name);
|
|
13
|
+
if (!name)
|
|
14
|
+
throw new ZellijError("missing_name", "name required");
|
|
15
|
+
const sess = await session(client, args);
|
|
16
|
+
const tabKey = optionalString(args.tab);
|
|
17
|
+
if (tabKey) {
|
|
18
|
+
const tab = client.resolveTab(await client.listTabs(sess), tabKey);
|
|
19
|
+
const renamed = await client.renameTab({
|
|
20
|
+
session: sess,
|
|
21
|
+
tabId: tab.id,
|
|
22
|
+
name,
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
data: { session: sess, tab: renamed.tabId, was: tab.name, name },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const to = optionalString(args.to);
|
|
30
|
+
if (!to)
|
|
31
|
+
throw new ZellijError("missing_peer", "to (or tab) required");
|
|
32
|
+
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
33
|
+
await client.renamePane({ session: sess, paneId: pane.id, name });
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
data: { session: sess, to: pane.id, was: pane.title, name },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function focusTarget(client, args) {
|
|
40
|
+
const to = optionalString(args.to);
|
|
41
|
+
if (!to)
|
|
42
|
+
throw new ZellijError("missing_peer", "to required");
|
|
43
|
+
const sess = await session(client, args);
|
|
44
|
+
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
45
|
+
// Zellij exits non-zero when asked to focus the already-focused pane.
|
|
46
|
+
if (pane.focused) {
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
data: {
|
|
50
|
+
session: sess,
|
|
51
|
+
focused: pane.id,
|
|
52
|
+
title: pane.title,
|
|
53
|
+
already: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const focused = await client.focusPane({ session: sess, paneId: pane.id });
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
data: { session: sess, focused: focused.paneId, title: pane.title },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function listTabsOp(client, args) {
|
|
64
|
+
const sess = await session(client, args);
|
|
65
|
+
const tabs = await client.listTabs(sess);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
data: {
|
|
69
|
+
session: sess,
|
|
70
|
+
tabs: tabs.map((t) => ({
|
|
71
|
+
id: t.id,
|
|
72
|
+
name: t.name,
|
|
73
|
+
panes: t.panes,
|
|
74
|
+
active: t.active,
|
|
75
|
+
layout: t.layout,
|
|
76
|
+
})),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The session's current layout as KDL — a spawnable description of the crew. */
|
|
81
|
+
export async function dumpLayoutOp(client, args) {
|
|
82
|
+
const sess = await session(client, args);
|
|
83
|
+
const layout = await client.dumpLayout(sess);
|
|
84
|
+
const max = dumpMaxChars(args);
|
|
85
|
+
const clipped = truncateDumpText(layout, max, "head");
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
data: {
|
|
89
|
+
session: sess,
|
|
90
|
+
layout: clipped.text,
|
|
91
|
+
truncated: clipped.truncated,
|
|
92
|
+
chars: clipped.chars,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Collapse several peers into one stack so a big crew stays readable. */
|
|
97
|
+
export async function stackTargets(client, args) {
|
|
98
|
+
const list = optionalString(args.to);
|
|
99
|
+
if (!list)
|
|
100
|
+
throw new ZellijError("missing_peer", "to (comma list) required");
|
|
101
|
+
const sess = await session(client, args);
|
|
102
|
+
const panes = await client.listPanes(sess);
|
|
103
|
+
const ids = list
|
|
104
|
+
.split(",")
|
|
105
|
+
.map((k) => k.trim())
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((key) => client.resolvePane(panes, key).id);
|
|
108
|
+
const unique = [...new Set(ids)];
|
|
109
|
+
const stacked = await client.stackPanes({ session: sess, paneIds: unique });
|
|
110
|
+
return { ok: true, data: { session: sess, stacked: stacked.paneIds } };
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type GitClient } from "../git.js";
|
|
2
|
+
import type { Clock, OpsResult } from "./types.js";
|
|
3
|
+
/** Show what a peer worktree changed vs HEAD (untracked included). */
|
|
4
|
+
export declare function peerDiff(git: GitClient, args: Record<string, unknown>): Promise<OpsResult>;
|
|
5
|
+
/**
|
|
6
|
+
* Commit all changes in a peer worktree so the pane can close and resume.
|
|
7
|
+
* A clean tree is success with committed:false, nothingToCommit:true.
|
|
8
|
+
*/
|
|
9
|
+
export declare function peerCheckpoint(git: GitClient, args: Record<string, unknown>, clock: Clock): Promise<OpsResult>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { normalizeRepoPath, } from "../git.js";
|
|
3
|
+
import { dumpMaxChars, isTrue, optionalString } from "./util.js";
|
|
4
|
+
function findByPath(trees, path) {
|
|
5
|
+
const wanted = normalizeRepoPath(path);
|
|
6
|
+
return trees.find((t) => normalizeRepoPath(t.path) === wanted) ?? null;
|
|
7
|
+
}
|
|
8
|
+
function findByBranch(trees, branch) {
|
|
9
|
+
const matches = trees.filter((t) => t.branch === branch);
|
|
10
|
+
if (matches.length === 0) {
|
|
11
|
+
throw new ZellijError("worktree_not_found", `no worktree for ${branch}`);
|
|
12
|
+
}
|
|
13
|
+
if (matches.length > 1) {
|
|
14
|
+
throw new ZellijError("worktree_ambiguous", `multiple worktrees on branch ${branch}; pass path=`);
|
|
15
|
+
}
|
|
16
|
+
return matches[0];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the peer working directory: args.path, else the worktree on
|
|
20
|
+
* args.branch, else args.cwd, else process.cwd().
|
|
21
|
+
*/
|
|
22
|
+
async function resolvePeerDir(git, args) {
|
|
23
|
+
const pathArg = optionalString(args.path);
|
|
24
|
+
const branchArg = optionalString(args.branch);
|
|
25
|
+
const cwd = optionalString(args.cwd) ?? process.cwd();
|
|
26
|
+
if (pathArg) {
|
|
27
|
+
const root = await git.repoRoot(pathArg);
|
|
28
|
+
const match = findByPath(await git.listWorktrees(root), pathArg);
|
|
29
|
+
return { path: match?.path ?? pathArg, branch: match?.branch ?? null };
|
|
30
|
+
}
|
|
31
|
+
if (branchArg) {
|
|
32
|
+
const root = await git.repoRoot(cwd);
|
|
33
|
+
const match = findByBranch(await git.listWorktrees(root), branchArg);
|
|
34
|
+
return { path: match.path, branch: match.branch };
|
|
35
|
+
}
|
|
36
|
+
const root = await git.repoRoot(cwd);
|
|
37
|
+
const match = findByPath(await git.listWorktrees(root), cwd);
|
|
38
|
+
return { path: match?.path ?? cwd, branch: match?.branch ?? null };
|
|
39
|
+
}
|
|
40
|
+
/** Show what a peer worktree changed vs HEAD (untracked included). */
|
|
41
|
+
export async function peerDiff(git, args) {
|
|
42
|
+
const { path, branch } = await resolvePeerDir(git, args);
|
|
43
|
+
const max = dumpMaxChars(args);
|
|
44
|
+
const statOnly = isTrue(args.stat);
|
|
45
|
+
const stat = await git.diffStat(path);
|
|
46
|
+
if (statOnly) {
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
data: {
|
|
50
|
+
path,
|
|
51
|
+
branch,
|
|
52
|
+
stat,
|
|
53
|
+
patch: "",
|
|
54
|
+
truncated: false,
|
|
55
|
+
chars: 0,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const { patch, truncated, chars } = await git.diffPatch(path, max);
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
data: { path, branch, stat, patch, truncated, chars },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Commit all changes in a peer worktree so the pane can close and resume.
|
|
67
|
+
* A clean tree is success with committed:false, nothingToCommit:true.
|
|
68
|
+
*/
|
|
69
|
+
export async function peerCheckpoint(git, args, clock) {
|
|
70
|
+
const { path, branch } = await resolvePeerDir(git, args);
|
|
71
|
+
const message = optionalString(args.message) ??
|
|
72
|
+
`zswarm checkpoint ${new Date(clock.now()).toISOString()}`;
|
|
73
|
+
if (!(await git.isDirty(path))) {
|
|
74
|
+
const sha = await git.headSha(path);
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
data: {
|
|
78
|
+
path,
|
|
79
|
+
branch,
|
|
80
|
+
sha,
|
|
81
|
+
committed: false,
|
|
82
|
+
nothingToCommit: true,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
let sha;
|
|
87
|
+
try {
|
|
88
|
+
sha = await git.commitAll(path, message);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// git's own message here is long and buried; name the actual fix.
|
|
92
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
93
|
+
if (/Author identity unknown|unable to auto-detect email/i.test(text)) {
|
|
94
|
+
throw new ZellijError("git_identity", "git has no author identity on this machine; set user.name and user.email (git config --global), or export GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL and GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL before checkpointing");
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
data: {
|
|
101
|
+
path,
|
|
102
|
+
branch,
|
|
103
|
+
sha,
|
|
104
|
+
committed: true,
|
|
105
|
+
nothingToCommit: false,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { StateStore } from "../state.js";
|
|
2
|
+
import type { Clock, OpsResult } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Post to a channel. Counts are cumulative and survive across processes, so a
|
|
5
|
+
* crew can rendezvous without any of them staying resident.
|
|
6
|
+
*/
|
|
7
|
+
export declare function postSignal(state: StateStore, args: Record<string, unknown>, clock: Clock): OpsResult;
|
|
8
|
+
export declare function listSignals(state: StateStore): OpsResult;
|
|
9
|
+
/** Block until a channel reaches `count` posts, or the deadline passes. */
|
|
10
|
+
export declare function awaitSignal(state: StateStore, args: Record<string, unknown>, clock: Clock): Promise<OpsResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { isTrue, numberArg, optionalString } from "./util.js";
|
|
3
|
+
const AWAIT_LIMITS = {
|
|
4
|
+
pollMs: { min: 100, max: 30_000 },
|
|
5
|
+
timeoutMs: { min: 1_000, max: 900_000 },
|
|
6
|
+
count: { min: 1, max: 1_000 },
|
|
7
|
+
};
|
|
8
|
+
function channelOf(args) {
|
|
9
|
+
const channel = optionalString(args.channel);
|
|
10
|
+
if (!channel)
|
|
11
|
+
throw new ZellijError("missing_channel", "channel required");
|
|
12
|
+
return channel;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Post to a channel. Counts are cumulative and survive across processes, so a
|
|
16
|
+
* crew can rendezvous without any of them staying resident.
|
|
17
|
+
*/
|
|
18
|
+
export function postSignal(state, args, clock) {
|
|
19
|
+
if (isTrue(args.clear)) {
|
|
20
|
+
const channel = optionalString(args.channel);
|
|
21
|
+
state.clearSignal(channel);
|
|
22
|
+
return { ok: true, data: { cleared: channel ?? "all" } };
|
|
23
|
+
}
|
|
24
|
+
const channel = channelOf(args);
|
|
25
|
+
const posted = state.postSignal(channel, optionalString(args.payload), clock.now());
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
data: { channel, count: posted.count, at: posted.at, payload: posted.last },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function listSignals(state) {
|
|
32
|
+
const all = state.readSignals();
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
data: {
|
|
36
|
+
channels: Object.entries(all)
|
|
37
|
+
.map(([channel, v]) => ({
|
|
38
|
+
channel,
|
|
39
|
+
count: v.count,
|
|
40
|
+
at: v.at,
|
|
41
|
+
last: v.last ?? null,
|
|
42
|
+
}))
|
|
43
|
+
.sort((a, b) => a.channel.localeCompare(b.channel)),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Block until a channel reaches `count` posts, or the deadline passes. */
|
|
48
|
+
export async function awaitSignal(state, args, clock) {
|
|
49
|
+
const channel = channelOf(args);
|
|
50
|
+
const count = numberArg(args, "count", 1, AWAIT_LIMITS.count);
|
|
51
|
+
const pollMs = numberArg(args, "pollMs", 500, AWAIT_LIMITS.pollMs);
|
|
52
|
+
const timeoutMs = numberArg(args, "timeoutMs", 60_000, AWAIT_LIMITS.timeoutMs);
|
|
53
|
+
const started = clock.now();
|
|
54
|
+
for (;;) {
|
|
55
|
+
const current = state.readSignals()[channel];
|
|
56
|
+
const have = current?.count ?? 0;
|
|
57
|
+
const at = clock.now();
|
|
58
|
+
if (have >= count) {
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
data: {
|
|
62
|
+
channel,
|
|
63
|
+
reason: "signalled",
|
|
64
|
+
count: have,
|
|
65
|
+
wanted: count,
|
|
66
|
+
waitedMs: at - started,
|
|
67
|
+
last: current?.last ?? null,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (at - started >= timeoutMs) {
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
data: {
|
|
75
|
+
channel,
|
|
76
|
+
reason: "timeout",
|
|
77
|
+
count: have,
|
|
78
|
+
wanted: count,
|
|
79
|
+
waitedMs: at - started,
|
|
80
|
+
last: current?.last ?? null,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
await clock.sleep(pollMs);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type GitClient } from "../git.js";
|
|
2
|
+
import type { ZellijClient } from "../zellij/client.js";
|
|
3
|
+
import type { OpsResult } from "./types.js";
|
|
4
|
+
/** Open a new pane (or tab) and report which pane it became. */
|
|
5
|
+
export declare function spawnPane(client: ZellijClient, args: Record<string, unknown>, git?: GitClient): Promise<OpsResult>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { createGitClient } from "../git.js";
|
|
3
|
+
import { tokenizeCommand } from "../keys.js";
|
|
4
|
+
import { isTrue, isVerbose, optionalString, paneViewFull } from "./util.js";
|
|
5
|
+
import { ensurePeerWorktree } from "./worktree.js";
|
|
6
|
+
function paneDirection(value) {
|
|
7
|
+
const dir = optionalString(value);
|
|
8
|
+
if (!dir)
|
|
9
|
+
return null;
|
|
10
|
+
const lowered = dir.toLowerCase();
|
|
11
|
+
if (!["right", "left", "up", "down"].includes(lowered)) {
|
|
12
|
+
throw new ZellijError("bad_arg", "direction must be right|left|up|down");
|
|
13
|
+
}
|
|
14
|
+
return lowered;
|
|
15
|
+
}
|
|
16
|
+
/** Open a new pane (or tab) and report which pane it became. */
|
|
17
|
+
export async function spawnPane(client, args, git) {
|
|
18
|
+
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
19
|
+
const command = tokenizeCommand(args.command ?? args.cmd);
|
|
20
|
+
// A worktree peer works on its own branch, in its own directory.
|
|
21
|
+
const worktree = optionalString(args.worktree)
|
|
22
|
+
? await ensurePeerWorktree(git ?? createGitClient(), args)
|
|
23
|
+
: null;
|
|
24
|
+
const cwd = worktree ? worktree.path : optionalString(args.cwd);
|
|
25
|
+
const name = optionalString(args.name) ?? worktree?.branch ?? null;
|
|
26
|
+
const closeOnExit = isTrue(args.closeOnExit);
|
|
27
|
+
const before = new Set((await client.listPanes(session)).map((p) => p.id));
|
|
28
|
+
let paneId = null;
|
|
29
|
+
let tabId = null;
|
|
30
|
+
if (isTrue(args.newTab) || args.tab === true) {
|
|
31
|
+
const created = await client.newTab({
|
|
32
|
+
session,
|
|
33
|
+
command,
|
|
34
|
+
cwd,
|
|
35
|
+
name,
|
|
36
|
+
layout: optionalString(args.layout),
|
|
37
|
+
closeOnExit,
|
|
38
|
+
});
|
|
39
|
+
tabId = created.tabId;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const input = {
|
|
43
|
+
session,
|
|
44
|
+
command,
|
|
45
|
+
cwd,
|
|
46
|
+
name,
|
|
47
|
+
direction: paneDirection(args.direction),
|
|
48
|
+
floating: isTrue(args.floating),
|
|
49
|
+
closeOnExit,
|
|
50
|
+
width: optionalString(args.width),
|
|
51
|
+
height: optionalString(args.height),
|
|
52
|
+
};
|
|
53
|
+
if (typeof args.tabId === "number")
|
|
54
|
+
input.tabId = args.tabId;
|
|
55
|
+
// `tab` as a name places the pane in that tab; ids come via tabId.
|
|
56
|
+
const tabName = optionalString(args.tab);
|
|
57
|
+
if (input.tabId === undefined && tabName) {
|
|
58
|
+
input.tabId = client.resolveTab(await client.listTabs(session), tabName).id;
|
|
59
|
+
}
|
|
60
|
+
paneId = (await client.newPane(input)).paneId;
|
|
61
|
+
}
|
|
62
|
+
// new-tab reports a tab id, and a command that exits at once can vanish, so
|
|
63
|
+
// fall back to diffing the pane list against the pre-spawn snapshot.
|
|
64
|
+
const panes = await client.listPanes(session);
|
|
65
|
+
let resolvedBy = paneId
|
|
66
|
+
? "stdout"
|
|
67
|
+
: "unresolved";
|
|
68
|
+
let pane = paneId ? (panes.find((p) => p.id === paneId) ?? null) : null;
|
|
69
|
+
if (!paneId) {
|
|
70
|
+
const fresh = panes.filter((p) => !p.isPlugin && !before.has(p.id));
|
|
71
|
+
const inTab = tabId === null ? fresh : fresh.filter((p) => p.tabId === tabId);
|
|
72
|
+
const candidates = inTab.length > 0 ? inTab : fresh;
|
|
73
|
+
if (candidates.length === 1) {
|
|
74
|
+
pane = candidates[0];
|
|
75
|
+
paneId = pane.id;
|
|
76
|
+
resolvedBy = "diff";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const data = {
|
|
80
|
+
session,
|
|
81
|
+
paneId,
|
|
82
|
+
resolvedBy,
|
|
83
|
+
live: pane !== null,
|
|
84
|
+
command: command.length > 0 ? command : null,
|
|
85
|
+
};
|
|
86
|
+
if (worktree) {
|
|
87
|
+
data.worktree = {
|
|
88
|
+
path: worktree.path,
|
|
89
|
+
branch: worktree.branch,
|
|
90
|
+
created: worktree.created,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (tabId !== null)
|
|
94
|
+
data.tabId = tabId;
|
|
95
|
+
if (pane && isVerbose(args))
|
|
96
|
+
data.pane = paneViewFull(pane);
|
|
97
|
+
return { ok: true, data };
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type HarnessProfile } from "../harness.js";
|
|
2
|
+
import type { ZellijClient } from "../zellij/client.js";
|
|
3
|
+
import type { ZellijPane } from "../zellij/panes.js";
|
|
4
|
+
import type { Clock, OpsResult } from "./types.js";
|
|
5
|
+
/** `running` only appears when sampling is off — busy and idle are indistinguishable then. */
|
|
6
|
+
export type PeerState = "busy" | "waiting" | "idle" | "exited" | "running";
|
|
7
|
+
/** Pane list already in hand, so status does not re-fetch what the caller has. */
|
|
8
|
+
export type StatusSource = {
|
|
9
|
+
session: string;
|
|
10
|
+
panes: ZellijPane[];
|
|
11
|
+
source: "plugin" | "zellij";
|
|
12
|
+
/**
|
|
13
|
+
* Batched screen reader. Returns null when it cannot serve the whole set, and
|
|
14
|
+
* status falls back to one `dump-screen` per pane.
|
|
15
|
+
*/
|
|
16
|
+
readScreens?: (paneIds: string[]) => Promise<Map<string, string> | null>;
|
|
17
|
+
/** "Has this moved since you last asked?", answered without a sample gap. */
|
|
18
|
+
readChanged?: (paneIds: string[]) => Promise<Map<string, {
|
|
19
|
+
changed: boolean;
|
|
20
|
+
first: boolean;
|
|
21
|
+
screen: string;
|
|
22
|
+
}> | null>;
|
|
23
|
+
};
|
|
24
|
+
export declare function lastLine(screen: string): string;
|
|
25
|
+
export declare function classify(input: {
|
|
26
|
+
exited: boolean;
|
|
27
|
+
before: string;
|
|
28
|
+
after: string;
|
|
29
|
+
/** The pane's harness; its waiting patterns name the prompts worth blocking on. */
|
|
30
|
+
profile?: HarnessProfile | null;
|
|
31
|
+
}): PeerState;
|
|
32
|
+
/**
|
|
33
|
+
* Sample every pane twice and say who is working, who is stuck on a prompt,
|
|
34
|
+
* and who is free — the routing question `list` cannot answer.
|
|
35
|
+
*/
|
|
36
|
+
export declare function peerStatus(client: ZellijClient, args: Record<string, unknown>, clock: Clock, supplied?: StatusSource | null): Promise<OpsResult>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { resolveHarness } from "../harness.js";
|
|
2
|
+
import { isTrue, normalizeScreen, numberArg, optionalString } from "./util.js";
|
|
3
|
+
/**
|
|
4
|
+
* Legacy last-line prompt shapes for callers without a profile. A term counts
|
|
5
|
+
* only when it is a question, not chrome: a y/n form, a trailing `?`, or the
|
|
6
|
+
* canonical "press enter to continue" pause. A bare "confirm" or "continue"
|
|
7
|
+
* sits in status bars ("select enter confirm") and must not qualify.
|
|
8
|
+
*/
|
|
9
|
+
const QUESTION = /(\(y\/n\)|\[y\/n\]|\(yes\/no\)|\[y\/n\/a\]|password:|passphrase:|(?:continue|proceed|overwrite|confirm)\?|press\s+enter\s+to\s+continue)/i;
|
|
10
|
+
/**
|
|
11
|
+
* How many trailing non-empty lines `status` inspects for a named prompt. A
|
|
12
|
+
* full-screen TUI hides its prompt under chrome, so the last line alone is
|
|
13
|
+
* the wrong place to look — measured live, gemini's question sat 4 lines
|
|
14
|
+
* above "esc to cancel", and a six-option permission menu with wrapped
|
|
15
|
+
* lines put it 12 lines up. 24 clears the measured 12 with headroom for
|
|
16
|
+
* wider menus. Widening is safe for the named patterns because they are
|
|
17
|
+
* specific strings; the legacy last-line QUESTION fallback stays exactly
|
|
18
|
+
* where it is or it would fire on chrome.
|
|
19
|
+
*/
|
|
20
|
+
const PROMPT_WINDOW = 24;
|
|
21
|
+
export function lastLine(screen) {
|
|
22
|
+
const lines = screen.split("\n").filter((l) => l.trim());
|
|
23
|
+
return lines.length > 0 ? lines[lines.length - 1].trim() : "";
|
|
24
|
+
}
|
|
25
|
+
function trailingLines(screen, n) {
|
|
26
|
+
return screen
|
|
27
|
+
.replace(/\r/g, "")
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter((line) => line.length > 0)
|
|
31
|
+
.slice(-n);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* True when the trailing window holds a named approval prompt. The profile's
|
|
35
|
+
* waiting patterns run across the window; the legacy last-line QUESTION check
|
|
36
|
+
* stays as the conservative fallback for callers without a profile. Bias is
|
|
37
|
+
* toward false negatives: a hit must be prompt UI we can name, never a bare
|
|
38
|
+
* question mark or "confirm".
|
|
39
|
+
*/
|
|
40
|
+
function promptHolds(screen, profile) {
|
|
41
|
+
const lines = trailingLines(screen, PROMPT_WINDOW);
|
|
42
|
+
const patterns = profile?.waiting;
|
|
43
|
+
if (patterns && patterns.length > 0) {
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
for (const re of patterns) {
|
|
46
|
+
if (re.test(line))
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return QUESTION.test(lines[lines.length - 1] ?? "");
|
|
52
|
+
}
|
|
53
|
+
export function classify(input) {
|
|
54
|
+
if (input.exited)
|
|
55
|
+
return "exited";
|
|
56
|
+
if (input.before !== input.after)
|
|
57
|
+
return "busy";
|
|
58
|
+
return promptHolds(input.after, input.profile) ? "waiting" : "idle";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sample every pane twice and say who is working, who is stuck on a prompt,
|
|
62
|
+
* and who is free — the routing question `list` cannot answer.
|
|
63
|
+
*/
|
|
64
|
+
export async function peerStatus(client, args, clock, supplied) {
|
|
65
|
+
const session = supplied?.session ??
|
|
66
|
+
(await client.resolveSession(typeof args.session === "string" ? args.session : undefined)).session;
|
|
67
|
+
const panes = supplied?.panes ?? (await client.listPanes(session));
|
|
68
|
+
const source = supplied?.source ?? "zellij";
|
|
69
|
+
const only = optionalString(args.to);
|
|
70
|
+
const targets = only
|
|
71
|
+
? [client.resolvePane(panes, only)]
|
|
72
|
+
: panes.filter((p) => !p.isPlugin);
|
|
73
|
+
// sampleMs=0 asks for the cheap answer: who is alive, from state the plugin
|
|
74
|
+
// already holds. Below that, two samples too close together read as idle.
|
|
75
|
+
const requested = numberArg(args, "sampleMs", 400, { min: 0, max: 10_000 });
|
|
76
|
+
if (requested === 0) {
|
|
77
|
+
const peers = targets
|
|
78
|
+
.map((pane) => {
|
|
79
|
+
const entry = {
|
|
80
|
+
id: pane.id,
|
|
81
|
+
title: pane.title,
|
|
82
|
+
state: pane.exited ? "exited" : "running",
|
|
83
|
+
};
|
|
84
|
+
if (isTrue(args.verbose)) {
|
|
85
|
+
entry.command = pane.command ?? null;
|
|
86
|
+
entry.cwd = pane.cwd ?? null;
|
|
87
|
+
entry.tab = pane.tabName ?? null;
|
|
88
|
+
}
|
|
89
|
+
return entry;
|
|
90
|
+
})
|
|
91
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
92
|
+
// No `free`: without sampling there is no way to tell busy from idle.
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
data: { session, source, sampled: false, sampleMs: 0, peers },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const sampleMs = Math.max(50, requested);
|
|
99
|
+
const live = targets.filter((pane) => !pane.exited).map((pane) => pane.id);
|
|
100
|
+
// sinceLast trades the fixed 400ms window for "moved since your last call".
|
|
101
|
+
// The plugin remembers the previous screen, so there is no gap to wait out —
|
|
102
|
+
// but ask twice in quick succession and everything reads idle.
|
|
103
|
+
if (isTrue(args.sinceLast) && supplied?.readChanged) {
|
|
104
|
+
const changed = await supplied.readChanged(live);
|
|
105
|
+
if (changed) {
|
|
106
|
+
const peers = targets
|
|
107
|
+
.map((pane) => {
|
|
108
|
+
const row = changed.get(pane.id);
|
|
109
|
+
const state = pane.exited
|
|
110
|
+
? "exited"
|
|
111
|
+
: row?.changed
|
|
112
|
+
? "busy"
|
|
113
|
+
: promptHolds(row?.screen ?? "", resolveHarness(pane))
|
|
114
|
+
? "waiting"
|
|
115
|
+
: "idle";
|
|
116
|
+
const entry = {
|
|
117
|
+
id: pane.id,
|
|
118
|
+
title: pane.title,
|
|
119
|
+
state,
|
|
120
|
+
lastLine: lastLine(row?.screen ?? "").slice(0, 160),
|
|
121
|
+
};
|
|
122
|
+
// First sight has nothing to compare against, so idle is a guess.
|
|
123
|
+
if (row?.first)
|
|
124
|
+
entry.first = true;
|
|
125
|
+
return entry;
|
|
126
|
+
})
|
|
127
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
data: {
|
|
131
|
+
session,
|
|
132
|
+
source,
|
|
133
|
+
sampled: false,
|
|
134
|
+
sinceLast: true,
|
|
135
|
+
peers,
|
|
136
|
+
free: peers.filter((p) => p.state === "idle").map((p) => p.id),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* One batched read when the bus can serve it, otherwise a process per pane.
|
|
143
|
+
* Both paths normalize, which is what makes them comparable: the plugin pads
|
|
144
|
+
* lines to the terminal width and `dump-screen` does not.
|
|
145
|
+
*/
|
|
146
|
+
const sample = async () => {
|
|
147
|
+
const batched = supplied?.readScreens
|
|
148
|
+
? await supplied.readScreens(live)
|
|
149
|
+
: null;
|
|
150
|
+
if (batched) {
|
|
151
|
+
return new Map([...batched].map(([id, text]) => [id, normalizeScreen(text)]));
|
|
152
|
+
}
|
|
153
|
+
const screens = new Map();
|
|
154
|
+
for (const id of live) {
|
|
155
|
+
const dumped = await client.dumpPane({ session, paneId: id });
|
|
156
|
+
screens.set(id, normalizeScreen(dumped.text));
|
|
157
|
+
}
|
|
158
|
+
return screens;
|
|
159
|
+
};
|
|
160
|
+
const before = await sample();
|
|
161
|
+
await clock.sleep(sampleMs);
|
|
162
|
+
const afterScreens = await sample();
|
|
163
|
+
const peers = [];
|
|
164
|
+
for (const pane of targets) {
|
|
165
|
+
const first = before.get(pane.id) ?? "";
|
|
166
|
+
const after = pane.exited ? first : (afterScreens.get(pane.id) ?? "");
|
|
167
|
+
const state = classify({
|
|
168
|
+
exited: pane.exited,
|
|
169
|
+
before: first,
|
|
170
|
+
after,
|
|
171
|
+
profile: resolveHarness(pane),
|
|
172
|
+
});
|
|
173
|
+
const entry = {
|
|
174
|
+
id: pane.id,
|
|
175
|
+
title: pane.title,
|
|
176
|
+
state,
|
|
177
|
+
lastLine: lastLine(after).slice(0, 160),
|
|
178
|
+
};
|
|
179
|
+
if (isTrue(args.verbose)) {
|
|
180
|
+
entry.command = pane.command ?? null;
|
|
181
|
+
entry.cwd = pane.cwd ?? null;
|
|
182
|
+
entry.tab = pane.tabName ?? null;
|
|
183
|
+
}
|
|
184
|
+
peers.push(entry);
|
|
185
|
+
}
|
|
186
|
+
peers.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
187
|
+
const free = peers.filter((p) => p.state === "idle").map((p) => p.id);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
data: { session, source, sampled: true, sampleMs, peers, free },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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 { OpsResult } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* What is new in `cur` given the previously seen screen. The viewport scrolls,
|
|
7
|
+
* so the old bottom shows up as the new top: find the longest suffix of the
|
|
8
|
+
* previous screen that still heads the current one, and return the rest.
|
|
9
|
+
*/
|
|
10
|
+
export declare function diffScreens(prev: string | null, cur: string): {
|
|
11
|
+
text: string;
|
|
12
|
+
reset: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function cursorKey(session: string, paneId: string): string;
|
|
15
|
+
/** Read only what a pane printed since the last tail. */
|
|
16
|
+
export declare function tailPane(client: ZellijClient, state: StateStore, args: Record<string, unknown>, target: {
|
|
17
|
+
session: string;
|
|
18
|
+
pane: ZellijPane;
|
|
19
|
+
}): Promise<OpsResult>;
|