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
@@ -10,19 +10,16 @@ import type { PairedHost } from "../types";
10
10
 
11
11
  interface HostStoreContextValue {
12
12
  pairedHosts: PairedHost[];
13
- activeHostId: string | null;
14
13
  addPairedHost(host: PairedHost): void;
15
14
  removePairedHost(hostId: string): void;
16
15
  renamePairedHost(hostId: string, name: string): void;
17
16
  setHostLanUrl(hostId: string, lanUrl: string | undefined): void;
18
- setActiveHostId(hostId: string): void;
19
- getActiveHost(): PairedHost | null;
17
+ setHostLastAgent(hostId: string, agent: string): void;
20
18
  }
21
19
 
22
20
  const HostStoreContext = createContext<HostStoreContextValue | null>(null);
23
21
 
24
22
  const STORAGE_KEY = "palmier_paired_hosts";
25
- const ACTIVE_KEY = "palmier_active_host";
26
23
 
27
24
  function loadHosts(): PairedHost[] {
28
25
  try {
@@ -38,76 +35,36 @@ function saveHosts(hosts: PairedHost[]) {
38
35
  localStorage.setItem(STORAGE_KEY, JSON.stringify(hosts));
39
36
  }
40
37
 
41
- function loadActiveId(): string | null {
42
- return localStorage.getItem(ACTIVE_KEY);
43
- }
44
-
45
- function saveActiveId(id: string | null) {
46
- if (id) localStorage.setItem(ACTIVE_KEY, id);
47
- else localStorage.removeItem(ACTIVE_KEY);
48
- }
38
+ export const LOCAL_HOST_ID = "local";
49
39
 
50
- /** Local mode: served by palmier serve on localhost — auto-connect without pairing. */
40
+ /** Local mode: served by palmier serve on localhost — auto-inject a local host entry. */
51
41
  const isLocalMode = !!(window as any).__PALMIER_SERVE__
52
42
  && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
53
43
 
54
44
  export function HostStoreProvider({ children }: { children: ReactNode }) {
55
- const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(loadHosts);
56
- const [activeHostId, setActiveHostIdState] = useState<string | null>(() => {
57
- const saved = loadActiveId();
45
+ const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(() => {
58
46
  const hosts = loadHosts();
59
- if (saved && hosts.some((h) => h.hostId === saved)) return saved;
60
- return hosts.length > 0 ? hosts[0].hostId : null;
61
- });
62
-
63
- // Auto-connect in local mode: inject a local host entry without pairing
64
- useEffect(() => {
65
- if (!isLocalMode) return;
66
- const localHostId = "local";
67
- const existing = loadHosts().find((h) => h.hostId === localHostId);
68
- if (!existing) {
69
- const localHost: PairedHost = { hostId: localHostId, clientToken: "", directUrl: window.location.origin };
70
- setPairedHosts((prev) => [...prev.filter((h) => h.hostId !== localHostId), localHost]);
71
- setActiveHostIdState(localHostId);
47
+ if (isLocalMode && !hosts.some((h) => h.hostId === LOCAL_HOST_ID)) {
48
+ hosts.push({ hostId: LOCAL_HOST_ID, clientToken: "", directUrl: window.location.origin });
72
49
  }
73
- }, []);
50
+ return hosts;
51
+ });
74
52
 
75
53
  useEffect(() => {
76
54
  saveHosts(pairedHosts);
77
55
  }, [pairedHosts]);
78
56
 
79
- useEffect(() => {
80
- saveActiveId(activeHostId);
81
- }, [activeHostId]);
82
-
83
57
  const addPairedHost = useCallback((host: PairedHost) => {
84
58
  setPairedHosts((prev) => {
85
59
  const filtered = prev.filter((h) => h.hostId !== host.hostId);
86
60
  return [...filtered, host];
87
61
  });
88
- setActiveHostIdState(host.hostId);
89
62
  }, []);
90
63
 
91
64
  const removePairedHost = useCallback((hostId: string) => {
92
65
  setPairedHosts((prev) => prev.filter((h) => h.hostId !== hostId));
93
- // Only change the active host when the active one is the one being deleted.
94
- setActiveHostIdState((current) => {
95
- if (current !== hostId) return current;
96
- // The deleted host was active; we'll re-select once pairedHosts settles.
97
- return null;
98
- });
99
66
  }, []);
100
67
 
101
- // When the active host disappears (e.g. it was just unpaired), fall back to
102
- // any remaining paired host, otherwise null.
103
- useEffect(() => {
104
- if (activeHostId !== null && !pairedHosts.find((h) => h.hostId === activeHostId)) {
105
- setActiveHostIdState(pairedHosts[0]?.hostId ?? null);
106
- } else if (activeHostId === null && pairedHosts.length > 0) {
107
- setActiveHostIdState(pairedHosts[0].hostId);
108
- }
109
- }, [pairedHosts, activeHostId]);
110
-
111
68
  const renamePairedHost = useCallback((hostId: string, name: string) => {
112
69
  setPairedHosts((prev) =>
113
70
  prev.map((h) => (h.hostId === hostId ? { ...h, name } : h))
@@ -124,24 +81,24 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
124
81
  );
125
82
  }, []);
126
83
 
127
- const setActiveHostId = useCallback((hostId: string) => {
128
- setActiveHostIdState(hostId);
84
+ const setHostLastAgent = useCallback((hostId: string, agent: string) => {
85
+ setPairedHosts((prev) =>
86
+ prev.map((h) => {
87
+ if (h.hostId !== hostId) return h;
88
+ if (h.lastAgent === agent) return h;
89
+ return { ...h, lastAgent: agent };
90
+ })
91
+ );
129
92
  }, []);
130
93
 
131
- const getActiveHost = useCallback((): PairedHost | null => {
132
- return pairedHosts.find((h) => h.hostId === activeHostId) ?? null;
133
- }, [pairedHosts, activeHostId]);
134
-
135
94
  return (
136
95
  <HostStoreContext.Provider value={{
137
96
  pairedHosts,
138
- activeHostId,
139
97
  addPairedHost,
140
98
  removePairedHost,
141
99
  renamePairedHost,
142
100
  setHostLanUrl,
143
- setActiveHostId,
144
- getActiveHost,
101
+ setHostLastAgent,
145
102
  }}>
146
103
  {children}
147
104
  </HostStoreContext.Provider>
@@ -1,18 +1,17 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { Capacitor } from "@capacitor/core";
3
- import { useHostStore } from "../contexts/HostStoreContext";
3
+ import { useHostConnection } from "../contexts/HostConnectionContext";
4
4
  import { apiPost, apiGet } from "../api";
5
5
 
6
6
  export function usePushSubscription() {
7
- const { getActiveHost } = useHostStore();
8
- const activeHost = getActiveHost();
7
+ const { activeHost } = useHostConnection();
9
8
  const subscribedRef = useRef<string | null>(null);
10
9
 
11
10
  useEffect(() => {
12
11
  // Native app uses FCM for notifications, not Web Push
13
12
  if (Capacitor.isNativePlatform()) return;
14
13
  // Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
15
- if (!activeHost || activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
14
+ if (activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
16
15
 
17
16
  async function subscribe() {
18
17
  try {
@@ -26,7 +25,7 @@ export function usePushSubscription() {
26
25
  // Send hostId to SW so it can include it in push responses
27
26
  registration.active?.postMessage({
28
27
  type: "set-host-id",
29
- hostId: activeHost!.hostId,
28
+ hostId: activeHost.hostId,
30
29
  });
31
30
 
32
31
  let subscription = await registration.pushManager.getSubscription();
@@ -59,7 +58,7 @@ export function usePushSubscription() {
59
58
  // Always ensure subscription is saved to server
60
59
  const sub = subscription.toJSON();
61
60
  await apiPost("/api/push/subscribe", {
62
- hostId: activeHost!.hostId,
61
+ hostId: activeHost.hostId,
63
62
  endpoint: sub.endpoint,
64
63
  keys: {
65
64
  p256dh: sub.keys!.p256dh,
@@ -67,7 +66,7 @@ export function usePushSubscription() {
67
66
  },
68
67
  });
69
68
 
70
- subscribedRef.current = activeHost!.hostId;
69
+ subscribedRef.current = activeHost.hostId;
71
70
  } catch (err) {
72
71
  console.error("[Push] Subscription failed:", err);
73
72
  }
@@ -1,26 +1,5 @@
1
1
  import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core";
2
2
 
3
- export type PermissionType =
4
- | "location"
5
- | "smsRead"
6
- | "smsSend"
7
- | "contacts"
8
- | "calendar"
9
- | "notificationListener"
10
- | "dnd"
11
- | "fullScreenIntent"
12
- | "postNotifications";
13
-
14
- export interface PermissionResult {
15
- granted: boolean;
16
- /**
17
- * False when the native build doesn't recognize this permission type — the PWA
18
- * is served remotely and may ship ahead of the installed APK. Callers should
19
- * treat unsupported types as "cannot enable" rather than as a hard error.
20
- */
21
- supported: boolean;
22
- }
23
-
24
3
  export interface DeepLinkEvent {
25
4
  path: string;
26
5
  }
@@ -30,28 +9,34 @@ export interface InstalledApp {
30
9
  appName: string;
31
10
  }
32
11
 
12
+ export interface CapabilityStatus {
13
+ name: string;
14
+ enabled: boolean;
15
+ supported: boolean;
16
+ }
17
+
18
+ export type SetCapabilityReason = "denied" | "no-email-client" | "unsupported";
19
+
20
+ export interface SetCapabilityResult {
21
+ enabled: boolean;
22
+ reason?: SetCapabilityReason;
23
+ }
24
+
33
25
  interface DevicePlugin {
34
26
  getFcmToken(): Promise<{ token: string }>;
35
- /** Returns the set of PermissionType strings this native build understands. */
36
- getSupportedPermissions(): Promise<{ types: PermissionType[] }>;
37
- checkPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
38
- requestPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
27
+ /** Returns one entry per capability the APK knows about. The PWA renders only these. */
28
+ getCapabilityStatus(): Promise<{ capabilities: CapabilityStatus[] }>;
39
29
  /**
40
- * Authoritative list of capabilities the user has enabled on this device.
41
- * Native receivers + handlers consult this as a local kill-switch. Unknown
42
- * capability names are stored but ignored safe for PWAs that ship new caps
43
- * ahead of the installed APK.
30
+ * Atomically gates a single capability. When `enabled: true`, the plugin drives
31
+ * any required permission dialog or system-Settings round-trip and writes the
32
+ * native kill-switch only on grant. When `enabled: false`, the kill-switch is
33
+ * removed (no OS dialog). The returned `enabled` reflects the actual stored
34
+ * state; `reason` distinguishes denial from preconditions like
35
+ * "no email app installed".
44
36
  */
45
- setEnabledCapabilities(opts: { capabilities: string[] }): Promise<void>;
46
- /** Returns user-visible (launcher) apps on the device, with 96x96 PNG icons. */
37
+ setCapabilityEnabled(opts: { capability: string; enabled: boolean }): Promise<SetCapabilityResult>;
38
+ /** Returns user-visible (launcher) apps on the device. */
47
39
  getInstalledApps(): Promise<{ apps: InstalledApp[] }>;
48
- /**
49
- * Returns whether the device has at least one app that can handle a mailto:
50
- * intent. Used to gate the Sending Email capability — silent PackageManager
51
- * lookup, no side effects. `supported: false` on older APKs that don't expose
52
- * this method; treat as "cannot enable" rather than as a hard error.
53
- */
54
- hasEmailClient(): Promise<{ available: boolean; supported: boolean }>;
55
40
  addListener(
56
41
  event: "deepLink",
57
42
  handler: (ev: DeepLinkEvent) => void
@@ -10,6 +10,7 @@ import HostMenu from "../components/HostMenu";
10
10
  import ConnectionStatusIcon from "../components/ConnectionStatusIcon";
11
11
  import SessionsView from "../components/SessionsView";
12
12
  import RunDetailView from "../components/RunDetailView";
13
+ import { loadEnabledCapabilities } from "../components/CapabilityToggles";
13
14
  import { usePushSubscription } from "../hooks/usePushSubscription";
14
15
  import { useMediaQuery } from "../hooks/useMediaQuery";
15
16
  import { setAgentLabels } from "../agentLabels";
@@ -45,14 +46,16 @@ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: st
45
46
  interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
46
47
 
47
48
  export default function Dashboard() {
48
- const { pairedHosts, activeHostId, removePairedHost, setHostLanUrl } = useHostStore();
49
- const { connected, request, subscribeEvents, unauthorized } = useHostConnection();
49
+ const { removePairedHost, setHostLanUrl } = useHostStore();
50
+ const { connected, request, subscribeEvents, unauthorized, activeHost } = useHostConnection();
51
+ const hostId = activeHost.hostId;
52
+ const activeClientToken = activeHost.clientToken || null;
50
53
  const navigate = useNavigate();
51
54
  const location = useLocation();
52
55
  const params = useParams<{ taskId?: string; runId?: string }>();
53
56
  const isDesktop = useMediaQuery("(min-width: 768px)");
54
57
 
55
- const isTasksTab = location.pathname.startsWith("/tasks");
58
+ const isTasksTab = location.pathname.endsWith("/tasks");
56
59
  const runsFilterTaskId = params.runId ? undefined : params.taskId;
57
60
 
58
61
  // "latest" is passed through to RunDetailView, which does its own resolution
@@ -63,10 +66,13 @@ export default function Dashboard() {
63
66
  const [updating, setUpdating] = useState(false);
64
67
  const [updateError, setUpdateError] = useState<string | null>(null);
65
68
  const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
66
- const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
69
+ const [linkedClientToken, setLinkedClientToken] = useState<string | null>(null);
70
+ const [enabledCapabilities, setEnabledCapabilities] = useState<Set<string>>(new Set());
67
71
  const [agents, setAgents] = useState<AgentInfo[]>([]);
68
72
  const [hostPlatform, setHostPlatform] = useState<string | undefined>();
69
73
 
74
+ const isLinkedDevice = !!activeClientToken && linkedClientToken === activeClientToken;
75
+
70
76
  // Pending prompt state — owned by Dashboard because these modals must show
71
77
  // regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
72
78
  const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
@@ -78,7 +84,13 @@ export default function Dashboard() {
78
84
 
79
85
  useEffect(() => {
80
86
  window.scrollTo(0, 0);
81
- }, [activeHostId]);
87
+ }, [hostId]);
88
+
89
+ useEffect(() => {
90
+ let cancelled = false;
91
+ loadEnabledCapabilities().then((caps) => { if (!cancelled) setEnabledCapabilities(caps); });
92
+ return () => { cancelled = true; };
93
+ }, []);
82
94
 
83
95
  // host.info bootstrap: agents/version/platform + any prompts that were already
84
96
  // pending when this PWA connected. Runs once per (host, connection).
@@ -88,21 +100,19 @@ export default function Dashboard() {
88
100
  agents?: AgentInfo[];
89
101
  version?: string | null;
90
102
  host_platform?: string;
91
- capability_tokens?: Record<string, string | null>;
103
+ linked_client_token?: string | null;
92
104
  pending_prompts?: PendingPrompt[];
93
105
  lan_url?: string | null;
94
106
  }>("host.info")
95
107
  .then((result) => {
96
108
  setAgents(result.agents ?? []);
97
109
  setHostPlatform(result.host_platform);
98
- setCapabilityTokens(result.capability_tokens ?? {});
110
+ setLinkedClientToken(result.linked_client_token ?? null);
99
111
  setAgentLabels(result.agents ?? []);
100
112
  const version = result.version ?? null;
101
113
  setDaemonVersion(version);
102
114
  setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
103
- if (activeHostId) {
104
- setHostLanUrl(activeHostId, result.lan_url ?? undefined);
105
- }
115
+ setHostLanUrl(hostId, result.lan_url ?? undefined);
106
116
 
107
117
  // Seed modal state from already-pending prompts.
108
118
  const confirms = new Map<string, ConfirmPrompt>();
@@ -136,13 +146,13 @@ export default function Dashboard() {
136
146
  setInputValues(inputVals);
137
147
  })
138
148
  .catch(() => { /* silent — update-required prompt guards the broken case */ });
139
- }, [connected, activeHostId, request, setHostLanUrl]);
149
+ }, [connected, hostId, request, setHostLanUrl]);
140
150
 
141
151
  // Always-on event subscription for modal lifecycle. Independent of which tab
142
152
  // is active. Task-card status updates happen inside TasksView while mounted.
143
153
  useEffect(() => {
144
- if (!connected || !activeHostId) return;
145
- const unsubscribe = subscribeEvents(activeHostId, (msg) => {
154
+ if (!connected) return;
155
+ const unsubscribe = subscribeEvents(hostId, (msg) => {
146
156
  const tokens = msg.subject.split(".");
147
157
  if (tokens.length < 3) return;
148
158
  const taskId = tokens.slice(2).join(".");
@@ -239,7 +249,7 @@ export default function Dashboard() {
239
249
  }
240
250
  });
241
251
  return unsubscribe;
242
- }, [connected, activeHostId, subscribeEvents]);
252
+ }, [connected, hostId, subscribeEvents]);
243
253
 
244
254
  async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
245
255
  try {
@@ -267,11 +277,8 @@ export default function Dashboard() {
267
277
 
268
278
  function handleViewRun(taskId: string, runId?: string) {
269
279
  if (!confirmLeaveDraft()) return;
270
- if (runId) {
271
- navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
272
- } else {
273
- navigate(`/runs/${encodeURIComponent(taskId)}`);
274
- }
280
+ const base = `/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}`;
281
+ navigate(runId ? `${base}/${encodeURIComponent(runId)}` : base);
275
282
  }
276
283
 
277
284
  async function handleUpdate() {
@@ -290,18 +297,16 @@ export default function Dashboard() {
290
297
  setTimeout(() => window.location.reload(), 15000);
291
298
  }
292
299
 
293
- const hasHosts = pairedHosts.length > 0;
294
- const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
295
- const activeClientToken = pairedHosts.find((h) => h.hostId === activeHostId)?.clientToken ?? null;
300
+ const showTaskContent = connected && !unauthorized;
296
301
 
297
302
  return (
298
303
  <div className="dashboard">
299
- {isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
304
+ {isDesktop && <HostMenu daemonVersion={daemonVersion} linkedClientToken={linkedClientToken} request={request} onEnabledCapabilitiesChange={setEnabledCapabilities} onLinkedClientTokenChange={setLinkedClientToken} />}
300
305
 
301
306
  <div className="dashboard-content">
302
307
  <header className="app-header">
303
308
  <div className="app-title-bar">
304
- {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
309
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} linkedClientToken={linkedClientToken} request={request} onEnabledCapabilitiesChange={setEnabledCapabilities} onLinkedClientTokenChange={setLinkedClientToken} />}
305
310
  <h1 className="app-title">Palmier</h1>
306
311
  <ConnectionStatusIcon />
307
312
  </div>
@@ -336,9 +341,7 @@ export default function Dashboard() {
336
341
  </button>
337
342
  <button
338
343
  className="btn btn-secondary"
339
- onClick={() => {
340
- if (activeHostId) removePairedHost(activeHostId);
341
- }}
344
+ onClick={() => { removePairedHost(hostId); navigate("/", { replace: true }); }}
342
345
  >
343
346
  Remove Host
344
347
  </button>
@@ -349,19 +352,19 @@ export default function Dashboard() {
349
352
  {isTasksTab && !isRunDetail && (
350
353
  <TasksView
351
354
  connected={connected}
352
- hostId={activeHostId}
355
+ hostId={hostId}
353
356
  request={request}
354
357
  subscribeEvents={subscribeEvents}
355
358
  agents={agents}
356
359
  hostPlatform={hostPlatform}
357
- isNotificationListener={!!activeClientToken && capabilityTokens["notifications"] === activeClientToken}
360
+ isNotificationListener={isLinkedDevice && enabledCapabilities.has("notifications")}
358
361
  onViewRun={handleViewRun}
359
362
  />
360
363
  )}
361
364
  {isRunDetail ? (
362
365
  <RunDetailView
363
366
  connected={connected}
364
- hostId={activeHostId}
367
+ hostId={hostId}
365
368
  request={request}
366
369
  subscribeEvents={subscribeEvents}
367
370
  taskId={params.taskId!}
@@ -370,27 +373,19 @@ export default function Dashboard() {
370
373
  ) : !isTasksTab ? (
371
374
  <SessionsView
372
375
  connected={connected}
373
- hostId={activeHostId}
376
+ hostId={hostId}
374
377
  request={request}
375
378
  subscribeEvents={subscribeEvents}
376
379
  agents={agents}
377
380
  hostPlatform={hostPlatform}
378
381
  filterTaskId={runsFilterTaskId}
379
- onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
382
+ onClearFilter={() => { if (confirmLeaveDraft()) navigate(`/hosts/${encodeURIComponent(hostId)}`); }}
380
383
  />
381
384
  ) : null}
382
385
  </>
383
386
  ) : (
384
387
  <div className="empty-state">
385
- <p>{hasHosts ? "Connecting to host..." : "No host computer paired yet."}</p>
386
- {!hasHosts && (
387
- <button
388
- className="btn btn-primary"
389
- onClick={() => navigate("/pair")}
390
- >
391
- Pair Host
392
- </button>
393
- )}
388
+ <p>Connecting to host...</p>
394
389
  </div>
395
390
  )}
396
391
  </main>
@@ -18,8 +18,11 @@ interface PairResponse {
18
18
  const isLoopback = !!(window as any).__PALMIER_SERVE__
19
19
  && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
20
20
 
21
+ const isNative = Capacitor.isNativePlatform();
22
+
21
23
  export default function PairHost() {
22
24
  const [code, setCode] = useState("");
25
+ const [makeLinked, setMakeLinked] = useState(true);
23
26
  const [pairing, setPairing] = useState(false);
24
27
  const [error, setError] = useState<string | null>(null);
25
28
  const { addPairedHost } = useHostStore();
@@ -106,7 +109,8 @@ export default function PairHost() {
106
109
  }
107
110
  }
108
111
 
109
- navigate(Capacitor.isNativePlatform() ? "/pair/setup" : "/");
112
+ const base = `/hosts/${encodeURIComponent(response.hostId)}`;
113
+ navigate(isNative && makeLinked ? `${base}/pair/setup` : base);
110
114
  } catch (err) {
111
115
  const message = err instanceof Error ? err.message : String(err);
112
116
  if (message.includes("timeout") || message.includes("TIMEOUT") || message.includes("503") || message.toLowerCase().includes("no responders")) {
@@ -176,6 +180,21 @@ export default function PairHost() {
176
180
  />
177
181
  </label>
178
182
 
183
+ {isNative && (
184
+ <label className="pair-checkbox">
185
+ <input
186
+ type="checkbox"
187
+ checked={makeLinked}
188
+ onChange={(e) => setMakeLinked(e.target.checked)}
189
+ disabled={pairing}
190
+ />
191
+ <span className="pair-checkbox-text">
192
+ <span className="pair-checkbox-title">Link this device</span>
193
+ <span className="pair-checkbox-hint">The host will use this device for SMS, contacts, calendar, location, and alarms. Only one device can be linked at a time.</span>
194
+ </span>
195
+ </label>
196
+ )}
197
+
179
198
  {error && <p className="pair-error">{error}</p>}
180
199
 
181
200
  <button