@zswarm/core 0.1.7 → 0.1.9

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.
@@ -0,0 +1,1377 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { ZellijError } from "../errors.js";
5
+ import { NOT_FOUND_EXIT } from "../exec.js";
6
+ import { isBusPluginPane, resolveBusPlugin } from "../zellij/bus.js";
7
+ import { createZellijClient } from "../zellij/client.js";
8
+ import { liveSessionNames, sessionFromEnv, } from "../zellij/session.js";
9
+ import { createStateStore } from "../state.js";
10
+ import { validateSshMode } from "../zellij/binary.js";
11
+ import { observationBudget } from "./observation.js";
12
+ import { callServe, probeServe, serveChildEnv, } from "./serve.js";
13
+ import { createServeTunnelManager, describeServeTarget, isSshServeTarget, parseSshServeTarget, } from "./serve-tunnel.js";
14
+ import { numberArg, optionalString, throwIfAborted } from "./util.js";
15
+ /** Overall doctor deadline when `timeoutMs` is omitted. */
16
+ export const DEFAULT_DOCTOR_TIMEOUT_MS = 10_000;
17
+ /** Internal serve-worker flag. Never a public CLI flag or MCP param. */
18
+ export const DOCTOR_SCOPE_FIELD = "doctorScope";
19
+ export const DOCTOR_SCOPE_HOST = "host";
20
+ export const DOCTOR_FAILED_CODE = "doctor_failed";
21
+ /** Host doctor reply after hello was empty, malformed, or not a report. */
22
+ export const HOST_REPORT_INVALID_CODE = "host_report_invalid";
23
+ /** Host doctor reply omitted required host/session coverage. */
24
+ export const HOST_REPORT_INCOMPLETE_CODE = "host_report_incomplete";
25
+ /** Cap for optional Tailscale so required SSH/hello/session keep budget. */
26
+ export const DOCTOR_TAILSCALE_MAX_MS = 1_500;
27
+ const DOCTOR_TAILSCALE_RESERVE_MS = 4_000;
28
+ const DOCTOR_TAILSCALE_MIN_MS = 200;
29
+ const DOCTOR_TAILSCALE_MAX_BYTES = 256 * 1024;
30
+ const HOST_CHECK_IDS = [
31
+ "zellij_binary",
32
+ "zellij_ipc",
33
+ "zellij_sessions",
34
+ "session",
35
+ "bus_artifact",
36
+ "bus_marker",
37
+ "bus_instance",
38
+ ];
39
+ /** Success reports must include these host layers; skipped placeholders do not count. */
40
+ const REQUIRED_HOST_LAYERS = ["zellij_binary", "zellij_ipc", "zellij_sessions", "session"];
41
+ const REMEDY = {
42
+ serve: "Run zswarm serve beside Zellij on the crew host and reach it with --serve (ssh:// or an existing tunnel). Direct SSH cannot install or inspect the event bus.",
43
+ token: "Set the same ZSWARM_SERVE_TOKEN on the server and this caller.",
44
+ upgrade: "This serve peer does not implement doctor. Upgrade zswarm serve on the crew host, then rerun doctor.",
45
+ hostReport: "Authenticated hello succeeded, but the host doctor reply was missing, malformed, or did not cover host/session inspection. Upgrade zswarm serve on the crew host and rerun doctor.",
46
+ session: "Start the requested Zellij session on the crew host (the account that owns the desktop session), or pass --session for a live session.",
47
+ zellij: "Install Zellij ≥ 0.42 on the crew host and set ZSWARM_BIN / ZSWARM_REMOTE_BIN to that binary, not zswarm.",
48
+ ipc: "Point ZSWARM_TMP at the desktop TEMP (or auto) so listings use the crew host IPC. A reachable listener is not a usable crew.",
49
+ tailscale: "Optional Tailscale evidence only. Install/login of the Tailscale CLI is out of scope for doctor; a working SSH/serve route does not require it.",
50
+ interactive: "Doctor does not run the Windows interactive scheduled-task path. Use zswarm serve on the desktop account and --serve through an SSH tunnel.",
51
+ };
52
+ function asDetails(report) {
53
+ return report;
54
+ }
55
+ function originLabel(source) {
56
+ if (source === "arg")
57
+ return "explicit";
58
+ if (source === "env_zswarm" || source === "env_zellij")
59
+ return "inherited";
60
+ if (source === "sole_live")
61
+ return "host_default";
62
+ if (source === "server")
63
+ return "server";
64
+ if (source === "unresolved" || !source)
65
+ return "unresolved";
66
+ return source;
67
+ }
68
+ function elapsedSince(clock, started) {
69
+ return Math.max(0, Math.round(clock.now() - started));
70
+ }
71
+ function check(id, scope, state, code, elapsedMs, detail = {}, remedy = null) {
72
+ return { id, scope, state, code, elapsedMs, detail, remedy };
73
+ }
74
+ function skipHostChecks(existing, reason, remedy, elapsedMs = 0) {
75
+ return HOST_CHECK_IDS.filter((id) => !existing.some((item) => item.id === id)).map((id) => check(id, "host", "skipped", reason, elapsedMs, { reason }, remedy));
76
+ }
77
+ function isLoopbackEndpoint(endpoint) {
78
+ const text = endpoint.trim().toLowerCase();
79
+ return (text.startsWith("127.0.0.1") ||
80
+ text.startsWith("localhost") ||
81
+ text.startsWith("[::1]") ||
82
+ text.startsWith("::1"));
83
+ }
84
+ function sshFailure(message) {
85
+ const text = message.toLowerCase();
86
+ if (/permission denied|publickey|authentication failed|too many authentication|no matching host key type/.test(text)) {
87
+ return {
88
+ code: "ssh_auth",
89
+ remedy: "SSH authentication failed. Check the identity, agent, and BatchMode destination; doctor does not prompt for a password.",
90
+ };
91
+ }
92
+ if (/host key verification failed|known_hosts|remote host identification has changed/.test(text)) {
93
+ return {
94
+ code: "ssh_host_key",
95
+ remedy: "SSH host-key verification failed. Confirm the destination in known_hosts; doctor does not disable StrictHostKeyChecking.",
96
+ };
97
+ }
98
+ if (/remote port forwarding failed|administratively prohibited|cannot listen|channel .* failed/.test(text)) {
99
+ return {
100
+ code: "ssh_forward",
101
+ remedy: "SSH connected but LocalForward failed. Confirm GatewayPorts/AllowTcpForwarding and that the remote loopback serve port is free.",
102
+ };
103
+ }
104
+ if (/spawn ssh|\bssh\b.*\benoent\b|\benoent\b.*\bssh\b|ssh:.*(not found|is not recognized)/.test(text)) {
105
+ return {
106
+ code: "ssh_unavailable",
107
+ remedy: "ssh is not available on PATH. Install OpenSSH and retry; doctor does not invent a destination.",
108
+ };
109
+ }
110
+ if (/could not resolve|name or service not known|no route to host|network is unreachable|connection refused|connection timed out|timed out|connection reset/.test(text)) {
111
+ return {
112
+ code: "ssh_connect",
113
+ remedy: "Cannot reach the SSH destination. Check the host/alias, Tailscale/OpenSSH connectivity, and that ssh is on PATH.",
114
+ };
115
+ }
116
+ return {
117
+ code: "ssh_unverified",
118
+ remedy: "SSH did not produce remote evidence. Inspect the destination and OpenSSH configuration; doctor does not infer ssh_ready from a missing error match.",
119
+ };
120
+ }
121
+ function serveFailure(result) {
122
+ const err = !result.ok ? result.error : undefined;
123
+ const details = err?.details && typeof err.details === "object" ? { ...err.details } : {};
124
+ const code = err?.code ?? "serve_protocol";
125
+ const mapped = code === "serve_unauthorized"
126
+ ? "serve_unauthorized"
127
+ : code === "serve_hello_unsupported"
128
+ ? "serve_hello_unsupported"
129
+ : code === "serve_incompatible"
130
+ ? "serve_incompatible"
131
+ : code === "timeout"
132
+ ? "serve_timeout"
133
+ : code === "cancelled"
134
+ ? "serve_cancelled"
135
+ : code === "serve_unreachable"
136
+ ? "serve_connect"
137
+ : "serve_protocol";
138
+ const remedy = typeof details.remedy === "string"
139
+ ? details.remedy
140
+ : mapped === "serve_unauthorized"
141
+ ? REMEDY.token
142
+ : mapped === "serve_hello_unsupported" || mapped === "serve_incompatible"
143
+ ? REMEDY.upgrade
144
+ : "Check that zswarm serve is listening on the forwarded loopback port. A TCP connect alone is not readiness; doctor requires authenticated hello.";
145
+ return {
146
+ code: mapped,
147
+ remedy,
148
+ detail: {
149
+ ...details,
150
+ message: err?.message,
151
+ transportCode: code,
152
+ },
153
+ };
154
+ }
155
+ function selectedSession(args) {
156
+ return optionalString(args.session);
157
+ }
158
+ function controllerSessionOrigin(context) {
159
+ return originLabel(context.origin.session);
160
+ }
161
+ function reportHas(checks, id) {
162
+ return checks.some((item) => item.id === id);
163
+ }
164
+ /** Required-layer failure using accepted doctor semantics. Host install must reuse this. */
165
+ export function isRequiredFailure(report, item) {
166
+ if (item.state === "ok" || item.state === "warn")
167
+ return false;
168
+ if (item.id === "route")
169
+ return item.state === "fail";
170
+ if (item.id === "ssh") {
171
+ if (item.code === "ssh_not_applicable" || item.code === "ssh_skipped_upstream")
172
+ return false;
173
+ if (item.code === "ssh_interactive_uninspected")
174
+ return false;
175
+ return item.state === "fail";
176
+ }
177
+ if (item.id === "serve") {
178
+ if (item.code === "serve_not_applicable" || item.code === "serve_skipped_upstream") {
179
+ return false;
180
+ }
181
+ return item.state === "fail";
182
+ }
183
+ if (item.id === "zellij_binary") {
184
+ if (item.state === "skipped")
185
+ return false;
186
+ return item.state === "fail";
187
+ }
188
+ if (item.id === "zellij_ipc") {
189
+ // Established IPC failure is required even with no selected session.
190
+ // Unresolved/optional IPC stays warn and advisory.
191
+ return item.state === "fail";
192
+ }
193
+ if (item.id === "zellij_sessions") {
194
+ // Missing/skipped/failed listing coverage is required; sessions_none stays warn.
195
+ return item.state === "fail";
196
+ }
197
+ if (item.id === "host_request") {
198
+ return item.state === "fail";
199
+ }
200
+ if (item.id === "session") {
201
+ const selected = report.route.sessionOrigin === "explicit" ||
202
+ report.route.sessionOrigin === "inherited" ||
203
+ report.route.sessionOrigin === "host_default";
204
+ if (!selected)
205
+ return item.state === "fail";
206
+ return item.state === "fail" || item.state === "skipped";
207
+ }
208
+ return false;
209
+ }
210
+ function summarizeFailure(report) {
211
+ const failed = report.checks.filter((item) => isRequiredFailure(report, item));
212
+ if (failed.length === 0)
213
+ return "doctor failed";
214
+ return failed.map((item) => `${item.id}:${item.code}`).join(", ");
215
+ }
216
+ function finishReport(report, status) {
217
+ if (status.cancelled) {
218
+ return {
219
+ ok: false,
220
+ error: {
221
+ code: "cancelled",
222
+ message: "operation cancelled",
223
+ details: asDetails(report),
224
+ },
225
+ };
226
+ }
227
+ if (status.timedOut) {
228
+ return {
229
+ ok: false,
230
+ error: {
231
+ code: "timeout",
232
+ message: "doctor timed out",
233
+ details: asDetails(report),
234
+ },
235
+ };
236
+ }
237
+ if (status.requiredFailure || report.checks.some((item) => isRequiredFailure(report, item))) {
238
+ return {
239
+ ok: false,
240
+ error: {
241
+ code: DOCTOR_FAILED_CODE,
242
+ message: summarizeFailure(report),
243
+ details: asDetails(report),
244
+ },
245
+ };
246
+ }
247
+ return { ok: true, data: report };
248
+ }
249
+ function defaultTailscaleStatus(input) {
250
+ const bin = "tailscale";
251
+ return new Promise((resolve) => {
252
+ execFile(bin, ["status", "--json"], {
253
+ timeout: Math.max(1, input.timeoutMs),
254
+ maxBuffer: DOCTOR_TAILSCALE_MAX_BYTES,
255
+ windowsHide: true,
256
+ signal: input.signal,
257
+ env: input.env,
258
+ }, (error, stdout, stderr) => {
259
+ const failure = error;
260
+ if (failure && typeof failure.code === "string") {
261
+ const missing = failure.code === "ENOENT" || failure.code === "ENOTDIR";
262
+ resolve({
263
+ code: missing ? NOT_FOUND_EXIT : 1,
264
+ stdout: "",
265
+ stderr: `${failure.code}: ${failure.message}`,
266
+ });
267
+ return;
268
+ }
269
+ if (failure && (failure.killed === true || failure.name === "AbortError")) {
270
+ resolve({
271
+ code: -1,
272
+ stdout: String(stdout ?? ""),
273
+ stderr: failure.name === "AbortError" ? "tailscale cancelled" : "tailscale timed out",
274
+ });
275
+ return;
276
+ }
277
+ resolve({
278
+ code: failure && typeof failure.code === "number"
279
+ ? failure.code
280
+ : failure
281
+ ? 1
282
+ : 0,
283
+ stdout: String(stdout ?? "").slice(0, DOCTOR_TAILSCALE_MAX_BYTES),
284
+ stderr: String(stderr ?? "").slice(0, 4_096),
285
+ });
286
+ });
287
+ });
288
+ }
289
+ function asStringArray(value) {
290
+ if (!Array.isArray(value))
291
+ return [];
292
+ return value.filter((item) => typeof item === "string" && item.trim() !== "");
293
+ }
294
+ function normalizeDns(name) {
295
+ return name.trim().toLowerCase().replace(/\.$/, "");
296
+ }
297
+ function peerFromStatus(raw) {
298
+ if (!raw || typeof raw !== "object")
299
+ return null;
300
+ const row = raw;
301
+ const hostName = typeof row.HostName === "string" ? row.HostName : undefined;
302
+ const dnsName = typeof row.DNSName === "string" ? row.DNSName : undefined;
303
+ const online = typeof row.Online === "boolean" ? row.Online : undefined;
304
+ const tailscaleIPs = asStringArray(row.TailscaleIPs);
305
+ if (!hostName && !dnsName && tailscaleIPs.length === 0)
306
+ return null;
307
+ return { hostName, dnsName, online, tailscaleIPs };
308
+ }
309
+ function candidateHosts(endpoint, transport) {
310
+ if (transport === "local")
311
+ return [];
312
+ if (transport === "ssh") {
313
+ const dest = endpoint.trim();
314
+ const host = dest.includes("@") ? dest.slice(dest.lastIndexOf("@") + 1) : dest;
315
+ return host ? [host] : [];
316
+ }
317
+ if (isSshServeTarget(endpoint)) {
318
+ try {
319
+ return [parseSshServeTarget(endpoint).host];
320
+ }
321
+ catch {
322
+ return [];
323
+ }
324
+ }
325
+ const stripped = endpoint.replace(/^tcp:\/\//i, "");
326
+ const host = stripped.startsWith("[")
327
+ ? stripped.slice(1, stripped.indexOf("]"))
328
+ : stripped.split(":")[0] ?? "";
329
+ return host ? [host] : [];
330
+ }
331
+ function matchPeer(peers, host) {
332
+ const needle = host.trim().toLowerCase();
333
+ if (!needle)
334
+ return null;
335
+ if (needle === "127.0.0.1" || needle === "localhost" || needle === "::1")
336
+ return null;
337
+ for (const peer of peers) {
338
+ const ips = (peer.tailscaleIPs ?? []).map((ip) => ip.toLowerCase());
339
+ if (ips.includes(needle))
340
+ return peer;
341
+ const dns = peer.dnsName ? normalizeDns(peer.dnsName) : "";
342
+ const dnsShort = dns.split(".")[0] ?? "";
343
+ const hostName = peer.hostName?.trim().toLowerCase() ?? "";
344
+ if (dns && (needle === dns || needle === dnsShort || dns.startsWith(`${needle}.`))) {
345
+ return peer;
346
+ }
347
+ if (hostName && needle === hostName)
348
+ return peer;
349
+ }
350
+ return null;
351
+ }
352
+ async function inspectTailscale(input) {
353
+ const started = input.clock.now();
354
+ const elapsed = () => elapsedSince(input.clock, started);
355
+ if (input.timeoutMs < DOCTOR_TAILSCALE_MIN_MS) {
356
+ return check("tailscale", "controller", "skipped", "tailscale_skipped", elapsed(), { reason: "budget_reserved" }, REMEDY.tailscale);
357
+ }
358
+ const hosts = candidateHosts(input.route.endpoint, input.route.transport);
359
+ const host = hosts[0] ?? "";
360
+ if (!host || isLoopbackEndpoint(host) || host === "127.0.0.1") {
361
+ return check("tailscale", "controller", "skipped", "tailscale_unmappable", elapsed(), {
362
+ reason: "loopback_or_manual_tunnel",
363
+ endpoint: input.route.endpoint,
364
+ }, REMEDY.tailscale);
365
+ }
366
+ try {
367
+ throwIfAborted(input.signal);
368
+ const result = await input.runner({
369
+ timeoutMs: input.timeoutMs,
370
+ signal: input.signal,
371
+ env: input.env,
372
+ });
373
+ if (result.code === NOT_FOUND_EXIT) {
374
+ return check("tailscale", "controller", "skipped", "tailscale_cli_missing", elapsed(), { reason: "cli_missing" }, REMEDY.tailscale);
375
+ }
376
+ if (result.code !== 0) {
377
+ const combined = `${result.stdout}\n${result.stderr}`.toLowerCase();
378
+ const denied = /permission denied|access denied|failed to connect|tailscaled|daemon/.test(combined);
379
+ return check("tailscale", "controller", "skipped", denied ? "tailscale_daemon_unavailable" : "tailscale_cli_missing", elapsed(), { exit: result.code }, REMEDY.tailscale);
380
+ }
381
+ let parsed;
382
+ try {
383
+ parsed = JSON.parse(result.stdout);
384
+ }
385
+ catch {
386
+ return check("tailscale", "controller", "skipped", "tailscale_unmappable", elapsed(), { reason: "invalid_json" }, REMEDY.tailscale);
387
+ }
388
+ if (!parsed || typeof parsed !== "object") {
389
+ return check("tailscale", "controller", "skipped", "tailscale_unmappable", elapsed(), { reason: "invalid_status" }, REMEDY.tailscale);
390
+ }
391
+ const status = parsed;
392
+ const peers = [];
393
+ const self = peerFromStatus(status.Self);
394
+ if (self)
395
+ peers.push(self);
396
+ if (status.Peer && typeof status.Peer === "object") {
397
+ for (const value of Object.values(status.Peer)) {
398
+ const peer = peerFromStatus(value);
399
+ if (peer)
400
+ peers.push(peer);
401
+ }
402
+ }
403
+ const matched = matchPeer(peers, host);
404
+ if (!matched) {
405
+ return check("tailscale", "controller", "skipped", "tailscale_peer_unknown", elapsed(), {
406
+ reason: "no_exact_peer",
407
+ host,
408
+ note: "Never guessed from 127.0.0.1 or a 100.* prefix alone.",
409
+ }, REMEDY.tailscale);
410
+ }
411
+ const online = matched.online === true;
412
+ return check("tailscale", "controller", online ? "ok" : "warn", online ? "tailscale_peer_online" : "tailscale_peer_offline", elapsed(), {
413
+ hostName: matched.hostName ?? null,
414
+ dnsName: matched.dnsName ?? null,
415
+ online,
416
+ tailscaleIPs: matched.tailscaleIPs ?? [],
417
+ note: "Peer/online evidence does not prove SSH, forwarding, or app access.",
418
+ }, online ? null : "Tailscale reports the mapped peer offline; this is advisory and does not override SSH/hello/session checks.");
419
+ }
420
+ catch (err) {
421
+ if (err instanceof ZellijError && err.code === "cancelled") {
422
+ throw err;
423
+ }
424
+ return check("tailscale", "controller", "skipped", "tailscale_daemon_unavailable", elapsed(), { reason: err instanceof Error ? err.message : "tailscale_failed" }, REMEDY.tailscale);
425
+ }
426
+ }
427
+ function busDisabled(env) {
428
+ const v = (env.ZSWARM_BUS ?? "").trim().toLowerCase();
429
+ return v === "0" || v === "false" || v === "no" || v === "off";
430
+ }
431
+ /**
432
+ * Passive host inspection. Safe reads only: binary/version via the client,
433
+ * session listing, pane listing for an existing plugin pane, and StateStore
434
+ * reads. Never pipes, launches, nudges, or writes markers.
435
+ */
436
+ export async function inspectDoctorHost(input) {
437
+ const checks = [];
438
+ const budget = observationBudget(input.clock, Math.max(0, input.timeoutMs), input.signal);
439
+ const started = () => input.clock.now();
440
+ let cancelled = false;
441
+ let timedOut = false;
442
+ const markStop = (err) => {
443
+ const zerr = err instanceof ZellijError ? err : null;
444
+ if (zerr?.code === "cancelled") {
445
+ cancelled = true;
446
+ return true;
447
+ }
448
+ if (zerr?.code === "observation_timeout" || zerr?.code === "timeout") {
449
+ timedOut = true;
450
+ return true;
451
+ }
452
+ return false;
453
+ };
454
+ /** After a host read resolves/rejects, expiry and cancel stay terminal. */
455
+ const deadlineOrSignal = () => {
456
+ if (cancelled || timedOut)
457
+ return true;
458
+ try {
459
+ budget.require();
460
+ return false;
461
+ }
462
+ catch (err) {
463
+ return markStop(err);
464
+ }
465
+ };
466
+ const finishHost = (session, sessionOrigin) => {
467
+ const reason = cancelled ? "skipped_upstream" : timedOut ? "zellij_timeout" : "skipped_upstream";
468
+ const remedy = timedOut
469
+ ? "Doctor budget expired during host inspection."
470
+ : cancelled
471
+ ? null
472
+ : REMEDY.zellij;
473
+ checks.push(...skipHostChecks(checks, reason, remedy ?? REMEDY.zellij));
474
+ return { checks, session, sessionOrigin, cancelled, timedOut };
475
+ };
476
+ try {
477
+ budget.require();
478
+ }
479
+ catch (err) {
480
+ if (markStop(err)) {
481
+ const code = timedOut ? "zellij_timeout" : "zellij_cancelled";
482
+ checks.push(check("zellij_binary", "host", "skipped", code, 0, { reason: timedOut ? "deadline_expired" : "cancelled" }, timedOut ? "Doctor budget expired during host inspection." : null));
483
+ const resolvedEarly = sessionFromEnv(input.env, input.sessionArg);
484
+ return finishHost(resolvedEarly?.session ?? null, originLabel(resolvedEarly?.source));
485
+ }
486
+ throw err;
487
+ }
488
+ if (input.busUnsupported) {
489
+ const remedy = input.busRemedy ?? REMEDY.serve;
490
+ checks.push(check("bus_artifact", "host", "skipped", "bus_remote_unsupported", 0, { reason: "direct_ssh" }, remedy), check("bus_marker", "host", "skipped", "bus_remote_unsupported", 0, { reason: "direct_ssh" }, remedy), check("bus_instance", "host", "skipped", "bus_remote_unsupported", 0, { reason: "direct_ssh", readiness: "unsupported" }, remedy));
491
+ }
492
+ let sessions = [];
493
+ let binaryOk = false;
494
+ const binaryStarted = started();
495
+ try {
496
+ const left = budget.require();
497
+ sessions = await input.client.listSessions(left, { fresh: true });
498
+ binaryOk = true;
499
+ checks.push(check("zellij_binary", "host", "ok", "zellij_ok", elapsedSince(input.clock, binaryStarted), {
500
+ zellij: input.client.zellijPath,
501
+ transport: input.client.transport.kind,
502
+ }));
503
+ }
504
+ catch (err) {
505
+ const zerr = err instanceof ZellijError ? err : null;
506
+ const code = zerr?.code === "zellij_missing"
507
+ ? "zellij_missing"
508
+ : zerr?.code === "zellij_wrong_bin"
509
+ ? "zellij_wrong_bin"
510
+ : zerr?.code === "zellij_incompatible"
511
+ ? "zellij_incompatible"
512
+ : zerr?.code === "cancelled"
513
+ ? "zellij_cancelled"
514
+ : zerr?.code === "observation_timeout"
515
+ ? "zellij_timeout"
516
+ : "zellij_unreachable";
517
+ const sshish = zerr &&
518
+ /permission denied|host key|could not resolve|connection refused|authentication/.test(zerr.message.toLowerCase());
519
+ const origin = zerr?.details && typeof zerr.details.origin === "string" ? zerr.details.origin : undefined;
520
+ const spawn = zerr?.details?.spawn;
521
+ checks.push(check("zellij_binary", "host", zerr?.code === "cancelled" || code === "zellij_timeout" ? "skipped" : "fail", sshish ? "zellij_unreachable" : code, elapsedSince(input.clock, binaryStarted), {
522
+ message: zerr?.message ?? (err instanceof Error ? err.message : String(err)),
523
+ ...(origin ? { origin } : {}),
524
+ ...(spawn && typeof spawn === "object" ? { spawn } : {}),
525
+ }, REMEDY.zellij));
526
+ if (markStop(err)) {
527
+ const resolvedEarly = sessionFromEnv(input.env, input.sessionArg);
528
+ return finishHost(resolvedEarly?.session ?? null, originLabel(resolvedEarly?.source));
529
+ }
530
+ }
531
+ const ipcStarted = started();
532
+ const ipc = input.client.transport.ipc;
533
+ if (!binaryOk) {
534
+ checks.push(check("zellij_ipc", "host", "skipped", "ipc_skipped_upstream", elapsedSince(input.clock, ipcStarted), {}, REMEDY.ipc));
535
+ }
536
+ else if (input.client.transport.kind === "local") {
537
+ checks.push(check("zellij_ipc", "host", "ok", "ipc_local", elapsedSince(input.clock, ipcStarted), { transport: "local" }));
538
+ }
539
+ else if (!ipc || ipc.status === "none" || ipc.status === "skipped") {
540
+ checks.push(check("zellij_ipc", "host", "warn", "ipc_unresolved", elapsedSince(input.clock, ipcStarted), { requested: ipc?.requested ?? null, status: ipc?.status ?? "none" }, REMEDY.ipc));
541
+ }
542
+ else if (ipc.status === "resolved") {
543
+ checks.push(check("zellij_ipc", "host", "ok", "ipc_ok", elapsedSince(input.clock, ipcStarted), { requested: ipc.requested ?? null, tmp: ipc.tmp ?? null, status: ipc.status }));
544
+ }
545
+ else {
546
+ checks.push(check("zellij_ipc", "host", "fail", "ipc_failed", elapsedSince(input.clock, ipcStarted), { requested: ipc.requested ?? null, status: ipc.status }, REMEDY.ipc));
547
+ }
548
+ const listed = liveSessionNames(sessions);
549
+ const sessionsStarted = started();
550
+ if (!binaryOk) {
551
+ checks.push(check("zellij_sessions", "host", "skipped", "sessions_skipped_upstream", elapsedSince(input.clock, sessionsStarted), {}, REMEDY.session));
552
+ }
553
+ else if (listed.length === 0) {
554
+ checks.push(check("zellij_sessions", "host", "warn", "sessions_none", elapsedSince(input.clock, sessionsStarted), { live: [], exited: sessions.filter((row) => row.exited).map((row) => row.name) }, REMEDY.session));
555
+ }
556
+ else {
557
+ checks.push(check("zellij_sessions", "host", "ok", "sessions_visible", elapsedSince(input.clock, sessionsStarted), { live: listed }));
558
+ }
559
+ const resolved = sessionFromEnv(input.env, input.sessionArg);
560
+ const sessionStarted = started();
561
+ let session = resolved?.session ?? null;
562
+ let sessionOrigin = originLabel(resolved?.source);
563
+ if (!binaryOk) {
564
+ checks.push(check("session", "host", "skipped", "session_skipped_upstream", elapsedSince(input.clock, sessionStarted), { requested: session }, REMEDY.session));
565
+ }
566
+ else if (session) {
567
+ const live = listed.includes(session);
568
+ const exited = sessions.some((row) => row.name === session && row.exited);
569
+ checks.push(check("session", "host", live ? "ok" : "fail", live ? "session_present" : "session_missing", elapsedSince(input.clock, sessionStarted), { session, origin: sessionOrigin, exited }, live ? null : REMEDY.session));
570
+ }
571
+ else if (listed.length === 1) {
572
+ session = listed[0];
573
+ sessionOrigin = "host_default";
574
+ checks.push(check("session", "host", "ok", "session_present", elapsedSince(input.clock, sessionStarted), { session, origin: sessionOrigin }));
575
+ }
576
+ else {
577
+ checks.push(check("session", "host", listed.length === 0 ? "warn" : "warn", listed.length === 0 ? "session_unresolved" : "session_unresolved", elapsedSince(input.clock, sessionStarted), { live: listed, origin: "unresolved" }, listed.length === 0 ? REMEDY.session : "Pass --session; multiple live sessions are present."));
578
+ sessionOrigin = "unresolved";
579
+ }
580
+ if (!input.busUnsupported) {
581
+ const artifactStarted = started();
582
+ const plugin = resolveBusPlugin(input.env);
583
+ const explicit = (input.env.ZSWARM_BUS_PLUGIN ?? "").trim();
584
+ if (plugin && existsSync(plugin)) {
585
+ checks.push(check("bus_artifact", "host", "ok", "bus_artifact_present", elapsedSince(input.clock, artifactStarted), { plugin }));
586
+ }
587
+ else {
588
+ checks.push(check("bus_artifact", "host", "warn", "bus_artifact_missing", elapsedSince(input.clock, artifactStarted), { configured: explicit || null }, "Missing wasm is degraded performance, not an unusable crew. Build or set ZSWARM_BUS_PLUGIN on the crew host."));
589
+ }
590
+ const markerStarted = started();
591
+ if (!session) {
592
+ checks.push(check("bus_marker", "host", "skipped", "bus_marker_no_session", elapsedSince(input.clock, markerStarted), {}, "Install the bus per session after a live crew session exists."));
593
+ checks.push(check("bus_instance", "host", "skipped", "bus_instance_no_session", 0, { readiness: "unknown" }, "Bus readiness stays unknown until a session can be listed without launching a plugin."));
594
+ }
595
+ else {
596
+ const marker = input.state.readBus(session);
597
+ if (!marker) {
598
+ checks.push(check("bus_marker", "host", "warn", "bus_marker_missing", elapsedSince(input.clock, markerStarted), { session }, 'Bus is not installed for this session; run zswarm bus --install on the crew host.'));
599
+ }
600
+ else if (!existsSync(marker.plugin)) {
601
+ checks.push(check("bus_marker", "host", "warn", "bus_marker_stale", elapsedSince(input.clock, markerStarted), { session, plugin: marker.plugin, installedAt: marker.installedAt }, "The install marker names a wasm that is no longer present. Reinstall the bus on the crew host."));
602
+ }
603
+ else {
604
+ checks.push(check("bus_marker", "host", "ok", "bus_marker_present", elapsedSince(input.clock, markerStarted), { session, plugin: marker.plugin, installedAt: marker.installedAt }));
605
+ }
606
+ const instanceStarted = started();
607
+ if (busDisabled(input.env)) {
608
+ checks.push(check("bus_instance", "host", "skipped", "bus_disabled", elapsedSince(input.clock, instanceStarted), { readiness: "disabled", policy: "ZSWARM_BUS=0" }, "Host policy disabled the bus (ZSWARM_BUS=0)."));
609
+ }
610
+ else if (!binaryOk) {
611
+ checks.push(check("bus_instance", "host", "skipped", "bus_instance_skipped_upstream", elapsedSince(input.clock, instanceStarted), { readiness: "unknown" }, null));
612
+ }
613
+ else if (deadlineOrSignal()) {
614
+ checks.push(check("bus_instance", "host", "skipped", timedOut ? "zellij_timeout" : "zellij_cancelled", elapsedSince(input.clock, instanceStarted), { readiness: "unknown", reason: timedOut ? "deadline_expired" : "cancelled" }, timedOut ? "Doctor budget expired during host inspection." : null));
615
+ return finishHost(session, sessionOrigin);
616
+ }
617
+ else {
618
+ try {
619
+ const panes = await input.client.listPanes(session, budget.require(), {
620
+ fresh: true,
621
+ });
622
+ const late = deadlineOrSignal();
623
+ const pluginPath = marker && existsSync(marker.plugin) ? marker.plugin : plugin;
624
+ const live = panes.filter((pane) => isBusPluginPane(pane, pluginPath));
625
+ checks.push(check("bus_instance", "host", live.length > 0 ? "ok" : "warn", live.length > 0 ? "bus_instance_observed" : "bus_instance_absent", elapsedSince(input.clock, instanceStarted), {
626
+ session,
627
+ instances: live.map((pane) => pane.id),
628
+ readiness: "unknown",
629
+ note: "Readiness is unknown: doctor does not pipe to the plugin or nudge panes.",
630
+ }, live.length > 0
631
+ ? null
632
+ : "No live bus plugin pane was observed. Missing/unready bus is degraded performance; doctor will not launch one."));
633
+ if (late)
634
+ return finishHost(session, sessionOrigin);
635
+ }
636
+ catch (err) {
637
+ const late = markStop(err) || deadlineOrSignal();
638
+ if (late) {
639
+ checks.push(check("bus_instance", "host", "skipped", timedOut ? "zellij_timeout" : "zellij_cancelled", elapsedSince(input.clock, instanceStarted), { readiness: "unknown", reason: timedOut ? "deadline_expired" : "cancelled" }, timedOut ? "Doctor budget expired during host inspection." : null));
640
+ return finishHost(session, sessionOrigin);
641
+ }
642
+ checks.push(check("bus_instance", "host", "warn", "bus_readiness_unknown", elapsedSince(input.clock, instanceStarted), {
643
+ readiness: "unknown",
644
+ message: err instanceof Error ? err.message : String(err),
645
+ }, "Could not list panes to observe a bus instance. Silence does not mean permissions are pending."));
646
+ }
647
+ }
648
+ }
649
+ }
650
+ if (deadlineOrSignal())
651
+ return finishHost(session, sessionOrigin);
652
+ return { checks, session, sessionOrigin, cancelled, timedOut };
653
+ }
654
+ export function hostDoctorRequest(remainingMs, explicitSession) {
655
+ const request = {
656
+ op: "doctor",
657
+ [DOCTOR_SCOPE_FIELD]: DOCTOR_SCOPE_HOST,
658
+ timeoutMs: Math.max(1, remainingMs),
659
+ };
660
+ if (explicitSession)
661
+ request.session = explicitSession;
662
+ return request;
663
+ }
664
+ function asDoctorCheck(value) {
665
+ if (!value || typeof value !== "object")
666
+ return null;
667
+ const row = value;
668
+ if (typeof row.id !== "string" || row.id.trim() === "")
669
+ return null;
670
+ if (row.scope !== "controller" && row.scope !== "host")
671
+ return null;
672
+ if (row.state !== "ok" && row.state !== "warn" && row.state !== "fail" && row.state !== "skipped") {
673
+ return null;
674
+ }
675
+ if (typeof row.code !== "string" || row.code.trim() === "")
676
+ return null;
677
+ const elapsedMs = typeof row.elapsedMs === "number" && Number.isFinite(row.elapsedMs) ? row.elapsedMs : 0;
678
+ const detail = row.detail && typeof row.detail === "object" && !Array.isArray(row.detail)
679
+ ? row.detail
680
+ : {};
681
+ const remedy = typeof row.remedy === "string" ? row.remedy : row.remedy === null ? null : null;
682
+ return {
683
+ id: row.id,
684
+ scope: row.scope,
685
+ state: row.state,
686
+ code: row.code,
687
+ elapsedMs,
688
+ detail,
689
+ remedy,
690
+ };
691
+ }
692
+ function reportFromUnknown(value) {
693
+ if (!value || typeof value !== "object")
694
+ return null;
695
+ const row = value;
696
+ const routeRaw = row.route;
697
+ const checksRaw = row.checks;
698
+ if (!routeRaw || typeof routeRaw !== "object" || Array.isArray(routeRaw) || !Array.isArray(checksRaw)) {
699
+ return null;
700
+ }
701
+ const routeRow = routeRaw;
702
+ if (routeRow.transport !== "local" && routeRow.transport !== "ssh" && routeRow.transport !== "serve") {
703
+ return null;
704
+ }
705
+ const checks = [];
706
+ const seenRequired = new Set();
707
+ for (const item of checksRaw) {
708
+ const parsed = asDoctorCheck(item);
709
+ if (!parsed)
710
+ return null;
711
+ if (REQUIRED_HOST_LAYERS.includes(parsed.id) && parsed.scope === "host") {
712
+ if (seenRequired.has(parsed.id))
713
+ return null;
714
+ seenRequired.add(parsed.id);
715
+ }
716
+ checks.push(parsed);
717
+ }
718
+ const endpoint = typeof routeRow.endpoint === "string" ? routeRow.endpoint : "";
719
+ const session = typeof routeRow.session === "string" ? routeRow.session : routeRow.session === null ? null : null;
720
+ const sessionOrigin = typeof routeRow.sessionOrigin === "string" ? routeRow.sessionOrigin : "unresolved";
721
+ return {
722
+ route: {
723
+ transport: routeRow.transport,
724
+ endpoint,
725
+ session,
726
+ sessionOrigin,
727
+ },
728
+ checks,
729
+ };
730
+ }
731
+ function hostChecksFrom(report) {
732
+ return report.checks.filter((item) => item.scope === "host");
733
+ }
734
+ function hostSessionCovers(checks, explicit) {
735
+ const row = checks.find((item) => item.id === "session");
736
+ if (!row)
737
+ return false;
738
+ if (!explicit)
739
+ return true;
740
+ const named = [row.detail.session, row.detail.requested].filter((value) => typeof value === "string" && value.length > 0);
741
+ return named.includes(explicit);
742
+ }
743
+ function hostRequestFailure(result) {
744
+ const failed = serveFailure(result);
745
+ const mapped = failed.code === "serve_unauthorized"
746
+ ? "host_request_unauthorized"
747
+ : failed.code === "serve_connect"
748
+ ? "host_request_connect"
749
+ : failed.code === "serve_timeout"
750
+ ? "host_request_timeout"
751
+ : failed.code === "serve_cancelled"
752
+ ? "host_request_cancelled"
753
+ : "host_request_protocol";
754
+ return { code: mapped, remedy: failed.remedy, detail: failed.detail };
755
+ }
756
+ function failReachedHostLayer(existing, code, remedy, detail = {}) {
757
+ const extra = [];
758
+ if (!existing.some((item) => item.id === "zellij_binary")) {
759
+ extra.push(check("zellij_binary", "host", "fail", code, 0, detail, remedy));
760
+ }
761
+ extra.push(...skipHostChecks([...existing, ...extra], "skipped_upstream", remedy));
762
+ return extra;
763
+ }
764
+ /** Envelope failure after hello: keep completed host rows; serialize the request cause once. */
765
+ function hostRequestEvidence(failed, message) {
766
+ const cause = typeof failed.detail.transportCode === "string" && failed.detail.transportCode.length > 0
767
+ ? failed.detail.transportCode
768
+ : failed.code;
769
+ return check("host_request", "host", "fail", failed.code, 0, { cause, message }, failed.remedy);
770
+ }
771
+ function appendHostRequestFailure(existing, failed, message) {
772
+ const extra = [hostRequestEvidence(failed, message)];
773
+ extra.push(...skipHostChecks([...existing, ...extra], "skipped_upstream", failed.remedy));
774
+ return extra;
775
+ }
776
+ export function coverHostReport(checks, explicit) {
777
+ const out = [...checks];
778
+ if (!out.some((item) => item.id === "zellij_binary")) {
779
+ out.push(check("zellij_binary", "host", "fail", HOST_REPORT_INCOMPLETE_CODE, 0, { missing: "zellij_binary" }, REMEDY.hostReport));
780
+ }
781
+ const sessionRow = out.find((item) => item.id === "session");
782
+ if (!sessionRow || !hostSessionCovers(out, explicit)) {
783
+ const replacement = check("session", "host", "fail", HOST_REPORT_INCOMPLETE_CODE, 0, { requested: explicit, reason: sessionRow ? "session_not_covered" : "session_missing_from_report" }, REMEDY.hostReport);
784
+ const idx = out.findIndex((item) => item.id === "session");
785
+ if (idx >= 0)
786
+ out[idx] = replacement;
787
+ else
788
+ out.push(replacement);
789
+ }
790
+ for (const id of REQUIRED_HOST_LAYERS) {
791
+ const idx = out.findIndex((item) => item.id === id);
792
+ if (idx < 0) {
793
+ out.push(check(id, "host", "fail", HOST_REPORT_INCOMPLETE_CODE, 0, { missing: id }, REMEDY.hostReport));
794
+ continue;
795
+ }
796
+ if (out[idx].state === "skipped") {
797
+ out[idx] = check(id, "host", "fail", HOST_REPORT_INCOMPLETE_CODE, out[idx].elapsedMs, { ...out[idx].detail, missing: id, reason: "skipped_placeholder" }, REMEDY.hostReport);
798
+ }
799
+ }
800
+ out.push(...skipHostChecks(out, "skipped_upstream", REMEDY.hostReport));
801
+ return out;
802
+ }
803
+ export function mergeHostReply(result) {
804
+ if (!result.ok && (result.error.code === "usage" || /requires op=/.test(result.error.message))) {
805
+ return { checks: [], unsupported: true };
806
+ }
807
+ const raw = result.ok ? result.data : result.error.details;
808
+ const report = reportFromUnknown(raw);
809
+ if (result.ok) {
810
+ if (!report)
811
+ return { checks: [], invalid: true };
812
+ const checks = hostChecksFrom(report);
813
+ if (checks.length === 0)
814
+ return { checks: [], invalid: true, session: report.route.session, sessionOrigin: report.route.sessionOrigin };
815
+ return {
816
+ checks,
817
+ session: report.route.session,
818
+ sessionOrigin: report.route.sessionOrigin,
819
+ };
820
+ }
821
+ if (result.error.code === DOCTOR_FAILED_CODE) {
822
+ if (!report)
823
+ return { checks: [], invalid: true };
824
+ const checks = hostChecksFrom(report);
825
+ if (checks.length === 0) {
826
+ return { checks: [], invalid: true, session: report.route.session, sessionOrigin: report.route.sessionOrigin };
827
+ }
828
+ return {
829
+ checks,
830
+ session: report.route.session,
831
+ sessionOrigin: report.route.sessionOrigin,
832
+ };
833
+ }
834
+ if (!report)
835
+ return { checks: [] };
836
+ return {
837
+ checks: hostChecksFrom(report),
838
+ session: report.route.session,
839
+ sessionOrigin: report.route.sessionOrigin,
840
+ };
841
+ }
842
+ async function hostDoctorInProcess(input) {
843
+ const hostEnv = input.stripRouting === false ? input.env : serveChildEnv(input.env);
844
+ const client = input.injected ??
845
+ createZellijClient({
846
+ env: hostEnv,
847
+ signal: input.signal,
848
+ now: input.clock.now,
849
+ });
850
+ const state = input.deps.state ?? createStateStore({ env: hostEnv });
851
+ return inspectDoctorHost({
852
+ client,
853
+ state,
854
+ env: hostEnv,
855
+ sessionArg: selectedSession(input.args),
856
+ clock: input.clock,
857
+ timeoutMs: input.timeoutMs,
858
+ signal: input.signal,
859
+ busUnsupported: input.busUnsupported,
860
+ busRemedy: REMEDY.serve,
861
+ });
862
+ }
863
+ function interactiveSsh(env) {
864
+ try {
865
+ return validateSshMode(env.ZSWARM_SSH_MODE ?? "") === "interactive";
866
+ }
867
+ catch {
868
+ return false;
869
+ }
870
+ }
871
+ function sshHasPositiveEvidence(inspect) {
872
+ return inspect.checks.some((item) => {
873
+ if (item.id === "zellij_sessions" && item.state === "ok")
874
+ return true;
875
+ if (item.id === "session" && item.state === "ok")
876
+ return true;
877
+ if (item.id !== "zellij_binary")
878
+ return false;
879
+ if (item.state === "ok")
880
+ return true;
881
+ // Local spawn/preflight is not remote proof. Missing origin is not remote.
882
+ if (item.detail.origin !== "remote")
883
+ return false;
884
+ return (item.code === "zellij_missing" ||
885
+ item.code === "zellij_wrong_bin" ||
886
+ item.code === "zellij_incompatible");
887
+ });
888
+ }
889
+ function adoptHostInspectSession(report, inspect) {
890
+ const explicit = report.route.sessionOrigin === "explicit" || report.route.sessionOrigin === "inherited";
891
+ if (explicit) {
892
+ if (inspect.session && report.route.sessionOrigin === "explicit") {
893
+ report.route.session = inspect.session;
894
+ }
895
+ return;
896
+ }
897
+ if (inspect.session) {
898
+ report.route.session = inspect.session;
899
+ if (inspect.sessionOrigin && inspect.sessionOrigin !== "unresolved") {
900
+ report.route.sessionOrigin = inspect.sessionOrigin;
901
+ }
902
+ return;
903
+ }
904
+ if (inspect.sessionOrigin && report.route.sessionOrigin === "unresolved") {
905
+ report.route.sessionOrigin = inspect.sessionOrigin;
906
+ }
907
+ }
908
+ async function inspectDirectSsh(input) {
909
+ const sshStarted = input.clock.now();
910
+ if (interactiveSsh(input.env) && !input.injected) {
911
+ input.checks.push(check("ssh", "controller", "skipped", "ssh_interactive_uninspected", elapsedSince(input.clock, sshStarted), { mode: "interactive" }, REMEDY.interactive), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...skipHostChecks(input.checks, "zellij_interactive_unsupported", REMEDY.interactive));
912
+ return;
913
+ }
914
+ try {
915
+ throwIfAborted(input.signal);
916
+ if (input.remaining() <= 0) {
917
+ input.status.timedOut = true;
918
+ input.checks.push(check("ssh", "controller", "fail", "ssh_timeout", elapsedSince(input.clock, sshStarted), {}, "SSH inspection ran out of doctor budget."));
919
+ input.checks.push(check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
920
+ return;
921
+ }
922
+ const inspect = await hostDoctorInProcess({
923
+ args: input.args,
924
+ injected: input.injected,
925
+ deps: input.deps,
926
+ env: input.env,
927
+ clock: input.clock,
928
+ timeoutMs: input.remaining(),
929
+ signal: input.signal,
930
+ busUnsupported: true,
931
+ stripRouting: false,
932
+ });
933
+ if (inspect.cancelled) {
934
+ input.status.cancelled = true;
935
+ adoptHostInspectSession(input.report, inspect);
936
+ input.checks.push(check("ssh", "controller", "skipped", "ssh_cancelled", elapsedSince(input.clock, sshStarted), {}, null), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...(inspect.checks.length ? inspect.checks : skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve)));
937
+ return;
938
+ }
939
+ if (inspect.timedOut) {
940
+ input.status.timedOut = true;
941
+ adoptHostInspectSession(input.report, inspect);
942
+ const binaryOk = inspect.checks.some((item) => item.id === "zellij_binary" && item.state === "ok");
943
+ input.checks.push(binaryOk
944
+ ? check("ssh", "controller", "ok", "ssh_ready", elapsedSince(input.clock, sshStarted), { destination: input.env.ZSWARM_SSH ?? null })
945
+ : check("ssh", "controller", "fail", "ssh_timeout", elapsedSince(input.clock, sshStarted), {}, "SSH inspection ran out of doctor budget."), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...(inspect.checks.length ? inspect.checks : skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve)));
946
+ return;
947
+ }
948
+ adoptHostInspectSession(input.report, inspect);
949
+ if (!sshHasPositiveEvidence(inspect)) {
950
+ const binary = inspect.checks.find((item) => item.id === "zellij_binary");
951
+ const classified = sshFailure(String(binary?.detail.message ?? ""));
952
+ input.checks.push(check("ssh", "controller", "fail", classified.code, elapsedSince(input.clock, sshStarted), binary?.detail ?? {}, classified.remedy), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...inspect.checks);
953
+ return;
954
+ }
955
+ input.checks.push(check("ssh", "controller", "ok", "ssh_ready", elapsedSince(input.clock, sshStarted), { destination: input.env.ZSWARM_SSH ?? null }), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...inspect.checks);
956
+ }
957
+ catch (err) {
958
+ if (err instanceof ZellijError && err.code === "cancelled") {
959
+ input.status.cancelled = true;
960
+ input.checks.push(check("ssh", "controller", "skipped", "ssh_cancelled", elapsedSince(input.clock, sshStarted), {}, null), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
961
+ return;
962
+ }
963
+ if (err instanceof ZellijError && (err.code === "timeout" || err.code === "observation_timeout")) {
964
+ input.status.timedOut = true;
965
+ input.checks.push(check("ssh", "controller", "fail", "ssh_timeout", elapsedSince(input.clock, sshStarted), {}, "SSH inspection ran out of doctor budget."), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
966
+ return;
967
+ }
968
+ if (err instanceof ZellijError && (err.code === "usage" || err.code === "bad_arg" || err.code === "bad_ssh" || err.code === "bad_ssh_mode")) {
969
+ throw err;
970
+ }
971
+ const message = err instanceof Error ? err.message : String(err);
972
+ const classified = sshFailure(message);
973
+ input.checks.push(check("ssh", "controller", "fail", classified.code, elapsedSince(input.clock, sshStarted), { message }, classified.remedy), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null), ...skipHostChecks(input.checks, "skipped_upstream", classified.remedy));
974
+ }
975
+ }
976
+ async function inspectServeRoute(input) {
977
+ const sshTarget = isSshServeTarget(input.target);
978
+ let manager = input.deps.serveTunnels;
979
+ let ownedManager;
980
+ let handle;
981
+ let endpoint = input.target;
982
+ const token = input.env.ZSWARM_SERVE_TOKEN;
983
+ const explicit = selectedSession(input.args);
984
+ const releaseOwned = async () => {
985
+ try {
986
+ await handle?.release();
987
+ }
988
+ finally {
989
+ handle = undefined;
990
+ if (ownedManager) {
991
+ await ownedManager.closeAll();
992
+ ownedManager = undefined;
993
+ }
994
+ }
995
+ };
996
+ try {
997
+ if (sshTarget) {
998
+ const sshStarted = input.clock.now();
999
+ if (!manager) {
1000
+ ownedManager = createServeTunnelManager({
1001
+ persistIdle: false,
1002
+ now: input.clock.now,
1003
+ sleep: input.clock.sleep,
1004
+ });
1005
+ manager = ownedManager;
1006
+ }
1007
+ if (input.remaining() <= 0) {
1008
+ input.status.timedOut = true;
1009
+ input.checks.push(check("ssh", "controller", "fail", "ssh_timeout", elapsedSince(input.clock, sshStarted), {}, "SSH LocalForward ran out of doctor budget."), check("serve", "controller", "skipped", "serve_skipped_upstream", 0, {}, REMEDY.serve), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
1010
+ return;
1011
+ }
1012
+ const acquired = await manager.acquire(input.target, {
1013
+ env: input.env,
1014
+ token,
1015
+ timeoutMs: input.remaining(),
1016
+ signal: input.signal,
1017
+ });
1018
+ if (!("handle" in acquired) || acquired.ok !== true) {
1019
+ const err = acquired;
1020
+ const message = err.ok ? "ssh tunnel failed" : err.error.message;
1021
+ const code = err.ok ? "serve_unreachable" : err.error.code;
1022
+ if (code === "cancelled") {
1023
+ input.status.cancelled = true;
1024
+ input.checks.push(check("ssh", "controller", "skipped", "ssh_cancelled", elapsedSince(input.clock, sshStarted), { message }, null), check("serve", "controller", "skipped", "serve_skipped_upstream", 0, {}, REMEDY.serve), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
1025
+ return;
1026
+ }
1027
+ if (code === "timeout") {
1028
+ input.status.timedOut = true;
1029
+ input.checks.push(check("ssh", "controller", "fail", "ssh_timeout", elapsedSince(input.clock, sshStarted), { message }, "SSH LocalForward timed out inside the doctor budget."), check("serve", "controller", "skipped", "serve_skipped_upstream", 0, {}, REMEDY.serve), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
1030
+ return;
1031
+ }
1032
+ if (code === "serve_unauthorized" ||
1033
+ code === "serve_hello_unsupported" ||
1034
+ code === "serve_incompatible" ||
1035
+ code === "serve_protocol") {
1036
+ input.checks.push(check("ssh", "controller", "ok", "ssh_ready", elapsedSince(input.clock, sshStarted), { destination: describeServeTarget(input.target) }));
1037
+ const failed = serveFailure(err);
1038
+ input.checks.push(check("serve", "controller", "fail", failed.code, 0, failed.detail, failed.remedy), ...skipHostChecks(input.checks, "skipped_upstream", failed.remedy));
1039
+ return;
1040
+ }
1041
+ const classified = sshFailure(message);
1042
+ input.checks.push(check("ssh", "controller", "fail", classified.code, elapsedSince(input.clock, sshStarted), { message, transportCode: code }, classified.remedy), check("serve", "controller", "skipped", "serve_skipped_upstream", 0, {}, classified.remedy), ...skipHostChecks(input.checks, "skipped_upstream", classified.remedy));
1043
+ return;
1044
+ }
1045
+ handle = acquired.handle;
1046
+ endpoint = acquired.localTarget;
1047
+ input.report.server = acquired.hello;
1048
+ input.checks.push(check("ssh", "controller", "ok", "ssh_ready", elapsedSince(input.clock, sshStarted), {
1049
+ destination: acquired.handle.identity,
1050
+ localTarget: acquired.localTarget,
1051
+ }));
1052
+ input.checks.push(check("serve", "controller", "ok", "serve_hello_ok", 0, { ...acquired.hello, endpoint: acquired.localTarget }));
1053
+ }
1054
+ else {
1055
+ input.checks.push(check("ssh", "controller", "skipped", "ssh_not_applicable", 0, { endpoint: input.target }, null));
1056
+ const helloStarted = input.clock.now();
1057
+ if (input.remaining() <= 0) {
1058
+ input.status.timedOut = true;
1059
+ input.checks.push(check("serve", "controller", "fail", "serve_timeout", elapsedSince(input.clock, helloStarted), {}, "Serve hello ran out of doctor budget."), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
1060
+ return;
1061
+ }
1062
+ const probed = await probeServe(endpoint, {
1063
+ token,
1064
+ timeoutMs: input.remaining(),
1065
+ signal: input.signal,
1066
+ });
1067
+ if (!probed.ok) {
1068
+ if (probed.error.code === "cancelled") {
1069
+ input.status.cancelled = true;
1070
+ input.checks.push(check("serve", "controller", "skipped", "serve_cancelled", elapsedSince(input.clock, helloStarted), serveFailure(probed).detail, null), ...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
1071
+ return;
1072
+ }
1073
+ if (probed.error.code === "timeout") {
1074
+ input.status.timedOut = true;
1075
+ }
1076
+ const failed = serveFailure(probed);
1077
+ input.checks.push(check("serve", "controller", "fail", failed.code, elapsedSince(input.clock, helloStarted), failed.detail, failed.remedy), ...skipHostChecks(input.checks, "skipped_upstream", failed.remedy));
1078
+ return;
1079
+ }
1080
+ input.report.server = probed.data;
1081
+ input.checks.push(check("serve", "controller", "ok", "serve_hello_ok", elapsedSince(input.clock, helloStarted), { ...probed.data, endpoint }));
1082
+ }
1083
+ if (input.status.cancelled || input.status.timedOut)
1084
+ return;
1085
+ if (input.remaining() <= 0) {
1086
+ input.status.timedOut = true;
1087
+ input.checks.push(...skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve));
1088
+ return;
1089
+ }
1090
+ const hostResult = await callServe(endpoint, hostDoctorRequest(input.remaining(), explicit), {
1091
+ timeoutMs: Math.max(1, input.remaining()),
1092
+ token,
1093
+ signal: input.signal,
1094
+ });
1095
+ const merged = mergeHostReply(hostResult);
1096
+ const adoptHostSession = () => {
1097
+ if (merged.session && input.report.route.sessionOrigin === "unresolved") {
1098
+ input.report.route.session = merged.session ?? null;
1099
+ if (merged.sessionOrigin)
1100
+ input.report.route.sessionOrigin = merged.sessionOrigin;
1101
+ }
1102
+ if (explicit && merged.sessionOrigin === "explicit") {
1103
+ input.report.route.session = merged.session ?? explicit;
1104
+ input.report.route.sessionOrigin = "explicit";
1105
+ }
1106
+ };
1107
+ if (!hostResult.ok && hostResult.error.code === "cancelled") {
1108
+ input.status.cancelled = true;
1109
+ input.checks.push(...(merged.checks.length
1110
+ ? [...merged.checks, ...skipHostChecks([...input.checks, ...merged.checks], "skipped_upstream", REMEDY.serve)]
1111
+ : skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve)));
1112
+ adoptHostSession();
1113
+ return;
1114
+ }
1115
+ if (!hostResult.ok && hostResult.error.code === "timeout") {
1116
+ input.status.timedOut = true;
1117
+ input.checks.push(...(merged.checks.length
1118
+ ? [...merged.checks, ...skipHostChecks([...input.checks, ...merged.checks], "skipped_upstream", REMEDY.serve)]
1119
+ : skipHostChecks(input.checks, "skipped_upstream", REMEDY.serve)));
1120
+ adoptHostSession();
1121
+ return;
1122
+ }
1123
+ if (merged.unsupported) {
1124
+ input.checks.push(...skipHostChecks(input.checks, "doctor_unsupported", REMEDY.upgrade));
1125
+ return;
1126
+ }
1127
+ if (merged.invalid) {
1128
+ input.checks.push(...failReachedHostLayer(input.checks, HOST_REPORT_INVALID_CODE, REMEDY.hostReport, {
1129
+ reason: "empty_or_malformed_host_report",
1130
+ }));
1131
+ input.status.requiredFailure = true;
1132
+ return;
1133
+ }
1134
+ if (!hostResult.ok) {
1135
+ const failed = hostRequestFailure(hostResult);
1136
+ const preserved = merged.checks.filter((item) => item.id !== "host_request");
1137
+ input.checks.push(...preserved);
1138
+ input.checks.push(...appendHostRequestFailure(input.checks, failed, hostResult.error.message));
1139
+ input.status.requiredFailure = true;
1140
+ adoptHostSession();
1141
+ return;
1142
+ }
1143
+ input.checks.push(...coverHostReport(merged.checks, explicit));
1144
+ adoptHostSession();
1145
+ }
1146
+ finally {
1147
+ await releaseOwned();
1148
+ }
1149
+ }
1150
+ /**
1151
+ * Inspect-only layered diagnostics. Handle this before ordinary serve
1152
+ * forwarding so a dead tunnel still reports controller findings.
1153
+ */
1154
+ export async function doctorOp(args, injected, deps, context, env, clock) {
1155
+ const timeoutMs = numberArg(args, "timeoutMs", DEFAULT_DOCTOR_TIMEOUT_MS, {
1156
+ min: 1,
1157
+ max: 900_000,
1158
+ });
1159
+ const signal = deps.signal;
1160
+ const budget = observationBudget(clock, timeoutMs, signal);
1161
+ const remaining = () => {
1162
+ try {
1163
+ return budget.remaining();
1164
+ }
1165
+ catch (err) {
1166
+ if (err instanceof ZellijError && err.code === "cancelled")
1167
+ return 0;
1168
+ return 0;
1169
+ }
1170
+ };
1171
+ const status = { cancelled: false, timedOut: false, requiredFailure: false };
1172
+ const hostOnly = args[DOCTOR_SCOPE_FIELD] === DOCTOR_SCOPE_HOST;
1173
+ const serveTarget = optionalString(env.ZSWARM_SERVE);
1174
+ const sshTarget = optionalString(env.ZSWARM_SSH);
1175
+ const routeTransport = hostOnly
1176
+ ? "local"
1177
+ : serveTarget
1178
+ ? "serve"
1179
+ : sshTarget
1180
+ ? "ssh"
1181
+ : "local";
1182
+ const routeEndpoint = hostOnly
1183
+ ? hostname() || "local"
1184
+ : serveTarget
1185
+ ? describeServeTarget(serveTarget)
1186
+ : sshTarget ?? (hostname() || "local");
1187
+ const report = {
1188
+ route: {
1189
+ transport: routeTransport,
1190
+ endpoint: routeEndpoint,
1191
+ session: hostOnly ? selectedSession(args) : context.session,
1192
+ sessionOrigin: hostOnly
1193
+ ? originLabel(sessionFromEnv(serveChildEnv(env), selectedSession(args))?.source)
1194
+ : controllerSessionOrigin(context),
1195
+ },
1196
+ checks: [],
1197
+ };
1198
+ const markStop = (err) => {
1199
+ if (err instanceof ZellijError && err.code === "cancelled") {
1200
+ status.cancelled = true;
1201
+ return true;
1202
+ }
1203
+ if (err instanceof ZellijError && (err.code === "timeout" || err.code === "observation_timeout")) {
1204
+ status.timedOut = true;
1205
+ return true;
1206
+ }
1207
+ return false;
1208
+ };
1209
+ const applyDeadline = () => {
1210
+ if (status.cancelled || status.timedOut)
1211
+ return;
1212
+ try {
1213
+ throwIfAborted(signal);
1214
+ }
1215
+ catch {
1216
+ status.cancelled = true;
1217
+ return;
1218
+ }
1219
+ if (remaining() <= 0)
1220
+ status.timedOut = true;
1221
+ };
1222
+ const done = () => {
1223
+ applyDeadline();
1224
+ return finishReport(report, status);
1225
+ };
1226
+ try {
1227
+ throwIfAborted(signal);
1228
+ if (hostOnly) {
1229
+ if (remaining() <= 0) {
1230
+ if (signal?.aborted)
1231
+ status.cancelled = true;
1232
+ else if (!status.cancelled)
1233
+ status.timedOut = true;
1234
+ report.checks.push(...skipHostChecks(report.checks, "skipped_upstream", REMEDY.zellij));
1235
+ return done();
1236
+ }
1237
+ const inspect = await hostDoctorInProcess({
1238
+ args,
1239
+ injected,
1240
+ deps,
1241
+ env,
1242
+ clock,
1243
+ timeoutMs: remaining(),
1244
+ signal,
1245
+ });
1246
+ if (inspect.cancelled)
1247
+ status.cancelled = true;
1248
+ if (inspect.timedOut)
1249
+ status.timedOut = true;
1250
+ report.route.session = inspect.session;
1251
+ report.route.sessionOrigin = inspect.sessionOrigin;
1252
+ report.checks.push(...inspect.checks);
1253
+ return done();
1254
+ }
1255
+ report.checks.push(check("route", "controller", "ok", "route_selected", 0, {
1256
+ transport: report.route.transport,
1257
+ endpoint: report.route.endpoint,
1258
+ session: report.route.session,
1259
+ sessionOrigin: report.route.sessionOrigin,
1260
+ origin: context.origin,
1261
+ note: report.route.transport === "serve" && isLoopbackEndpoint(report.route.endpoint)
1262
+ ? "Loopback is the tunnel endpoint, not the crew host."
1263
+ : null,
1264
+ }));
1265
+ const tailscaleBudget = Math.min(DOCTOR_TAILSCALE_MAX_MS, Math.max(0, timeoutMs - DOCTOR_TAILSCALE_RESERVE_MS), remaining());
1266
+ const tailscaleRunner = deps.tailscaleStatus ?? defaultTailscaleStatus;
1267
+ try {
1268
+ report.checks.push(await inspectTailscale({
1269
+ route: report.route,
1270
+ env,
1271
+ clock,
1272
+ timeoutMs: tailscaleBudget,
1273
+ signal,
1274
+ runner: tailscaleRunner,
1275
+ }));
1276
+ }
1277
+ catch (err) {
1278
+ if (!markStop(err))
1279
+ throw err;
1280
+ report.checks.push(check("tailscale", "controller", "skipped", "tailscale_skipped", 0, { reason: "cancelled" }, REMEDY.tailscale));
1281
+ }
1282
+ if (status.cancelled || status.timedOut || remaining() <= 0) {
1283
+ if (remaining() <= 0 && !status.cancelled)
1284
+ status.timedOut = true;
1285
+ if (routeTransport === "serve") {
1286
+ if (!reportHas(report.checks, "ssh")) {
1287
+ report.checks.push(check("ssh", "controller", "skipped", isSshServeTarget(env.ZSWARM_SERVE ?? "") ? "ssh_skipped_upstream" : "ssh_not_applicable", 0, {}, null));
1288
+ }
1289
+ if (!reportHas(report.checks, "serve")) {
1290
+ report.checks.push(check("serve", "controller", "skipped", "serve_skipped_upstream", 0, {}, REMEDY.serve));
1291
+ }
1292
+ }
1293
+ else if (routeTransport === "ssh") {
1294
+ if (!reportHas(report.checks, "ssh")) {
1295
+ report.checks.push(check("ssh", "controller", "skipped", "ssh_skipped_upstream", 0, {}, REMEDY.serve));
1296
+ }
1297
+ }
1298
+ if (!HOST_CHECK_IDS.every((id) => reportHas(report.checks, id))) {
1299
+ report.checks.push(...skipHostChecks(report.checks, "skipped_upstream", REMEDY.serve));
1300
+ }
1301
+ return done();
1302
+ }
1303
+ if (routeTransport === "ssh") {
1304
+ await inspectDirectSsh({
1305
+ args,
1306
+ injected,
1307
+ deps,
1308
+ env,
1309
+ clock,
1310
+ remaining,
1311
+ signal,
1312
+ checks: report.checks,
1313
+ report,
1314
+ status,
1315
+ });
1316
+ if (status.cancelled || status.timedOut)
1317
+ return done();
1318
+ }
1319
+ else if (routeTransport === "serve") {
1320
+ const target = env.ZSWARM_SERVE?.trim();
1321
+ if (!target) {
1322
+ report.checks.push(check("ssh", "controller", "skipped", "ssh_not_applicable", 0, {}, null), check("serve", "controller", "fail", "serve_connect", 0, { reason: "missing_target" }, REMEDY.serve), ...skipHostChecks(report.checks, "skipped_upstream", REMEDY.serve));
1323
+ }
1324
+ else {
1325
+ await inspectServeRoute({
1326
+ args,
1327
+ deps,
1328
+ env,
1329
+ clock,
1330
+ remaining,
1331
+ signal,
1332
+ target,
1333
+ checks: report.checks,
1334
+ report,
1335
+ status,
1336
+ });
1337
+ }
1338
+ }
1339
+ else {
1340
+ report.checks.push(check("ssh", "controller", "skipped", "ssh_not_applicable", 0, {}, null), check("serve", "controller", "skipped", "serve_not_applicable", 0, {}, null));
1341
+ const inspect = await hostDoctorInProcess({
1342
+ args,
1343
+ injected,
1344
+ deps,
1345
+ env,
1346
+ clock,
1347
+ timeoutMs: remaining(),
1348
+ signal,
1349
+ });
1350
+ if (inspect.cancelled)
1351
+ status.cancelled = true;
1352
+ if (inspect.timedOut)
1353
+ status.timedOut = true;
1354
+ report.route.session = inspect.session ?? report.route.session;
1355
+ if (inspect.sessionOrigin !== "unresolved" && report.route.sessionOrigin === "unresolved") {
1356
+ report.route.sessionOrigin = inspect.sessionOrigin;
1357
+ }
1358
+ if (inspect.session && report.route.sessionOrigin === "explicit") {
1359
+ report.route.session = inspect.session;
1360
+ }
1361
+ report.checks.push(...inspect.checks);
1362
+ }
1363
+ return done();
1364
+ }
1365
+ catch (err) {
1366
+ if (err instanceof ZellijError && (err.code === "usage" || err.code === "bad_arg" || err.code === "bad_ssh" || err.code === "bad_ssh_mode" || err.code === "policy_denied")) {
1367
+ return { ok: false, error: { code: err.code, message: err.message } };
1368
+ }
1369
+ if (markStop(err)) {
1370
+ if (!HOST_CHECK_IDS.every((id) => reportHas(report.checks, id))) {
1371
+ report.checks.push(...skipHostChecks(report.checks, "skipped_upstream", REMEDY.serve));
1372
+ }
1373
+ return done();
1374
+ }
1375
+ throw err;
1376
+ }
1377
+ }