@zswarm/core 0.3.1 → 0.4.1

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/dist/index.d.ts CHANGED
@@ -24,6 +24,7 @@ export { buildServeTaskScript, DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLog
24
24
  export { allocateLoopbackPort, assertSafeSshTunnelOpts, buildSshTunnelArgv, createServeTunnelManager, DEFAULT_SSH_PORT, DEFAULT_SSH_SERVE_PORT, describeServeTarget, formatSshServeTarget, forwardServe, isSshServeTarget, parseSshServeTarget, redactSshUserinfo, serveTunnelCacheKey, SSH_SERVE_REMOTE_HOST, SSH_TUNNEL_KEEPALIVE_COUNT, SSH_TUNNEL_KEEPALIVE_INTERVAL_S, stripControllerRouting, type AcquireServeTunnelOptions, type ParsedSshServeTarget, type ServeTunnelHandle, type ServeTunnelManager, type ServeTunnelManagerOptions, } from "./ops/serve-tunnel.js";
25
25
  export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
26
26
  export { selectTargets } from "./ops/broadcast.js";
27
+ export { originFromServeRequest, originSuffix, resolveSenderOrigin, sanitizeOriginPart, type SenderOrigin, } from "./ops/delivery.js";
27
28
  export { diffScreens, cursorKey } from "./ops/tail.js";
28
29
  export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, type PeerState, } from "./ops/status.js";
29
30
  export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ export { buildServeTaskScript, DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLog
24
24
  export { allocateLoopbackPort, assertSafeSshTunnelOpts, buildSshTunnelArgv, createServeTunnelManager, DEFAULT_SSH_PORT, DEFAULT_SSH_SERVE_PORT, describeServeTarget, formatSshServeTarget, forwardServe, isSshServeTarget, parseSshServeTarget, redactSshUserinfo, serveTunnelCacheKey, SSH_SERVE_REMOTE_HOST, SSH_TUNNEL_KEEPALIVE_COUNT, SSH_TUNNEL_KEEPALIVE_INTERVAL_S, stripControllerRouting, } from "./ops/serve-tunnel.js";
25
25
  export { parseTabList, resolveTab } from "./zellij/tabs.js";
26
26
  export { selectTargets } from "./ops/broadcast.js";
27
+ export { originFromServeRequest, originSuffix, resolveSenderOrigin, sanitizeOriginPart, } from "./ops/delivery.js";
27
28
  export { diffScreens, cursorKey } from "./ops/tail.js";
28
29
  export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, } from "./ops/status.js";
