lattice-talk 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.
package/README.md CHANGED
@@ -135,6 +135,20 @@ Setup writes user-level settings to `~/.lattice/config.json` (restricted permiss
135
135
 
136
136
  Without `LATTICE_REDIS_URL`, legacy Redis variables also work: `REDIS_HOST`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`, `REDIS_DB`, `REDIS_SSL`.
137
137
 
138
+ **Custom / remote Redis** — point `LATTICE_REDIS_URL` (or the setup screen's Redis URL field) at any reachable Redis: local, Docker, a VM, or managed (Upstash, Redis Cloud, ElastiCache). `rediss://` enables TLS.
139
+
140
+ **Redis behind a bastion (SSH tunnel)** — set `LATTICE_SSH_HOST` and Lattice opens an `ssh -N -L` forward to your Redis before connecting. Works identically for the TUI, `serve`, `bridge`, and every harness-registered MCP server (the variables propagate).
141
+
142
+ | Variable | Default | Purpose |
143
+ | --- | --- | --- |
144
+ | `LATTICE_SSH_HOST` | — | Bastion host (`user@host` also works via `LATTICE_SSH_USER`) |
145
+ | `LATTICE_SSH_PORT` | `22` | SSH port on the bastion |
146
+ | `LATTICE_SSH_USER` | — | SSH user (else your ssh config/default user) |
147
+ | `LATTICE_SSH_KEY` | — | Identity file, e.g. `~/.ssh/id_ed25519` |
148
+ | `LATTICE_SSH_LOCAL_PORT` | auto | Pin the local forward port (default: free ephemeral port) |
149
+
150
+ Authentication must be non-interactive — ssh agent, key file, or `~/.ssh/config` (`BatchMode` is on, so a passphrase prompt never hangs the bus). `ssh` must be on PATH; on Windows 10+ it's the built-in OpenSSH client. TLS is dropped inside the tunnel — SSH already encrypts that hop, and `rediss://` through the forward would fail certificate checks against `127.0.0.1`.
151
+
138
152
  ## Security model
139
153
 
140
154
  - Agent identity is owned by the joined MCP process — a model can't claim another agent's id on later calls.
@@ -150,7 +164,7 @@ Don't paste passwords, API keys, or secrets into messages or shared memory.
150
164
 
151
165
  **Dashboard won't open** — install Bun, or Node.js 26.4+. `serve` still works on Node 20+.
152
166
 
153
- **Dashboard opens but looks wrong** — garbled icons or washed-out colors on an old terminal mean the capability detection missed. Try `LATTICE_ASCII=1` for plain-ASCII glyphs, or set `COLORTERM=truecolor` if your terminal does support 24-bit color. `NO_COLOR=1` gives a clean monochrome UI.
167
+ **Dashboard opens but looks wrong** — garbled icons or washed-out colors on an old terminal mean the capability detection missed. Try `LATTICE_ASCII=1` for plain-ASCII glyphs, or set `COLORTERM=truecolor` if your terminal does support 24-bit color. `NO_COLOR=1` gives a clean monochrome UI, and `LATTICE_NO_ANIM=1` disables all animation (also automatic on legacy consoles). If it still renders badly, run with `LATTICE_TUI_LOG=/path/to/log.txt` once — the log records the detected terminal capabilities, palette tier, and any renderer errors, which pinpoints the cause. The dashboard header shows the running version so you can confirm you're not on a cached release.
154
168
 
155
169
  **Agents can't see each other** — confirm every harness uses the same Redis, namespace, and workspace name, and the same `LATTICE_JOIN_TOKEN` if the workspace is protected. Restart the harness after changing MCP config.
156
170
 
package/dist/index.js CHANGED
@@ -19021,7 +19021,7 @@ init_esm_shims();
19021
19021
 
19022
19022
  // src/cli.ts
19023
19023
  init_esm_shims();
19024
- import { spawn as spawn2, spawnSync } from "child_process";
19024
+ import { spawn as spawn3, spawnSync } from "child_process";
19025
19025
  import { existsSync as existsSync3 } from "fs";
19026
19026
  import { homedir as homedir3 } from "os";
19027
19027
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -19052,6 +19052,10 @@ function loadFileConfig(env = process.env, home = homedir()) {
19052
19052
  if (typeof raw.namespace === "string" && raw.namespace.trim()) config2.namespace = raw.namespace.trim();
19053
19053
  if (typeof raw.workspace === "string" && raw.workspace.trim()) config2.workspace = raw.workspace.trim();
19054
19054
  if (typeof raw.joinToken === "string" && raw.joinToken.trim()) config2.joinToken = raw.joinToken.trim();
19055
+ if (typeof raw.sshHost === "string" && raw.sshHost.trim()) config2.sshHost = raw.sshHost.trim();
19056
+ if (typeof raw.sshPort === "string" && raw.sshPort.trim()) config2.sshPort = raw.sshPort.trim();
19057
+ if (typeof raw.sshUser === "string" && raw.sshUser.trim()) config2.sshUser = raw.sshUser.trim();
19058
+ if (typeof raw.sshKey === "string" && raw.sshKey.trim()) config2.sshKey = raw.sshKey.trim();
19055
19059
  return { path: path2, config: config2, exists: true, corrupt: false };
19056
19060
  } catch {
19057
19061
  return { path: path2, config: {}, exists: true, corrupt: true };
@@ -19062,7 +19066,11 @@ function resolveConnection(file2, env = process.env) {
19062
19066
  redisUrl: env.LATTICE_REDIS_URL?.trim() || env.REDIS_URL?.trim() || file2.redisUrl,
19063
19067
  namespace: env.LATTICE_NAMESPACE?.trim() || file2.namespace || "dev",
19064
19068
  workspace: env.LATTICE_DEFAULT_SESSION_ID?.trim() || file2.workspace,
19065
- joinToken: env.LATTICE_JOIN_TOKEN?.trim() || file2.joinToken
19069
+ joinToken: env.LATTICE_JOIN_TOKEN?.trim() || file2.joinToken,
19070
+ sshHost: env.LATTICE_SSH_HOST?.trim() || file2.sshHost,
19071
+ sshPort: env.LATTICE_SSH_PORT?.trim() || file2.sshPort,
19072
+ sshUser: env.LATTICE_SSH_USER?.trim() || file2.sshUser,
19073
+ sshKey: env.LATTICE_SSH_KEY?.trim() || file2.sshKey
19066
19074
  };
19067
19075
  }
19068
19076
  function connectionToEnv(conn) {
@@ -19071,6 +19079,10 @@ function connectionToEnv(conn) {
19071
19079
  if (conn.namespace) env.LATTICE_NAMESPACE = conn.namespace;
19072
19080
  if (conn.workspace) env.LATTICE_DEFAULT_SESSION_ID = conn.workspace;
19073
19081
  if (conn.joinToken) env.LATTICE_JOIN_TOKEN = conn.joinToken;
19082
+ if (conn.sshHost) env.LATTICE_SSH_HOST = conn.sshHost;
19083
+ if (conn.sshPort) env.LATTICE_SSH_PORT = conn.sshPort;
19084
+ if (conn.sshUser) env.LATTICE_SSH_USER = conn.sshUser;
19085
+ if (conn.sshKey) env.LATTICE_SSH_KEY = conn.sshKey;
19074
19086
  return env;
19075
19087
  }
19076
19088
  var HARNESS_ENV_KEYS = [
@@ -19080,6 +19092,10 @@ var HARNESS_ENV_KEYS = [
19080
19092
  "REDIS_PASSWORD",
19081
19093
  "REDIS_DB",
19082
19094
  "REDIS_SSL",
19095
+ "LATTICE_SSH_HOST",
19096
+ "LATTICE_SSH_PORT",
19097
+ "LATTICE_SSH_USER",
19098
+ "LATTICE_SSH_KEY",
19083
19099
  "LATTICE_PRESENCE_TTL",
19084
19100
  "LATTICE_STREAM_MAXLEN",
19085
19101
  "OTEL_EXPORTER_OTLP_ENDPOINT",
@@ -19362,6 +19378,11 @@ function loadConfig(env = process.env) {
19362
19378
  redisPassword: env.REDIS_PASSWORD || void 0,
19363
19379
  redisDb: envIntInRange(env, "REDIS_DB", 0, 0, 9999),
19364
19380
  redisSsl: envFlag(env.REDIS_SSL) || sslFromUrl,
19381
+ sshHost: env.LATTICE_SSH_HOST?.trim() || void 0,
19382
+ sshPort: envPort(env, "LATTICE_SSH_PORT", 22),
19383
+ sshUser: env.LATTICE_SSH_USER?.trim() || void 0,
19384
+ sshKey: env.LATTICE_SSH_KEY?.trim() || void 0,
19385
+ sshLocalPort: envPort(env, "LATTICE_SSH_LOCAL_PORT", 0) || void 0,
19365
19386
  otelEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() || void 0,
19366
19387
  otelServiceName: (env.OTEL_SERVICE_NAME ?? "lattice-talk").trim() || "lattice-talk",
19367
19388
  otelHeaders: parseOtelHeaders(env.OTEL_EXPORTER_OTLP_HEADERS),
@@ -21086,7 +21107,7 @@ init_esm_shims();
21086
21107
  // package.json
21087
21108
  var package_default = {
21088
21109
  name: "lattice-talk",
21089
- version: "0.1.6",
21110
+ version: "0.1.8",
21090
21111
  description: "Lattice \u2014 Cross-Harness MCP Session Bus. Shared sessions, DMs, rooms, memory, and OpenTelemetry for agents across Claude Code, Cursor, Codex, and custom harnesses.",
21091
21112
  type: "module",
21092
21113
  main: "./dist/index.js",
@@ -22401,6 +22422,155 @@ var RedisStore = class _RedisStore {
22401
22422
  }
22402
22423
  };
22403
22424
 
22425
+ // src/core/ssh-tunnel.ts
22426
+ init_esm_shims();
22427
+ import { spawn } from "child_process";
22428
+ import { createConnection, createServer } from "net";
22429
+ function buildSshArgs(config2, localPort, targetHost, targetPort) {
22430
+ const args = [
22431
+ "-N",
22432
+ "-T",
22433
+ "-o",
22434
+ "BatchMode=yes",
22435
+ "-o",
22436
+ "ExitOnForwardFailure=yes",
22437
+ "-o",
22438
+ "StrictHostKeyChecking=accept-new",
22439
+ "-o",
22440
+ "ServerAliveInterval=15",
22441
+ "-o",
22442
+ "ServerAliveCountMax=2",
22443
+ "-L",
22444
+ `127.0.0.1:${localPort}:${targetHost}:${targetPort}`
22445
+ ];
22446
+ if (config2.sshPort && config2.sshPort !== 22) args.push("-p", String(config2.sshPort));
22447
+ if (config2.sshKey) args.push("-i", config2.sshKey);
22448
+ const userHost = config2.sshUser ? `${config2.sshUser}@${config2.sshHost}` : config2.sshHost;
22449
+ args.push(userHost);
22450
+ return args;
22451
+ }
22452
+ async function freePort() {
22453
+ const server = createServer();
22454
+ await new Promise((resolve, reject) => {
22455
+ server.once("error", reject);
22456
+ server.listen(0, "127.0.0.1", () => resolve());
22457
+ });
22458
+ const address = server.address();
22459
+ const port = typeof address === "object" && address ? address.port : 0;
22460
+ await new Promise((resolve) => server.close(() => resolve()));
22461
+ if (!port) throw new Error("could not allocate a local port for the SSH tunnel");
22462
+ return port;
22463
+ }
22464
+ function waitForPort(port, timeoutMs) {
22465
+ const deadline = Date.now() + timeoutMs;
22466
+ return new Promise((resolve, reject) => {
22467
+ const attempt = () => {
22468
+ const socket = createConnection({ host: "127.0.0.1", port });
22469
+ socket.once("connect", () => {
22470
+ socket.destroy();
22471
+ resolve();
22472
+ });
22473
+ socket.once("error", () => {
22474
+ socket.destroy();
22475
+ if (Date.now() >= deadline) {
22476
+ reject(new Error("SSH tunnel did not become ready in time"));
22477
+ } else {
22478
+ setTimeout(attempt, 120);
22479
+ }
22480
+ });
22481
+ };
22482
+ attempt();
22483
+ });
22484
+ }
22485
+ function tunneledConfig(config2, localPort) {
22486
+ let auth = "";
22487
+ let db = "";
22488
+ if (config2.redisUrl) {
22489
+ try {
22490
+ const url2 = new URL(config2.redisUrl);
22491
+ const user = decodeURIComponent(url2.username);
22492
+ const pass = decodeURIComponent(url2.password);
22493
+ if (user || pass) auth = `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@`;
22494
+ if (url2.pathname && url2.pathname !== "/") db = url2.pathname;
22495
+ } catch {
22496
+ }
22497
+ } else {
22498
+ const user = config2.redisUsername;
22499
+ const pass = config2.redisPassword;
22500
+ if (user || pass) {
22501
+ auth = `${encodeURIComponent(user ?? "")}:${encodeURIComponent(pass ?? "")}@`;
22502
+ }
22503
+ if (config2.redisDb) db = `/${config2.redisDb}`;
22504
+ }
22505
+ return {
22506
+ ...config2,
22507
+ redisUrl: `redis://${auth}127.0.0.1:${localPort}${db}`,
22508
+ redisSsl: false
22509
+ };
22510
+ }
22511
+ function tunnelTarget(config2) {
22512
+ if (config2.redisUrl) {
22513
+ try {
22514
+ const url2 = new URL(config2.redisUrl);
22515
+ return { host: url2.hostname, port: Number.parseInt(url2.port, 10) || 6379 };
22516
+ } catch {
22517
+ }
22518
+ }
22519
+ return { host: config2.redisHost ?? "127.0.0.1", port: config2.redisPort };
22520
+ }
22521
+ async function openSshTunnel(config2, options) {
22522
+ if (!config2.sshHost) return void 0;
22523
+ const target = tunnelTarget(config2);
22524
+ const localPort = config2.sshLocalPort || await freePort();
22525
+ const args = buildSshArgs(config2, localPort, target.host, target.port);
22526
+ let proc;
22527
+ try {
22528
+ proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
22529
+ } catch (e) {
22530
+ throw new Error(
22531
+ `SSH tunnel requested (LATTICE_SSH_HOST) but spawning ssh failed: ${e instanceof Error ? e.message : String(e)}`
22532
+ );
22533
+ }
22534
+ let stderrTail = "";
22535
+ proc.stderr?.on("data", (chunk) => {
22536
+ stderrTail = (stderrTail + String(chunk)).slice(-2e3);
22537
+ });
22538
+ const spawnError = new Promise((_, reject) => {
22539
+ proc.once(
22540
+ "error",
22541
+ () => reject(
22542
+ new Error(
22543
+ "SSH tunnel requested (LATTICE_SSH_HOST) but `ssh` is not on PATH. Install an OpenSSH client (built into Windows 10+ via Optional Features) or unset LATTICE_SSH_HOST."
22544
+ )
22545
+ )
22546
+ );
22547
+ proc.once(
22548
+ "exit",
22549
+ (code) => reject(
22550
+ new Error(
22551
+ `ssh exited before the tunnel was ready (code ${code}).` + (stderrTail.trim() ? ` ssh says: ${stderrTail.trim().split("\n").pop()}` : "") + " Check LATTICE_SSH_HOST/USER/KEY \u2014 authentication must work non-interactively (ssh-agent or key file)."
22552
+ )
22553
+ )
22554
+ );
22555
+ });
22556
+ try {
22557
+ await Promise.race([waitForPort(localPort, options?.timeoutMs ?? 1e4), spawnError]);
22558
+ } catch (e) {
22559
+ proc.kill();
22560
+ throw e;
22561
+ }
22562
+ return {
22563
+ localPort,
22564
+ target: `${target.host}:${target.port}`,
22565
+ close: () => new Promise((resolve) => {
22566
+ if (proc.exitCode !== null || proc.killed) return resolve();
22567
+ proc.once("exit", () => resolve());
22568
+ proc.kill();
22569
+ setTimeout(resolve, 2e3).unref();
22570
+ })
22571
+ };
22572
+ }
22573
+
22404
22574
  // src/core/store.ts
22405
22575
  async function createStore(config2, options) {
22406
22576
  if (config2.store === "memory") {
@@ -22411,7 +22581,25 @@ async function createStore(config2, options) {
22411
22581
  "LATTICE_STORE=redis requires LATTICE_REDIS_URL or REDIS_HOST. Use LATTICE_STORE=memory for single-process smoke tests."
22412
22582
  );
22413
22583
  }
22414
- return RedisStore.connect(config2);
22584
+ const tunnel = await openSshTunnel(config2);
22585
+ if (!tunnel) {
22586
+ return RedisStore.connect(config2);
22587
+ }
22588
+ try {
22589
+ const store = await RedisStore.connect(tunneledConfig(config2, tunnel.localPort));
22590
+ const closeStore = store.close.bind(store);
22591
+ store.close = async () => {
22592
+ try {
22593
+ await closeStore();
22594
+ } finally {
22595
+ await tunnel.close();
22596
+ }
22597
+ };
22598
+ return store;
22599
+ } catch (e) {
22600
+ await tunnel.close();
22601
+ throw e;
22602
+ }
22415
22603
  }
22416
22604
 
22417
22605
  // src/bridge/acp.ts
@@ -22419,7 +22607,7 @@ init_esm_shims();
22419
22607
 
22420
22608
  // src/bridge/ndjson-rpc.ts
22421
22609
  init_esm_shims();
22422
- import { spawn } from "child_process";
22610
+ import { spawn as spawn2 } from "child_process";
22423
22611
  import { createInterface } from "readline";
22424
22612
  var NdjsonRpc = class {
22425
22613
  proc;
@@ -22429,7 +22617,7 @@ var NdjsonRpc = class {
22429
22617
  notificationHandlers = /* @__PURE__ */ new Map();
22430
22618
  closed = false;
22431
22619
  constructor(command, args, opts = {}) {
22432
- this.proc = spawn(command, args, {
22620
+ this.proc = spawn2(command, args, {
22433
22621
  cwd: opts.cwd,
22434
22622
  env: { ...process.env, ...opts.env },
22435
22623
  stdio: ["pipe", "pipe", "pipe"],
@@ -58143,7 +58331,7 @@ async function runTui(tuiArgs) {
58143
58331
  );
58144
58332
  return 1;
58145
58333
  }
58146
- const child = spawn2(runtime.cmd, [...runtime.preArgs, tuiEntryPath(), ...tuiArgs], {
58334
+ const child = spawn3(runtime.cmd, [...runtime.preArgs, tuiEntryPath(), ...tuiArgs], {
58147
58335
  stdio: "inherit",
58148
58336
  env: process.env
58149
58337
  });