@zswarm/core 0.1.6 → 0.1.8

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.
@@ -6,7 +6,7 @@ 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";
@@ -22,7 +22,10 @@ import { OP_NAMES } from "../schema.js";
22
22
  import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
23
23
  import { waitForPane } from "./wait.js";
24
24
  import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
25
- import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, } from "./serve.js";
25
+ import { DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, uninstallServeLogon, } from "./serve-install.js";
26
+ import { serveCallTimeout, } from "./serve.js";
27
+ import { doctorOp } from "./doctor.js";
28
+ import { forwardServe } from "./serve-tunnel.js";
26
29
  /**
27
30
  * Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
28
31
  * and serve (and remote IPC). `--ssh` sets the destination for this call and
@@ -31,12 +34,18 @@ import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, }
31
34
  export function resolveInvocationEnv(args, base) {
32
35
  const local = isTrue(args.local);
33
36
  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");
37
+ const serveAddress = optionalString(args.serveAddress);
38
+ if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(serveAddress)) > 1) {
39
+ throw new ZellijError("usage", "choose only one of --local, --ssh, or --serve");
36
40
  }
37
- if (!local && !sshDest)
41
+ if (!local && !sshDest && !serveAddress)
38
42
  return base;
39
43
  const env = { ...base };
44
+ if (serveAddress) {
45
+ env.ZSWARM_SERVE = serveAddress;
46
+ delete env.ZSWARM_SSH;
47
+ return env;
48
+ }
40
49
  if (local) {
41
50
  delete env.ZSWARM_SSH;
42
51
  delete env.ZSWARM_SSH_OPTS;
@@ -107,9 +116,10 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
107
116
  }
108
117
  const until = Math.min(budget.deadline, clock.now() + observeMs);
109
118
  let pane;
119
+ let fresh = isTrue(args.fresh) || Boolean(op);
110
120
  for (;;) {
111
121
  const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
112
- panes ??= await client.listPanes(session, left);
122
+ panes ??= await client.listPanes(session, left, { fresh });
113
123
  throwIfAborted(signal);
114
124
  try {
115
125
  pane = client.resolvePane(panes, to);
@@ -122,6 +132,7 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
122
132
  if (clock.now() >= until)
123
133
  throw err;
124
134
  panes = null;
135
+ fresh = true;
125
136
  }
126
137
  }
127
138
  if (policy && op)
@@ -163,27 +174,60 @@ async function dispatchOperation(args, injected, deps, context, env) {
163
174
  // Policy gates the op before anything touches the session.
164
175
  assertOpAllowed(policy, op);
165
176
  assertSshGitAllowed(env, op, args);
177
+ if (op === "doctor") {
178
+ // Before serve forwarding and session resolution so a dead tunnel still
179
+ // yields controller findings. Policy already ran.
180
+ return await doctorOp(args, injected, deps, context, env, clock);
181
+ }
166
182
  if (op === "serve") {
183
+ const timeoutMs = numberArg(args, "timeoutMs", DEFAULT_SERVE_INSTALL_TIMEOUT_MS, {
184
+ min: 1,
185
+ max: 900_000,
186
+ });
167
187
  if (isTrue(args.clear)) {
168
188
  const cleared = await uninstallServeLogon({
169
- platform: process.platform,
189
+ platform: deps.serveInstall?.platform ?? process.platform,
190
+ timeoutMs,
191
+ signal,
192
+ env,
193
+ token: env.ZSWARM_SERVE_TOKEN,
194
+ ...deps.serveInstall,
170
195
  });
171
196
  return { ok: true, data: cleared };
172
197
  }
173
198
  if (isTrue(args.install)) {
174
199
  const installed = await installServeLogon({
175
200
  listen: typeof args.listen === "string" ? args.listen : undefined,
201
+ session: optionalString(args.session),
202
+ timeoutMs,
203
+ signal,
204
+ env,
205
+ token: env.ZSWARM_SERVE_TOKEN,
206
+ now: clock.now,
207
+ sleep: clock.sleep,
208
+ tailscaleStatus: deps.tailscaleStatus,
209
+ networkInterfaces: deps.networkInterfaces,
210
+ ...deps.serveInstall,
176
211
  });
177
- return { ok: true, data: { ...installed, running: true } };
212
+ return { ok: true, data: installed };
178
213
  }
179
214
  throw new ZellijError("usage", "serve --listen is CLI-only; MCP/dispatch would hang the tool call. Run `zswarm serve --listen` in the session that owns Zellij");
180
215
  }
181
216
  // An injected client is a unit-test (or in-process) Zellij; do not skip it
182
217
  // just because the host env has ZSWARM_SERVE set.
183
218
  if (!injected && env.ZSWARM_SERVE?.trim()) {
184
- return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
219
+ const request = { ...attachKnownSender(args, env) };
220
+ return await forwardServe({
221
+ target: env.ZSWARM_SERVE.trim(),
222
+ args: request,
223
+ timeoutMs: serveCallTimeout(args),
224
+ token: env.ZSWARM_SERVE_TOKEN,
225
+ signal,
226
+ env,
227
+ manager: deps.serveTunnels,
228
+ });
185
229
  }
186
- const baseClient = injected ?? createZellijClient({ env, signal });
230
+ const baseClient = injected ?? createZellijClient({ env, signal, cache: isTrue(args.fresh) ? false : undefined });
187
231
  const client = {
188
232
  ...baseClient,
189
233
  get transport() { return baseClient.transport; },
@@ -224,9 +268,10 @@ async function dispatchOperation(args, injected, deps, context, env) {
224
268
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
225
269
  // The plugin's manifest carries no cwd or floating flag, so anything
226
270
  // verbose has to go the polling route.
227
- const bus = verbose
271
+ const observedBus = verbose
228
272
  ? null
229
273
  : await busSnapshot(client, state(), session, clock, env);
274
+ const bus = observedBus?.snapshot.ready ? observedBus : null;
230
275
  const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
231
276
  const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
232
277
  .filter((p) => !p.isPlugin)
@@ -391,24 +436,39 @@ async function dispatchOperation(args, injected, deps, context, env) {
391
436
  throwIfAborted(signal);
392
437
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
393
438
  throwIfAborted(signal);
394
- // Verbose reports cwd and command, which the plugin manifest lacks.
395
- const bus = verbose || remaining() <= 0
396
- ? null
397
- : await busSnapshot(client, state(), session, clock, env, "status", {
398
- deadlineAt,
399
- signal,
400
- });
439
+ // Once the session is known, verbose metadata and the bus manifest
440
+ // are independent reads. Both consume this operation's remaining time.
441
+ const readBus = () => remaining() <= 0 ? Promise.resolve(null)
442
+ : busSnapshot(client, state(), session, clock, env, "status", { deadlineAt, signal });
443
+ let observedBus;
444
+ let detailedPanes;
445
+ if (verbose) {
446
+ const reads = await Promise.allSettled([
447
+ readBus(), client.listPanes(session, setupBudget(), { fresh: true }),
448
+ ]);
449
+ throwIfAborted(signal);
450
+ if (reads[1].status === "rejected")
451
+ throw reads[1].reason;
452
+ if (reads[0].status === "rejected")
453
+ throw reads[0].reason;
454
+ observedBus = reads[0].value;
455
+ detailedPanes = reads[1].value;
456
+ }
457
+ else {
458
+ observedBus = await readBus();
459
+ }
460
+ const bus = observedBus?.snapshot.ready ? observedBus : null;
401
461
  throwIfAborted(signal);
402
462
  // Always hand peerStatus the resolved session (and panes) so it does
403
463
  // not spend the overall budget resolving again.
404
- const panes = bus
464
+ const panes = detailedPanes ?? (bus
405
465
  ? busToPanes(bus.snapshot)
406
- : await client.listPanes(session, setupBudget());
466
+ : await client.listPanes(session, setupBudget()));
407
467
  throwIfAborted(signal);
408
468
  let supplied = {
409
469
  session,
410
470
  panes,
411
- source: bus ? "plugin" : "zellij",
471
+ source: bus && !verbose ? "plugin" : "zellij",
412
472
  };
413
473
  if (bus) {
414
474
  supplied = {
@@ -433,13 +493,24 @@ async function dispatchOperation(args, injected, deps, context, env) {
433
493
  catch {
434
494
  // Command-shaped `to` needs the polled list; re-fetch without bus.
435
495
  const polled = await client.listPanes(session, setupBudget());
436
- supplied = { session, panes: polled, source: "zellij" };
496
+ supplied = { ...supplied, session, panes: polled, source: "zellij" };
437
497
  }
438
498
  }
439
- return await peerStatus(client, args, clock, supplied, {
499
+ const result = await peerStatus(client, args, clock, supplied, {
440
500
  deadlineAt,
441
501
  signal,
442
502
  });
503
+ if (result.ok && result.data && typeof result.data === "object") {
504
+ const data = result.data;
505
+ data.polling = {
506
+ busAvailable: Boolean(bus),
507
+ ...(bus ? {} : { reason: planBus(client, state(), env, session).reason }),
508
+ ...(client.remote ? {
509
+ 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.",
510
+ } : {}),
511
+ };
512
+ }
513
+ return result;
443
514
  }
444
515
  case "bus":
445
516
  return await busOp(client, state(), args, clock, env, policy);
@@ -0,0 +1,80 @@
1
+ import { type ZellijClient } from "../zellij/client.js";
2
+ import { type StateStore } from "../state.js";
3
+ import type { RoutingContext } from "./routing.js";
4
+ import { type ServeHelloData } from "./serve.js";
5
+ import type { Clock, DispatchDeps, OpsResult } from "./types.js";
6
+ /** Overall doctor deadline when `timeoutMs` is omitted. */
7
+ export declare const DEFAULT_DOCTOR_TIMEOUT_MS = 10000;
8
+ /** Internal serve-worker flag. Never a public CLI flag or MCP param. */
9
+ export declare const DOCTOR_SCOPE_FIELD = "doctorScope";
10
+ export declare const DOCTOR_SCOPE_HOST = "host";
11
+ export declare const DOCTOR_FAILED_CODE = "doctor_failed";
12
+ /** Host doctor reply after hello was empty, malformed, or not a report. */
13
+ export declare const HOST_REPORT_INVALID_CODE = "host_report_invalid";
14
+ /** Host doctor reply omitted required host/session coverage. */
15
+ export declare const HOST_REPORT_INCOMPLETE_CODE = "host_report_incomplete";
16
+ /** Cap for optional Tailscale so required SSH/hello/session keep budget. */
17
+ export declare const DOCTOR_TAILSCALE_MAX_MS = 1500;
18
+ export type DoctorCheckState = "ok" | "warn" | "fail" | "skipped";
19
+ export type DoctorCheckScope = "controller" | "host";
20
+ export type DoctorCheck = {
21
+ id: string;
22
+ scope: DoctorCheckScope;
23
+ state: DoctorCheckState;
24
+ code: string;
25
+ elapsedMs: number;
26
+ detail: Record<string, unknown>;
27
+ remedy: string | null;
28
+ };
29
+ export type DoctorRoute = {
30
+ transport: "local" | "ssh" | "serve";
31
+ endpoint: string;
32
+ session: string | null;
33
+ sessionOrigin: string;
34
+ };
35
+ export type DoctorReport = {
36
+ route: DoctorRoute;
37
+ server?: ServeHelloData;
38
+ checks: DoctorCheck[];
39
+ };
40
+ export type HostInspectInput = {
41
+ client: ZellijClient;
42
+ state: StateStore;
43
+ env: NodeJS.ProcessEnv;
44
+ sessionArg?: string | null;
45
+ clock: Clock;
46
+ timeoutMs: number;
47
+ signal?: AbortSignal;
48
+ /** Direct SSH cannot treat controller-local bus files as host facts. */
49
+ busUnsupported?: boolean;
50
+ busRemedy?: string;
51
+ };
52
+ export type HostInspectResult = {
53
+ checks: DoctorCheck[];
54
+ session: string | null;
55
+ sessionOrigin: string;
56
+ cancelled?: boolean;
57
+ timedOut?: boolean;
58
+ };
59
+ /** Required-layer failure using accepted doctor semantics. Host install must reuse this. */
60
+ export declare function isRequiredFailure(report: DoctorReport, item: DoctorCheck): boolean;
61
+ /**
62
+ * Passive host inspection. Safe reads only: binary/version via the client,
63
+ * session listing, pane listing for an existing plugin pane, and StateStore
64
+ * reads. Never pipes, launches, nudges, or writes markers.
65
+ */
66
+ export declare function inspectDoctorHost(input: HostInspectInput): Promise<HostInspectResult>;
67
+ export declare function hostDoctorRequest(remainingMs: number, explicitSession: string | null): Record<string, unknown>;
68
+ export declare function coverHostReport(checks: DoctorCheck[], explicit: string | null): DoctorCheck[];
69
+ export declare function mergeHostReply(result: OpsResult): {
70
+ checks: DoctorCheck[];
71
+ session?: string | null;
72
+ sessionOrigin?: string;
73
+ unsupported?: boolean;
74
+ invalid?: boolean;
75
+ };
76
+ /**
77
+ * Inspect-only layered diagnostics. Handle this before ordinary serve
78
+ * forwarding so a dead tunnel still reports controller findings.
79
+ */
80
+ export declare function doctorOp(args: Record<string, unknown>, injected: ZellijClient | undefined, deps: DispatchDeps, context: RoutingContext, env: NodeJS.ProcessEnv, clock: Clock): Promise<OpsResult>;