palmier 0.8.10 → 0.9.2

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 (49) hide show
  1. package/README.md +8 -1
  2. package/dist/commands/init.js +13 -2
  3. package/dist/commands/pair.js +3 -9
  4. package/dist/linked-device.d.ts +9 -0
  5. package/dist/linked-device.js +45 -0
  6. package/dist/mcp-tools.js +19 -19
  7. package/dist/network.d.ts +0 -5
  8. package/dist/network.js +75 -9
  9. package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
  10. package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
  11. package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
  12. package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
  13. package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
  14. package/dist/pwa/index.html +2 -2
  15. package/dist/pwa/service-worker.js +2 -2
  16. package/dist/rpc-handler.js +17 -23
  17. package/package.json +1 -2
  18. package/palmier-server/README.md +3 -2
  19. package/palmier-server/pwa/src/App.css +45 -4
  20. package/palmier-server/pwa/src/App.tsx +36 -15
  21. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
  22. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
  23. package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
  24. package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
  25. package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
  26. package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
  27. package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
  28. package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
  29. package/palmier-server/pwa/src/constants.ts +1 -1
  30. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
  31. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
  32. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
  33. package/palmier-server/pwa/src/native/Device.ts +23 -38
  34. package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
  35. package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
  36. package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
  37. package/palmier-server/pwa/src/service-worker.ts +9 -6
  38. package/palmier-server/pwa/src/types.ts +2 -0
  39. package/palmier-server/spec.md +44 -15
  40. package/src/commands/init.ts +13 -2
  41. package/src/commands/pair.ts +3 -9
  42. package/src/linked-device.ts +52 -0
  43. package/src/mcp-tools.ts +19 -19
  44. package/src/network.ts +73 -9
  45. package/src/rpc-handler.ts +14 -22
  46. package/dist/device-capabilities.d.ts +0 -9
  47. package/dist/device-capabilities.js +0 -36
  48. package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
  49. package/src/device-capabilities.ts +0 -57
@@ -1,10 +1,12 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import { useNavigate } from "react-router-dom";
3
+ import { useNavigate, useParams, useLocation } from "react-router-dom";
4
4
  import { Capacitor } from "@capacitor/core";
5
5
  import { useHostStore } from "../contexts/HostStoreContext";
6
+ import { useHostConnection } from "../contexts/HostConnectionContext";
6
7
  import { useMediaQuery } from "../hooks/useMediaQuery";
7
8
  import { confirmLeaveDraft } from "../draftGuard";
9
+ import { Device } from "../native/Device";
8
10
  import CapabilityToggles from "./CapabilityToggles";
9
11
 
10
12
  /** Local mode: PWA is served by palmier serve on loopback. */
@@ -14,22 +16,64 @@ const isNative = Capacitor.isNativePlatform();
14
16
 
15
17
  interface HostMenuProps {
16
18
  daemonVersion?: string | null;
17
- capabilityTokens?: Record<string, string | null>;
18
- activeClientToken?: string | null;
19
+ linkedClientToken?: string | null;
19
20
  request?<T = unknown>(method: string, params?: unknown): Promise<T>;
20
- onCapabilityTokensChange?(tokens: Record<string, string | null>): void;
21
+ onEnabledCapabilitiesChange?(next: Set<string>): void;
22
+ onLinkedClientTokenChange?(next: string | null): void;
21
23
  }
22
24
 
23
- export default function HostMenu({ daemonVersion, capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: HostMenuProps) {
24
- const { pairedHosts, activeHostId, setActiveHostId, removePairedHost, renamePairedHost } = useHostStore();
25
+ export default function HostMenu({ daemonVersion, linkedClientToken, request, onEnabledCapabilitiesChange, onLinkedClientTokenChange }: HostMenuProps) {
26
+ const { pairedHosts, removePairedHost, renamePairedHost } = useHostStore();
27
+ const { activeHost } = useHostConnection();
25
28
  const navigate = useNavigate();
29
+ const location = useLocation();
30
+ const params = useParams<{ hostId?: string }>();
31
+ const activeHostId = params.hostId ?? null;
32
+ const activeClientToken = activeHost.clientToken || null;
33
+ const isLinkedDevice = !!activeClientToken && linkedClientToken === activeClientToken;
26
34
  const isDesktop = useMediaQuery("(min-width: 768px)");
35
+ const [linkingBusy, setLinkingBusy] = useState(false);
36
+
37
+ async function makeThisLinkedDevice() {
38
+ if (!Device || !request || !activeClientToken) return;
39
+ setLinkingBusy(true);
40
+ try {
41
+ const { token: fcmToken } = await Device.getFcmToken();
42
+ if (!fcmToken) return;
43
+ await request("device.link", { fcmToken });
44
+ onLinkedClientTokenChange?.(activeClientToken);
45
+ } catch (err) {
46
+ console.error("Failed to make this the linked device:", err);
47
+ } finally {
48
+ setLinkingBusy(false);
49
+ }
50
+ }
51
+
52
+ async function handleDelete(hostId: string) {
53
+ const wasActive = hostId === activeHostId;
54
+ // Only revoke against the currently-active host — `request` uses its client
55
+ // token. Non-active unpair is local-only; the host keeps a dangling token
56
+ // the user can clear via `palmier clients revoke`.
57
+ if (wasActive && request) {
58
+ try { await request("clients.revoke_self"); } catch { /* best effort */ }
59
+ }
60
+ removePairedHost(hostId);
61
+ setConfirmingDeleteId(null);
62
+ if (wasActive) navigate("/", { replace: true });
63
+ }
64
+
65
+ function switchHost(newHostId: string) {
66
+ const onTasks = location.pathname.endsWith("/tasks");
67
+ const tabSuffix = onTasks ? "/tasks" : "";
68
+ navigate(`/hosts/${encodeURIComponent(newHostId)}${tabSuffix}`);
69
+ }
27
70
 
28
71
  const [visible, setVisible] = useState(false);
29
72
  const [closing, setClosing] = useState(false);
30
73
  const [renamingId, setRenamingId] = useState<string | null>(null);
31
74
  const [renameValue, setRenameValue] = useState("");
32
75
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
76
+ const [confirmingLink, setConfirmingLink] = useState(false);
33
77
 
34
78
  const drawerRef = useRef<HTMLDivElement>(null);
35
79
  const renameInputRef = useRef<HTMLInputElement>(null);
@@ -117,7 +161,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
117
161
  if (isRenaming) return;
118
162
  if (!isActive) {
119
163
  if (!confirmLeaveDraft()) return;
120
- setActiveHostId(host.hostId);
164
+ switchHost(host.hostId);
121
165
  if (!isDesktop) close();
122
166
  }
123
167
  }}
@@ -210,13 +254,29 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
210
254
  <>
211
255
  <div className="drawer-divider" />
212
256
  <div className="drawer-section">
213
- <h3 className="drawer-section-label">Host capabilities on this device</h3>
214
- <CapabilityToggles
215
- capabilityTokens={capabilityTokens}
216
- activeClientToken={activeClientToken}
217
- request={request}
218
- onCapabilityTokensChange={(tokens) => onCapabilityTokensChange?.(tokens)}
219
- />
257
+ <h3 className="drawer-section-label">Device Capabilities</h3>
258
+ {isLinkedDevice ? (
259
+ <CapabilityToggles onChange={onEnabledCapabilitiesChange} />
260
+ ) : (
261
+ <>
262
+ <p className="drawer-section-hint">
263
+ This device isn't the linked device for this host, so it can't provide capabilities (SMS, contacts, location, etc.).
264
+ </p>
265
+ <button
266
+ className="btn btn-secondary btn-full"
267
+ onClick={() => {
268
+ if (linkedClientToken && linkedClientToken !== activeClientToken) {
269
+ setConfirmingLink(true);
270
+ } else {
271
+ makeThisLinkedDevice();
272
+ }
273
+ }}
274
+ disabled={linkingBusy}
275
+ >
276
+ {linkingBusy ? "Linking…" : "Link this device"}
277
+ </button>
278
+ </>
279
+ )}
220
280
  </div>
221
281
  </>
222
282
  )}
@@ -236,12 +296,16 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
236
296
  </>
237
297
  );
238
298
 
299
+ const deletingIsLinked = !!confirmingDeleteId && confirmingDeleteId === activeHostId && isLinkedDevice;
239
300
  const deleteModal = confirmingDeleteId && createPortal(
240
301
  <div className="confirm-modal-overlay" onClick={() => setConfirmingDeleteId(null)}>
241
302
  <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
242
- <h2 className="confirm-modal-title">Delete host?</h2>
303
+ <h2 className="confirm-modal-title">Unpair host?</h2>
243
304
  <p className="confirm-modal-message">
244
- "{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be unpaired from this device.
305
+ "{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be unpaired.
306
+ {deletingIsLinked && (
307
+ <> This device is currently linked to the host — unpairing will revoke its access to all device capabilities (SMS, contacts, location, etc.) until another device is linked.</>
308
+ )}
245
309
  </p>
246
310
  <div className="confirm-modal-actions">
247
311
  <button
@@ -252,12 +316,35 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
252
316
  </button>
253
317
  <button
254
318
  className="btn btn-danger"
255
- onClick={() => {
256
- removePairedHost(confirmingDeleteId);
257
- setConfirmingDeleteId(null);
258
- }}
319
+ onClick={() => handleDelete(confirmingDeleteId)}
320
+ >
321
+ Unpair
322
+ </button>
323
+ </div>
324
+ </div>
325
+ </div>,
326
+ document.body
327
+ );
328
+
329
+ const linkModal = confirmingLink && createPortal(
330
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingLink(false)}>
331
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
332
+ <h2 className="confirm-modal-title">Link this device?</h2>
333
+ <p className="confirm-modal-message">
334
+ Only one device can be linked at a time — switching will disable those capabilities on the currently linked device.
335
+ </p>
336
+ <div className="confirm-modal-actions">
337
+ <button
338
+ className="btn btn-secondary"
339
+ onClick={() => setConfirmingLink(false)}
340
+ >
341
+ Cancel
342
+ </button>
343
+ <button
344
+ className="btn btn-primary"
345
+ onClick={() => { setConfirmingLink(false); makeThisLinkedDevice(); }}
259
346
  >
260
- Delete
347
+ Link
261
348
  </button>
262
349
  </div>
263
350
  </div>
@@ -273,6 +360,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
273
360
  {drawerContent}
274
361
  </div>
275
362
  {deleteModal}
363
+ {linkModal}
276
364
  </>
277
365
  );
278
366
  }
@@ -308,6 +396,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
308
396
  )}
309
397
 
310
398
  {deleteModal}
399
+ {linkModal}
311
400
  </>
312
401
  );
313
402
  }
@@ -69,7 +69,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
69
69
 
70
70
  if (result.error) {
71
71
  console.error("No result:", result.error);
72
- navigate("/runs", { replace: true });
72
+ navigate(hostId ? `/hosts/${encodeURIComponent(hostId)}` : "/", { replace: true });
73
73
  return;
74
74
  }
75
75
  setMessages(result.messages ?? []);
@@ -77,7 +77,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
77
77
  setAgent(result.agent);
78
78
  } catch (err) {
79
79
  console.error("Failed to load result:", err);
80
- navigate("/runs", { replace: true });
80
+ navigate(hostId ? `/hosts/${encodeURIComponent(hostId)}` : "/", { replace: true });
81
81
  } finally {
82
82
  setLoading(false);
83
83
  }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { useHostConnection } from "../contexts/HostConnectionContext";
3
+ import { useHostStore } from "../contexts/HostStoreContext";
3
4
  import { setDraftMessage } from "../draftGuard";
4
5
  import type { AgentInfo } from "../types";
5
6
 
@@ -9,17 +10,17 @@ interface SessionComposerProps {
9
10
  onStarted(taskId: string, runId?: string): void;
10
11
  }
11
12
 
12
- function pickDefaultAgent(agents: AgentInfo[]): string {
13
- const stored = localStorage.getItem("palmier:lastAgent");
13
+ function pickDefaultAgent(agents: AgentInfo[], preferred?: string): string {
14
14
  const keys = agents.map((a) => a.key);
15
- if (stored && keys.includes(stored)) return stored;
15
+ if (preferred && keys.includes(preferred)) return preferred;
16
16
  return agents[0]?.key ?? "";
17
17
  }
18
18
 
19
19
  export default function SessionComposer({ agents, hostPlatform, onStarted }: SessionComposerProps) {
20
- const { request } = useHostConnection();
20
+ const { request, activeHost } = useHostConnection();
21
+ const { setHostLastAgent } = useHostStore();
21
22
  const [prompt, setPrompt] = useState("");
22
- const [agent, setAgent] = useState(() => pickDefaultAgent(agents));
23
+ const [agent, setAgent] = useState(() => pickDefaultAgent(agents, activeHost.lastAgent));
23
24
  const [yoloMode, setYoloMode] = useState(false);
24
25
  const [running, setRunning] = useState(false);
25
26
  const [error, setError] = useState<string | null>(null);
@@ -28,9 +29,9 @@ export default function SessionComposer({ agents, hostPlatform, onStarted }: Ses
28
29
  useEffect(() => {
29
30
  if (!agents.length) return;
30
31
  if (!agents.find((a) => a.key === agent)) {
31
- setAgent(pickDefaultAgent(agents));
32
+ setAgent(pickDefaultAgent(agents, activeHost.lastAgent));
32
33
  }
33
- }, [agents, agent]);
34
+ }, [agents, agent, activeHost.lastAgent]);
34
35
 
35
36
  // Draft guard: warns on navigation / reload when the input has content.
36
37
  useEffect(() => {
@@ -77,7 +78,7 @@ export default function SessionComposer({ agents, hostPlatform, onStarted }: Ses
77
78
  setError(result.error);
78
79
  return;
79
80
  }
80
- localStorage.setItem("palmier:lastAgent", agent);
81
+ setHostLastAgent(activeHost.hostId, agent);
81
82
  setPrompt("");
82
83
  setDraftMessage(null);
83
84
  if (result.task_id) onStarted(result.task_id, result.run_id);
@@ -170,7 +170,8 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
170
170
 
171
171
  function handleCardClick(taskId: string, runId: string) {
172
172
  if (!confirmLeaveDraft()) return;
173
- navigate(`/runs/${taskId}/${encodeURIComponent(runId)}`);
173
+ if (!hostId) return;
174
+ navigate(`/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
174
175
  }
175
176
 
176
177
  const composer = !filterTaskId && (
@@ -178,8 +179,9 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
178
179
  agents={agents}
179
180
  hostPlatform={hostPlatform}
180
181
  onStarted={(taskId, runId) => {
181
- if (runId) navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
182
- else navigate(`/runs/${encodeURIComponent(taskId)}`);
182
+ if (!hostId) return;
183
+ const base = `/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}`;
184
+ navigate(runId ? `${base}/${encodeURIComponent(runId)}` : base);
183
185
  }}
184
186
  />
185
187
  );
@@ -1,22 +1,24 @@
1
- import { useNavigate, useLocation } from "react-router-dom";
1
+ import { useNavigate, useLocation, useParams } from "react-router-dom";
2
2
  import { confirmLeaveDraft } from "../draftGuard";
3
3
 
4
4
  export default function TabBar() {
5
5
  const navigate = useNavigate();
6
6
  const location = useLocation();
7
- const isTasks = location.pathname.startsWith("/tasks");
7
+ const { hostId } = useParams<{ hostId: string }>();
8
+ const isTasks = location.pathname.endsWith("/tasks");
8
9
  const isSessions = !isTasks;
9
10
 
10
- function go(path: string) {
11
+ function go(suffix: string) {
11
12
  if (!confirmLeaveDraft()) return;
12
- navigate(path);
13
+ if (!hostId) return;
14
+ navigate(`/hosts/${encodeURIComponent(hostId)}${suffix}`);
13
15
  }
14
16
 
15
17
  return (
16
18
  <>
17
19
  <button
18
20
  className={`tab-btn ${isSessions ? "tab-btn-active" : ""}`}
19
- onClick={() => go("/")}
21
+ onClick={() => go("")}
20
22
  >
21
23
  <svg className="tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
22
24
  <path d="M2 8H4.5L6 4L8 12L10 6L11.5 8H14" />
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState, useCallback, useRef } from "react";
2
2
  import { Capacitor } from "@capacitor/core";
3
3
  import { useHostConnection } from "../contexts/HostConnectionContext";
4
+ import { useHostStore } from "../contexts/HostStoreContext";
4
5
  import PlanDialog from "./PlanDialog";
5
6
  import { useBackClose } from "../hooks/useBackClose";
6
7
  import { Device } from "../native/Device";
@@ -103,10 +104,11 @@ interface TaskFormProps {
103
104
  }
104
105
 
105
106
  export default function TaskForm({ initial, agents, hostPlatform, isNotificationListener, onSaved, onRun, onCancel }: TaskFormProps) {
106
- const { request } = useHostConnection();
107
+ const { request, activeHost } = useHostConnection();
108
+ const { setHostLastAgent } = useHostStore();
107
109
 
108
110
  const defaultAgent = () => {
109
- const lastAgent = localStorage.getItem("palmier:lastAgent");
111
+ const lastAgent = activeHost.lastAgent;
110
112
  const agentKeys = agents.map((a) => a.key);
111
113
  if (lastAgent && agentKeys.includes(lastAgent)) return lastAgent;
112
114
  return agents[0]?.key ?? "";
@@ -326,7 +328,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
326
328
  setError(result.error);
327
329
  return;
328
330
  }
329
- if (!isEdit) localStorage.setItem("palmier:lastAgent", agent);
331
+ if (!isEdit) setHostLastAgent(activeHost.hostId, agent);
330
332
 
331
333
  // Command-triggered on create: save the task, then start it and navigate
332
334
  // to the run. Event-triggered tasks are started by the daemon in response
@@ -1,2 +1,2 @@
1
1
  /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.8.9";
2
+ export const MIN_HOST_VERSION = "0.8.11";
@@ -12,7 +12,6 @@ import { Capacitor } from "@capacitor/core";
12
12
  import { App as CapacitorApp } from "@capacitor/app";
13
13
  import { Network } from "@capacitor/network";
14
14
  import { SERVER_URL } from "../api";
15
- import { useHostStore } from "./HostStoreContext";
16
15
  import type { PairedHost } from "../types";
17
16
 
18
17
  type ConnectionMode = "nats" | "lan" | "direct" | "connecting" | "disconnected";
@@ -31,29 +30,19 @@ interface HostConnectionContextValue {
31
30
  /** Subscribe to task events. Returns unsubscribe function. */
32
31
  subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
33
32
  /** Current active host. */
34
- activeHost: PairedHost | null;
33
+ activeHost: PairedHost;
35
34
  /** Whether the current client has been revoked by the host. */
36
35
  unauthorized: boolean;
37
36
  }
38
37
 
39
- const HostConnectionContext = createContext<HostConnectionContextValue>({
40
- connected: false,
41
- mode: "connecting",
42
- nc: null,
43
- request() { return Promise.reject(new Error("No host connection")); },
44
- subscribeEvents() { return () => {}; },
45
- activeHost: null,
46
- unauthorized: false,
47
- });
38
+ const HostConnectionContext = createContext<HostConnectionContextValue | null>(null);
48
39
 
49
40
  const SSE_CONNECT_TIMEOUT_MS = 2_000;
50
41
  const HEARTBEAT_TIMEOUT_MS = 6_000;
51
42
  const LAN_PROBE_TIMEOUT_MS = 1_500;
52
43
  const LAN_KEEPALIVE_MS = 60_000;
53
44
 
54
- export function HostConnectionProvider({ children }: { children: ReactNode }) {
55
- const { getActiveHost } = useHostStore();
56
- const activeHost = getActiveHost();
45
+ export function HostConnectionProvider({ children, activeHost }: { children: ReactNode; activeHost: PairedHost }) {
57
46
 
58
47
  const [nc, setNc] = useState<NatsConnection | null>(null);
59
48
  const [natsConnected, setNatsConnected] = useState(false);
@@ -69,31 +58,29 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
69
58
  const lastHeartbeat = useRef(0);
70
59
 
71
60
  // Host is a "direct-only" host if it has a directUrl (paired with address)
72
- const isDirectHost = activeHost != null && !!activeHost.directUrl;
61
+ const isDirectHost = !!activeHost.directUrl;
73
62
  // Auto-LAN is opportunistic on native server-paired hosts that have a known LAN URL.
74
- const useLanRpc = isNative && !isDirectHost && !!activeHost?.lanUrl && lanReachable;
63
+ const useLanRpc = isNative && !isDirectHost && !!activeHost.lanUrl && lanReachable;
75
64
 
76
65
  const mode: ConnectionMode = unauthorized || hostNotFound
77
66
  ? "disconnected"
78
- : !activeHost
79
- ? "connecting"
80
- : isDirectHost
81
- ? (sseConnected ? "direct" : "connecting")
82
- : !natsConnected
83
- ? "connecting"
84
- : (useLanRpc ? "lan" : "nats");
67
+ : isDirectHost
68
+ ? (sseConnected ? "direct" : "connecting")
69
+ : !natsConnected
70
+ ? "connecting"
71
+ : (useLanRpc ? "lan" : "nats");
85
72
  const connected = mode !== "connecting" && mode !== "disconnected";
86
73
 
87
74
  // Reset terminal states when switching hosts or re-pairing (new client token).
88
75
  useEffect(() => {
89
76
  setUnauthorized(false);
90
77
  setHostNotFound(false);
91
- }, [activeHost?.hostId, activeHost?.clientToken]);
78
+ }, [activeHost.hostId, activeHost.clientToken]);
92
79
 
93
80
  // Probe the host's LAN URL on native server-paired hosts. RPC routes via direct
94
81
  // HTTP when the probe succeeds; events stay on NATS regardless.
95
82
  useEffect(() => {
96
- if (!isNative || isDirectHost || !activeHost?.lanUrl) {
83
+ if (!isNative || isDirectHost || !activeHost.lanUrl) {
97
84
  setLanReachable(false);
98
85
  return;
99
86
  }
@@ -141,7 +128,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
141
128
  appHandle.then((h) => h.remove());
142
129
  netHandle.then((h) => h.remove());
143
130
  };
144
- }, [activeHost?.hostId, activeHost?.lanUrl, isDirectHost]);
131
+ }, [activeHost.hostId, activeHost.lanUrl, isDirectHost]);
145
132
 
146
133
  // Fetch NATS config from server and connect (only for NATS hosts)
147
134
  useEffect(() => {
@@ -155,8 +142,6 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
155
142
  return;
156
143
  }
157
144
 
158
- if (!activeHost) return;
159
-
160
145
  let cancelled = false;
161
146
  let retryDelayMs = 1_000;
162
147
  const MAX_RETRY_MS = 30_000;
@@ -164,7 +149,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
164
149
  async function connectLoop() {
165
150
  while (!cancelled) {
166
151
  try {
167
- const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
152
+ const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost.hostId}`);
168
153
  if (cancelled) return;
169
154
  if (res.status === 401 || res.status === 403 || res.status === 404) {
170
155
  console.error("[NATS] Host not found or rejected by relay:", res.status);
@@ -226,9 +211,24 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
226
211
  };
227
212
  }, [isDirectHost, activeHost]);
228
213
 
214
+ // Mobile WebViews suspend WebSockets when the app backgrounds; nats.ws often
215
+ // doesn't notice the dead socket on resume, leaving RPCs to hang/throw "Not
216
+ // connected." Force-close on resume so the connectLoop reconnects with a
217
+ // fresh socket.
218
+ useEffect(() => {
219
+ if (isDirectHost) return;
220
+ const handle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
221
+ if (state.isActive && ncRef.current) {
222
+ console.log("[NATS] App resumed; cycling NATS conn to recover from possible dead socket");
223
+ ncRef.current.close().catch(() => {});
224
+ }
225
+ });
226
+ return () => { handle.then((h) => h.remove()); };
227
+ }, [isDirectHost]);
228
+
229
229
  // SSE connection for direct (LAN) hosts only
