palmier 0.8.7 → 0.8.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.
Files changed (35) hide show
  1. package/README.md +26 -17
  2. package/dist/commands/init.js +6 -11
  3. package/dist/commands/pair.js +3 -0
  4. package/dist/platform/macos.js +2 -4
  5. package/dist/pwa/assets/index-1gs4vwFo.js +120 -0
  6. package/dist/pwa/assets/{index-UaZFu6XL.css → index-DQJHVyP6.css} +1 -1
  7. package/dist/pwa/assets/{web-DYwZE4qa.js → web-BqVsIFtP.js} +1 -1
  8. package/dist/pwa/assets/web-DrSNtZ3i.js +1 -0
  9. package/dist/pwa/assets/{web-nSzKzI8x.js → web-lefgO9YR.js} +1 -1
  10. package/dist/pwa/index.html +2 -2
  11. package/dist/pwa/service-worker.js +1 -1
  12. package/dist/transports/http-transport.js +42 -27
  13. package/dist/types.d.ts +0 -2
  14. package/package.json +1 -1
  15. package/palmier-server/CLAUDE.md +4 -0
  16. package/palmier-server/PRODUCTION.md +1 -1
  17. package/palmier-server/README.md +1 -1
  18. package/palmier-server/pnpm-lock.yaml +12 -0
  19. package/palmier-server/pwa/package.json +1 -0
  20. package/palmier-server/pwa/src/App.css +61 -0
  21. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +1 -1
  22. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +69 -0
  23. package/palmier-server/pwa/src/components/HostMenu.tsx +5 -4
  24. package/palmier-server/pwa/src/constants.ts +1 -1
  25. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +156 -66
  26. package/palmier-server/pwa/src/pages/Dashboard.tsx +2 -0
  27. package/palmier-server/pwa/src/pages/PairHost.tsx +9 -9
  28. package/palmier-server/pwa/src/types.ts +3 -1
  29. package/palmier-server/spec.md +21 -19
  30. package/src/commands/init.ts +7 -12
  31. package/src/commands/pair.ts +3 -0
  32. package/src/platform/macos.ts +2 -1
  33. package/src/transports/http-transport.ts +34 -29
  34. package/src/types.ts +0 -2
  35. package/dist/pwa/assets/index-BiAE5qeC.js +0 -120
