@zswarm/core 0.1.5 → 0.1.7
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/README.md +4 -1
- package/dist/exec.d.ts +47 -2
- package/dist/exec.js +249 -20
- package/dist/harness.js +12 -7
- package/dist/index.d.ts +8 -6
- package/dist/index.js +5 -4
- package/dist/ops/broadcast.js +1 -1
- package/dist/ops/bus.d.ts +6 -3
- package/dist/ops/bus.js +85 -35
- package/dist/ops/dispatch.d.ts +7 -1
- package/dist/ops/dispatch.js +235 -70
- package/dist/ops/guards.d.ts +2 -0
- package/dist/ops/guards.js +11 -1
- package/dist/ops/observation.d.ts +8 -0
- package/dist/ops/observation.js +26 -0
- package/dist/ops/panes.js +4 -4
- package/dist/ops/routing.d.ts +13 -0
- package/dist/ops/routing.js +21 -0
- package/dist/ops/spawn.d.ts +3 -3
- package/dist/ops/spawn.js +130 -64
- package/dist/ops/status.d.ts +30 -5
- package/dist/ops/status.js +245 -96
- package/dist/ops/types.d.ts +5 -1
- package/dist/ops/util.d.ts +3 -0
- package/dist/ops/util.js +2 -1
- package/dist/ops/worktree.js +2 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +60 -9
- package/dist/zellij/args.d.ts +2 -0
- package/dist/zellij/binary.d.ts +47 -0
- package/dist/zellij/binary.js +201 -8
- package/dist/zellij/bus.d.ts +2 -0
- package/dist/zellij/bus.js +3 -2
- package/dist/zellij/cache.d.ts +24 -0
- package/dist/zellij/cache.js +90 -0
- package/dist/zellij/client.d.ts +37 -6
- package/dist/zellij/client.js +181 -42
- package/dist/zellij/panes.js +3 -0
- package/dist/zellij/session.d.ts +19 -3
- package/dist/zellij/session.js +39 -9
- package/package.json +3 -3
package/dist/ops/dispatch.js
CHANGED
|
@@ -6,21 +6,60 @@ import { assertOpAllowed, assertPaneAllowed, loadPolicy, } from "../policy.js";
|
|
|
6
6
|
import { createStateStore } from "../state.js";
|
|
7
7
|
import { busToPanes } from "../zellij/bus.js";
|
|
8
8
|
import { broadcast } from "./broadcast.js";
|
|
9
|
-
import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
|
|
9
|
+
import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
|
|
10
10
|
import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
|
|
11
11
|
import { peerCheckpoint, peerDiff } from "./review.js";
|
|
12
12
|
import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
|
|
13
|
-
import { assertNotPlugin, assertNotSelf,
|
|
13
|
+
import { assertNotPlugin, assertNotSelf, assertExpectedScreen, assertWritable, } from "./guards.js";
|
|
14
14
|
import { readDeliveryLog } from "./log.js";
|
|
15
15
|
import { awaitSignal, listSignals, postSignal } from "./signals.js";
|
|
16
16
|
import { spawnPane } from "./spawn.js";
|
|
17
|
-
import {
|
|
17
|
+
import { observationBudget } from "./observation.js";
|
|
18
|
+
import { invocationContext } from "./routing.js";
|
|
19
|
+
import { DEFAULT_STATUS_TIMEOUT_MS, peerStatus, } from "./status.js";
|
|
18
20
|
import { tailPane } from "./tail.js";
|
|
19
21
|
import { OP_NAMES } from "../schema.js";
|
|
20
|
-
import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
22
|
+
import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
21
23
|
import { waitForPane } from "./wait.js";
|
|
22
24
|
import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
|
|
23
25
|
import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, } from "./serve.js";
|
|
26
|
+
/**
|
|
27
|
+
* Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
|
|
28
|
+
* and serve (and remote IPC). `--ssh` sets the destination for this call and
|
|
29
|
+
* clears serve so the flag wins over a sticky ZSWARM_SERVE.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveInvocationEnv(args, base) {
|
|
32
|
+
const local = isTrue(args.local);
|
|
33
|
+
const sshDest = typeof args.ssh === "string" && args.ssh.trim() ? args.ssh.trim() : null;
|
|
34
|
+
const serveAddress = optionalString(args.serveAddress);
|
|
35
|
+
if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(serveAddress)) > 1) {
|
|
36
|
+
throw new ZellijError("usage", "choose only one of --local, --ssh, or --serve");
|
|
37
|
+
}
|
|
38
|
+
if (!local && !sshDest && !serveAddress)
|
|
39
|
+
return base;
|
|
40
|
+
const env = { ...base };
|
|
41
|
+
if (serveAddress) {
|
|
42
|
+
env.ZSWARM_SERVE = serveAddress;
|
|
43
|
+
delete env.ZSWARM_SSH;
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
46
|
+
if (local) {
|
|
47
|
+
delete env.ZSWARM_SSH;
|
|
48
|
+
delete env.ZSWARM_SSH_OPTS;
|
|
49
|
+
delete env.ZSWARM_SSH_MODE;
|
|
50
|
+
delete env.ZSWARM_SSH_BIN;
|
|
51
|
+
delete env.ZSWARM_TMP;
|
|
52
|
+
delete env.ZSWARM_REMOTE_BIN;
|
|
53
|
+
delete env.ZSWARM_REMOTE_SHELL;
|
|
54
|
+
delete env.ZSWARM_SERVE;
|
|
55
|
+
delete env.ZSWARM_SERVE_TOKEN;
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
58
|
+
env.ZSWARM_SSH = sshDest;
|
|
59
|
+
delete env.ZSWARM_SERVE;
|
|
60
|
+
delete env.ZSWARM_SERVE_TOKEN;
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
24
63
|
/** Same default `wait` applies: match when a needle is given, else idle. */
|
|
25
64
|
function waitMode(args) {
|
|
26
65
|
const requested = typeof args.for === "string" ? args.for.trim() : "";
|
|
@@ -53,40 +92,54 @@ function assertSshGitAllowed(env, op, args) {
|
|
|
53
92
|
: "uses local git";
|
|
54
93
|
throw new ZellijError("ssh_git_unsupported", `${op} ${why}; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
|
|
55
94
|
}
|
|
56
|
-
async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
95
|
+
async function resolveTarget(client, args, state, clock, env, policy, op, signal) {
|
|
57
96
|
const to = String(args.to ?? "").trim();
|
|
58
97
|
if (!to)
|
|
59
98
|
throw new ZellijError("missing_peer", "to required");
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const bus = isVerbose(args)
|
|
64
|
-
|
|
65
|
-
|
|
99
|
+
const budget = observationBudget(clock, numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 }), signal);
|
|
100
|
+
const observeMs = numberArg(args, "observeMs", 1_000, { min: 0, max: 30_000 });
|
|
101
|
+
const { session } = await client.resolveSession(optionalString(args.session), budget.require());
|
|
102
|
+
const bus = isVerbose(args) ? null : await busSnapshot(client, state, session, clock, env, "status", {
|
|
103
|
+
deadlineAt: budget.deadline, signal,
|
|
104
|
+
});
|
|
66
105
|
let panes = null;
|
|
67
106
|
if (bus) {
|
|
68
|
-
const
|
|
107
|
+
const snapshot = busToPanes(bus.snapshot);
|
|
69
108
|
try {
|
|
70
|
-
client.resolvePane(
|
|
71
|
-
panes =
|
|
109
|
+
client.resolvePane(snapshot, to);
|
|
110
|
+
panes = snapshot;
|
|
72
111
|
}
|
|
73
|
-
catch {
|
|
74
|
-
|
|
112
|
+
catch { /* An absent title/command/ID gets a fresh direct observation. */ }
|
|
113
|
+
}
|
|
114
|
+
const until = Math.min(budget.deadline, clock.now() + observeMs);
|
|
115
|
+
let pane;
|
|
116
|
+
let fresh = isTrue(args.fresh) || Boolean(op);
|
|
117
|
+
for (;;) {
|
|
118
|
+
const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
|
|
119
|
+
panes ??= await client.listPanes(session, left, { fresh });
|
|
120
|
+
throwIfAborted(signal);
|
|
121
|
+
try {
|
|
122
|
+
pane = client.resolvePane(panes, to);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (!(err instanceof ZellijError) || err.code !== "peer_not_found" || clock.now() >= until)
|
|
127
|
+
throw err;
|
|
128
|
+
await budget.pause(until);
|
|
129
|
+
if (clock.now() >= until)
|
|
130
|
+
throw err;
|
|
131
|
+
panes = null;
|
|
132
|
+
fresh = true;
|
|
75
133
|
}
|
|
76
134
|
}
|
|
77
|
-
if (!panes)
|
|
78
|
-
panes = await client.listPanes(session);
|
|
79
|
-
const pane = client.resolvePane(panes, to);
|
|
80
135
|
if (policy && op)
|
|
81
136
|
assertPaneAllowed(policy, pane, op);
|
|
82
137
|
return { session, panes, pane };
|
|
83
138
|
}
|
|
84
|
-
|
|
85
|
-
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
139
|
+
async function dispatchOperation(args, injected, deps, context, env) {
|
|
86
140
|
const op = String(args.op ?? "");
|
|
87
141
|
const verbose = isVerbose(args);
|
|
88
142
|
const signal = deps.signal;
|
|
89
|
-
throwIfAborted(signal);
|
|
90
143
|
const clock = {
|
|
91
144
|
now: deps.now ?? (() => Date.now()),
|
|
92
145
|
sleep: deps.sleep ??
|
|
@@ -95,9 +148,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
95
148
|
reject(new ZellijError("cancelled", "operation cancelled"));
|
|
96
149
|
return;
|
|
97
150
|
}
|
|
98
|
-
const timer = setTimeout(
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
signal?.removeEventListener("abort", onAbort);
|
|
153
|
+
resolve();
|
|
154
|
+
}, ms);
|
|
99
155
|
const onAbort = () => {
|
|
100
156
|
clearTimeout(timer);
|
|
157
|
+
signal?.removeEventListener("abort", onAbort);
|
|
101
158
|
reject(new ZellijError("cancelled", "operation cancelled"));
|
|
102
159
|
};
|
|
103
160
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -107,10 +164,10 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
107
164
|
let gitClient = deps.git ?? null;
|
|
108
165
|
const git = () => (gitClient ??= createGitClient());
|
|
109
166
|
let stateStore = deps.state ?? null;
|
|
110
|
-
const state = () => (stateStore ??= createStateStore());
|
|
111
|
-
const policy = deps.policy ?? loadPolicy(deps.env);
|
|
112
|
-
const env = deps.env ?? process.env;
|
|
167
|
+
const state = () => (stateStore ??= createStateStore({ env }));
|
|
113
168
|
try {
|
|
169
|
+
throwIfAborted(signal);
|
|
170
|
+
const policy = deps.policy ?? loadPolicy(env);
|
|
114
171
|
// Policy gates the op before anything touches the session.
|
|
115
172
|
assertOpAllowed(policy, op);
|
|
116
173
|
assertSshGitAllowed(env, op, args);
|
|
@@ -132,21 +189,55 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
132
189
|
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
133
190
|
// just because the host env has ZSWARM_SERVE set.
|
|
134
191
|
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
135
|
-
|
|
192
|
+
const request = { ...attachKnownSender(args, env) };
|
|
193
|
+
delete request.serveAddress; // Routing is consumed here, never forwarded back into a tunnel.
|
|
194
|
+
return await callServe(env.ZSWARM_SERVE.trim(), request, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
136
195
|
}
|
|
137
|
-
const
|
|
196
|
+
const baseClient = injected ?? createZellijClient({ env, signal, cache: isTrue(args.fresh) ? false : undefined });
|
|
197
|
+
const client = {
|
|
198
|
+
...baseClient,
|
|
199
|
+
get transport() { return baseClient.transport; },
|
|
200
|
+
async resolveSession(...input) {
|
|
201
|
+
const resolved = await baseClient.resolveSession(...input);
|
|
202
|
+
context.session = resolved.session;
|
|
203
|
+
context.origin.session = resolved.source;
|
|
204
|
+
return resolved;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
138
207
|
switch (op) {
|
|
139
208
|
case "sessions": {
|
|
140
|
-
|
|
141
|
-
|
|
209
|
+
// Resolve SSH/IPC first so a Windows interactive crew is reachable
|
|
210
|
+
// before --live filters EXITED rows.
|
|
211
|
+
const listed = await client.listSessions();
|
|
212
|
+
const transport = client.transport;
|
|
213
|
+
const ipc = transport.ipc;
|
|
214
|
+
if (ipc &&
|
|
215
|
+
ipc.requested?.toLowerCase() === "auto" &&
|
|
216
|
+
(ipc.status === "failed" ||
|
|
217
|
+
ipc.status === "expired" ||
|
|
218
|
+
ipc.status === "cancelled")) {
|
|
219
|
+
throw new ZellijError("ipc_unreachable", `ZSWARM_TMP=auto could not discover the remote Zellij IPC directory (status=${ipc.status}). Set ZSWARM_TMP to the desktop TEMP, confirm ZSWARM_SSH_MODE=interactive with a logged-on user, or use zswarm serve. Refusing to treat EXITED rows as "no live crew".`);
|
|
220
|
+
}
|
|
221
|
+
const includeExited = isTrue(args.all);
|
|
222
|
+
const sessions = includeExited
|
|
223
|
+
? listed
|
|
224
|
+
: listed.filter((s) => !s.exited);
|
|
225
|
+
const data = {
|
|
226
|
+
sessions,
|
|
227
|
+
zellij: client.zellijPath,
|
|
228
|
+
transport,
|
|
229
|
+
filter: includeExited ? "all" : "live",
|
|
230
|
+
};
|
|
231
|
+
return { ok: true, data };
|
|
142
232
|
}
|
|
143
233
|
case "list": {
|
|
144
234
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
145
235
|
// The plugin's manifest carries no cwd or floating flag, so anything
|
|
146
236
|
// verbose has to go the polling route.
|
|
147
|
-
const
|
|
237
|
+
const observedBus = verbose
|
|
148
238
|
? null
|
|
149
|
-
: await busSnapshot(client, state(), session, clock,
|
|
239
|
+
: await busSnapshot(client, state(), session, clock, env);
|
|
240
|
+
const bus = observedBus?.snapshot.ready ? observedBus : null;
|
|
150
241
|
const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
|
|
151
242
|
const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
|
|
152
243
|
.filter((p) => !p.isPlugin)
|
|
@@ -165,16 +256,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
165
256
|
const body = String(args.body ?? args.text ?? "");
|
|
166
257
|
if (!body.trim())
|
|
167
258
|
throw new ZellijError("missing_body", "body required");
|
|
168
|
-
const { session, pane, panes } = await resolveTarget(client, args, state(), clock,
|
|
259
|
+
const { session, pane, panes } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
169
260
|
assertWritable(client, pane, args, "send");
|
|
170
|
-
|
|
171
|
-
// no reliable way to tell an agent from a prompt, so the caller names
|
|
172
|
-
// something the screen must show first.
|
|
173
|
-
const expect = typeof args.expect === "string" ? args.expect.trim() : "";
|
|
174
|
-
if (expect) {
|
|
175
|
-
const screen = await client.dumpPane({ session, paneId: pane.id });
|
|
176
|
-
assertPaneExpects(screen.text, expect, pane.id);
|
|
177
|
-
}
|
|
261
|
+
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
178
262
|
const labeled = withSenderLabel(args, {
|
|
179
263
|
env,
|
|
180
264
|
selfTitle: selfPaneTitle(client, panes),
|
|
@@ -205,8 +289,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
205
289
|
return await broadcast(client, state(), args, clock, policy, env);
|
|
206
290
|
case "keys":
|
|
207
291
|
case "interrupt": {
|
|
208
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock,
|
|
292
|
+
const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
209
293
|
assertWritable(client, pane, args, op);
|
|
294
|
+
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
210
295
|
const chars = typeof args.chars === "string" ? args.chars : "";
|
|
211
296
|
if (op === "keys" && chars) {
|
|
212
297
|
await client.writeChars({ session, paneId: pane.id, chars });
|
|
@@ -259,7 +344,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
259
344
|
};
|
|
260
345
|
}
|
|
261
346
|
case "dump": {
|
|
262
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock,
|
|
347
|
+
const { session, pane } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
263
348
|
const dumped = await client.dumpPane({
|
|
264
349
|
session,
|
|
265
350
|
paneId: pane.id,
|
|
@@ -281,11 +366,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
281
366
|
};
|
|
282
367
|
}
|
|
283
368
|
case "tail": {
|
|
284
|
-
const target = await resolveTarget(client, args, state(), clock,
|
|
369
|
+
const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
285
370
|
return await tailPane(client, state(), args, target);
|
|
286
371
|
}
|
|
287
372
|
case "wait": {
|
|
288
|
-
const target = await resolveTarget(client, args, state(), clock,
|
|
373
|
+
const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
289
374
|
// The plugin holds one pipe for the whole wait and polls far tighter
|
|
290
375
|
// than spawning a process allows. It declines a regex needle — it has
|
|
291
376
|
// no engine — and then this is the loop that runs.
|
|
@@ -297,24 +382,66 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
297
382
|
match: typeof args.match === "string" ? args.match : null,
|
|
298
383
|
ignoreCase: isTrue(args.ignoreCase),
|
|
299
384
|
...timing,
|
|
300
|
-
},
|
|
385
|
+
}, env);
|
|
301
386
|
return await waitForPane(client, target, args, clock, viaBus, deps.signal);
|
|
302
387
|
}
|
|
303
388
|
case "status": {
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
389
|
+
// One overall deadline covering session resolve, bus snapshot, IPC,
|
|
390
|
+
// and screen samples — not just the dump loop inside peerStatus.
|
|
391
|
+
const statusBudget = numberArg(args, "timeoutMs", DEFAULT_STATUS_TIMEOUT_MS, { min: 1_000, max: 900_000 });
|
|
392
|
+
const deadlineAt = clock.now() + statusBudget;
|
|
393
|
+
const remaining = () => Math.max(0, deadlineAt - clock.now());
|
|
394
|
+
const setupBudget = () => {
|
|
395
|
+
throwIfAborted(signal);
|
|
396
|
+
const left = remaining();
|
|
397
|
+
if (left <= 0) {
|
|
398
|
+
throw new ZellijError("zellij_failed", "status timed out during setup");
|
|
399
|
+
}
|
|
400
|
+
return left;
|
|
401
|
+
};
|
|
402
|
+
throwIfAborted(signal);
|
|
403
|
+
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
|
|
404
|
+
throwIfAborted(signal);
|
|
405
|
+
// Once the session is known, verbose metadata and the bus manifest
|
|
406
|
+
// are independent reads. Both consume this operation's remaining time.
|
|
407
|
+
const readBus = () => remaining() <= 0 ? Promise.resolve(null)
|
|
408
|
+
: busSnapshot(client, state(), session, clock, env, "status", { deadlineAt, signal });
|
|
409
|
+
let observedBus;
|
|
410
|
+
let detailedPanes;
|
|
411
|
+
if (verbose) {
|
|
412
|
+
const reads = await Promise.allSettled([
|
|
413
|
+
readBus(), client.listPanes(session, setupBudget(), { fresh: true }),
|
|
414
|
+
]);
|
|
415
|
+
throwIfAborted(signal);
|
|
416
|
+
if (reads[1].status === "rejected")
|
|
417
|
+
throw reads[1].reason;
|
|
418
|
+
if (reads[0].status === "rejected")
|
|
419
|
+
throw reads[0].reason;
|
|
420
|
+
observedBus = reads[0].value;
|
|
421
|
+
detailedPanes = reads[1].value;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
observedBus = await readBus();
|
|
425
|
+
}
|
|
426
|
+
const bus = observedBus?.snapshot.ready ? observedBus : null;
|
|
427
|
+
throwIfAborted(signal);
|
|
428
|
+
// Always hand peerStatus the resolved session (and panes) so it does
|
|
429
|
+
// not spend the overall budget resolving again.
|
|
430
|
+
const panes = detailedPanes ?? (bus
|
|
431
|
+
? busToPanes(bus.snapshot)
|
|
432
|
+
: await client.listPanes(session, setupBudget()));
|
|
433
|
+
throwIfAborted(signal);
|
|
434
|
+
let supplied = {
|
|
435
|
+
session,
|
|
436
|
+
panes,
|
|
437
|
+
source: bus && !verbose ? "plugin" : "zellij",
|
|
438
|
+
};
|
|
439
|
+
if (bus) {
|
|
440
|
+
supplied = {
|
|
441
|
+
...supplied,
|
|
442
|
+
readScreens: (paneIds, timeoutMs) => busScreens(client, state(), session, paneIds, clock, env, timeoutMs),
|
|
443
|
+
readChanged: async (paneIds, timeoutMs) => {
|
|
444
|
+
const reply = await busChanged(client, state(), session, paneIds, env, timeoutMs);
|
|
318
445
|
if (!reply)
|
|
319
446
|
return null;
|
|
320
447
|
return new Map(reply.panes.map((p) => [
|
|
@@ -322,23 +449,37 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
322
449
|
{ changed: p.changed, first: p.first, screen: p.screen },
|
|
323
450
|
]));
|
|
324
451
|
},
|
|
325
|
-
}
|
|
326
|
-
|
|
452
|
+
};
|
|
453
|
+
}
|
|
327
454
|
const only = typeof args.to === "string" ? args.to.trim() : "";
|
|
328
|
-
if (
|
|
455
|
+
if (bus && only) {
|
|
329
456
|
try {
|
|
330
457
|
client.resolvePane(supplied.panes, only);
|
|
331
458
|
}
|
|
332
459
|
catch {
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
supplied =
|
|
460
|
+
// Command-shaped `to` needs the polled list; re-fetch without bus.
|
|
461
|
+
const polled = await client.listPanes(session, setupBudget());
|
|
462
|
+
supplied = { ...supplied, session, panes: polled, source: "zellij" };
|
|
336
463
|
}
|
|
337
464
|
}
|
|
338
|
-
|
|
465
|
+
const result = await peerStatus(client, args, clock, supplied, {
|
|
466
|
+
deadlineAt,
|
|
467
|
+
signal,
|
|
468
|
+
});
|
|
469
|
+
if (result.ok && result.data && typeof result.data === "object") {
|
|
470
|
+
const data = result.data;
|
|
471
|
+
data.polling = {
|
|
472
|
+
busAvailable: Boolean(bus),
|
|
473
|
+
...(bus ? {} : { reason: planBus(client, state(), env, session).reason }),
|
|
474
|
+
...(client.remote ? {
|
|
475
|
+
recommendation: "For frequent status calls, run zswarm serve beside Zellij and use --serve 127.0.0.1:9419 through an SSH tunnel; direct SSH cannot use the local event bus.",
|
|
476
|
+
} : {}),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return result;
|
|
339
480
|
}
|
|
340
481
|
case "bus":
|
|
341
|
-
return await busOp(client, state(), args, clock,
|
|
482
|
+
return await busOp(client, state(), args, clock, env, policy);
|
|
342
483
|
case "signal":
|
|
343
484
|
return postSignal(state(), args, clock);
|
|
344
485
|
case "signals":
|
|
@@ -362,13 +503,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
362
503
|
case "checkpoint":
|
|
363
504
|
return await peerCheckpoint(git(), args, clock);
|
|
364
505
|
case "spawn":
|
|
365
|
-
return await spawnPane(client, args, deps.git);
|
|
506
|
+
return await spawnPane(client, args, deps.git, clock, signal);
|
|
366
507
|
case "worktrees":
|
|
367
508
|
return await listPeerWorktrees(git(), client, args);
|
|
368
509
|
case "unworktree":
|
|
369
510
|
return await removePeerWorktree(git(), client, args);
|
|
370
511
|
case "close": {
|
|
371
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock,
|
|
512
|
+
const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
372
513
|
// Not assertWritable: closing an exited pane is the point of close.
|
|
373
514
|
assertNotPlugin(pane, "close");
|
|
374
515
|
assertNotSelf(client, pane, args, "close");
|
|
@@ -393,3 +534,27 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
393
534
|
return fail(err);
|
|
394
535
|
}
|
|
395
536
|
}
|
|
537
|
+
/** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
|
|
538
|
+
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
539
|
+
try {
|
|
540
|
+
const env = resolveInvocationEnv(args, deps.env ?? process.env);
|
|
541
|
+
const context = invocationContext(args, env, injected?.transport);
|
|
542
|
+
const result = await dispatchOperation(args, injected, deps, context, env);
|
|
543
|
+
if (context.transport === "serve" && result.context) {
|
|
544
|
+
context.server = result.context;
|
|
545
|
+
context.session = result.context.session;
|
|
546
|
+
context.origin.session = result.context.origin.session;
|
|
547
|
+
}
|
|
548
|
+
else if (context.transport === "serve" && result.ok && result.data && typeof result.data === "object") {
|
|
549
|
+
const session = result.data.session;
|
|
550
|
+
if (typeof session === "string") {
|
|
551
|
+
context.session = session;
|
|
552
|
+
context.origin.session = "server";
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { ...result, context };
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
return fail(err);
|
|
559
|
+
}
|
|
560
|
+
}
|
package/dist/ops/guards.d.ts
CHANGED
|
@@ -13,3 +13,5 @@ export declare function assertWritable(client: ZellijClient, pane: ZellijPane, a
|
|
|
13
13
|
* in a safety check is a foot-gun.
|
|
14
14
|
*/
|
|
15
15
|
export declare function assertPaneExpects(screen: string, expect: string, paneId: string): void;
|
|
16
|
+
/** Check immediately before input. A failed read or mismatch must not write. */
|
|
17
|
+
export declare function assertExpectedScreen(client: ZellijClient, session: string, paneId: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<void>;
|
package/dist/ops/guards.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
|
-
import { isTrue } from "./util.js";
|
|
2
|
+
import { isTrue, optionalString, throwIfAborted } from "./util.js";
|
|
3
3
|
export function assertNotPlugin(pane, action) {
|
|
4
4
|
if (pane.isPlugin) {
|
|
5
5
|
throw new ZellijError("pane_is_plugin", `${pane.id} is a plugin pane; ${action} targets terminal panes`);
|
|
@@ -34,3 +34,13 @@ export function assertPaneExpects(screen, expect, paneId) {
|
|
|
34
34
|
return;
|
|
35
35
|
throw new ZellijError("expect_missing", `${paneId} screen does not contain ${JSON.stringify(expect)}`);
|
|
36
36
|
}
|
|
37
|
+
/** Check immediately before input. A failed read or mismatch must not write. */
|
|
38
|
+
export async function assertExpectedScreen(client, session, paneId, args, signal) {
|
|
39
|
+
throwIfAborted(signal);
|
|
40
|
+
const expect = optionalString(args.expect);
|
|
41
|
+
if (!expect)
|
|
42
|
+
return;
|
|
43
|
+
const screen = await client.dumpPane({ session, paneId });
|
|
44
|
+
throwIfAborted(signal);
|
|
45
|
+
assertPaneExpects(screen.text, expect, paneId);
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Clock } from "./types.js";
|
|
2
|
+
/** One deadline shared by setup, child processes, retries, and sleeps. */
|
|
3
|
+
export declare function observationBudget(clock: Clock, timeoutMs: number, signal?: AbortSignal): {
|
|
4
|
+
deadline: number;
|
|
5
|
+
remaining: () => number;
|
|
6
|
+
require(): number;
|
|
7
|
+
pause(until?: number): Promise<void>;
|
|
8
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { throwIfAborted } from "./util.js";
|
|
3
|
+
/** One deadline shared by setup, child processes, retries, and sleeps. */
|
|
4
|
+
export function observationBudget(clock, timeoutMs, signal) {
|
|
5
|
+
const deadline = clock.now() + timeoutMs;
|
|
6
|
+
const remaining = () => {
|
|
7
|
+
throwIfAborted(signal);
|
|
8
|
+
return Math.max(0, deadline - clock.now());
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
deadline,
|
|
12
|
+
remaining,
|
|
13
|
+
require() {
|
|
14
|
+
const left = remaining();
|
|
15
|
+
if (left <= 0)
|
|
16
|
+
throw new ZellijError("observation_timeout", "observation deadline expired");
|
|
17
|
+
return left;
|
|
18
|
+
},
|
|
19
|
+
async pause(until = deadline) {
|
|
20
|
+
const ms = Math.min(100, remaining(), Math.max(0, until - clock.now()));
|
|
21
|
+
if (ms > 0)
|
|
22
|
+
await clock.sleep(ms);
|
|
23
|
+
throwIfAborted(signal);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/ops/panes.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function renameTarget(client, args, policy) {
|
|
|
16
16
|
const sess = await session(client, args);
|
|
17
17
|
const tabKey = optionalString(args.tab);
|
|
18
18
|
if (tabKey) {
|
|
19
|
-
const tab = client.resolveTab(await client.listTabs(sess), tabKey);
|
|
19
|
+
const tab = client.resolveTab(await client.listTabs(sess, undefined, { fresh: true }), tabKey);
|
|
20
20
|
const renamed = await client.renameTab({
|
|
21
21
|
session: sess,
|
|
22
22
|
tabId: tab.id,
|
|
@@ -30,7 +30,7 @@ export async function renameTarget(client, args, policy) {
|
|
|
30
30
|
const to = optionalString(args.to);
|
|
31
31
|
if (!to)
|
|
32
32
|
throw new ZellijError("missing_peer", "to (or tab) required");
|
|
33
|
-
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
33
|
+
const pane = client.resolvePane(await client.listPanes(sess, undefined, { fresh: true }), to);
|
|
34
34
|
if (policy)
|
|
35
35
|
assertPaneAllowed(policy, pane, "rename");
|
|
36
36
|
await client.renamePane({ session: sess, paneId: pane.id, name });
|
|
@@ -44,7 +44,7 @@ export async function focusTarget(client, args, policy) {
|
|
|
44
44
|
if (!to)
|
|
45
45
|
throw new ZellijError("missing_peer", "to required");
|
|
46
46
|
const sess = await session(client, args);
|
|
47
|
-
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
47
|
+
const pane = client.resolvePane(await client.listPanes(sess, undefined, { fresh: true }), to);
|
|
48
48
|
if (policy)
|
|
49
49
|
assertPaneAllowed(policy, pane, "focus");
|
|
50
50
|
// Zellij exits non-zero when asked to focus the already-focused pane.
|
|
@@ -104,7 +104,7 @@ export async function stackTargets(client, args, policy) {
|
|
|
104
104
|
if (!list)
|
|
105
105
|
throw new ZellijError("missing_peer", "to (comma list) required");
|
|
106
106
|
const sess = await session(client, args);
|
|
107
|
-
const panes = await client.listPanes(sess);
|
|
107
|
+
const panes = await client.listPanes(sess, undefined, { fresh: true });
|
|
108
108
|
const ids = list
|
|
109
109
|
.split(",")
|
|
110
110
|
.map((k) => k.trim())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ZellijTransport } from "../zellij/client.js";
|
|
2
|
+
export type RoutingContext = {
|
|
3
|
+
transport: "local" | "ssh" | "serve";
|
|
4
|
+
host: string;
|
|
5
|
+
session: string | null;
|
|
6
|
+
origin: {
|
|
7
|
+
transport: string;
|
|
8
|
+
session: string;
|
|
9
|
+
};
|
|
10
|
+
/** The serve endpoint can be a local tunnel; this identifies its server. */
|
|
11
|
+
server?: RoutingContext;
|
|
12
|
+
};
|
|
13
|
+
export declare function invocationContext(args: Record<string, unknown>, env: NodeJS.ProcessEnv, injected?: ZellijTransport): RoutingContext;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { sessionFromEnv } from "../zellij/session.js";
|
|
3
|
+
import { isTrue, optionalString } from "./util.js";
|
|
4
|
+
export function invocationContext(args, env, injected) {
|
|
5
|
+
const serve = optionalString(env.ZSWARM_SERVE);
|
|
6
|
+
const ssh = optionalString(env.ZSWARM_SSH);
|
|
7
|
+
// Serve forwards explicit arguments; inherited session defaults belong to
|
|
8
|
+
// the server, not the caller (which might only be a local tunnel).
|
|
9
|
+
const selected = sessionFromEnv(serve && !injected ? {} : env, optionalString(args.session));
|
|
10
|
+
return {
|
|
11
|
+
transport: injected?.kind ?? (serve ? "serve" : ssh ? "ssh" : "local"),
|
|
12
|
+
host: injected ? injected.host ?? hostname() : serve ?? ssh ?? hostname(),
|
|
13
|
+
session: selected?.session ?? null,
|
|
14
|
+
origin: {
|
|
15
|
+
transport: injected ? "injected" : isTrue(args.local) ? "--local"
|
|
16
|
+
: optionalString(args.ssh) ? "--ssh" : optionalString(args.serveAddress) ? "--serve" : serve ? "ZSWARM_SERVE"
|
|
17
|
+
: ssh ? "ZSWARM_SSH" : "default",
|
|
18
|
+
session: selected?.source ?? "unresolved",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/ops/spawn.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type GitClient } from "../git.js";
|
|
2
2
|
import type { ZellijClient } from "../zellij/client.js";
|
|
3
|
-
import type { OpsResult } from "./types.js";
|
|
4
|
-
/**
|
|
5
|
-
export declare function spawnPane(client: ZellijClient, args: Record<string, unknown>, git?: GitClient): Promise<OpsResult>;
|
|
3
|
+
import type { Clock, OpsResult } from "./types.js";
|
|
4
|
+
/** Create exactly once. Only observations are retried, never the create action. */
|
|
5
|
+
export declare function spawnPane(client: ZellijClient, args: Record<string, unknown>, git?: GitClient, clock?: Clock, signal?: AbortSignal): Promise<OpsResult>;
|