29
30
  export { normalizeScreen, unfoldScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
@@ -4,6 +4,41 @@ import type { ZellijClient } from "../zellij/client.js";
4
4
  import type { ZellijPane } from "../zellij/panes.js";
5
5
  import type { Clock } from "./types.js";
6
6
  export type Submitted = boolean | "queued" | "unverified" | "not-delivered";
7
+ /**
8
+ * Where a message came from: the sending machine, the sender's Zellij session
9
+ * and pane, and whether this host received the op over `serve`. Every part is
10
+ * optional so an older caller (or a host with no Zellij env) still works.
11
+ */
12
+ export type SenderOrigin = {
13
+ /** Sending machine. */
14
+ host: string | null;
15
+ /** Sender's Zellij session. */
16
+ session: string | null;
17
+ /** Sender's pane id, e.g. "terminal_3". */
18
+ pane: string | null;
19
+ /** "serve" only when this host received the op over serve. */
20
+ via: "serve" | null;
21
+ };
22
+ /**
23
+ * Clean one origin part for the `[zswarm from=…]` header: non-strings and
24
+ * blanks become null, header-breaking characters and whitespace runs become a
25
+ * single `_` (a value never holds a space, so `key=value` pairs stay
26
+ * splittable), and the result is capped at 64 chars.
27
+ */
28
+ export declare function sanitizeOriginPart(raw: unknown): string | null;
29
+ /** The origin of the caller's own process, read from its environment. */
30
+ export declare function resolveSenderOrigin(env: NodeJS.ProcessEnv): SenderOrigin;
31
+ /**
32
+ * The origin carried by a serve request. Only the serve worker trusts it: a
33
+ * local caller cannot claim another pane. Missing/garbage input (an older
34
+ * client) keeps every part null but still records that serve carried it.
35
+ */
36
+ export declare function originFromServeRequest(raw: unknown): SenderOrigin;
37
+ /**
38
+ * " src=session/pane host=host via=serve" (only the parts that are known).
39
+ * `from=` stays the bare label, so a reader can split the header on spaces.
40
+ */
41
+ export declare function originSuffix(origin: SenderOrigin | null | undefined): string;
7
42
  export type DeliveryResult = {
8
43
  to: string;
9
44
  ok: boolean;
@@ -143,4 +178,6 @@ export declare function deliverTo(client: ZellijClient, state: StateStore, args:
143
178
  firstCallMs?: number;
144
179
  /** The observation timeout the scaled windows are capped by. */
145
180
  timeoutMs?: number;
181
+ /** Sender's environment; direct callers (restart, relay) fall back to it. */
182
+ env?: NodeJS.ProcessEnv;
146
183
  }): Promise<DeliveryResult>;
@@ -1,5 +1,84 @@
1
+ import { hostname } from "node:os";
1
2
  import { busyMarker, queuedPrompt, resolveHarness } from "../harness.js";
3
+ import { resolveSelfPaneId, resolveSelfSession } from "../zellij/session.js";
2
4
  import { isTrue, numberArg, optionalString } from "./util.js";
5
+ /** Cap for one origin part; keeps a spoofed value from wrapping the header. */
6
+ const ORIGIN_PART_MAX = 64;
7
+ /**
8
+ * Clean one origin part for the `[zswarm from=…]` header: non-strings and
9
+ * blanks become null, header-breaking characters and whitespace runs become a
10
+ * single `_` (a value never holds a space, so `key=value` pairs stay
11
+ * splittable), and the result is capped at 64 chars.
12
+ */
13
+ export function sanitizeOriginPart(raw) {
14
+ if (typeof raw !== "string")
15
+ return null;
16
+ const cleaned = raw.replace(/[\r\n[\]\s]+/g, " ").trim().replace(/ /g, "_");
17
+ if (!cleaned)
18
+ return null;
19
+ return cleaned.length <= ORIGIN_PART_MAX
20
+ ? cleaned
21
+ : cleaned.slice(0, ORIGIN_PART_MAX).replace(/_+$/, "");
22
+ }
23
+ /** The origin of the caller's own process, read from its environment. */
24
+ export function resolveSenderOrigin(env) {
25
+ const configured = env.ZSWARM_HOST;
26
+ const host = configured === undefined
27
+ ? sanitizeOriginPart(hostname().split(".")[0])
28
+ : configured === "-"
29
+ ? null
30
+ : sanitizeOriginPart(configured);
31
+ return {
32
+ host,
33
+ session: sanitizeOriginPart(resolveSelfSession(env)),
34
+ pane: sanitizeOriginPart(resolveSelfPaneId(env)),
35
+ via: null,
36
+ };
37
+ }
38
+ /**
39
+ * The origin carried by a serve request. Only the serve worker trusts it: a
40
+ * local caller cannot claim another pane. Missing/garbage input (an older
41
+ * client) keeps every part null but still records that serve carried it.
42
+ */
43
+ export function originFromServeRequest(raw) {
44
+ const record = raw !== null && typeof raw === "object" && !Array.isArray(raw)
45
+ ? raw
46
+ : {};
47
+ return {
48
+ host: sanitizeOriginPart(record.host),
49
+ session: sanitizeOriginPart(record.session),
50
+ pane: sanitizeOriginPart(record.pane),
51
+ via: "serve",
52
+ };
53
+ }
54
+ /**
55
+ * " src=session/pane host=host via=serve" (only the parts that are known).
56
+ * `from=` stays the bare label, so a reader can split the header on spaces.
57
+ */
58
+ export function originSuffix(origin) {
59
+ if (!origin)
60
+ return "";
61
+ const { session, pane, host, via } = origin;
62
+ const src = session && pane ? `${session}/${pane}` : (pane ?? session ?? null);
63
+ return ((src ? " src=" + src : "") +
64
+ (host ? " host=" + host : "") +
65
+ (via === "serve" ? " via=serve" : ""));
66
+ }
67
+ /**
68
+ * An args-supplied origin, cleaned again (a direct caller may skip dispatch),
69
+ * or null when it is absent/not an object.
70
+ */
71
+ function originArg(raw) {
72
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
73
+ return null;
74
+ const record = raw;
75
+ return {
76
+ host: sanitizeOriginPart(record.host),
77
+ session: sanitizeOriginPart(record.session),
78
+ pane: sanitizeOriginPart(record.pane),
79
+ via: record.via === "serve" ? "serve" : null,
80
+ };
81
+ }
3
82
  /** Fixed send windows before adaptive scaling; explicit flags always win. */
4
83
  export const SEND_BUDGETS = {
5
84
  /** Pause before checking the paste landed. */
@@ -97,7 +176,9 @@ export function attachKnownSender(args, env) {
97
176
  return { ...args, from: sender.label };
98
177
  }
99
178
  export function bodyText(client, args, body) {
100
- return isTrue(args.raw) ? body : client.formatPeerMessage(senderLabel(args), body);
179
+ return isTrue(args.raw)
180
+ ? body
181
+ : client.formatPeerMessage(senderLabel(args), body, originArg(args.origin));
101
182
  }
102
183
  /**
103
184
  * Caller `submit=` always wins. With none given, the pane's harness picks
@@ -292,6 +373,10 @@ export async function submitAfterEnter(client, input) {
292
373
  export async function deliverTo(client, state, args, input) {
293
374
  const { session, pane, body, op, at, clock, firstCallMs = 0, timeoutMs = 30_000 } = input;
294
375
  const from = senderLabel(args);
376
+ // Dispatch stamps args.origin for the header; a direct caller (restart
377
+ // handoff, relay) has none and is described by its own environment.
378
+ const origin = originArg(args.origin) ?? resolveSenderOrigin(input.env ?? process.env);
379
+ const headerArgs = { ...args, origin };
295
380
  const override = optionalString(args.harness);
296
381
  const mode = resolveSubmitMode(args, pane);
297
382
  const profile = resolveHarness(pane, override);
@@ -313,7 +398,7 @@ export async function deliverTo(client, state, args, input) {
313
398
  const paste = () => client.injectPane({
314
399
  session,
315
400
  paneId: pane.id,
316
- text: bodyText(client, args, body),
401
+ text: bodyText(client, headerArgs, body),
317
402
  });
318
403
  let delivered = await paste();
319
404
  let submitted = await verifySubmit(client, {
@@ -371,6 +456,10 @@ export async function deliverTo(client, state, args, input) {
371
456
  session,
372
457
  to: delivered.paneId,
373
458
  from,
459
+ fromHost: origin.host,
460
+ fromSession: origin.session,
461
+ fromPane: origin.pane,
462
+ via: origin.via,
374
463
  bytes: body.length,
375
464
  ok: false,
376
465
  detail: "submitted=not-delivered",
@@ -392,6 +481,10 @@ export async function deliverTo(client, state, args, input) {
392
481
  session,
393
482
  to: delivered.paneId,
394
483
  from,
484
+ fromHost: origin.host,
485
+ fromSession: origin.session,
486
+ fromPane: origin.pane,
487
+ via: origin.via,
395
488
  bytes: body.length,
396
489
  ok: true,
397
490
  detail: `submitted=${submitted}`,
@@ -414,6 +507,10 @@ export async function deliverTo(client, state, args, input) {
414
507
  session,
415
508
  to: pane.id,
416
509
  from,
510
+ fromHost: origin.host,
511
+ fromSession: origin.session,
512
+ fromPane: origin.pane,
513
+ via: origin.via,
417
514
  bytes: body.length,
418
515
  ok: false,
419
516
  detail: `${code}: ${message}`,
@@ -12,7 +12,7 @@ import { broadcast } from "./broadcast.js";
12
12
  import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
13
13
  import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
14
14
  import { peerCheckpoint, peerDiff } from "./review.js";
15
- import { attachKnownSender, deliverTo, scaleBudgets, withSenderLabel, selfPaneTitle } from "./delivery.js";
15
+ import { attachKnownSender, deliverTo, originFromServeRequest, resolveSenderOrigin, scaleBudgets, withSenderLabel, selfPaneTitle, } from "./delivery.js";
16
16
  import { assertNotPlugin, assertNotSelf, assertExpectedScreen, assertWritable, } from "./guards.js";
17
17
  import { readDeliveryLog } from "./log.js";
18
18
  import { awaitSignal, listSignals, postSignal } from "./signals.js";
@@ -754,6 +754,13 @@ async function pruneExitedSessions(client, listed, args) {
754
754
  export async function dispatchZswarm(args, injected, deps = {}) {
755
755
  try {
756
756
  const env = resolveInvocationEnv(args, deps.env ?? process.env);
757
+ // A local caller can never claim another pane or `via serve`: its origin is
758
+ // always read from the environment it runs in. Only the serve worker trusts
759
+ // the request's origin, sanitized, and stamps it `via serve`.
760
+ args =
761
+ deps.viaServe === true
762
+ ? { ...args, origin: originFromServeRequest(args.origin) }
763
+ : { ...args, origin: resolveSenderOrigin(env) };
757
764
  const context = invocationContext(args, env, injected?.transport);
758
765
  const result = await dispatchOperation(args, injected, deps, context, env);
759
766
  if (context.transport === "serve" && result.context) {
@@ -25,6 +25,12 @@ export type DispatchDeps = {
25
25
  state?: StateStore;
26
26
  policy?: Policy;
27
27
  env?: NodeJS.ProcessEnv;
28
+ /**
29
+ * True when this dispatch is the local worker behind `zswarm serve`: the
30
+ * request's own origin is trusted and stamped `via serve`, never replaced by
31
+ * the worker host's environment.
32
+ */
33
+ viaServe?: boolean;
28
34
  /** MCP cancellation; aborted waits stop instead of running to timeout. */
29
35
  signal?: AbortSignal;
30
36
  /** Process-owned ssh:// LocalForward manager. CLI disposes; MCP reuses. */
package/dist/schema.js CHANGED
@@ -281,7 +281,7 @@ export const PARAMS = [
281
281
  type: "string",
282
282
  flags: ["--from", "-f"],
283
283
  ops: ["send", "broadcast"],
284
- description: "send: sender label in the [zswarm from=…] prefix (default: ZSWARM_FROM, else the sending pane's title, else swarm)",
284
+ description: "send: sender label in the [zswarm from=…] prefix (default: ZSWARM_FROM, else the sending pane's title, else swarm). zswarm appends src=<session>/<pane>, host=<host> (ZSWARM_HOST) and via=serve",
285
285
  },
286
286
  {
287
287
  name: "raw",
@@ -648,7 +648,7 @@ export function cliUsage() {
648
648
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
649
649
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
650
650
  }
651
- lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Hosts (CLI only, see docs/hosts.md): `zswarm slot`, `zswarm crew`, `zswarm relay`, `zswarm host`; `zswarm <command> --help` for each.", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
651
+ lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Hosts (CLI only, see docs/hosts.md): `zswarm slot`, `zswarm crew`, `zswarm relay`, `zswarm host`; `zswarm <command> --help` for each.", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZSWARM_HOST, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
652
652
  return lines.join("\n");
653
653
  }
654
654
  /**
package/dist/state.d.ts CHANGED
@@ -9,6 +9,14 @@ export type LogEntry = {
9
9
  session?: string | null;
10
10
  to?: string | null;
11
11
  from?: string | null;
12
+ /** Sender's machine when the send was logged. */
13
+ fromHost?: string | null;
14
+ /** Sender's Zellij session. */
15
+ fromSession?: string | null;
16
+ /** Sender's pane id, e.g. "terminal_3". */
17
+ fromPane?: string | null;
18
+ /** "serve" when this host received the op over serve. */
19
+ via?: "serve" | null;
12
20
  bytes?: number;
13
21
  ok: boolean;
14
22
  detail?: string | null;
@@ -2,6 +2,7 @@ import { ListingCache } from "./cache.js";
2
2
  import { buildClosePaneArgs, buildDumpArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, type WaitRequest, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PipeInput } from "./args.js";
3
3
  import { resolveTab, type ZellijTab } from "./tabs.js";
4
4
  import { type IpcDiscoveryState } from "../exec.js";
5
+ import { type SenderOrigin } from "../ops/delivery.js";
5
6
  import { type ZellijExecFn } from "./binary.js";
6
7
  import { normalizePaneId, resolvePane, type ZellijPane } from "./panes.js";
7
8
  import { type ZellijSession, type ZellijSessionResolve } from "./session.js";
@@ -183,7 +184,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
183
184
  }>;
184
185
  resolveTab: typeof resolveTab;
185
186
  normalizePaneId: typeof normalizePaneId;
186
- formatPeerMessage: (from: string, body: string) => string;
187
+ formatPeerMessage: (from: string, body: string, origin?: SenderOrigin | null) => string;
187
188
  buildListPanesArgs: typeof buildListPanesArgs;
188
189
  buildPasteArgs: typeof buildPasteArgs;
189
190
  buildSendEnterArgs: typeof buildSendEnterArgs;
@@ -4,6 +4,7 @@ import { buildClosePaneArgs, buildDumpArgs, buildDumpLayoutArgs, buildFocusPaneA
4
4
  import { DEFAULT_BUS_TIMEOUT_MS, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, } from "./bus.js";
5
5
  import { parseTabList, resolveTab } from "./tabs.js";
6
6
  import { createSshExec } from "../exec.js";
7
+ import { originSuffix } from "../ops/delivery.js";
7
8
  import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijProbes, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, zellijExecDetails, zellijMissingError, } from "./binary.js";
8
9
  import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
9
10
  import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, resolveSelfSession, sessionFromEnv, sessionFromList, } from "./session.js";
@@ -363,9 +364,9 @@ export function createZellijClient(options = {}) {
363
364
  };
364
365
  }
365
366
  /** Visible prefix so peer CLIs can tell zSwarm injects from human prompts. */
366
- function formatPeerMessage(from, body) {
367
+ function formatPeerMessage(from, body, origin) {
367
368
  const sender = from.trim() || "swarm";
368
- return `[zswarm from=${sender}]\n${body}`;
369
+ return `[zswarm from=${sender}${originSuffix(origin)}]\n${body}`;
369
370
  }
370
371
  return {
371
372
  zellijPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {