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,71 +1,130 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import { useNavigate } from "react-router-dom";
3
4
  import { useHostConnection } from "../contexts/HostConnectionContext";
4
5
  import { useHostStore } from "../contexts/HostStoreContext";
5
6
  import CapabilityToggles from "../components/CapabilityToggles";
7
+ import { Device } from "../native/Device";
6
8
 
7
9
  interface HostInfoResponse {
8
- capability_tokens?: Record<string, string | null>;
9
10
  lan_url?: string | null;
11
+ linked_client_token?: string | null;
10
12
  }
11
13
 
14
+ type Phase = "loading" | "confirming" | "linking" | "wizard" | "linkError";
15
+
12
16
  export default function PairSetup() {
13
17
  const navigate = useNavigate();
14
- const { connected, request } = useHostConnection();
15
- const { getActiveHost, setHostLanUrl } = useHostStore();
16
- const activeHost = getActiveHost();
17
- const activeClientToken = activeHost?.clientToken ?? null;
18
+ const { connected, request, activeHost } = useHostConnection();
19
+ const { setHostLanUrl, pairedHosts } = useHostStore();
20
+ const isFirstHost = pairedHosts.length <= 1;
21
+
22
+ const [phase, setPhase] = useState<Phase>("loading");
23
+ const [linkedClientToken, setLinkedClientToken] = useState<string | null>(null);
24
+ const [linkError, setLinkError] = useState<string | null>(null);
18
25
 
19
- const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
20
- const [loaded, setLoaded] = useState(false);
26
+ function goToHost() {
27
+ navigate(`/hosts/${encodeURIComponent(activeHost.hostId)}`, { replace: true });
28
+ }
21
29
 
22
- // If the user lands here without an active host (direct URL, refresh), bounce
23
- // back to the dashboard — setup only makes sense right after pairing.
30
+ // Phase: loading fetch host.info, then transition.
24
31
  useEffect(() => {
25
- if (!activeHost) navigate("/", { replace: true });
26
- }, [activeHost, navigate]);
32
+ if (!connected || phase !== "loading") return;
33
+ let cancelled = false;
34
+ request<HostInfoResponse>("host.info").catch(() => ({} as HostInfoResponse)).then((info) => {
35
+ if (cancelled) return;
36
+ setHostLanUrl(activeHost.hostId, info.lan_url ?? undefined);
37
+ const linked = info.linked_client_token ?? null;
38
+ setLinkedClientToken(linked);
39
+ const otherDeviceLinked = !!linked && linked !== activeHost.clientToken;
40
+ setPhase(otherDeviceLinked ? "confirming" : "linking");
41
+ });
42
+ return () => { cancelled = true; };
43
+ }, [connected, phase, activeHost, request, setHostLanUrl]);
27
44
 
45
+ // Phase: linking → call device.link, then either show wizard (first host)
46
+ // or navigate (subsequent host).
28
47
  useEffect(() => {
29
- if (!connected || !activeHost) return;
30
- const activeHostId = activeHost.hostId;
48
+ if (phase !== "linking") return;
31
49
  let cancelled = false;
32
- request<HostInfoResponse>("host.info")
33
- .then((res) => {
50
+ (async () => {
51
+ try {
52
+ if (Device) {
53
+ const { token: fcmToken } = await Device.getFcmToken();
54
+ if (!fcmToken) throw new Error("Could not read FCM token");
55
+ await request("device.link", { fcmToken });
56
+ }
34
57
  if (cancelled) return;
35
- setCapabilityTokens(res.capability_tokens ?? {});
36
- setHostLanUrl(activeHostId, res.lan_url ?? undefined);
37
- setLoaded(true);
38
- })
39
- .catch(() => { if (!cancelled) setLoaded(true); });
58
+ if (isFirstHost) setPhase("wizard");
59
+ else goToHost();
60
+ } catch (err) {
61
+ if (cancelled) return;
62
+ setLinkError(err instanceof Error ? err.message : String(err));
63
+ setPhase("linkError");
64
+ }
65
+ })();
40
66
  return () => { cancelled = true; };
41
- }, [connected, activeHost, request, setHostLanUrl]);
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, [phase]);
69
+
70
+ function confirmLink() { setPhase("linking"); }
71
+ function cancelLink() { goToHost(); }
72
+ function retryLink() { setLinkError(null); setPhase("linking"); }
73
+
74
+ const linkModal = phase === "confirming" && createPortal(
75
+ <div className="confirm-modal-overlay" onClick={cancelLink}>
76
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
77
+ <h2 className="confirm-modal-title">Link this device?</h2>
78
+ <p className="confirm-modal-message">
79
+ Only one device can be linked at a time — switching will disable those capabilities on the currently linked device.
80
+ </p>
81
+ <div className="confirm-modal-actions">
82
+ <button className="btn btn-secondary" onClick={cancelLink}>Cancel</button>
83
+ <button className="btn btn-primary" onClick={confirmLink}>Link</button>
84
+ </div>
85
+ </div>
86
+ </div>,
87
+ document.body,
88
+ );
89
+
90
+ if (phase === "loading" || phase === "confirming" || phase === "linking" || phase === "linkError") {
91
+ const isWizardCandidate = isFirstHost;
92
+ return (
93
+ <div className="pair-setup">
94
+ <div className="pair-setup-inner">
95
+ {isWizardCandidate && <h1 className="pair-setup-title">Device Capabilities</h1>}
96
+ <div className="pair-setup-loading">
97
+ {phase === "loading" && "Connecting to host…"}
98
+ {phase === "confirming" && "Awaiting confirmation…"}
99
+ {phase === "linking" && "Linking device…"}
100
+ {phase === "linkError" && (
101
+ <>
102
+ <p className="pair-error">{linkError}</p>
103
+ <button className="btn btn-primary" onClick={retryLink}>Retry</button>
104
+ <button className="btn btn-secondary" onClick={goToHost} style={{ marginTop: 8 }}>Skip linking</button>
105
+ </>
106
+ )}
107
+ </div>
108
+ </div>
109
+ {linkModal}
110
+ </div>
111
+ );
112
+ }
42
113
 