@@ -26,7 +26,7 @@ const CAPABILITIES: CapabilityDefinition[] = [
26
26
  { capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
27
27
  { capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
28
28
  { capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
29
- { capability: "notifications", label: "Read Notifications", group: "Data", permission: "notificationListener" },
29
+ { capability: "notifications", label: "Notifications from Other Apps", group: "Data", permission: "notificationListener" },
30
30
  { capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
31
31
  { capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
32
32
  {
@@ -0,0 +1,69 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { Capacitor } from "@capacitor/core";
3
+ import { useHostConnection } from "../contexts/HostConnectionContext";
4
+
5
+ const isNative = Capacitor.isNativePlatform();
6
+
7
+ export default function ConnectionStatusIcon() {
8
+ const { mode } = useHostConnection();
9
+ const [popoverOpen, setPopoverOpen] = useState(false);
10
+ const containerRef = useRef<HTMLDivElement>(null);
11
+
12
+ useEffect(() => {
13
+ if (!popoverOpen) return;
14
+ function onPointerDown(e: PointerEvent) {
15
+ if (!containerRef.current?.contains(e.target as Node)) setPopoverOpen(false);
16
+ }
17
+ document.addEventListener("pointerdown", onPointerDown);
18
+ return () => document.removeEventListener("pointerdown", onPointerDown);
19
+ }, [popoverOpen]);
20
+
21
+ if (mode === "direct") return null;
22
+
23
+ let icon: string;
24
+ let label: string;
25
+ let modifier: string;
26
+ switch (mode) {
27
+ case "lan":
28
+ icon = "\u{1F4F6}";
29
+ label = "Connected via LAN";
30
+ modifier = "lan";
31
+ break;
32
+ case "nats":
33
+ icon = "\u{1F310}";
34
+ label = isNative ? "Connected via relay" : "Connected";
35
+ modifier = "relay";
36
+ break;
37
+ case "disconnected":
38
+ icon = "\u26A0\uFE0F";
39
+ label = "Disconnected";
40
+ modifier = "disconnected";
41
+ break;
42
+ case "connecting":
43
+ default:
44
+ icon = "\u{1F310}";
45
+ label = "Connecting…";
46
+ modifier = "connecting";
47
+ break;
48
+ }
49
+
50
+ return (
51
+ <div
52
+ ref={containerRef}
53
+ className={`conn-status conn-status--${modifier}`}
54
+ >
55
+ <button
56
+ type="button"
57
+ className="conn-status-btn"
58
+ aria-label={label}
59
+ title={label}
60
+ onClick={() => setPopoverOpen((v) => !v)}
61
+ >
62
+ <span aria-hidden="true">{icon}</span>
63
+ </button>
64
+ <div className={`conn-status-popover ${popoverOpen ? "conn-status-popover--open" : ""}`} role="tooltip">
65
+ {label}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -7,8 +7,9 @@ import { useMediaQuery } from "../hooks/useMediaQuery";
7
7
  import { confirmLeaveDraft } from "../draftGuard";
8
8
  import CapabilityToggles from "./CapabilityToggles";
9
9
 
10
- /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
11
- const isLanMode = !!(window as any).__PALMIER_SERVE__;
10
+ /** Local mode: PWA is served by palmier serve on loopback. */
11
+ const isLoopback = !!(window as any).__PALMIER_SERVE__
12
+ && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
12
13
  const isNative = Capacitor.isNativePlatform();
13
14
 
14
15
  interface HostMenuProps {
@@ -99,7 +100,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
99
100
  </button>
100
101
  )}
101
102
 
102
- {!isLanMode && pairedHosts.length > 0 && (
103
+ {!isLoopback && pairedHosts.length > 0 && (
103
104
  <div className="drawer-section">
104
105
  <h3 className="drawer-section-label">Hosts</h3>
105
106
  <div className="host-picker-inline">
@@ -193,7 +194,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
193
194
  </div>
194
195
  )}
195
196
 
196
- {!isLanMode && (<>
197
+ {!isLoopback && (<>
197
198
  <div className="drawer-divider" />
198
199
  <div className="drawer-section">
199
200
  <button
@@ -1,2 +1,2 @@
1
1
  /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.8.5";
2
+ export const MIN_HOST_VERSION = "0.8.8";
@@ -8,11 +8,16 @@ import {
8
8
  type ReactNode,
9
9
  } from "react";
10
10
  import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
11
+ import { Capacitor } from "@capacitor/core";
12
+ import { App as CapacitorApp } from "@capacitor/app";
13
+ import { Network } from "@capacitor/network";
11
14
  import { SERVER_URL } from "../api";
12
15
  import { useHostStore } from "./HostStoreContext";
13
16
  import type { PairedHost } from "../types";
14
17
 
15
- type ConnectionMode = "nats" | "direct" | "disconnected";
18
+ type ConnectionMode = "nats" | "lan" | "direct" | "connecting" | "disconnected";
19
+
20
+ const isNative = Capacitor.isNativePlatform();
16
21
 
17
22
  interface HostConnectionContextValue {
18
23
  /** Whether we have an active connection to the host. */
@@ -33,7 +38,7 @@ interface HostConnectionContextValue {
33
38
 
34
39
  const HostConnectionContext = createContext<HostConnectionContextValue>({
35
40
  connected: false,
36
- mode: "disconnected",
41
+ mode: "connecting",
37
42
  nc: null,
38
43
  request() { return Promise.reject(new Error("No host connection")); },
39
44
  subscribeEvents() { return () => {}; },
@@ -43,6 +48,8 @@ const HostConnectionContext = createContext<HostConnectionContextValue>({
43
48
 
44
49
  const SSE_CONNECT_TIMEOUT_MS = 2_000;
45
50
  const HEARTBEAT_TIMEOUT_MS = 6_000;
51
+ const LAN_PROBE_TIMEOUT_MS = 1_500;
52
+ const LAN_KEEPALIVE_MS = 60_000;
46
53
 
47
54
  export function HostConnectionProvider({ children }: { children: ReactNode }) {
48
55
  const { getActiveHost } = useHostStore();
@@ -54,6 +61,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
54
61
 
55
62
  const [sseConnected, setSseConnected] = useState(false);
56
63
  const [unauthorized, setUnauthorized] = useState(false);
64
+ const [hostNotFound, setHostNotFound] = useState(false);
65
+ const [lanReachable, setLanReachable] = useState(false);
57
66
 
58
67
  const sc = useRef(StringCodec());
59
68
  const sseEventCallbacksRef = useRef<Set<(msg: { subject: string; data: Uint8Array }) => void>>(new Set());
@@ -61,16 +70,69 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
61
70
 
62
71
  // Host is a "direct-only" host if it has a directUrl (paired with address)
63
72
  const isDirectHost = activeHost != null && !!activeHost.directUrl;
73
+ // Auto-LAN is opportunistic on native server-paired hosts that have a known LAN URL.
74
+ const useLanRpc = isNative && !isDirectHost && !!activeHost?.lanUrl && lanReachable;
64
75
 
65
- const mode: ConnectionMode = !activeHost
76
+ const mode: ConnectionMode = unauthorized || hostNotFound
66
77
  ? "disconnected"
67
- : isDirectHost
68
- ? (sseConnected ? "direct" : "disconnected")
69
- : (natsConnected ? "nats" : "disconnected");
70
- const connected = mode !== "disconnected";
78
+ : !activeHost
79
+ ? "connecting"
80
+ : isDirectHost
81
+ ? (sseConnected ? "direct" : "connecting")
82
+ : !natsConnected
83
+ ? "connecting"
84
+ : (useLanRpc ? "lan" : "nats");
85
+ const connected = mode !== "connecting" && mode !== "disconnected";
86
+
87
+ // Reset terminal states when switching hosts or re-pairing (new client token).
88
+ useEffect(() => {
89
+ setUnauthorized(false);
90
+ setHostNotFound(false);
91
+ }, [activeHost?.hostId, activeHost?.clientToken]);
71
92
 
72
- // Reset unauthorized when switching hosts or re-pairing (new client token)
73
- useEffect(() => { setUnauthorized(false); }, [activeHost?.hostId, activeHost?.clientToken]);
93
+ // Probe the host's LAN URL on native server-paired hosts. RPC routes via direct
94
+ // HTTP when the probe succeeds; events stay on NATS regardless.
95
+ useEffect(() => {
96
+ if (!isNative || isDirectHost || !activeHost?.lanUrl) {
97
+ setLanReachable(false);
98
+ return;
99
+ }
100
+
101
+ let cancelled = false;
102
+ const lanUrl = activeHost.lanUrl;
103
+ const expectedHostId = activeHost.hostId;
104
+
105
+ async function probe() {
106
+ try {
107
+ const res = await fetch(`${lanUrl}/health`, {
108
+ signal: AbortSignal.timeout(LAN_PROBE_TIMEOUT_MS),
109
+ });
110
+ if (cancelled) return;
111
+ if (!res.ok) { setLanReachable(false); return; }
112
+ const data = await res.json() as { hostId?: string };
113
+ const reachable = data.hostId === expectedHostId;
114
+ setLanReachable(reachable);
115
+ if (reachable) console.log("[HOST/LAN] reachable:", lanUrl);
116
+ } catch {
117
+ if (!cancelled) setLanReachable(false);
118
+ }
119
+ }
120
+
121
+ probe();
122
+ const intervalId = window.setInterval(probe, LAN_KEEPALIVE_MS);
123
+
124
+ const appHandle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
125
+ if (state.isActive) probe();
126
+ });
127
+ const netHandle = Network.addListener("networkStatusChange", () => probe());
128
+
129
+ return () => {
130
+ cancelled = true;
131
+ clearInterval(intervalId);
132
+ appHandle.then((h) => h.remove());
133
+ netHandle.then((h) => h.remove());
134
+ };
135
+ }, [activeHost?.hostId, activeHost?.lanUrl, isDirectHost]);
74
136
 
75
137
  // Fetch NATS config from server and connect (only for NATS hosts)
76
138
  useEffect(() => {
@@ -87,51 +149,63 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
87
149
  if (!activeHost) return;
88
150
 
89
151
  let cancelled = false;
152
+ let retryDelayMs = 1_000;
153
+ const MAX_RETRY_MS = 30_000;
90
154
 
91
- async function init() {
92
- try {
93
- // Fetch host-scoped NATS credentials (can only access this host's subjects)
94
- const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
95
- if (!res.ok) {
96
- console.error("[NATS] Failed to fetch credentials");
97
- return;
98
- }
99
- const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
100
- if (!config.natsWsUrl) {
101
- console.warn("[NATS] No WebSocket URL configured");
102
- return;
103
- }
104
- if (cancelled) return;
155
+ async function connectLoop() {
156
+ while (!cancelled) {
157
+ try {
158
+ const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
159
+ if (cancelled) return;
160
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
161
+ console.error("[NATS] Host not found or rejected by relay:", res.status);
162
+ setHostNotFound(true);
163
+ return;
164
+ }
165
+ if (!res.ok) throw new Error(`credentials fetch ${res.status}`);
105
166
 
106
- console.log("[NATS] Connecting to", config.natsWsUrl);
107
- const conn = await connect({
108
- servers: config.natsWsUrl,
109
- authenticator: jwtAuthenticator(
110
- config.natsJwt,
111
- new TextEncoder().encode(config.natsNkeySeed),
112
- ),
113
- });
114
- if (cancelled) { conn.close().catch(() => {}); return; }
115
- console.log("[NATS] Connected");
116
- ncRef.current = conn;
117
- setNc(conn);
118
- setNatsConnected(true);
119
-
120
- conn.closed().then(() => {
121
- console.log("[NATS] Connection closed");
122
- if (!cancelled) {
123
- setNc(null);
124
- setNatsConnected(false);
125
- ncRef.current = null;
167
+ const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
168
+ if (!config.natsWsUrl) {
169
+ console.error("[NATS] Relay returned empty natsWsUrl — treating as terminal");
170
+ setHostNotFound(true);
171
+ return;
126
172
  }
127
- });
128
- } catch (err) {
129
- console.error("[NATS] Connection failed:", err);
130
- if (!cancelled) setNatsConnected(false);
173
+ if (cancelled) return;
174
+
175
+ console.log("[NATS] Connecting to", config.natsWsUrl);
176
+ const conn = await connect({
177
+ servers: config.natsWsUrl,
178
+ authenticator: jwtAuthenticator(
179
+ config.natsJwt,
180
+ new TextEncoder().encode(config.natsNkeySeed),
181
+ ),
182
+ });
183
+ if (cancelled) { conn.close().catch(() => {}); return; }
184
+ console.log("[NATS] Connected");
185
+ ncRef.current = conn;
186
+ setNc(conn);
187
+ setNatsConnected(true);
188
+ retryDelayMs = 1_000;
189
+
190
+ await conn.closed();
191
+ if (cancelled) return;
192
+
193
+ console.log("[NATS] Connection closed, will reconnect");
194
+ ncRef.current = null;
195
+ setNc(null);
196
+ setNatsConnected(false);
197
+ } catch (err) {
198
+ if (cancelled) return;
199
+ console.warn(`[NATS] Connect failed, retrying in ${retryDelayMs}ms:`, err);
200
+ setNatsConnected(false);
201
+ await new Promise((r) => setTimeout(r, retryDelayMs));
202
+ retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_MS);
203
+ }
131
204
  }
132
205
  }
133
206
 
134
- init();
207
+ connectLoop();
208
+
135
209
  return () => {
136
210
  cancelled = true;
137
211
  if (ncRef.current) {
@@ -239,14 +313,13 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
239
313
  }
240
314
  }
241
315
 
242
- // Direct host (paired with address) always use HTTP
243
- if (isDirectHost) {
244
- console.log(`[HOST/HTTP] ${method}`, params ?? "");
245
- const res = await fetch(`${activeHost.directUrl}/rpc/${method}`, {
316
+ async function callHttp(baseUrl: string, label: string): Promise<T> {
317
+ console.log(`[HOST/${label}] → ${method}`, params ?? "");
318
+ const res = await fetch(`${baseUrl}/rpc/${method}`, {
246
319
  method: "POST",
247
320
  headers: {
248
321
  "Content-Type": "application/json",
249
- Authorization: `Bearer ${activeHost.clientToken}`,
322
+ Authorization: `Bearer ${activeHost!.clientToken}`,
250
323
  },
251
324
  body: params != null ? JSON.stringify(params) : undefined,
252
325
  signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
@@ -257,22 +330,39 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
257
330
  }
258
331
  const data = await res.json() as T;
259
332
  checkUnauthorized(data);
260
- console.log(`[HOST/HTTP] ← ${method}`, data);
333
+ console.log(`[HOST/${label}] ← ${method}`, data);
261
334
  return data;
262
335
  }
263
336
 
264
- // NATS mode
265
- if (!ncRef.current) throw new Error("Not connected");
266
- const subject = `host.${activeHost.hostId}.rpc.${method}`;
267
- const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost.clientToken };
268
- const body = sc.current.encode(JSON.stringify(payload));
269
- console.log(`[HOST/NATS] → ${method}`, params ?? "");
270
- const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
271
- const decoded = JSON.parse(sc.current.decode(msg.data)) as T;
272
- checkUnauthorized(decoded);
273
- console.log(`[HOST/NATS] ← ${method}`, decoded);
274
- return decoded;
275
- }, [activeHost, isDirectHost]);
337
+ async function callNats(): Promise<T> {
338
+ if (!ncRef.current) throw new Error("Not connected");
339
+ const subject = `host.${activeHost!.hostId}.rpc.${method}`;
340
+ const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost!.clientToken };
341
+ const body = sc.current.encode(JSON.stringify(payload));
342
+ console.log(`[HOST/NATS] → ${method}`, params ?? "");
343
+ const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
344
+ const decoded = JSON.parse(sc.current.decode(msg.data)) as T;
345
+ checkUnauthorized(decoded);
346
+ console.log(`[HOST/NATS] ← ${method}`, decoded);
347
+ return decoded;
348
+ }
349
+
350
+ // Direct (loopback) host — always HTTP, no fallback.
351
+ if (isDirectHost) return callHttp(activeHost.directUrl!, "HTTP");
352
+
353
+ // Auto-LAN: try HTTP first; on failure mark unreachable and retry over NATS.
354
+ if (useLanRpc && activeHost.lanUrl) {
355
+ try {
356
+ return await callHttp(activeHost.lanUrl, "LAN");
357
+ } catch (err) {
358
+ if (err instanceof Error && err.message === "Unauthorized") throw err;
359
+ console.log("[HOST/LAN] failed, falling back to NATS:", err);
360
+ setLanReachable(false);
361
+ }
362
+ }
363
+
364
+ return callNats();
365
+ }, [activeHost, isDirectHost, useLanRpc]);
276
366
 
277
367
  // Subscribe to task events
278
368
  const subscribeEvents = useCallback((hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void => {
@@ -7,6 +7,7 @@ import { useHostConnection } from "../contexts/HostConnectionContext";
7
7
  import TasksView from "../components/TasksView";
8
8
  import TabBar from "../components/TabBar";
9
9
  import HostMenu from "../components/HostMenu";
10
+ import ConnectionStatusIcon from "../components/ConnectionStatusIcon";
10
11
  import SessionsView from "../components/SessionsView";
11
12
  import RunDetailView from "../components/RunDetailView";
12
13
  import { usePushSubscription } from "../hooks/usePushSubscription";
@@ -298,6 +299,7 @@ export default function Dashboard() {
298
299
  <div className="app-title-bar">
299
300
  {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
300
301
  <h1 className="app-title">Palmier</h1>
302
+ <ConnectionStatusIcon />
301
303
  </div>
302
304
  <div className="tab-bar">
303
305
  <TabBar />
@@ -15,8 +15,9 @@ interface PairResponse {
15
15
  hostName?: string;
16
16
  }
17
17
 
18
- /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
19
- const isLanMode = !!(window as any).__PALMIER_SERVE__;
18
+ /** Loopback (Local) mode: PWA is served by palmier serve on localhost. */
19
+ const isLoopback = !!(window as any).__PALMIER_SERVE__
20
+ && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
20
21
 
21
22
  export default function PairHost() {
22
23
  const [code, setCode] = useState("");
@@ -38,8 +39,7 @@ export default function PairHost() {
38
39
  try {
39
40
  let response: PairResponse;
40
41
 
41
- if (isLanMode) {
42
- // LAN mode — same-origin fetch to the host serving this page
42
+ if (isLoopback) {
43
43
  const res = await fetch("/pair", {
44
44
  method: "POST",
45
45
  headers: { "Content-Type": "application/json" },
@@ -53,7 +53,6 @@ export default function PairHost() {
53
53
 
54
54
  response = await res.json() as PairResponse;
55
55
  } else {
56
- // Server mode — pair via NATS
57
56
  const configRes = await fetch(`${SERVER_URL}/api/config`);
58
57
  if (!configRes.ok) throw new Error("Failed to fetch server config");
59
58
  const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
@@ -82,7 +81,8 @@ export default function PairHost() {
82
81
  const host: PairedHost = {
83
82
  hostId: response.hostId,
84
83
  clientToken: response.clientToken,
85
- directUrl: isLanMode ? window.location.origin : undefined,
84
+ directUrl: isLoopback ? window.location.origin : undefined,
85
+ lanUrl: !isLoopback ? response.directUrl : undefined,
86
86
  ...(response.hostName ? { name: response.hostName } : {}),
87
87
  };
88
88
 
@@ -125,15 +125,15 @@ export default function PairHost() {
125
125
  <div className="pair-page">
126
126
  <div className="pair-card">
127
127
  <div className="pair-header">
128
- <h1 className="pair-title">{isLanMode ? "Pair" : "Pair with Host"}</h1>
128
+ <h1 className="pair-title">{isLoopback ? "Pair" : "Pair with Host"}</h1>
129
129
  <p className="pair-subtitle">
130
- {isLanMode
130
+ {isLoopback
131
131
  ? "Enter the pairing code shown in your terminal."
132
132
  : "Connect this device to a Palmier host"}
133
133
  </p>
134
134
  </div>
135
135
 
136
- {!isLanMode && (
136
+ {!isLoopback && (
137
137
  <div className="pair-instructions">
138
138
  <div className="pair-instruction-block">
139
139
  <h3 className="pair-instruction-heading">Setting up a new host?</h3>
@@ -64,6 +64,8 @@ export interface PairedHost {
64
64
  hostId: string;
65
65
  clientToken: string;
66
66
  name?: string;
67
- /** If set, all communication uses HTTP to this URL instead of NATS. */
67
+ /** If set, all communication uses HTTP to this URL instead of NATS. Set only for loopback (Local mode). */
68
68
  directUrl?: string;
69
+ /** Host's LAN URL captured at pair time. Native Capacitor app probes for reachability and routes RPC over HTTP when reachable; events stay on NATS. */
70
+ lanUrl?: string;
69
71
  }
@@ -12,7 +12,7 @@ The host supports **Linux** (systemd), **macOS** (launchd user LaunchAgent), and
12
12
 
13
13
  ### 1.2 Components
14
14
 
15
- * **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms-message`, `send-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms-messages://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
15
+ * **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts an HTTP server bound to `0.0.0.0` alongside the NATS transport. The web UI, `/pair`, and `/events` are gated to loopback callers; `/rpc/<method>` (bearer-auth) and `/health` (public) are reachable from the LAN to support the Capacitor app's auto-LAN mode. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms-message`, `send-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms-messages://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
16
16
 
17
17
  * **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Subscribes to `host.*.fcm.contacts`, `host.*.fcm.calendar`, `host.*.fcm.sms`, `host.*.fcm.alarm`, `host.*.fcm.battery`, and `host.*.fcm.ringer` to relay device capability requests via FCM. Provides HTTP endpoints for Android to post responses back (`/api/device/contacts-response`, `/api/device/calendar-response`, `/api/device/sms-response`, `/api/device/alarm-response`, `/api/device/battery-response`, `/api/device/ringer-response`). Co-located with the NATS server on the same machine.
18
18
 
@@ -90,42 +90,42 @@ Each host machine is provisioned via `palmier init`, an interactive wizard that
90
90
  `palmier init` is an interactive wizard that:
91
91
 
92
92
  1. Detects installed agent CLIs.
93
- 2. Asks whether to enable LAN access and which HTTP port to use (default 7256).
94
- 3. Shows a summary of task storage directory, local access URL, LAN URL (if enabled), detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
93
+ 2. Asks which HTTP port to use (default 7256).
94
+ 3. Shows a summary of task storage directory, local access URL, detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
95
95
  4. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }`.
96
- 5. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, `lanEnabled`, NATS credentials).
96
+ 5. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, NATS credentials).
97
97
  6. Installs a systemd user service (Linux), user LaunchAgent (macOS), or Task Scheduler entry (Windows) and auto-enters pair mode.
98
98
 
99
99
  The daemon automatically recovers existing tasks by reinstalling their system timers on startup.
100
100
 
101
- The `serve` daemon always starts an HTTP server on the configured port. Three access modes are available:
101
+ The `serve` daemon always starts an HTTP server bound to `0.0.0.0:<port>`. Two access modes are available:
102
102
 
103
- **Local mode** (always available):
104
- - HTTP server binds to `127.0.0.1:<port>`. The PWA is accessible at `http://localhost:<port>` without pairing or internet. The PWA is bundled with the host package. The serve daemon injects `window.__PALMIER_SERVE__=true` into the HTML; the PWA detects this and auto-connects.
103
+ **Local mode** (always available, loopback only):
104
+ - The PWA is accessible at `http://localhost:<port>` without pairing or internet. The PWA is bundled with the host package. The serve daemon injects `window.__PALMIER_SERVE__=true` into the HTML; the PWA detects this and auto-connects. Web UI assets, `/pair`, and `/events` are gated to loopback callers (`127.0.0.1`/`::1`); non-loopback requests get 404.
105
105
 
106
- **LAN mode** (enabled during init):
107
- - HTTP server binds to `0.0.0.0:<port>`, making the PWA accessible from the local network at `http://<host-ip>:<port>`. Non-localhost access requires pairing via a pairing code. Push notifications are not available.
108
-
109
- **Server mode** (NATS cloud relay, always on):
106
+ **Server mode** (NATS cloud relay):
110
107
  - Communication is relayed through the Palmier cloud server via NATS. PWA is accessed at `https://app.palmier.me`. Enables push notifications and remote access.
111
108
 
109
+ **Auto-LAN** (transparent perf optimization on top of Server mode):
110
+ - The host's HTTP server exposes `/rpc/<method>` (bearer-auth) and `/health` (public) on all interfaces, so other devices on the LAN can reach them. The native Capacitor app probes `${lanUrl}/health` (URL captured at pair time) and routes RPC over direct HTTP when reachable, falling back to NATS otherwise. Events stay on NATS regardless. Browser PWAs cannot use this path due to Private Network Access / mixed-content rules.
111
+
112
112
  ### 2.2 Device Pairing
113
113
 
114
114
  Local access (`http://localhost:<port>`) requires no pairing — the PWA auto-connects with a placeholder host ID.
115
115
 
116
- For LAN and server mode, `palmier pair` generates a 6-character pairing code from the charset `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (excludes ambiguous O/0/I/1/L) and listens on both transports in parallel:
116
+ For server-mode pairing, `palmier pair` generates a 6-character pairing code from the charset `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (excludes ambiguous O/0/I/1/L) and listens on both NATS (relay) and HTTP (loopback only) in parallel:
117
117
 
118
- **Server mode (NATS):**
118
+ **Server pairing (NATS, primary path):**
119
119
  1. Host subscribes to `pair.<CODE>` on NATS with a 5-minute timeout.
120
120
  2. User enters the code in the PWA at `https://app.palmier.me`.
121
- 3. Host validates the code, generates a client token via `addClient()`, and responds with `{ hostId, clientToken }`.
121
+ 3. Host validates the code, generates a client token via `addClient()`, and responds with `{ hostId, clientToken, directUrl, hostName }`. `directUrl` is the host's LAN URL (`http://<lan-ip>:<port>`) — stored on the device as `lanUrl` and probed by the native app for auto-LAN.
122
122
 
123
- **LAN mode (HTTP):**
124
- 1. Host registers the code with the serve daemon via `POST /pair-register`.
125
- 2. User opens `http://<host-ip>:<port>` and enters the code. No host address field is shown since the request is same-origin.
126
- 3. PWA posts `POST /pair` with `{ code }` to the same origin. Host responds with `{ hostId, clientToken, directUrl }`.
123
+ **Local pairing (HTTP, loopback only):**
124
+ 1. Host registers the code with the serve daemon via `POST /pair-register` (loopback-gated).
125
+ 2. User opens `http://localhost:<port>` on the host machine and enters the code.
126
+ 3. PWA posts `POST /pair` (loopback-gated) with `{ code }` and gets the same payload as above. The PWA treats `directUrl = window.location.origin` (loopback), and stores nothing as `lanUrl`.
127
127
 
128
- In both cases, the PWA stores the paired host in localStorage and navigates to the dashboard. Codes expire after 5 minutes or first successful use.
128
+ The PWA stores the paired host in localStorage and navigates to the dashboard. Codes expire after 5 minutes or first successful use.
129
129
 
130
130
  ### 2.3 Client Management
131
131
 
@@ -143,6 +143,8 @@ All communication is scoped per host. **Request-reply** is used for RPC-style ca
143
143
 
144
144
  The **RPC method is derived from the NATS subject**, not the message body. The host subscribes to `host.<host_id>.rpc.>` and extracts the method by splitting the subject at `rpc.` (e.g., `...rpc.task.create` → `task.create`). The message body contains the request parameters as JSON, including the `clientToken` field for authentication.
145
145
 
146
+ **Auto-LAN (native Capacitor app only).** When the device probes the host's LAN URL successfully, it routes the same RPC methods over direct HTTP (`POST <lanUrl>/rpc/<method>` with `Authorization: Bearer <clientToken>`) instead of through NATS. Identical request/response payloads, lower latency. Browser PWA always uses NATS. Events (`host-event.*`) always flow through NATS regardless of mode.
147
+
146
148
  **Host RPC endpoints** (request-reply, subject: `host.<host_id>.rpc.<method>`):
147
149
 
148
150
  | Method | Params | Description |
@@ -37,25 +37,21 @@ export async function initCommand(): Promise<void> {
37
37
 
38
38
  console.log(` Found: ${green(agents.map((a) => a.label).join(", "))}\n`);
39
39
 
40
- const lanAnswer = await ask("Enable LAN access (direct HTTP from local network)? (y/N): ");
41
- const lanEnabled = lanAnswer.trim().toLowerCase() === "y";
42
-
43
40
  let httpPort = 7256;
44
- const portLabel = lanEnabled ? "HTTP port for local and LAN access" : "HTTP port for local access";
45
- const portAnswer = await ask(`${portLabel} (default ${httpPort}): `);
41
+ const portAnswer = await ask(`HTTP port (default ${httpPort}): `);
46
42
  const parsed = parseInt(portAnswer.trim(), 10);
47
43
  if (parsed > 0 && parsed < 65536) httpPort = parsed;
48
44
 
45
+ const lanIp = detectLanIp();
46
+
49
47
  console.log(`\n${bold("Setup summary:")}\n`);
50
48
  console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
51
49
  console.log(` All tasks and execution data will be stored here.\n`);
52
50
  console.log(` ${dim("Local access:")} ${cyan(`http://localhost:${httpPort}`)}`);
53
- console.log(` Always available — no internet required.\n`);
54
- if (lanEnabled) {
55
- const ip = detectLanIp();
56
- console.log(` ${dim("LAN access:")} ${cyan(`http://${ip}:${httpPort}`)}`);
57
- console.log(` Accessible from other devices on your local network. Pairing required.\n`);
58
- }
51
+ console.log(` Open in a browser on this machine — no internet required.\n`);
52
+ console.log(` ${dim("Remote access:")} ${cyan("https://app.palmier.me")}`);
53
+ console.log(` Pair the app to your host. The app uses ${cyan(`http://${lanIp}:${httpPort}`)}`);
54
+ console.log(` for direct RPC when on the same network, otherwise the relay.\n`);
59
55
  console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
60
56
 
61
57
  const existingTasks = listTasks(process.cwd());
@@ -106,7 +102,6 @@ export async function initCommand(): Promise<void> {
106
102
  natsNkeySeed: registerResponse.natsNkeySeed,
107
103
  agents,
108
104
  httpPort,
109
- lanEnabled,
110
105
  };
111
106
 
112
107
  saveConfig(config);