230
230
  useEffect(() => {
231
- if (!activeHost || !isDirectHost) {
231
+ if (!isDirectHost) {
232
232
  setSseConnected(false);
233
233
  return;
234
234
  }
@@ -240,8 +240,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
240
240
  const timer = setTimeout(() => connectAc.abort(), SSE_CONNECT_TIMEOUT_MS);
241
241
  controller.signal.addEventListener("abort", () => connectAc.abort());
242
242
  try {
243
- const res = await fetch(`${activeHost!.directUrl}/events`, {
244
- headers: { Authorization: `Bearer ${activeHost!.clientToken}` },
243
+ const res = await fetch(`${activeHost.directUrl}/events`, {
244
+ headers: { Authorization: `Bearer ${activeHost.clientToken}` },
245
245
  signal: connectAc.signal,
246
246
  });
247
247
  clearTimeout(timer);
@@ -255,7 +255,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
255
255
 
256
256
  async function readStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
257
257
  setSseConnected(true);
258
- console.log("[HOST] SSE connected to", activeHost!.directUrl);
258
+ console.log("[HOST] SSE connected to", activeHost.directUrl);
259
259
 
260
260
  lastHeartbeat.current = Date.now();
261
261
  function checkHeartbeat() {
@@ -288,7 +288,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
288
288
  try {
289
289
  const event = JSON.parse(jsonStr) as { task_id?: string; event_type?: string };
290
290
  if (event.task_id && event.event_type) {
291
- const subject = `host-event.${activeHost!.hostId}.${event.task_id}`;
291
+ const subject = `host-event.${activeHost.hostId}.${event.task_id}`;
292
292
  for (const cb of sseEventCallbacksRef.current) cb({ subject, data: sc.current.encode(jsonStr) });
293
293
  }
294
294
  } catch { /* skip malformed — includes heartbeat pings */ }
@@ -314,8 +314,6 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
314
314
  params?: unknown,
315
315
  opts?: { timeout?: number },
316
316
  ): Promise<T> => {
317
- if (!activeHost) throw new Error("No active host");
318
-
319
317
  function checkUnauthorized(data: unknown): void {
320
318
  if (data && typeof data === "object" && (data as Record<string, unknown>).error === "Unauthorized") {
321
319
  setUnauthorized(true);
@@ -328,7 +326,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
328
326
  method: "POST",
329
327
  headers: {
330
328
  "Content-Type": "application/json",
331
- Authorization: `Bearer ${activeHost!.clientToken}`,
329
+ Authorization: `Bearer ${activeHost.clientToken}`,
332
330
  },
333
331
  body: params != null ? JSON.stringify(params) : undefined,
334
332
  signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
@@ -345,8 +343,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
345
343
 
346
344
  async function callNats(): Promise<T> {
347
345
  if (!ncRef.current) throw new Error("Not connected");
348
- const subject = `host.${activeHost!.hostId}.rpc.${method}`;
349
- const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost!.clientToken };
346
+ const subject = `host.${activeHost.hostId}.rpc.${method}`;
347
+ const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost.clientToken };
350
348
  const body = sc.current.encode(JSON.stringify(payload));
351
349
  console.log(`[HOST/NATS] → ${method}`, params ?? "");
352
350
  const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
@@ -412,6 +410,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
412
410
  );
413
411
  }
414
412
 
415
- export function useHostConnection() {
416
- return useContext(HostConnectionContext);
413
+ export function useHostConnection(): HostConnectionContextValue {
414
+ const ctx = useContext(HostConnectionContext);
415
+ if (!ctx) throw new Error("useHostConnection must be used within HostConnectionProvider");
416
+ return ctx;
417
417
  }