@zswarm/core 0.1.4 → 0.1.6
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 +14 -1
- package/dist/exec.js +107 -14
- package/dist/harness.js +12 -7
- package/dist/index.d.ts +11 -10
- package/dist/index.js +8 -8
- package/dist/ops/bus.d.ts +16 -10
- package/dist/ops/bus.js +164 -98
- package/dist/ops/delivery.d.ts +3 -3
- package/dist/ops/delivery.js +4 -7
- package/dist/ops/dispatch.d.ts +7 -1
- package/dist/ops/dispatch.js +208 -68
- 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/routing.d.ts +13 -0
- package/dist/ops/routing.js +21 -0
- package/dist/ops/serve.d.ts +1 -0
- package/dist/ops/serve.js +7 -1
- 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 +231 -91
- package/dist/ops/types.d.ts +7 -1
- package/dist/ops/util.d.ts +4 -0
- package/dist/ops/util.js +7 -1
- package/dist/ops/wait.d.ts +8 -1
- package/dist/ops/wait.js +7 -3
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +50 -8
- package/dist/state.d.ts +7 -6
- package/dist/state.js +77 -17
- package/dist/zellij/args.d.ts +4 -0
- package/dist/zellij/args.js +1 -0
- package/dist/zellij/binary.d.ts +34 -0
- package/dist/zellij/binary.js +169 -8
- package/dist/zellij/bus.d.ts +11 -0
- package/dist/zellij/bus.js +22 -1
- package/dist/zellij/client.d.ts +23 -5
- package/dist/zellij/client.js +99 -33
- 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
|
@@ -10,17 +10,50 @@ import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
|
|
|
10
10
|
import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
|
|
11
11
|
import { peerCheckpoint, peerDiff } from "./review.js";
|
|
12
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, 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
|
+
if (local && sshDest) {
|
|
35
|
+
throw new ZellijError("usage", "pass --local or --ssh, not both");
|
|
36
|
+
}
|
|
37
|
+
if (!local && !sshDest)
|
|
38
|
+
return base;
|
|
39
|
+
const env = { ...base };
|
|
40
|
+
if (local) {
|
|
41
|
+
delete env.ZSWARM_SSH;
|
|
42
|
+
delete env.ZSWARM_SSH_OPTS;
|
|
43
|
+
delete env.ZSWARM_SSH_MODE;
|
|
44
|
+
delete env.ZSWARM_SSH_BIN;
|
|
45
|
+
delete env.ZSWARM_TMP;
|
|
46
|
+
delete env.ZSWARM_REMOTE_BIN;
|
|
47
|
+
delete env.ZSWARM_REMOTE_SHELL;
|
|
48
|
+
delete env.ZSWARM_SERVE;
|
|
49
|
+
delete env.ZSWARM_SERVE_TOKEN;
|
|
50
|
+
return env;
|
|
51
|
+
}
|
|
52
|
+
env.ZSWARM_SSH = sshDest;
|
|
53
|
+
delete env.ZSWARM_SERVE;
|
|
54
|
+
delete env.ZSWARM_SERVE_TOKEN;
|
|
55
|
+
return env;
|
|
56
|
+
}
|
|
24
57
|
/** Same default `wait` applies: match when a needle is given, else idle. */
|
|
25
58
|
function waitMode(args) {
|
|
26
59
|
const requested = typeof args.for === "string" ? args.for.trim() : "";
|
|
@@ -53,51 +86,80 @@ function assertSshGitAllowed(env, op, args) {
|
|
|
53
86
|
: "uses local git";
|
|
54
87
|
throw new ZellijError("ssh_git_unsupported", `${op} ${why}; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
|
|
55
88
|
}
|
|
56
|
-
async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
89
|
+
async function resolveTarget(client, args, state, clock, env, policy, op, signal) {
|
|
57
90
|
const to = String(args.to ?? "").trim();
|
|
58
91
|
if (!to)
|
|
59
92
|
throw new ZellijError("missing_peer", "to required");
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const bus = isVerbose(args)
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
const budget = observationBudget(clock, numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 }), signal);
|
|
94
|
+
const observeMs = numberArg(args, "observeMs", 1_000, { min: 0, max: 30_000 });
|
|
95
|
+
const { session } = await client.resolveSession(optionalString(args.session), budget.require());
|
|
96
|
+
const bus = isVerbose(args) ? null : await busSnapshot(client, state, session, clock, env, "status", {
|
|
97
|
+
deadlineAt: budget.deadline, signal,
|
|
98
|
+
});
|
|
66
99
|
let panes = null;
|
|
67
100
|
if (bus) {
|
|
68
|
-
const
|
|
101
|
+
const snapshot = busToPanes(bus.snapshot);
|
|
69
102
|
try {
|
|
70
|
-
client.resolvePane(
|
|
71
|
-
panes =
|
|
103
|
+
client.resolvePane(snapshot, to);
|
|
104
|
+
panes = snapshot;
|
|
72
105
|
}
|
|
73
|
-
catch {
|
|
74
|
-
|
|
106
|
+
catch { /* An absent title/command/ID gets a fresh direct observation. */ }
|
|
107
|
+
}
|
|
108
|
+
const until = Math.min(budget.deadline, clock.now() + observeMs);
|
|
109
|
+
let pane;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
|
|
112
|
+
panes ??= await client.listPanes(session, left);
|
|
113
|
+
throwIfAborted(signal);
|
|
114
|
+
try {
|
|
115
|
+
pane = client.resolvePane(panes, to);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (!(err instanceof ZellijError) || err.code !== "peer_not_found" || clock.now() >= until)
|
|
120
|
+
throw err;
|
|
121
|
+
await budget.pause(until);
|
|
122
|
+
if (clock.now() >= until)
|
|
123
|
+
throw err;
|
|
124
|
+
panes = null;
|
|
75
125
|
}
|
|
76
126
|
}
|
|
77
|
-
if (!panes)
|
|
78
|
-
panes = await client.listPanes(session);
|
|
79
|
-
const pane = client.resolvePane(panes, to);
|
|
80
127
|
if (policy && op)
|
|
81
128
|
assertPaneAllowed(policy, pane, op);
|
|
82
129
|
return { session, panes, pane };
|
|
83
130
|
}
|
|
84
|
-
|
|
85
|
-
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
131
|
+
async function dispatchOperation(args, injected, deps, context, env) {
|
|
86
132
|
const op = String(args.op ?? "");
|
|
87
133
|
const verbose = isVerbose(args);
|
|
134
|
+
const signal = deps.signal;
|
|
88
135
|
const clock = {
|
|
89
136
|
now: deps.now ?? (() => Date.now()),
|
|
90
137
|
sleep: deps.sleep ??
|
|
91
|
-
((ms) => new Promise((
|
|
138
|
+
((ms) => new Promise((resolve, reject) => {
|
|
139
|
+
if (signal?.aborted) {
|
|
140
|
+
reject(new ZellijError("cancelled", "operation cancelled"));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
signal?.removeEventListener("abort", onAbort);
|
|
145
|
+
resolve();
|
|
146
|
+
}, ms);
|
|
147
|
+
const onAbort = () => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
signal?.removeEventListener("abort", onAbort);
|
|
150
|
+
reject(new ZellijError("cancelled", "operation cancelled"));
|
|
151
|
+
};
|
|
152
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
153
|
+
})),
|
|
92
154
|
};
|
|
93
155
|
// Only the worktree ops need git, so the client is built on demand.
|
|
94
156
|
let gitClient = deps.git ?? null;
|
|
95
157
|
const git = () => (gitClient ??= createGitClient());
|
|
96
158
|
let stateStore = deps.state ?? null;
|
|
97
|
-
const state = () => (stateStore ??= createStateStore());
|
|
98
|
-
const policy = deps.policy ?? loadPolicy(deps.env);
|
|
99
|
-
const env = deps.env ?? process.env;
|
|
159
|
+
const state = () => (stateStore ??= createStateStore({ env }));
|
|
100
160
|
try {
|
|
161
|
+
throwIfAborted(signal);
|
|
162
|
+
const policy = deps.policy ?? loadPolicy(env);
|
|
101
163
|
// Policy gates the op before anything touches the session.
|
|
102
164
|
assertOpAllowed(policy, op);
|
|
103
165
|
assertSshGitAllowed(env, op, args);
|
|
@@ -121,11 +183,42 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
121
183
|
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
122
184
|
return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
123
185
|
}
|
|
124
|
-
const
|
|
186
|
+
const baseClient = injected ?? createZellijClient({ env, signal });
|
|
187
|
+
const client = {
|
|
188
|
+
...baseClient,
|
|
189
|
+
get transport() { return baseClient.transport; },
|
|
190
|
+
async resolveSession(...input) {
|
|
191
|
+
const resolved = await baseClient.resolveSession(...input);
|
|
192
|
+
context.session = resolved.session;
|
|
193
|
+
context.origin.session = resolved.source;
|
|
194
|
+
return resolved;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
125
197
|
switch (op) {
|
|
126
198
|
case "sessions": {
|
|
127
|
-
|
|
128
|
-
|
|
199
|
+
// Resolve SSH/IPC first so a Windows interactive crew is reachable
|
|
200
|
+
// before --live filters EXITED rows.
|
|
201
|
+
const listed = await client.listSessions();
|
|
202
|
+
const transport = client.transport;
|
|
203
|
+
const ipc = transport.ipc;
|
|
204
|
+
if (ipc &&
|
|
205
|
+
ipc.requested?.toLowerCase() === "auto" &&
|
|
206
|
+
(ipc.status === "failed" ||
|
|
207
|
+
ipc.status === "expired" ||
|
|
208
|
+
ipc.status === "cancelled")) {
|
|
209
|
+
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".`);
|
|
210
|
+
}
|
|
211
|
+
const includeExited = isTrue(args.all);
|
|
212
|
+
const sessions = includeExited
|
|
213
|
+
? listed
|
|
214
|
+
: listed.filter((s) => !s.exited);
|
|
215
|
+
const data = {
|
|
216
|
+
sessions,
|
|
217
|
+
zellij: client.zellijPath,
|
|
218
|
+
transport,
|
|
219
|
+
filter: includeExited ? "all" : "live",
|
|
220
|
+
};
|
|
221
|
+
return { ok: true, data };
|
|
129
222
|
}
|
|
130
223
|
case "list": {
|
|
131
224
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
@@ -133,7 +226,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
133
226
|
// verbose has to go the polling route.
|
|
134
227
|
const bus = verbose
|
|
135
228
|
? null
|
|
136
|
-
: await busSnapshot(client, state(), session, clock,
|
|
229
|
+
: await busSnapshot(client, state(), session, clock, env);
|
|
137
230
|
const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
|
|
138
231
|
const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
|
|
139
232
|
.filter((p) => !p.isPlugin)
|
|
@@ -152,16 +245,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
152
245
|
const body = String(args.body ?? args.text ?? "");
|
|
153
246
|
if (!body.trim())
|
|
154
247
|
throw new ZellijError("missing_body", "body required");
|
|
155
|
-
const { session, pane, panes } = await resolveTarget(client, args, state(), clock,
|
|
248
|
+
const { session, pane, panes } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
156
249
|
assertWritable(client, pane, args, "send");
|
|
157
|
-
|
|
158
|
-
// no reliable way to tell an agent from a prompt, so the caller names
|
|
159
|
-
// something the screen must show first.
|
|
160
|
-
const expect = typeof args.expect === "string" ? args.expect.trim() : "";
|
|
161
|
-
if (expect) {
|
|
162
|
-
const screen = await client.dumpPane({ session, paneId: pane.id });
|
|
163
|
-
assertPaneExpects(screen.text, expect, pane.id);
|
|
164
|
-
}
|
|
250
|
+
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
165
251
|
const labeled = withSenderLabel(args, {
|
|
166
252
|
env,
|
|
167
253
|
selfTitle: selfPaneTitle(client, panes),
|
|
@@ -192,8 +278,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
192
278
|
return await broadcast(client, state(), args, clock, policy, env);
|
|
193
279
|
case "keys":
|
|
194
280
|
case "interrupt": {
|
|
195
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock,
|
|
281
|
+
const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
196
282
|
assertWritable(client, pane, args, op);
|
|
283
|
+
await assertExpectedScreen(client, session, pane.id, args, signal);
|
|
197
284
|
const chars = typeof args.chars === "string" ? args.chars : "";
|
|
198
285
|
if (op === "keys" && chars) {
|
|
199
286
|
await client.writeChars({ session, paneId: pane.id, chars });
|
|
@@ -246,7 +333,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
246
333
|
};
|
|
247
334
|
}
|
|
248
335
|
case "dump": {
|
|
249
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock,
|
|
336
|
+
const { session, pane } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
250
337
|
const dumped = await client.dumpPane({
|
|
251
338
|
session,
|
|
252
339
|
paneId: pane.id,
|
|
@@ -268,41 +355,67 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
268
355
|
};
|
|
269
356
|
}
|
|
270
357
|
case "tail": {
|
|
271
|
-
const target = await resolveTarget(client, args, state(), clock,
|
|
358
|
+
const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
272
359
|
return await tailPane(client, state(), args, target);
|
|
273
360
|
}
|
|
274
361
|
case "wait": {
|
|
275
|
-
const target = await resolveTarget(client, args, state(), clock,
|
|
362
|
+
const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
|
|
276
363
|
// The plugin holds one pipe for the whole wait and polls far tighter
|
|
277
364
|
// than spawning a process allows. It declines a regex needle — it has
|
|
278
365
|
// no engine — and then this is the loop that runs.
|
|
279
366
|
const viaBus = isTrue(args.regex)
|
|
280
367
|
? undefined
|
|
281
|
-
: () => busWait(client, state(), target.session, {
|
|
368
|
+
: (timing) => busWait(client, state(), target.session, {
|
|
282
369
|
pane: target.pane.id,
|
|
283
370
|
for: waitMode(args),
|
|
284
371
|
match: typeof args.match === "string" ? args.match : null,
|
|
285
372
|
ignoreCase: isTrue(args.ignoreCase),
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return await waitForPane(client, target, args, clock, viaBus);
|
|
373
|
+
...timing,
|
|
374
|
+
}, env);
|
|
375
|
+
return await waitForPane(client, target, args, clock, viaBus, deps.signal);
|
|
290
376
|
}
|
|
291
377
|
case "status": {
|
|
292
|
-
|
|
378
|
+
// One overall deadline covering session resolve, bus snapshot, IPC,
|
|
379
|
+
// and screen samples — not just the dump loop inside peerStatus.
|
|
380
|
+
const statusBudget = numberArg(args, "timeoutMs", DEFAULT_STATUS_TIMEOUT_MS, { min: 1_000, max: 900_000 });
|
|
381
|
+
const deadlineAt = clock.now() + statusBudget;
|
|
382
|
+
const remaining = () => Math.max(0, deadlineAt - clock.now());
|
|
383
|
+
const setupBudget = () => {
|
|
384
|
+
throwIfAborted(signal);
|
|
385
|
+
const left = remaining();
|
|
386
|
+
if (left <= 0) {
|
|
387
|
+
throw new ZellijError("zellij_failed", "status timed out during setup");
|
|
388
|
+
}
|
|
389
|
+
return left;
|
|
390
|
+
};
|
|
391
|
+
throwIfAborted(signal);
|
|
392
|
+
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
|
|
393
|
+
throwIfAborted(signal);
|
|
293
394
|
// Verbose reports cwd and command, which the plugin manifest lacks.
|
|
294
|
-
const bus = verbose
|
|
395
|
+
const bus = verbose || remaining() <= 0
|
|
295
396
|
? null
|
|
296
|
-
: await busSnapshot(client, state(), session, clock,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
397
|
+
: await busSnapshot(client, state(), session, clock, env, "status", {
|
|
398
|
+
deadlineAt,
|
|
399
|
+
signal,
|
|
400
|
+
});
|
|
401
|
+
throwIfAborted(signal);
|
|
402
|
+
// Always hand peerStatus the resolved session (and panes) so it does
|
|
403
|
+
// not spend the overall budget resolving again.
|
|
404
|
+
const panes = bus
|
|
405
|
+
? busToPanes(bus.snapshot)
|
|
406
|
+
: await client.listPanes(session, setupBudget());
|
|
407
|
+
throwIfAborted(signal);
|
|
408
|
+
let supplied = {
|
|
409
|
+
session,
|
|
410
|
+
panes,
|
|
411
|
+
source: bus ? "plugin" : "zellij",
|
|
412
|
+
};
|
|
413
|
+
if (bus) {
|
|
414
|
+
supplied = {
|
|
415
|
+
...supplied,
|
|
416
|
+
readScreens: (paneIds, timeoutMs) => busScreens(client, state(), session, paneIds, clock, env, timeoutMs),
|
|
417
|
+
readChanged: async (paneIds, timeoutMs) => {
|
|
418
|
+
const reply = await busChanged(client, state(), session, paneIds, env, timeoutMs);
|
|
306
419
|
if (!reply)
|
|
307
420
|
return null;
|
|
308
421
|
return new Map(reply.panes.map((p) => [
|
|
@@ -310,23 +423,26 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
310
423
|
{ changed: p.changed, first: p.first, screen: p.screen },
|
|
311
424
|
]));
|
|
312
425
|
},
|
|
313
|
-
}
|
|
314
|
-
|
|
426
|
+
};
|
|
427
|
+
}
|
|
315
428
|
const only = typeof args.to === "string" ? args.to.trim() : "";
|
|
316
|
-
if (
|
|
429
|
+
if (bus && only) {
|
|
317
430
|
try {
|
|
318
431
|
client.resolvePane(supplied.panes, only);
|
|
319
432
|
}
|
|
320
433
|
catch {
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
supplied =
|
|
434
|
+
// Command-shaped `to` needs the polled list; re-fetch without bus.
|
|
435
|
+
const polled = await client.listPanes(session, setupBudget());
|
|
436
|
+
supplied = { session, panes: polled, source: "zellij" };
|
|
324
437
|
}
|
|
325
438
|
}
|
|
326
|
-
return await peerStatus(client, args, clock, supplied
|
|
439
|
+
return await peerStatus(client, args, clock, supplied, {
|
|
440
|
+
deadlineAt,
|
|
441
|
+
signal,
|
|
442
|
+
});
|
|
327
443
|
}
|
|
328
444
|
case "bus":
|
|
329
|
-
return await busOp(client, state(), args, clock,
|
|
445
|
+
return await busOp(client, state(), args, clock, env, policy);
|
|
330
446
|
case "signal":
|
|
331
447
|
return postSignal(state(), args, clock);
|
|
332
448
|
case "signals":
|
|
@@ -350,13 +466,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
350
466
|
case "checkpoint":
|
|
351
467
|
return await peerCheckpoint(git(), args, clock);
|
|
352
468
|
case "spawn":
|
|
353
|
-
return await spawnPane(client, args, deps.git);
|
|
469
|
+
return await spawnPane(client, args, deps.git, clock, signal);
|
|
354
470
|
case "worktrees":
|
|
355
471
|
return await listPeerWorktrees(git(), client, args);
|
|
356
472
|
case "unworktree":
|
|
357
473
|
return await removePeerWorktree(git(), client, args);
|
|
358
474
|
case "close": {
|
|
359
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock,
|
|
475
|
+
const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
|
|
360
476
|
// Not assertWritable: closing an exited pane is the point of close.
|
|
361
477
|
assertNotPlugin(pane, "close");
|
|
362
478
|
assertNotSelf(client, pane, args, "close");
|
|
@@ -381,3 +497,27 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
381
497
|
return fail(err);
|
|
382
498
|
}
|
|
383
499
|
}
|
|
500
|
+
/** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
|
|
501
|
+
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
502
|
+
try {
|
|
503
|
+
const env = resolveInvocationEnv(args, deps.env ?? process.env);
|
|
504
|
+
const context = invocationContext(args, env, injected?.transport);
|
|
505
|
+
const result = await dispatchOperation(args, injected, deps, context, env);
|
|
506
|
+
if (context.transport === "serve" && result.context) {
|
|
507
|
+
context.server = result.context;
|
|
508
|
+
context.session = result.context.session;
|
|
509
|
+
context.origin.session = result.context.origin.session;
|
|
510
|
+
}
|
|
511
|
+
else if (context.transport === "serve" && result.ok && result.data && typeof result.data === "object") {
|
|
512
|
+
const session = result.data.session;
|
|
513
|
+
if (typeof session === "string") {
|
|
514
|
+
context.session = session;
|
|
515
|
+
context.origin.session = "server";
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { ...result, context };
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
return fail(err);
|
|
522
|
+
}
|
|
523
|
+
}
|
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
|
+
}
|
|
@@ -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" : serve ? "ZSWARM_SERVE"
|
|
17
|
+
: ssh ? "ZSWARM_SSH" : "default",
|
|
18
|
+
session: selected?.source ?? "unresolved",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/ops/serve.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export declare function parseServeTarget(raw: string): {
|
|
|
36
36
|
port: number;
|
|
37
37
|
};
|
|
38
38
|
export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
|
|
39
|
+
export declare function redactServeSecret(command: string, token: string): string;
|
|
39
40
|
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
|
|
40
41
|
export declare function installServeLogon(input: {
|
|
41
42
|
listen?: string;
|
package/dist/ops/serve.js
CHANGED
|
@@ -252,6 +252,12 @@ export function callServe(target, args, timeoutMs = 15_000, token) {
|
|
|
252
252
|
});
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
|
+
export function redactServeSecret(command, token) {
|
|
256
|
+
const secret = token.trim();
|
|
257
|
+
if (!secret)
|
|
258
|
+
return command;
|
|
259
|
+
return command.split(secret).join("***");
|
|
260
|
+
}
|
|
255
261
|
export function serveLogonCommand(execPath, scriptPath, listen, token) {
|
|
256
262
|
const launch = `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
|
|
257
263
|
const secret = token?.trim();
|
|
@@ -323,7 +329,7 @@ export async function installServeLogon(input) {
|
|
|
323
329
|
if (result.code !== 0) {
|
|
324
330
|
throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
325
331
|
}
|
|
326
|
-
return { task: SERVE_TASK_NAME, listen: label, command };
|
|
332
|
+
return { task: SERVE_TASK_NAME, listen: label, command: redactServeSecret(command, token) };
|
|
327
333
|
}
|
|
328
334
|
export async function uninstallServeLogon(input = {}) {
|
|
329
335
|
if ((input.platform ?? process.platform) !== "win32") {
|
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>;
|