Ship Meshtastic Chat UX, embedded Infonet/SHELL panels, and Docker dev polish.

Rename Mesh Chat to Meshtastic Chat, embed the Infonet terminal with Arti/Tor warmup, improve the agent shell PTY (git in the backend image, operator PATH), and add docker-compose.override for local image builds. Gitignore Hermes Agent runtime installs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-11 00:55:38 -06:00
parent d1e1be4016
commit e78e4d186d
24 changed files with 1258 additions and 970 deletions
+5
View File
@@ -261,6 +261,11 @@ frontend/.desktop-export-stash-*/
backend/data/wormhole_stderr.log
backend/data/wormhole_stdout.log
# Hermes Agent (operator-local runtime install — not project source)
.hermes/
**/.hermes/
hermes-agent/
# Runtime caches that already slip through the backend/data/* blanket
# (these are caught by the wildcard but listing for clarity)
+2 -1
View File
@@ -27,6 +27,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
tor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
@@ -72,7 +73,7 @@ ENV PRIVACY_CORE_LIB=/app/libprivacy_core.so
# Create a non-root user for security
# Grant write access to /app so the auto-updater can extract files
# Pre-create /app/data so mounted volumes inherit correct ownership
RUN adduser --system --uid 1001 backenduser \
RUN adduser --system --uid 1001 --home /app backenduser \
&& mkdir -p /app/data \
&& chown -R backenduser /app \
&& chmod -R u+w /app
+36 -1
View File
@@ -43,9 +43,32 @@ def _set_winsize(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _published_local_dashboard_ws(ws: WebSocket) -> bool:
"""Browser → published Docker port appears as a bridge IP, not loopback.
For the operator shell only, also accept when the upgrade request clearly
targets the local dashboard (Host/Origin on localhost).
"""
host_header = str(ws.headers.get("host") or "").strip().lower()
host_name = host_header.split(":", 1)[0]
if host_name in {"127.0.0.1", "localhost", "::1"}:
return True
origin = str(ws.headers.get("origin") or "").strip().lower()
if origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:"):
return True
if origin.startswith("https://127.0.0.1:") or origin.startswith("https://localhost:"):
return True
return False
async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None:
host = (ws.client.host or "").lower() if ws.client else ""
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
if (
_is_trusted_local_runtime_host(host)
or _published_local_dashboard_ws(ws)
or (_debug_mode_enabled() and host == "test")
):
return
admin_key = _current_admin_key()
presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip()
@@ -164,6 +187,18 @@ async def agent_shell_websocket(
env = os.environ.copy()
env.setdefault("TERM", "xterm-256color")
env.setdefault("COLORTERM", "truecolor")
home = shell_cwd if os.path.isdir(shell_cwd) else "/app"
env["HOME"] = home
env["USER"] = env.get("USER") or "operator"
path_prefixes = [
os.path.join(home, ".local", "bin"),
os.path.join(home, ".hermes", "bin"),
]
path = env.get("PATH", "")
for prefix in path_prefixes:
if os.path.isdir(prefix):
path = f"{prefix}:{path}" if path else prefix
env["PATH"] = path
proc = await asyncio.create_subprocess_exec(
shell,
+7 -1
View File
@@ -16,7 +16,13 @@ _LOCK = threading.Lock()
def _default_working_directory() -> str:
return os.environ.get("AGENT_SHELL_DEFAULT_CWD") or os.environ.get("HOME") or "/app"
explicit = str(os.environ.get("AGENT_SHELL_DEFAULT_CWD") or "").strip()
if explicit and os.path.isdir(explicit):
return explicit
home = str(os.environ.get("HOME") or "").strip()
if home and home != "/nonexistent" and os.path.isdir(home):
return home
return "/app"
def get_agent_shell_settings() -> dict[str, Any]:
+1 -1
View File
@@ -132,7 +132,7 @@ try {
) {
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content -LiteralPath $localUpdaterKey -Raw
if (($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) -and (Test-Path $localUpdaterKeyPassword)) {
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = Get-Content -LiteralPath $localUpdaterKeyPassword -Raw
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = (Get-Content -LiteralPath $localUpdaterKeyPassword -Raw).Trim()
}
}
+10
View File
@@ -0,0 +1,10 @@
# Auto-loaded by `docker compose` — build from local source instead of pulling stale GHCR images.
services:
backend:
build:
context: .
dockerfile: ./backend/Dockerfile
frontend:
build:
context: ./frontend
+3 -1
View File
@@ -615,7 +615,7 @@ export default function Dashboard() {
)}
</div>
{/* 2. MESH CHAT (Middle) */}
{/* 2. MESHTASTIC CHAT (Middle) */}
{secondaryBootReady && (
<div className="contents" style={{ direction: 'ltr' }}>
<MeshChat
@@ -624,6 +624,8 @@ export default function Dashboard() {
onExpandedChange={setLeftMeshExpanded}
onSettingsClick={() => setSettingsOpen(true)}
onTerminalToggle={openSecureTerminalLauncher}
onOpenLiveGate={openLiveGateFromShell}
onOpenDeadDrop={openDeadDropFromShell}
launchRequest={meshChatLaunchRequest}
/>
</div>
@@ -137,6 +137,9 @@ interface CommandHistory {
interface InfonetShellProps {
isOpen: boolean;
embedded?: boolean;
launchGate?: string;
onLaunchGateConsumed?: () => void;
onClose: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
@@ -144,6 +147,9 @@ interface InfonetShellProps {
export default function InfonetShell({
isOpen,
embedded = false,
launchGate = '',
onLaunchGateConsumed,
onClose,
onOpenLiveGate,
onOpenDeadDrop,
@@ -176,6 +182,7 @@ export default function InfonetShell({
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const gateLaunchAttemptRef = useRef(0);
const launchGateConsumedRef = useRef('');
// Real mesh identity
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
@@ -203,6 +210,7 @@ export default function InfonetShell({
setPendingGate(null);
setInput('');
gateLaunchAttemptRef.current += 1;
launchGateConsumedRef.current = '';
setIsBooting(true);
setBootText([]);
@@ -600,6 +608,14 @@ export default function InfonetShell({
setHistory(prev => [...prev, { command: cmd, output }]);
};
useEffect(() => {
const gate = String(launchGate || '').trim().toLowerCase();
if (!isOpen || isBooting || !gate || launchGateConsumedRef.current === gate) return;
launchGateConsumedRef.current = gate;
handleCommand(`join ${gate}`);
onLaunchGateConsumed?.();
}, [isOpen, isBooting, launchGate, onLaunchGateConsumed]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
@@ -635,7 +651,12 @@ export default function InfonetShell({
}
return (
<div ref={containerRef} className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono relative flex flex-col overflow-hidden">
<div
ref={containerRef}
className={`h-full min-h-0 bg-[#0a0a0a] text-gray-300 font-mono relative flex flex-col overflow-hidden ${
embedded ? 'p-2 md:p-3 text-[13px]' : 'p-4 md:p-8'
}`}
>
{currentView === 'terminal' && (
<>
{/* Top Navigation / Quick Launch */}
@@ -13,11 +13,12 @@ interface Stats {
seedPeers: number;
nodeEnabled: boolean;
syncOutcome: string;
syncError: string;
}
const EMPTY: Stats = {
meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0,
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline', syncError: '',
};
export default function NetworkStats() {
@@ -41,6 +42,7 @@ export default function NetworkStats() {
?? infonet?.bootstrap?.default_sync_peer_count
?? 0,
);
const syncOutcome = String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase();
setStats({
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
aprs: Number(meshRes?.signal_counts?.aprs || 0),
@@ -49,26 +51,36 @@ export default function NetworkStats() {
syncPeers: syncPeerCount,
seedPeers: seedPeerCount,
nodeEnabled: Boolean(infonet?.node_enabled),
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
syncOutcome,
syncError: String(infonet?.sync_runtime?.last_error || '').trim(),
});
} catch { /* ignore */ }
};
poll();
const interval = setInterval(poll, 15000);
const interval = setInterval(poll, 8000);
return () => { alive = false; clearInterval(interval); };
}, []);
const nodeColor = stats.syncOutcome === 'ok' ? 'text-green-400'
const syncErrorLower = stats.syncError.toLowerCase();
const artiBlocked = syncErrorLower.includes('arti') || syncErrorLower.includes('onion');
const nodeColor = stats.syncOutcome === 'ok' || stats.syncOutcome === 'solo' ? 'text-green-400'
: stats.syncOutcome === 'running' ? 'text-amber-400'
: stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600';
const nodeLabel = stats.syncOutcome === 'ok' ? 'SEED SYNCED'
: stats.syncOutcome === 'solo' ? 'SOLO'
: stats.syncOutcome === 'running' ? 'SYNCING'
: stats.syncOutcome === 'error' || stats.syncOutcome === 'fork' ? 'RETRYING'
: stats.syncOutcome === 'error' || stats.syncOutcome === 'fork'
? (artiBlocked ? 'ARTI WARMING' : 'SYNC BACKOFF')
: stats.nodeEnabled ? 'WAITING' : 'OFFLINE';
const nodeTitle = stats.syncError
? `Infonet seed sync: ${stats.syncError}`
: stats.nodeEnabled
? 'Participant node enabled; waiting for seed ledger sync.'
: 'Participant node offline.';
return (
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 mt-5 text-sm font-mono text-gray-500">
<span>NODE <span className={nodeColor}>{nodeLabel}</span></span>
<span title={nodeTitle}>NODE <span className={nodeColor}>{nodeLabel}</span></span>
<span className="text-gray-700">|</span>
<span>MESH <span className={stats.meshtastic > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.meshtastic.toLocaleString()}</span></span>
<span className="text-gray-700">|</span>
@@ -4,9 +4,7 @@ import React, { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { X } from 'lucide-react';
import {
fetchInfonetNodeStatusSnapshot,
setInfonetNodeEnabled,
startTorHiddenService,
ensureInfonetParticipantNodeReady,
} from '@/mesh/controlPlaneStatusClient';
import InfonetShell from './InfonetShell';
@@ -39,16 +37,8 @@ export default function InfonetTerminal({
const connectParticipantNode = async () => {
try {
const nodeStatus = await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
if (cancelled || nodeStatus?.node_enabled) return;
const torStatus = await startTorHiddenService().catch(() => null);
if (cancelled || !torStatus?.running || !torStatus?.onion_address) return;
await setInfonetNodeEnabled(true);
if (!cancelled) {
await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
}
if (cancelled) return;
await ensureInfonetParticipantNodeReady();
} catch {
// Remote/shared viewers may not have local-operator rights. Leave manual controls intact.
}
@@ -1,232 +1,651 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Terminal } from 'lucide-react';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { resolveAgentShellWsUrl } from '@/lib/agentShellWs';
const SHELL_FONT_PX = 14;
const CWD_STORAGE_KEY = 'sb_agent_shell_cwd';
const INTRO_ACK_KEY = 'sb_agent_shell_intro_ack';
type Props = {
active: boolean;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
};
function readStoredCwd(): string {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(CWD_STORAGE_KEY) || '';
} catch {
return '';
}
}
export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) {
const hostRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle');
const [statusDetail, setStatusDetail] = useState('');
const [cwd, setCwd] = useState('');
const disconnect = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
setStatus('idle');
}, []);
const fitTerminal = useCallback(() => {
const fit = fitRef.current;
const term = termRef.current;
const ws = wsRef.current;
if (!fit || !term) return;
fit.fit();
if (ws?.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}),
);
}
}, []);
function readIntroAcknowledged(): boolean {
const connect = useCallback(() => {
if (!active || !hostRef.current) return;
disconnect();
if (typeof window === 'undefined') return false;
const term = new XTerm({
fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: SHELL_FONT_PX,
lineHeight: 1.35,
cursorBlink: true,
theme: {
background: '#04070b',
foreground: '#d9f7ff',
cursor: '#22d3ee',
selectionBackground: '#0e7490',
},
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
termRef.current = term;
fitRef.current = fit;
try {
const storedCwd = readStoredCwd();
setCwd(storedCwd);
setStatus('connecting');
setStatusDetail('');
return window.localStorage.getItem(INTRO_ACK_KEY) === '1';
const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd));
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
} catch {
ws.onopen = () => {
setStatus('open');
fit.fit();
ws.send(
JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}),
);
term.focus();
};
return false;
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
try {
const payload = JSON.parse(event.data) as { type?: string; message?: string };
if (payload.type === 'error') {
setStatus('error');
setStatusDetail(payload.message || 'Shell unavailable');
term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`);
return;
}
} catch {
term.write(event.data);
return;
}
}
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data));
}
};
ws.onerror = () => {
setStatus('error');
setStatusDetail('Could not connect to the local agent shell endpoint.');
term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m');
};
ws.onclose = () => {
setStatus((prev) => (prev === 'error' ? prev : 'idle'));
term.writeln('\r\n\x1b[90m[session closed]\x1b[0m');
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
}, [active, disconnect]);
useEffect(() => {
if (!active) {
disconnect();
return;
}
connect();
return () => disconnect();
}, [active, connect, disconnect]);
useEffect(() => {
if (!active) return;
const timer = window.setTimeout(() => fitTerminal(), expanded ? 220 : 0);
return () => window.clearTimeout(timer);
}, [active, expanded, fitTerminal]);
useEffect(() => {
if (!active) return;
const onResize = () => fitTerminal();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [active, fitTerminal]);
if (!active) {
return (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
<Terminal size={16} className="text-cyan-400 mb-2" />
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">LOCAL SHELL</div>
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
Expand Mesh Chat to open your operator shell.
</div>
</div>
);
}
}
function ShellIntro({ onAcknowledge }: { onAcknowledge: () => void }) {
return (
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
{cwd ? cwd : 'operator shell'}
</div>
<div className="flex items-center gap-1 shrink-0">
{!expanded ? (
<button
type="button"
onClick={() => onExpandedChange(true)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
EXPAND
</button>
) : (
<button
type="button"
onClick={() => onExpandedChange(false)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
SNAP
</button>
)}
<button
type="button"
onClick={connect}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
>
RECONNECT
</button>
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-5 py-8 text-center border-l-2 border-cyan-800/20 bg-[#04070b]">
<div className="w-full max-w-sm border border-cyan-900/40 bg-cyan-950/10 px-5 py-6">
<div className="inline-flex items-center justify-center w-10 h-10 border border-cyan-700/40 bg-black/30 text-cyan-300 mb-4">
<Terminal size={18} />
</div>
<div className="text-sm font-mono tracking-[0.22em] text-cyan-300 mb-3">OPERATOR SHELL</div>
<p className="text-[13px] font-mono text-[var(--text-secondary)] leading-[1.75] text-left">
Connect your own agent CLIs here OpenClaw, Codex, Gemini, or whatever you run locally.
</p>
<p className="mt-3 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.75] text-left">
The session opens in your Shadowbroker workspace by default. Use it for repo scripts, mesh
tools, or any terminal workflow you already rely on.
</p>
<button
type="button"
onClick={onAcknowledge}
className="mt-5 w-full px-4 py-2.5 text-sm font-mono tracking-[0.18em] text-cyan-200 border border-cyan-600/50 bg-cyan-950/30 hover:bg-cyan-950/50 hover:border-cyan-400/60 transition-colors"
>
OPEN SHELL
</button>
</div>
</div>
);
}
export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) {
const hostRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [introAcknowledged, setIntroAcknowledged] = useState(false);
const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle');
const [statusDetail, setStatusDetail] = useState('');
const [cwd, setCwd] = useState('');
const shellReady = active && introAcknowledged;
useEffect(() => {
setIntroAcknowledged(readIntroAcknowledged());
}, [active]);
const acknowledgeIntro = useCallback(() => {
try {
window.localStorage.setItem(INTRO_ACK_KEY, '1');
} catch {
// still allow in-session access if storage is blocked
}
setIntroAcknowledged(true);
}, []);
const disconnect = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
setStatus('idle');
}, []);
const fitTerminal = useCallback(() => {
const fit = fitRef.current;
const term = termRef.current;
const ws = wsRef.current;
if (!fit || !term) return;
fit.fit();
if (ws?.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}),
);
}
}, []);
const connect = useCallback(() => {
if (!hostRef.current) return;
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (termRef.current) {
termRef.current.dispose();
termRef.current = null;
fitRef.current = null;
}
const term = new XTerm({
fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: SHELL_FONT_PX,
lineHeight: 1.35,
cursorBlink: true,
theme: {
background: '#04070b',
foreground: '#d9f7ff',
cursor: '#22d3ee',
selectionBackground: '#0e7490',
},
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
termRef.current = term;
fitRef.current = fit;
const storedCwd = readStoredCwd();
setCwd(storedCwd);
setStatus('connecting');
setStatusDetail('');
const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd));
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('open');
fit.fit();
ws.send(
JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}),
);
term.focus();
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
try {
const payload = JSON.parse(event.data) as { type?: string; message?: string };
if (payload.type === 'error') {
setStatus('error');
setStatusDetail(payload.message || 'Shell unavailable');
term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`);
return;
}
} catch {
term.write(event.data);
return;
}
}
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data));
}
};
ws.onerror = () => {
setStatus('error');
setStatusDetail('Could not connect to the local agent shell endpoint.');
term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m');
};
ws.onclose = (event) => {
if (event.code === 4403) {
setStatus('error');
setStatusDetail('Local operator access only — reload the dashboard on localhost and retry.');
term.writeln('\r\n\x1b[31mShell blocked: local operator access only.\x1b[0m');
return;
}
setStatus((prev) => (prev === 'error' ? prev : 'idle'));
if (event.code !== 1000) {
term.writeln(`\r\n\x1b[90m[session closed: ${event.code}]\x1b[0m`);
}
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
}, []);
useLayoutEffect(() => {
if (!shellReady) {
disconnect();
return;
}
let cancelled = false;
const start = () => {
if (cancelled || !hostRef.current) return;
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
connect();
} else {
fitTerminal();
}
};
const frame = requestAnimationFrame(() => requestAnimationFrame(start));
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [shellReady, connect, disconnect, fitTerminal]);
useEffect(() => {
if (!shellReady) return;
const host = hostRef.current;
if (!host) return;
const ro = new ResizeObserver(() => fitTerminal());
ro.observe(host);
const timer = window.setTimeout(() => fitTerminal(), expanded ? 240 : 32);
return () => {
ro.disconnect();
window.clearTimeout(timer);
};
}, [shellReady, expanded, fitTerminal]);
useEffect(() => {
if (!shellReady) return;
const onResize = () => fitTerminal();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [shellReady, fitTerminal]);
if (!active) {
return (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
<Terminal size={16} className="text-cyan-400 mb-2" />
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">LOCAL SHELL</div>
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
Expand Meshtastic Chat to open your operator shell.
</div>
</div>
);
}
if (!introAcknowledged) {
return <ShellIntro onAcknowledge={acknowledgeIntro} />;
}
return (
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
{cwd ? cwd : 'operator shell'}
</div>
<div className="flex items-center gap-1 shrink-0">
{!expanded ? (
<button
type="button"
onClick={() => onExpandedChange(true)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
EXPAND
</button>
) : (
<button
type="button"
onClick={() => onExpandedChange(false)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
SNAP
</button>
)}
<button
type="button"
onClick={connect}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
>
RECONNECT
</button>
</div>
</div>
{status === 'error' && statusDetail && (
<div className="px-2 py-1 text-[12px] font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/10 shrink-0">
{statusDetail}
</div>
)}
<div ref={hostRef} className="flex-1 min-h-[220px] px-1 py-1 overflow-hidden" />
<div className="border-t border-cyan-900/30 px-2 py-1 text-[11px] font-mono text-slate-500 shrink-0">
{status === 'connecting'
? 'Connecting…'
: status === 'open'
? `${expanded ? 'Expanded' : 'Docked'} · ${SHELL_FONT_PX}px · map stays interactive`
: 'Local operator shell'}
</div>
{status === 'connecting' && (
<div className="px-2 py-1 text-[11px] font-mono text-slate-500 border-b border-cyan-900/20 shrink-0">
Connecting
</div>
)}
<div
ref={hostRef}
className="flex-1 min-h-[220px] min-w-0 px-1 py-1 overflow-hidden [&_.xterm]:h-full [&_.xterm]:w-full [&_.xterm-viewport]:!overflow-y-auto"
/>
</div>
);
}
@@ -0,0 +1,224 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Shield } from 'lucide-react';
import { ensureInfonetParticipantNodeReady } from '@/mesh/controlPlaneStatusClient';
import InfonetShell from '@/components/InfonetTerminal/InfonetShell';
type Props = {
active: boolean;
expanded: boolean;
wormholeBusy: boolean;
launchGate?: string;
onExpandedChange: (expanded: boolean) => void;
onEnterWormhole: () => Promise<void>;
onTeardown: () => void;
onLaunchGateConsumed?: () => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
};
function InfonetIntro({
busy,
status,
error,
onEnter,
}: {
busy: boolean;
status: string;
error: string;
onEnter: () => void;
}) {
return (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-3 py-4 text-center border-l-2 border-cyan-800/20 bg-[#04070b]">
<div className="w-full max-w-sm border border-cyan-900/40 bg-cyan-950/10 px-4 py-4">
<div className="text-sm font-mono tracking-[0.22em] text-cyan-300 mb-2">INFONET TERMINAL</div>
<p className="text-[12px] font-mono text-[var(--text-secondary)] leading-[1.6] text-left">
Obfuscated Wormhole lane for the Infonet shell. Leave this tab to shut Wormhole down.
</p>
{status && !error && (
<div className="mt-3 text-left text-[12px] font-mono text-cyan-300/85 leading-relaxed border border-cyan-900/30 bg-cyan-950/10 px-3 py-2">
{status}
</div>
)}
{error && (
<div className="mt-3 text-left text-[12px] font-mono text-amber-300/90 leading-relaxed border border-amber-900/30 bg-amber-950/10 px-3 py-2">
{error}
</div>
)}
<button
type="button"
onClick={onEnter}
disabled={busy}
className="mt-3 w-full px-4 py-2 text-sm font-mono tracking-[0.18em] text-cyan-200 border border-cyan-600/50 bg-cyan-950/30 hover:bg-cyan-950/50 hover:border-cyan-400/60 disabled:opacity-60 transition-colors"
>
{busy ? 'OPENING…' : 'ENTER WORMHOLE'}
</button>
</div>
</div>
);
}
export default function InfonetTerminalPanel({
active,
expanded,
wormholeBusy,
launchGate,
onExpandedChange,
onEnterWormhole,
onTeardown,
onLaunchGateConsumed,
onOpenDeadDrop,
}: Props) {
const [sessionActive, setSessionActive] = useState(false);
const [laneBusy, setLaneBusy] = useState(false);
const [laneError, setLaneError] = useState('');
const [laneStatus, setLaneStatus] = useState('');
const prepStartedRef = useRef(false);
const shellOpen = active && sessionActive;
const resetSession = useCallback(() => {
setSessionActive(false);
setLaneBusy(false);
setLaneError('');
setLaneStatus('');
prepStartedRef.current = false;
}, []);
useEffect(() => {
if (active) return;
resetSession();
onTeardown();
}, [active, onTeardown, resetSession]);
useEffect(() => {
if (!shellOpen) return;
let cancelled = false;
const connectParticipantNode = async () => {
try {
if (cancelled) return;
await ensureInfonetParticipantNodeReady();
} catch {
// Remote viewers may not have local-operator rights.
}
};
void connectParticipantNode();
return () => {
cancelled = true;
};
}, [shellOpen]);
const startWormholeLane = useCallback(async () => {
if (prepStartedRef.current) return;
prepStartedRef.current = true;
setLaneError('');
setLaneStatus('Starting Wormhole obfuscated lane…');
setLaneBusy(true);
try {
await onEnterWormhole();
setLaneStatus('');
} catch (err) {
prepStartedRef.current = false;
setSessionActive(false);
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: 'Could not start Wormhole.';
setLaneError(message);
setLaneStatus('');
} finally {
setLaneBusy(false);
}
}, [onEnterWormhole]);
const handleEnter = useCallback(() => {
setLaneError('');
setLaneStatus('');
setSessionActive(true);
onExpandedChange(true);
void startWormholeLane();
}, [onExpandedChange, startWormholeLane]);
const handleShellClose = useCallback(() => {
resetSession();
onTeardown();
}, [onTeardown, resetSession]);
if (!active) {
return (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
<Shield size={16} className="text-cyan-400 mb-2" />
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">INFONET TERMINAL</div>
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
Expand Meshtastic Chat to open the Wormhole terminal.
</div>
</div>
);
}
if (!sessionActive) {
return (
<InfonetIntro
busy={laneBusy || wormholeBusy}
status={laneStatus}
error={laneError}
onEnter={handleEnter}
/>
);
}
return (
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
{laneBusy ? 'wormhole · obfuscated lane starting' : 'infonet terminal'}
</div>
<div className="flex items-center gap-1 shrink-0">
{!expanded ? (
<button
type="button"
onClick={() => onExpandedChange(true)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
EXPAND
</button>
) : (
<button
type="button"
onClick={() => onExpandedChange(false)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
SNAP
</button>
)}
<button
type="button"
onClick={handleShellClose}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
>
LEAVE
</button>
</div>
</div>
{laneError && (
<div className="px-2 py-1 text-[12px] font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/10 shrink-0">
{laneError}
</div>
)}
<div className="flex-1 min-h-0 min-w-0 overflow-hidden infonet-font">
<InfonetShell
isOpen={shellOpen}
embedded
launchGate={launchGate}
onLaunchGateConsumed={onLaunchGateConsumed}
onClose={handleShellClose}
onOpenDeadDrop={onOpenDeadDrop}
/>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
export type MeshChatFlyoutRect = {
top: number;
left: number;
width: number;
height: number;
};
export function measureMeshChatFlyout(
anchor: DOMRect,
maxWidth: number,
minHeight: number,
): MeshChatFlyoutRect {
const width = Math.min(maxWidth, Math.max(320, window.innerWidth - 48));
const height = Math.min(
Math.max(anchor.height, minHeight),
window.innerHeight - anchor.top - 36,
);
return {
top: anchor.top,
left: anchor.left,
width,
height,
};
}
export const SHELL_FLYOUT_WIDTH = 760;
export const SHELL_FLYOUT_MIN_HEIGHT = 420;
export const INFONET_FLYOUT_WIDTH = 960;
export const INFONET_FLYOUT_MIN_HEIGHT = 480;
@@ -173,6 +173,8 @@ export interface MeshChatProps {
onExpandedChange?: (expanded: boolean) => void;
onSettingsClick?: () => void;
onTerminalToggle?: () => void;
onOpenLiveGate?: (gate: string) => void;
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
launchRequest?: { tab: Tab; gate?: string; peerId?: string; showSas?: boolean; nonce: number } | null;
}
@@ -91,6 +91,7 @@ import {
rotateWormholePairwiseAlias,
listWormholeGatePersonas,
postWormholeGateMessage,
prepareWormholeInteractiveLane,
recoverWormholeSasRootContinuity,
resyncWormholeGateState,
retireWormholeGatePersona,
@@ -317,7 +318,7 @@ function errorMessage(err: unknown, fallback: string = 'unknown error'): string
function describeMeshChatControlError(raw: string): string {
const message = String(raw || '').trim();
if (!message) return 'MeshChat could not update the local control plane.';
if (!message) return 'Meshtastic Chat could not update the local control plane.';
if (
message === 'control_plane_request_failed:530' ||
message === 'HTTP 530' ||
@@ -336,7 +337,7 @@ function describeMeshChatControlError(raw: string): string {
return 'This control action needs a local operator session. Open Settings or Node controls once so the app can authorize local changes, then try Mesh again.';
}
if (message.startsWith('{') || message.startsWith('<')) {
return 'MeshChat could not update the local control plane. Check the backend log for the upstream error.';
return 'Meshtastic Chat could not update the local control plane. Check the backend log for the upstream error.';
}
return message;
}
@@ -413,7 +414,7 @@ export function useMeshChatController({
setInternalExpanded(newVal);
onExpandedChange?.(newVal);
};
const [activeTab, setActiveTab] = useState<Tab>('meshtastic');
const [activeTab, setActiveTab] = useState<Tab>('dms');
const openTerminal = useCallback(() => {
if (onTerminalToggle) {
onTerminalToggle();
@@ -471,7 +472,6 @@ export function useMeshChatController({
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
const infonetAutoBootstrapRef = useRef(false);
const meshMqttRuntime = meshMqttSettings?.runtime;
const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
const canUsePublicMeshInput = Boolean(activePublicMeshAddress) && meshMqttEnabled && !publicMeshBlockedByWormhole;
@@ -905,6 +905,7 @@ export function useMeshChatController({
// ─── InfoNet State ───────────────────────────────────────────────────────
const [gates, setGates] = useState<Gate[]>([]);
const [selectedGate, setSelectedGate] = useState<string>('');
const [infonetLaunchGate, setInfonetLaunchGate] = useState('');
const [infoMessages, setInfoMessages] = useState<InfoNetMessage[]>([]);
const [infoVerification, setInfoVerification] = useState<
Record<string, 'verified' | 'failed' | 'unsigned'>
@@ -1335,7 +1336,7 @@ export function useMeshChatController({
setExpanded(true);
setActiveTab(launchRequest.tab);
if (launchRequest.tab === 'infonet' && launchRequest.gate) {
setSelectedGate(String(launchRequest.gate || '').trim().toLowerCase());
setInfonetLaunchGate(String(launchRequest.gate || '').trim().toLowerCase());
}
if (launchRequest.tab === 'dms') {
const peerId = String(launchRequest.peerId || '').trim();
@@ -2682,7 +2683,7 @@ export function useMeshChatController({
openIdentityWizard({
type: 'err',
text: hasStoredPublicLaneIdentity
? 'Quick fix: turn MeshChat on below, then retry your send.'
? 'Quick fix: turn Meshtastic Chat on below, then retry your send.'
: 'Quick fix: create a public mesh identity below, then retry your send.',
});
setTimeout(() => setSendError(''), 4000);
@@ -4122,10 +4123,9 @@ export function useMeshChatController({
const dmTrustHint = buildDmTrustHint(selectedContactInfo);
const dmTrustPrimaryAction = dmTrustPrimaryActionLabel(selectedContactInfo);
const wormholeDescriptor = getWormholeIdentityDescriptor();
const dashboardRestrictedTab: boolean = activeTab === 'infonet';
const dashboardRestrictedTitle = 'INFONET RESTRICTED';
const dashboardRestrictedDetail =
'Private Wormhole gate activity is staying in the terminal for this build. Dashboard integration is coming soon.';
const dashboardRestrictedTab = false;
const dashboardRestrictedTitle = '';
const dashboardRestrictedDetail = '';
const selectedGateKey = selectedGate.trim().toLowerCase();
const selectedGatePersonaList = selectedGateKey ? gatePersonas[selectedGateKey] || [] : [];
const selectedGateActivePersonaId = selectedGateKey ? activeGatePersonaId[selectedGateKey] || '' : '';
@@ -4223,7 +4223,7 @@ export function useMeshChatController({
(wormholeEnabled && !wormholeReadyState) ||
anonymousDmBlocked));
const privateInfonetBlockedDetail = !wormholeEnabled
? 'INFONET now lives behind Wormhole. Public mesh remains available under the MESH tab.'
? 'INFONET now lives behind Wormhole. Meshtastic radio chat remains available under the MESHTASTIC tab.'
: !wormholeReadyState
? 'Wormhole is enabled, but the local private agent is not ready yet. INFONET stays locked until the private lane is up.'
: 'Wormhole is up, but Reticulum is still warming on the private lane. Gate chat can run in transitional mode while strongest transport posture comes online. For strongest content privacy, use Dead Drop.';
@@ -4383,13 +4383,13 @@ export function useMeshChatController({
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
const text = `MeshChat is on. Address ${readyAddress}.`;
const text = `Meshtastic Chat is on. Address ${readyAddress}.`;
setIdentityWizardStatus({ type: 'ok', text });
setMeshQuickStatus(null);
return { ok: true as const, text };
} catch (err) {
const message = describeMeshChatControlError(errorMessage(err));
const text = `Could not turn MeshChat on: ${message}`;
const text = `Could not turn Meshtastic Chat on: ${message}`;
setIdentityWizardStatus({ type: 'err', text });
setMeshQuickStatus({ type: 'err', text });
return { ok: false as const, text };
@@ -4520,21 +4520,58 @@ export function useMeshChatController({
}
}, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]);
useEffect(() => {
if (!expanded || activeTab !== 'infonet') {
infonetAutoBootstrapRef.current = false;
const enterInfonetWormholeLane = useCallback(async () => {
setMeshSessionActive(false);
setMeshMessages([]);
if (wormholeEnabled && wormholeReadyState) {
try {
const wormholeIdentity = await fetchWormholeIdentity();
setIdentity({
publicKey: wormholeIdentity.public_key,
privateKey: '',
nodeId: wormholeIdentity.node_id,
});
} catch {
// Lane is already up; shell can still open.
}
return;
}
if (privateInfonetReady) {
infonetAutoBootstrapRef.current = false;
return;
setIdentityWizardBusy(true);
try {
const prepared = await prepareWormholeInteractiveLane({ bootstrapIdentity: true });
const [settings, runtime] = await Promise.all([
fetchWormholeSettings(true).catch(() => null),
fetchWormholeStatus().catch(() => null),
]);
const enabled = Boolean(
settings?.enabled ?? prepared.settingsEnabled ?? runtime?.running ?? runtime?.ready,
);
setSecureModeCached(enabled);
setWormholeEnabled(enabled);
setWormholeReadyState(Boolean(runtime?.ready ?? prepared.ready));
setWormholeRnsReady(Boolean(runtime?.rns_ready));
setWormholeRnsDirectReady(Boolean(runtime?.rns_private_dm_direct_ready));
setWormholeRnsPeers({
active: Number(runtime?.rns_active_peers ?? 0),
configured: Number(runtime?.rns_configured_peers ?? 0),
});
if (prepared.identity) {
purgeBrowserSigningMaterial();
purgeBrowserContactGraph();
await purgeBrowserDmState();
const hydratedContacts = await hydrateWormholeContacts(true);
setContacts(hydratedContacts);
setIdentity({
publicKey: prepared.identity.public_key,
privateKey: '',
nodeId: prepared.identity.node_id,
});
}
} finally {
setIdentityWizardBusy(false);
}
if (identityWizardBusy || infonetAutoBootstrapRef.current) return;
infonetAutoBootstrapRef.current = true;
void handleBootstrapPrivateIdentity().catch(() => {
infonetAutoBootstrapRef.current = false;
});
}, [activeTab, expanded, handleBootstrapPrivateIdentity, identityWizardBusy, privateInfonetReady]);
}, [wormholeEnabled, wormholeReadyState]);
return {
// UI state
@@ -4723,6 +4760,9 @@ export function useMeshChatController({
handleLeaveWormholeForPublicMesh,
handleResetPublicIdentity,
handleBootstrapPrivateIdentity,
enterInfonetWormholeLane,
infonetLaunchGate,
clearInfonetLaunchGate: () => setInfonetLaunchGate(''),
handleRefreshSelectedContact,
handleResetSelectedContact,
handleTrustSelectedRemotePrekey,
+2 -2
View File
@@ -1059,7 +1059,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
<div className="flex flex-col gap-1 p-3">
{!nodeId && (
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESHTASTIC CHAT FIRST
</div>
)}
{nodeId && predictions.length === 0 && (
@@ -1109,7 +1109,7 @@ const PredictionsPanel = React.memo(function PredictionsPanel() {
<div className="p-3">
{!nodeId && (
<div className="text-[12px] text-[var(--text-muted)] font-mono text-center py-6">
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESH CHAT FIRST
CONNECT WORMHOLE OR GENERATE IDENTITY IN MESHTASTIC CHAT FIRST
</div>
)}
{nodeId && !profile && (
+1 -1
View File
@@ -1905,7 +1905,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
</div>
<div className="text-[var(--text-muted)] leading-relaxed">
Mesh Terminal stays read-only for sensitive posting and DM actions while
the hidden transport policy is active. Use MeshChat for the hardened path.
the hidden transport policy is active. Use Meshtastic Chat for the hardened path.
</div>
<div className="text-[var(--text-muted)] leading-relaxed">
Relay fallback reduces metadata protection compared with direct obfuscated
+1 -1
View File
@@ -240,7 +240,7 @@
"disconnected": "Disconnected"
},
"meshChat": {
"title": "Mesh Chat",
"title": "Meshtastic Chat",
"infonet": "Infonet",
"meshtastic": "Meshtastic",
"deadDrop": "Dead Drop",
+1 -1
View File
@@ -240,7 +240,7 @@
"disconnected": "Déconnecté"
},
"meshChat": {
"title": "Chat Mesh",
"title": "Chat Meshtastic",
"infonet": "Infonet",
"meshtastic": "Meshtastic",
"deadDrop": "Dead Drop",
+1 -1
View File
@@ -240,7 +240,7 @@
"disconnected": "未连接"
},
"meshChat": {
"title": "Mesh 聊天",
"title": "Meshtastic 聊天",
"infonet": "Infonet",
"meshtastic": "Meshtastic",
"deadDrop": "死信箱",
+4 -1
View File
@@ -2,9 +2,12 @@ export function resolveAgentShellWsUrl(cwd?: string): string {
if (typeof window === 'undefined') return '';
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.hostname || '127.0.0.1';
const fePort = window.location.port;
const port =
process.env.NEXT_PUBLIC_BACKEND_PORT ||
(window.location.port === '3000' ? '8000' : window.location.port || '8000');
(fePort && fePort !== '3000'
? String(Number(fePort) - 3000 + 8000)
: '8000');
const params = new URLSearchParams();
const trimmed = String(cwd || '').trim();
if (trimmed) params.set('cwd', trimmed);
+2 -2
View File
@@ -10,13 +10,13 @@ export function getMeshTerminalWriteLockReason(state: MeshTerminalSecurityState)
if (!state.anonymousModeReady) {
return 'Mesh Terminal write commands are disabled until Wormhole hidden transport is ready for Anonymous Infonet mode.';
}
return 'Mesh Terminal write commands are disabled while Anonymous Infonet mode is active. Use MeshChat for gate chat (transitional lane) or Dead Drop (stronger private lane).';
return 'Mesh Terminal write commands are disabled while Anonymous Infonet mode is active. Use Meshtastic Chat for gate chat (transitional lane) or Dead Drop (stronger private lane).';
}
if (state.wormholeRequired) {
if (!state.wormholeReady) {
return 'Mesh Terminal write commands are disabled until Wormhole secure mode is ready.';
}
return 'Mesh Terminal write commands are disabled while Wormhole secure mode is active. Use MeshChat for gate chat (transitional lane) or Dead Drop (stronger private lane).';
return 'Mesh Terminal write commands are disabled while Wormhole secure mode is active. Use Meshtastic Chat for gate chat (transitional lane) or Dead Drop (stronger private lane).';
}
return '';
}
@@ -1,4 +1,5 @@
import { controlPlaneJson } from '@/lib/controlPlane';
import { generateNodeKeys, getNodeIdentity } from '@/mesh/meshIdentity';
export interface PrivacyProfileSnapshot {
profile?: string;
@@ -236,3 +237,13 @@ export async function startTorHiddenService(): Promise<TorHiddenServiceSnapshot>
requireAdminSession: false,
});
}
/** Warm Tor/Arti and (re)enable the participant node so Infonet seed sync can run. */
export async function ensureInfonetParticipantNodeReady(): Promise<void> {
if (!getNodeIdentity()) {
await generateNodeKeys().catch(() => null);
}
await startTorHiddenService().catch(() => null);
await setInfonetNodeEnabled(true);
await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
}