114
+ // phase === "wizard"
115
+ void linkedClientToken;
43
116
  return (
44
117
  <div className="pair-setup">
45
118
  <div className="pair-setup-inner">
46
- <h1 className="pair-setup-title">What capabilities of this device do you want your host computer to have?</h1>
119
+ <h1 className="pair-setup-title">Device Capabilities</h1>
47
120
  <p className="pair-setup-description">
48
- You can change these later from the menu.
121
+ Choose what the host can use this device for. You can change these later from the menu.
49
122
  </p>
50
123
 
51
- {!loaded ? (
52
- <div className="pair-setup-loading">Connecting to host…</div>
53
- ) : (
54
- <CapabilityToggles
55
- capabilityTokens={capabilityTokens}
56
- activeClientToken={activeClientToken}
57
- request={request}
58
- onCapabilityTokensChange={setCapabilityTokens}
59
- />
60
- )}
124
+ <CapabilityToggles />
61
125
 
62
126
  <div className="pair-setup-actions">
63
- <button
64
- className="btn btn-primary btn-full"
65
- onClick={() => navigate("/", { replace: true })}
66
- >
67
- Finish
68
- </button>
127
+ <button className="btn btn-primary btn-full" onClick={goToHost}>Finish</button>
69
128
  </div>
70
129
  </div>
71
130
  </div>
@@ -104,14 +104,17 @@ self.addEventListener("notificationclick", (event) => {
104
104
  );
105
105
  } else {
106
106
  // User tapped the notification body — open the PWA.
107
- // For task-complete/fail notifications, deep-link to the result view.
107
+ // For task-complete/fail notifications, deep-link to the result view
108
+ // scoped to the originating host so the PWA switches hosts automatically.
109
+ const hostId = data.host_id;
108
110
  const taskId = data.task_id;
109
111
  const runId = data.run_id;
110
- const targetUrl = taskId && runId
111
- ? `/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`
112
- : taskId
113
- ? `/runs/${encodeURIComponent(taskId)}/latest`
114
- : "/";
112
+ const hostPrefix = hostId ? `/hosts/${encodeURIComponent(hostId)}` : "";
113
+ const targetUrl = hostPrefix && taskId && runId
114
+ ? `${hostPrefix}/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`
115
+ : hostPrefix && taskId
116
+ ? `${hostPrefix}/runs/${encodeURIComponent(taskId)}/latest`
117
+ : hostPrefix || "/";
115
118
 
116
119
  event.waitUntil(
117
120
  self.clients
@@ -68,4 +68,6 @@ export interface PairedHost {
68
68
  directUrl?: string;
69
69
  /** Host's LAN URL, refreshed from each `host.info` response so laptop/DHCP IP changes propagate. Native Capacitor app probes for reachability and routes RPC over HTTP when reachable; events stay on NATS. */
70
70
  lanUrl?: string;
71
+ /** Last-used agent key for this host. Seeds the agent picker on the session composer and task form. */
72
+ lastAgent?: string;
71
73
  }
@@ -27,7 +27,7 @@ The host supports **Linux** (systemd), **macOS** (launchd user LaunchAgent), and
27
27
  - **Battery**: `BatteryHandler` — no permission required
28
28
  - **Ringer mode**: `RingerHandler` — requires Do Not Disturb access (system settings toggle)
29
29
 
30
- The notification listener excludes Palmier's own task notifications (channel `palmier_tasks`) and the default SMS app's notifications (to avoid duplicates with the SMS resource). Each capability can be toggled on/off via the app's settings menu; toggles are backed by SharedPreferences flags that handlers check before executing. See the `palmier-android` repo.
30
+ The notification listener excludes Palmier's own task notifications (channel `palmier_tasks`) and the default SMS app's notifications (to avoid duplicates with the SMS resource). Each capability can be toggled on/off from the drawer when the device is the host's **linked device** (the one device the host talks to for device capabilities); toggles are backed by SharedPreferences flags that handlers check before executing. See the `palmier-android` repo.
31
31
 
32
32
  * **PWA (React):** The user-facing frontend, primarily targeting mobile devices. Connects to the NATS server via **WebSockets** at `nats.palmier.me` (DNS only, not Cloudflare proxied, to avoid interference with persistent connections). No user accounts — paired hosts are stored in localStorage.
33
33
 
@@ -59,15 +59,15 @@ The Android app is a thin native shell over the remotely-hosted PWA. The design
59
59
 
60
60
  - **Server mode only.** LAN and Local modes are browser-only. The WebView blocks cleartext `http://<host-ip>:<port>` requests as mixed content, so LAN users must open the PWA from Chrome/Safari directly.
61
61
  - **Offline fallback.** When `app.palmier.me` is unreachable, the WebView loads `www/offline.html` (configured via `server.errorPath`), which auto-reloads when connectivity returns.
62
- - **PWA ships ahead of the APK.** The PWA may reference permission types or native methods that the installed APK doesn't implement yet. `Device.getSupportedPermissions()` returns the set the APK understands; `checkPermission` / `requestPermission` resolve with `{ granted: false, supported: false }` for unknown types rather than throwing. The PWA uses this to hide toggles it can't fulfill.
62
+ - **PWA ships ahead of the APK.** The PWA may reference capabilities or native methods that the installed APK doesn't implement yet. `Device.getCapabilityStatus()` only returns capabilities the APK knows about, so the PWA naturally hides toggles it can't fulfill. Calls to `setCapabilityEnabled` for unknown capabilities resolve with `{ enabled: false, reason: "unsupported" }` rather than throwing.
63
63
 
64
- **Unified `Device` Capacitor plugin.** A single plugin (`DevicePlugin.kt`) exposes the entire native surface — FCM token, permission gate, capability whitelist, installed-app enumeration, email-client availability, deep-link events. This replaces seven per-capability permission plugins. Methods: `getFcmToken`, `getSupportedPermissions`, `checkPermission({type})`, `requestPermission({type})`, `setEnabledCapabilities({capabilities})`, `getInstalledApps`, `hasEmailClient`, `addListener("deepLink", ...)`. Permission types: `location`, `smsRead`, `smsSend`, `contacts`, `calendar`, `notificationListener`, `dnd`, `fullScreenIntent`, `postNotifications`.
64
+ **Unified `Device` Capacitor plugin.** A single plugin (`DevicePlugin.kt`) exposes the entire native surface — FCM token, capability gating (with internal permission orchestration), installed-app enumeration, deep-link events. Methods: `getFcmToken`, `getCapabilityStatus`, `setCapabilityEnabled({capability, enabled})`, `getInstalledApps`, `addListener("deepLink", ...)`. Capabilities: `sms-read`, `sms-send`, `send-email`, `notifications`, `contacts`, `calendar`, `location`, `dnd`, `alarm`.
65
65
 
66
- **Capability kill-switch (`CapabilityState`).** Local whitelist persisted as a JSON-array string under `enabledCapabilities` in `CapacitorStorage` SharedPreferences, written only by `DevicePlugin.setEnabledCapabilities` from the PWA's derived state. Native receivers (`SmsBroadcastReceiver`, `DeviceNotificationListenerService`) and all FCM handlers consult this before acting — a second line of defense beyond the server-side capability token. If the user disables a capability in the drawer, the native side refuses to relay events or respond to requests even if the server still asks.
66
+ **Capability kill-switch (`CapabilityState`).** Local whitelist persisted as a JSON-array string under `enabledCapabilities` in `CapacitorStorage` SharedPreferences. Native receivers (`SmsBroadcastReceiver`, `DeviceNotificationListenerService`) and all FCM handlers consult `CapabilityState.isEnabled` before acting — a second line of defense beyond the server-side linked-device check. The plugin owns reads and writes through `setCapabilityEnabled` (writes only after permission grant) and prunes the set on every app resume so any permission revoked in system Settings auto-flips the corresponding toggle off. Battery is the one capability without a kill-switch — it's always allowed.
67
67
 
68
68
  **FCM token flow.** The PWA reads the current token on demand via `Device.getFcmToken()`; no cached copy in SharedPreferences. `PalmierFirebaseMessagingService.onNewToken` still re-registers with the relay server itself (using the stored `hostId`) because background token refreshes can fire while the PWA isn't running.
69
69
 
70
- **Deep links.** FCM notification taps pass a relative path (e.g. `/runs/:taskId/:runId`) via an `Intent` extra named `deepLink`. `MainActivity.handleDeepLink` forwards the path to `DevicePlugin.emitDeepLink`, which emits a `deepLink` event the PWA's router handles client-side. If the plugin isn't ready yet (intent arrives before `onPostCreate`), `MainActivity` buffers the path and flushes in `onPostCreate`. No external `intent-filter` is registered — Android 11+ `<queries>` entries are declared so `hasEmailClient` and installed-app enumeration work without `QUERY_ALL_PACKAGES`.
70
+ **Deep links.** FCM notification taps pass a host-scoped relative path (e.g. `/hosts/:hostId/runs/:taskId/:runId`) via an `Intent` extra named `deepLink`. Including the host in the path ensures the PWA switches to the originating host even if the user was viewing a different one when the notification arrived. `MainActivity.handleDeepLink` forwards the path to `DevicePlugin.emitDeepLink`, which emits a `deepLink` event the PWA's router handles client-side. If the plugin isn't ready yet (intent arrives before `onPostCreate`), `MainActivity` buffers the path and flushes in `onPostCreate`. No external `intent-filter` is registered — Android 11+ `<queries>` entries are declared so `hasEmailClient` and installed-app enumeration work without `QUERY_ALL_PACKAGES`.
71
71
 
72
72
  **Notification listener filtering and debounce.** `DeviceNotificationListenerService` drops notifications from (a) Palmier's own `palmier_tasks` channel to avoid feedback loops and (b) the default SMS app (SMS is captured separately via `SmsBroadcastReceiver`, which arrives before the SMS app's notification). Empty-title+body notifications are skipped. A 2-second debounce per `packageName:title` key dedupes rapid updates; the debounce map is LRU-capped at 200 entries.
73
73
 
@@ -75,7 +75,7 @@ The Android app is a thin native shell over the remotely-hosted PWA. The design
75
75
 
76
76
  **Alarm capability.** `AlarmHandler` posts a `CATEGORY_ALARM` notification on the DND-bypassing `palmier_alarms` channel with a full-screen intent targeting `AlarmActivity`. `AlarmActivity` extends `AppCompatActivity`, shows over the lock screen (`setShowWhenLocked`/`setTurnScreenOn` on O_MR1+, legacy window flags below), and plays the default alarm ringtone on the alarm audio stream via `RingtoneManager`. Requires `USE_FULL_SCREEN_INTENT`; Android 14+ requires the user to grant it in per-app settings.
77
77
 
78
- **Email capability.** FCM `send-email` messages post a "Pending email" notification whose tap launches `EmailActivity` — a translucent `Activity` (not `AppCompatActivity`, so `Theme.Translucent` applies) that builds a `mailto:` URI and starts the email app with `ACTION_SENDTO`. The activity auto-finishes on result, returning the user to the previous screen. `hasEmailClient` gates the toggle in the PWA to avoid enabling a capability no installed app can fulfill.
78
+ **Email capability.** FCM `send-email` messages post a "Pending email" notification whose tap launches `EmailActivity` — a translucent `Activity` (not `AppCompatActivity`, so `Theme.Translucent` applies) that builds a `mailto:` URI and starts the email app with `ACTION_SENDTO`. The activity auto-finishes on result, returning the user to the previous screen. `setCapabilityEnabled("send-email", true)` checks for an installed `mailto:` resolver before granting; if none exists it returns `{ enabled: false, reason: "no-email-client" }` so the PWA can prompt the user to install one.
79
79
 
80
80
  **Location capability.** `GeolocationForegroundService` briefly starts as a foreground service (`FOREGROUND_SERVICE_TYPE_LOCATION` on U+) and uses `FusedLocationProviderClient.getCurrentLocation(PRIORITY_HIGH_ACCURACY)` for a single fix before stopping itself. Requires both `ACCESS_FINE_LOCATION` and `ACCESS_BACKGROUND_LOCATION` on Q+; the plugin requests them sequentially.
81
81
 
@@ -91,13 +91,16 @@ Each host machine is provisioned via `palmier init`, an interactive wizard that
91
91
 
92
92
  1. Detects installed agent CLIs.
93
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
- 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`, NATS credentials).
97
- 6. Installs a systemd user service (Linux), user LaunchAgent (macOS), or Task Scheduler entry (Windows) and auto-enters pair mode.
94
+ 3. Detects the OS default network interface (used as the source for the host's LAN URL in `host.info` responses).
95
+ 4. Shows a summary of task storage directory, local access URL, detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
96
+ 5. Registers with the Palmier server via `POST <url>/api/hosts/register` server returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }`.
97
+ 6. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, `defaultInterface`, NATS credentials).
98
+ 7. Installs a systemd user service (Linux), user LaunchAgent (macOS), or Task Scheduler entry (Windows) and auto-enters pair mode.
98
99
 
99
100
  The daemon automatically recovers existing tasks by reinstalling their system timers on startup.
100
101
 
102
+ `defaultInterface` is captured once at `init` time. On each `host.info` RPC, the host re-reads the current IPv4 of that interface (`getInterfaceIpv4(config.defaultInterface)`) so DHCP-assigned IP changes on the same adapter propagate to clients without a re-pair. Users should re-run `palmier init` if they physically switch adapters (e.g., Ethernet ↔ WiFi, adding a new tethered interface).
103
+
101
104
  The `serve` daemon always starts an HTTP server bound to `0.0.0.0:<port>`. Two access modes are available:
102
105
 
103
106
  **Local mode** (always available, loopback only):
@@ -137,6 +140,17 @@ Client tokens are stored on the host in `~/.config/palmier/clients.json`. Each t
137
140
 
138
141
  If no clients exist, the host skips client validation (backward compatibility for unpaired hosts).
139
142
 
143
+ ### 2.3.1 Linked Device
144
+
145
+ Each host tracks a single **linked device** — the one paired device responsible for answering device capability requests (SMS, contacts, calendar, location, alarm, ringer, email, battery). The host stores only `{ clientToken, fcmToken }` in `~/.config/palmier/linked-device.json`; it has no knowledge of which capabilities are actually enabled on the device. That set lives in Android SharedPreferences on the linked device itself and is consulted by the FCM handlers as a local kill-switch.
146
+
147
+ - **Opt-in at pair time.** The PWA shows a "Link this device" checkbox during pairing (native only, default on). If checked, the pair flow continues to a setup step that calls `device.link` with the FCM token. On the very first host pair (when the device has no other paired hosts), that step also shows a one-time "Device Capabilities" screen with all toggles default OFF; the user opts into each one, and Finish writes the set to SharedPreferences. On subsequent host pairs the capability screen is skipped — the device-wide enabled set is host-agnostic and set once.
148
+ - **Reassignment.** Any paired device can take over as the linked device from the drawer's "Link this device" button. This displaces the previous linked device (its drawer toggles go dark).
149
+ - **Loss.** If the linked device is unpaired (via `clients.revoke_self` or CLI `palmier clients revoke`), `linked-device.json` is cleared and capability tools return "No linked device configured" until the user picks a new one.
150
+ - **Routing.** MCP capability tools look up the linked device once per invocation and publish FCM to its token (via `host.<host_id>.fcm.<capability>` relayed by the server). Non-linked devices aren't woken and don't receive capability FCMs.
151
+
152
+ Battery reads don't have a Settings toggle — capability is always on — but still route to the linked device (which is where the Android handler runs).
153
+
140
154
  ### 2.4 NATS Communication
141
155
 
142
156
  All communication is scoped per host. **Request-reply** is used for RPC-style calls (task CRUD, status queries) — the PWA publishes a request and receives a response on an auto-generated inbox, eliminating the need for separate response subjects.
@@ -149,7 +163,10 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
149
163
 
150
164
  | Method | Params | Description |
151
165
  |---|---|---|
152
- | `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform, capability_tokens, pending_prompts }`. `pending_prompts` is an array of prompts already waiting when the PWA reconnects (each `{ key, type, params?, meta? }`), so modals can render without replaying events. |
166
+ | `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform, linked_client_token, pending_prompts, lan_url }`. `linked_client_token` is the clientToken of the device currently linked to the host (or `null`); device capability requests route to that device's FCM token. `pending_prompts` is an array of prompts already waiting when the PWA reconnects (each `{ key, type, params?, meta? }`), so modals can render without replaying events. |
167
+ | `device.link` | `fcmToken` | Mark the calling client as the host's linked device. Stores `{ clientToken, fcmToken }` in `~/.config/palmier/linked-device.json` and replaces any existing linked device. Device capability tools (`device-geolocation`, `read-contacts`, `send-sms-message`, etc.) route FCM to this device. |
168
+ | `device.unlink` | *(none)* | Clear the linked device if the caller is currently linked. No-op otherwise. |
169
+ | `clients.revoke_self` | *(none)* | Revoke the calling client's token. Also clears the linked device when the caller was the linked one. Called by the PWA when the user unpairs the currently-active host. |
153
170
  | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
154
171
  | `task.get` | `id` | Get a single task with frontmatter and current status. |
155
172
  | `task.create` | `user_prompt`, `agent`, `schedule_type?`, `schedule_values?`, `schedule_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if a schedule is present. |
@@ -291,7 +308,7 @@ Task lifecycle status is persisted to a `status.json` file in the task directory
291
308
 
292
309
  The `task.list` RPC includes each task's current status (read from `status.json`). The `task.status` RPC returns the status for a single task.
293
310
 
294
- The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<activeHostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when the schedule is disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The `specific_times` date/time picker only allows selecting future dates and times.
311
+ The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<hostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when the schedule is disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The `specific_times` date/time picker only allows selecting future dates and times.
295
312
 
296
313
  The Web Server subscribes to `host-event.>` and sends push notifications based on `event_type`: confirmation pushes for `confirm-request`, dismiss pushes for `confirm-resolved`, permission pushes for `permission-request`, dismiss pushes for `permission-resolved`, and report-ready/failure pushes for `report-generated` events.
297
314
 
@@ -317,7 +334,7 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
317
334
 
318
335
  2. If hosts are paired, PWA fetches host-scoped NATS credentials from `GET /api/nats-credentials/<hostId>` (returns `{ natsWsUrl, natsJwt, natsNkeySeed }`) and connects to NATS via WebSocket using JWT auth. The credentials are scoped to the paired host's subjects only.
319
336
 
320
- 3. PWA sends a `host.info` request using NATS request-reply, including the `clientToken` in the payload. The response carries bootstrap metadata (`agents`, `host_platform`, `version`, `capability_tokens`) and `pending_prompts` — any prompts already open on the host when the PWA connected. The Dashboard consumes this once per connection; both tabs read from it.
337
+ 3. PWA sends a `host.info` request using NATS request-reply, including the `clientToken` in the payload. The response carries bootstrap metadata (`agents`, `host_platform`, `version`, `linked_client_token`) and `pending_prompts` — any prompts already open on the host when the PWA connected. The Dashboard consumes this once per connection; both tabs read from it.
321
338
 
322
339
  4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. `task.list` is not called at startup — it fires lazily the first time the user opens the Tasks tab. If either RPC fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
323
340
 
@@ -327,12 +344,24 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
327
344
 
328
345
  ### 4.2 UI Layout: Sessions & Tasks Tabs
329
346
 
330
- The PWA has two tabs: **Sessions** (default, at `/`) and **Tasks** (secondary, at `/tasks`). Sessions is the primary workflow it lists all run history across tasks (a "session" is a single run) and includes a session composer at the top of the list.
347
+ All authenticated views are scoped under `/hosts/:hostId/` so the URL is the source of truth for "which host am I looking at":
348
+
349
+ - `/hosts/:hostId` — Sessions tab (default)
350
+ - `/hosts/:hostId/tasks` — Tasks tab
351
+ - `/hosts/:hostId/runs/:taskId` — latest run for a task
352
+ - `/hosts/:hostId/runs/:taskId/:runId` — specific run
353
+ - `/hosts/:hostId/pair/setup` — capability setup right after pairing (native only)
354
+ - `/pair` — enter a pairing code
355
+ - `/` — redirects to `/hosts/<firstPairedHostId>`, or to `/pair` if no hosts are paired. No "last visited" state is persisted; the URL is the source of truth for the active host.
356
+
357
+ Unknown or stale `:hostId` values redirect back to `/`. This lets notification deep links (`/hosts/:hostId/runs/...`) switch the active host automatically instead of opening a run against whatever host happens to be selected.
358
+
359
+ The PWA has two tabs: **Sessions** (default) and **Tasks** (secondary). Sessions is the primary workflow — it lists all run history across tasks (a "session" is a single run) and includes a session composer at the top of the list.
331
360
 
332
361
  * **Session composer:** An inline textarea with an agent picker, a yolo-mode toggle, and a round play button. Entering text and clicking play dispatches `task.run_oneoff`, starting an immediate unsaved session. The composer never opens a dialog; typing is direct. When the textarea has content, navigating away (tab switch, host switch, browser reload, clicking a session row) triggers a confirmation dialog so the draft isn't lost silently.
333
362
  * **Tasks tab:** Lists saved tasks (scheduled or reusable). A floating round `+` button in the bottom-right of the screen opens the task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when a schedule is configured).
334
363
 
335
- Bootstrap data (agents, host version, host platform, capability tokens) is fetched once per connection at the Dashboard level via `host.info` — independent of which tab is active. `task.list` is called lazily on the Tasks tab mount; `taskrun.list` is called lazily on the Sessions tab mount. Neither list RPC carries bootstrap metadata.
364
+ Bootstrap data (agents, host version, host platform, linked device) is fetched once per connection at the Dashboard level via `host.info` — independent of which tab is active. `task.list` is called lazily on the Tasks tab mount; `taskrun.list` is called lazily on the Sessions tab mount. Neither list RPC carries bootstrap metadata.
336
365
 
337
366
  Dashboard owns the always-on NATS event subscription and renders pending `confirm-request` / `permission-request` / `input-request` modals via React portal, so prompts surface regardless of which tab is active. Initial pending prompts (those already open when the PWA connects) are seeded from `host.info`'s `pending_prompts` field — each entry carries the display context (`session_name`, `description`, `input_questions`) needed to render the modal cold, since the task list is no longer available at bootstrap. `session_name` is a unified label: agent name for confirm/input, task name for permission.
338
367
 
@@ -3,6 +3,7 @@ import { loadConfig, saveConfig } from "../config.js";
3
3
  import { detectAgents } from "../agents/agent.js";
4
4
  import { getPlatform } from "../platform/index.js";
5
5
  import { pairCommand } from "./pair.js";
6
+ import { detectDefaultInterface, getInterfaceIpv4 } from "../network.js";
6
7
  import { listTasks } from "../task.js";
7
8
  import type { HostConfig } from "../types.js";
8
9
 
@@ -41,6 +42,9 @@ export async function initCommand(): Promise<void> {
41
42
  const parsed = parseInt(portAnswer.trim(), 10);
42
43
  if (parsed > 0 && parsed < 65536) httpPort = parsed;
43
44
 
45
+ const defaultInterface = (await detectDefaultInterface()) ?? undefined;
46
+ const lanIp = defaultInterface ? getInterfaceIpv4(defaultInterface) : null;
47
+
44
48
  console.log(`\n${bold("Setup summary:")}\n`);
45
49
  console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
46
50
  console.log(` All tasks and execution data will be stored here.\n`);
@@ -49,8 +53,14 @@ export async function initCommand(): Promise<void> {
49
53
  console.log(` ${dim("Remote (web):")} ${cyan("https://app.palmier.me")}`);
50
54
  console.log(` Pair a browser on any device. Traffic always goes through the relay.\n`);
51
55
  console.log(` ${dim("Remote (app):")} ${cyan("https://github.com/caihongxu/palmier-android/releases")}`);
52
- console.log(` Download the Android APK. The app uses LAN for direct RPC`);
53
- console.log(` when on the same network, otherwise the relay.\n`);
56
+ if (lanIp) {
57
+ console.log(` Download the Android APK. The app uses LAN for direct RPC`);
58
+ console.log(` on the same network (detected ${cyan(`http://${lanIp}:${httpPort}`)}),`);
59
+ console.log(` otherwise the relay.\n`);
60
+ } else {
61
+ console.log(` Download the Android APK. Traffic will go through the relay —`);
62
+ console.log(` ${red("could not detect a LAN interface")} for direct RPC.\n`);
63
+ }
54
64
  console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
55
65
 
56
66
  const existingTasks = listTasks(process.cwd());
@@ -101,6 +111,7 @@ export async function initCommand(): Promise<void> {
101
111
  natsNkeySeed: registerResponse.natsNkeySeed,
102
112
  agents,
103
113
  httpPort,
114
+ defaultInterface,
104
115
  };
105
116
 
106
117
  saveConfig(config);
@@ -1,10 +1,9 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "node:os";
3
3
  import { StringCodec } from "nats";
4
- import { loadConfig, saveConfig } from "../config.js";
4
+ import { loadConfig } from "../config.js";
5
5
  import { connectNats } from "../nats-client.js";
6
6
  import { addClient } from "../client-store.js";
7
- import { detectDefaultInterface } from "../network.js";
8
7
  import type { HostConfig } from "../types.js";
9
8
 
10
9
  const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
@@ -18,13 +17,8 @@ export function generatePairingCode(): string {
18
17
  return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
19
18
  }
20
19
 
21
- async function buildPairResponse(config: HostConfig, label?: string) {
20
+ function buildPairResponse(config: HostConfig, label?: string) {
22
21
  const client = addClient(label);
23
- const iface = await detectDefaultInterface();
24
- if (iface && iface !== config.defaultInterface) {
25
- config.defaultInterface = iface;
26
- saveConfig(config);
27
- }
28
22
  return {
29
23
  hostId: config.hostId,
30
24
  clientToken: client.token,
@@ -108,7 +102,7 @@ export async function pairCommand(): Promise<void> {
108
102
  }
109
103
  } catch { /* empty body is fine */ }
110
104
 
111
- const response = await buildPairResponse(config, label);
105
+ const response = buildPairResponse(config, label);
112
106
  if (msg.reply) {
113
107
  msg.respond(sc.encode(JSON.stringify(response)));
114
108
  }
@@ -0,0 +1,52 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+
5
+ const LINKED_DEVICE_FILE = path.join(CONFIG_DIR, "linked-device.json");
6
+
7
+ export interface LinkedDevice {
8
+ clientToken: string;
9
+ fcmToken: string;
10
+ }
11
+
12
+ function read(): LinkedDevice | null {
13
+ try {
14
+ if (!fs.existsSync(LINKED_DEVICE_FILE)) return null;
15
+ const raw = fs.readFileSync(LINKED_DEVICE_FILE, "utf-8");
16
+ const parsed = JSON.parse(raw) as Partial<LinkedDevice>;
17
+ if (!parsed?.clientToken || !parsed?.fcmToken) return null;
18
+ return { clientToken: parsed.clientToken, fcmToken: parsed.fcmToken };
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function write(device: LinkedDevice | null): void {
25
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ if (!device) {
27
+ if (fs.existsSync(LINKED_DEVICE_FILE)) fs.unlinkSync(LINKED_DEVICE_FILE);
28
+ return;
29
+ }
30
+ fs.writeFileSync(LINKED_DEVICE_FILE, JSON.stringify(device, null, 2), "utf-8");
31
+ }
32
+
33
+ export function getLinkedDevice(): LinkedDevice | null {
34
+ return read();
35
+ }
36
+
37
+ export function setLinkedDevice(clientToken: string, fcmToken: string): void {
38
+ write({ clientToken, fcmToken });
39
+ }
40
+
41
+ export function clearLinkedDevice(): void {
42
+ write(null);
43
+ }
44
+
45
+ export function clearLinkedDeviceIfMatches(clientToken: string): boolean {
46
+ const current = read();
47
+ if (current?.clientToken === clientToken) {
48
+ write(null);
49
+ return true;
50
+ }
51
+ return false;
52
+ }
package/src/mcp-tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StringCodec, type NatsConnection } from "nats";
2
2
  import { registerPending } from "./pending-requests.js";
3
- import { getCapabilityDevice } from "./device-capabilities.js";
3
+ import { getLinkedDevice } from "./linked-device.js";
4
4
  import { getNotifications, onNotificationsChanged } from "./notification-store.js";
5
5
  import { getSmsMessages, onSmsChanged } from "./sms-store.js";
6
6
  import type { HostConfig } from "./types.js";
@@ -179,8 +179,8 @@ const deviceGeolocationTool: ToolDefinition = {
179
179
  async handler(_args, ctx) {
180
180
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
181
181
 
182
- const device = getCapabilityDevice("location");
183
- if (!device) throw new ToolError("No device has location access enabled", 400);
182
+ const device = getLinkedDevice();
183
+ if (!device) throw new ToolError("No linked device configured", 400);
184
184
 
185
185
  const sc = StringCodec();
186
186
 
@@ -227,8 +227,8 @@ const readContactsTool: ToolDefinition = {
227
227
  async handler(_args, ctx) {
228
228
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
229
229
 
230
- const device = getCapabilityDevice("contacts");
231
- if (!device) throw new ToolError("No device has contacts access enabled", 400);
230
+ const device = getLinkedDevice();
231
+ if (!device) throw new ToolError("No linked device configured", 400);
232
232
 
233
233
  const sc = StringCodec();
234
234
 
@@ -280,8 +280,8 @@ const createContactTool: ToolDefinition = {
280
280
  async handler(args, ctx) {
281
281
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
282
282
 
283
- const device = getCapabilityDevice("contacts");
284
- if (!device) throw new ToolError("No device has contacts access enabled", 400);
283
+ const device = getLinkedDevice();
284
+ if (!device) throw new ToolError("No linked device configured", 400);
285
285
 
286
286
  const { name, phone, email } = args as { name: string; phone?: string; email?: string };
287
287
  if (!name) throw new ToolError("name is required", 400);
@@ -338,8 +338,8 @@ const readCalendarTool: ToolDefinition = {
338
338
  async handler(args, ctx) {
339
339
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
340
340
 
341
- const device = getCapabilityDevice("calendar");
342
- if (!device) throw new ToolError("No device has calendar access enabled", 400);
341
+ const device = getLinkedDevice();
342
+ if (!device) throw new ToolError("No linked device configured", 400);
343
343
 
344
344
  const { startDate, endDate } = args as { startDate?: number; endDate?: number };
345
345
  const sc = StringCodec();
@@ -399,8 +399,8 @@ const createCalendarEventTool: ToolDefinition = {
399
399
  async handler(args, ctx) {
400
400
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
401
401
 
402
- const device = getCapabilityDevice("calendar");
403
- if (!device) throw new ToolError("No device has calendar access enabled", 400);
402
+ const device = getLinkedDevice();
403
+ if (!device) throw new ToolError("No linked device configured", 400);
404
404
 
405
405
  const { title, startTime, endTime, location, description } = args as {
406
406
  title: string; startTime: number; endTime: number; location?: string; description?: string;
@@ -462,7 +462,7 @@ const sendSmsTool: ToolDefinition = {
462
462
  async handler(args, ctx) {
463
463
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
464
464
 
465
- const device = getCapabilityDevice("sms-send");
465
+ const device = getLinkedDevice();
466
466
  if (!device) throw new ToolError("No device has SMS Send enabled", 400);
467
467
 
468
468
  const { to, body } = args as { to: string; body: string };
@@ -521,8 +521,8 @@ const sendAlarmTool: ToolDefinition = {
521
521
  async handler(args, ctx) {
522
522
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
523
523
 
524
- const device = getCapabilityDevice("alarm");
525
- if (!device) throw new ToolError("No device has alarm access enabled", 400);
524
+ const device = getLinkedDevice();
525
+ if (!device) throw new ToolError("No linked device configured", 400);
526
526
 
527
527
  const { title, description } = args as { title: string; description?: string };
528
528
  if (!title) throw new ToolError("title is required", 400);
@@ -578,8 +578,8 @@ const readBatteryTool: ToolDefinition = {
578
578
  async handler(_args, ctx) {
579
579
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
580
580
 
581
- const device = getCapabilityDevice("battery");
582
- if (!device) throw new ToolError("No device has battery access enabled", 400);
581
+ const device = getLinkedDevice();
582
+ if (!device) throw new ToolError("No linked device configured", 400);
583
583
 
584
584
  const sc = StringCodec();
585
585
 
@@ -629,7 +629,7 @@ const setRingerModeTool: ToolDefinition = {
629
629
  async handler(args, ctx) {
630
630
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
631
631
 
632
- const device = getCapabilityDevice("dnd");
632
+ const device = getLinkedDevice();
633
633
  if (!device) throw new ToolError("No device has Do Not Disturb control enabled", 400);
634
634
 
635
635
  const { mode } = args as { mode: string };
@@ -687,8 +687,8 @@ const sendEmailTool: ToolDefinition = {
687
687
  async handler(args, ctx) {
688
688
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
689
689
 
690
- const device = getCapabilityDevice("send-email");
691
- if (!device) throw new ToolError("No device has send-email access enabled", 400);
690
+ const device = getLinkedDevice();
691
+ if (!device) throw new ToolError("No linked device configured", 400);
692
692
 
693
693
  const { to, subject, body, cc, bcc } = args as { to: string; subject?: string; body?: string; cc?: string; bcc?: string };
694
694
  if (!to) throw new ToolError("to is required", 400);