palmier 0.8.11 → 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 (41) hide show
  1. package/README.md +3 -1
  2. package/dist/linked-device.d.ts +9 -0
  3. package/dist/linked-device.js +45 -0
  4. package/dist/mcp-tools.js +19 -19
  5. package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
  6. package/dist/pwa/assets/{index-DhphickB.css → index-Cjjw24Ok.css} +1 -1
  7. package/dist/pwa/assets/{web-4WNPL7z3.js → web-C2AU9S9n.js} +1 -1
  8. package/dist/pwa/assets/{web-DjwsAB0V.js → web-CfD_ah7K.js} +1 -1
  9. package/dist/pwa/assets/{web-Bpd2nO1M.js → web-DugGj1t8.js} +1 -1
  10. package/dist/pwa/index.html +2 -2
  11. package/dist/pwa/service-worker.js +2 -2
  12. package/dist/rpc-handler.js +17 -23
  13. package/package.json +1 -1
  14. package/palmier-server/README.md +2 -1
  15. package/palmier-server/pwa/src/App.css +37 -0
  16. package/palmier-server/pwa/src/App.tsx +36 -15
  17. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
  18. package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
  19. package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
  20. package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
  21. package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
  22. package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
  23. package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
  24. package/palmier-server/pwa/src/constants.ts +1 -1
  25. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
  26. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
  27. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
  28. package/palmier-server/pwa/src/native/Device.ts +23 -38
  29. package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
  30. package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
  31. package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
  32. package/palmier-server/pwa/src/service-worker.ts +9 -6
  33. package/palmier-server/pwa/src/types.ts +2 -0
  34. package/palmier-server/spec.md +37 -11
  35. package/src/linked-device.ts +52 -0
  36. package/src/mcp-tools.ts +19 -19
  37. package/src/rpc-handler.ts +14 -22
  38. package/dist/device-capabilities.d.ts +0 -9
  39. package/dist/device-capabilities.js +0 -36
  40. package/dist/pwa/assets/index-B7S0YoMo.js +0 -120
  41. package/src/device-capabilities.ts +0 -57
@@ -1,8 +1,7 @@
1
- import { useState, useEffect } from "react";
2
- import { createPortal } from "react-dom";
1
+ import { useState, useEffect, useCallback } from "react";
3
2
  import { Capacitor } from "@capacitor/core";
4
3
  import { App as CapacitorApp } from "@capacitor/app";
5
- import { Device, type PermissionType } from "../native/Device";
4
+ import { Device, type CapabilityStatus } from "../native/Device";
6
5
 
7
6
  const isNative = Capacitor.isNativePlatform();
8
7
 
@@ -14,265 +13,108 @@ interface CapabilityDefinition {
14
13
  capability: string;
15
14
  label: string;
16
15
  group: CapabilityGroup;
17
- permission?: PermissionType;
18
- needsFullScreenIntent?: boolean;
19
- enableMethod?: string;
20
- disableMethod?: string;
21
- enableParams?(fcmToken: string): Record<string, unknown>;
22
- disableParams?(): Record<string, unknown>;
23
16
  }
24
17
 
25
18
  const CAPABILITIES: CapabilityDefinition[] = [
26
- { capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
27
- { capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
28
- { capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
29
- { capability: "notifications", label: "Notifications from Other Apps", group: "Data", permission: "notificationListener" },
30
- { capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
31
- { capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
32
- {
33
- capability: "location",
34
- label: "Get Location",
35
- group: "Device",
36
- permission: "location",
37
- enableMethod: "device.location.enable",
38
- disableMethod: "device.location.disable",
39
- enableParams: (fcmToken) => ({ fcmToken }),
40
- disableParams: () => ({}),
41
- },
42
- { capability: "battery", label: "Read Battery Status", group: "Device" },
43
- { capability: "dnd", label: "Set Ringer Mode", group: "Device", permission: "dnd" },
44
- { capability: "alarm", label: "Trigger Alarms", group: "Device", needsFullScreenIntent: true },
19
+ { capability: "sms-read", label: "Read SMS", group: "Messaging" },
20
+ { capability: "sms-send", label: "Send SMS", group: "Messaging" },
21
+ { capability: "send-email", label: "Send Email", group: "Messaging" },
22
+ { capability: "notifications", label: "Notifications from Other Apps", group: "Data" },
23
+ { capability: "contacts", label: "Manage Contacts", group: "Data" },
24
+ { capability: "calendar", label: "Manage Calendar", group: "Data" },
25
+ { capability: "location", label: "Get Location", group: "Device" },
26
+ { capability: "dnd", label: "Set Ringer Mode", group: "Device" },
27
+ { capability: "alarm", label: "Trigger Alarms", group: "Device" },
45
28
  ];
46
29
 
47
- interface CapabilityTogglesProps {
48
- capabilityTokens?: Record<string, string | null>;
49
- activeClientToken?: string | null;
50
- request<T = unknown>(method: string, params?: unknown): Promise<T>;
51
- onCapabilityTokensChange(tokens: Record<string, string | null>): void;
52
- }
53
-
54
- export default function CapabilityToggles({ capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: CapabilityTogglesProps) {
55
- const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
56
- const [confirmingSwitchCapability, setConfirmingSwitchCapability] = useState<CapabilityDefinition | null>(null);
57
- const [confirmingDisableCapability, setConfirmingDisableCapability] = useState<CapabilityDefinition | null>(null);
58
-
59
- // Null while loading; empty set means the native plugin doesn't advertise the
60
- // list (old APK / web) — fall back to the per-call {supported} flag.
61
- const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
62
-
63
- useEffect(() => {
64
- if (!isNative || !Device) {
65
- setSupportedPerms(new Set());
66
- return;
67
- }
68
- Device.getSupportedPermissions()
69
- .then(({ types }) => setSupportedPerms(new Set(types)))
70
- .catch(() => setSupportedPerms(new Set()));
71
- }, []);
72
-
73
- function isCapabilityEnabled(capability: string): boolean {
74
- return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
30
+ export async function loadEnabledCapabilities(): Promise<Set<string>> {
31
+ if (!isNative || !Device) return new Set();
32
+ try {
33
+ const { capabilities } = await Device.getCapabilityStatus();
34
+ return new Set(capabilities.filter((c) => c.enabled).map((c) => c.name));
35
+ } catch {
36
+ return new Set();
75
37
  }
38
+ }
76
39
 
77
- function isCapabilityVisible(definition: CapabilityDefinition): boolean {
78
- if (!supportedPerms) return false;
79
- if (supportedPerms.size === 0) return true;
80
- if (definition.permission && !supportedPerms.has(definition.permission)) return false;
81
- if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
82
- return true;
83
- }
40
+ interface CapabilityTogglesProps {
41
+ onChange?(enabled: Set<string>): void;
42
+ }
84
43
 
85
- function setCapabilityEnabled(capability: string, enabled: boolean) {
86
- const updated: Record<string, string | null> = {};
87
- for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
88
- updated[capability] = enabled ? (activeClientToken ?? null) : null;
89
- onCapabilityTokensChange(updated);
90
- }
44
+ export default function CapabilityToggles({ onChange }: CapabilityTogglesProps) {
45
+ const [statuses, setStatuses] = useState<Map<string, CapabilityStatus>>(new Map());
46
+ const [busyCapability, setBusyCapability] = useState<string | null>(null);
91
47
 
92
- // If the OS location permission is revoked while the app is backgrounded,
93
- // disable the capability on the host so agents don't keep pinging for fixes.
94
- useEffect(() => {
48
+ const refresh = useCallback(async () => {
95
49
  if (!isNative || !Device) return;
96
- const locationEnabled = isCapabilityEnabled("location");
97
- if (!locationEnabled) return;
98
-
99
- function syncPermissionState() {
100
- Device!.checkPermission({ type: "location" }).then(({ granted }) => {
101
- if (!granted) {
102
- request("device.location.disable").then(() => {
103
- setCapabilityEnabled("location", false);
104
- }).catch(() => {});
105
- }
106
- });
50
+ try {
51
+ const { capabilities } = await Device.getCapabilityStatus();
52
+ const next = new Map<string, CapabilityStatus>();
53
+ for (const c of capabilities) next.set(c.name, c);
54
+ setStatuses(next);
55
+ onChange?.(new Set(capabilities.filter((c) => c.enabled).map((c) => c.name)));
56
+ } catch (err) {
57
+ console.error("Failed to read capability status:", err);
107
58
  }
59
+ }, [onChange]);
108
60
 
109
- syncPermissionState();
110
- const listener = CapacitorApp.addListener("resume", syncPermissionState);
111
- return () => { listener.then((h) => h.remove()); };
112
- }, [capabilityTokens, activeClientToken]);
61
+ useEffect(() => { refresh(); }, [refresh]);
113
62
 
114
- // Mirror the server-derived enabled set into native as a local kill-switch.
115
- // Toggle paths write through immediately; this catches host-initiated changes
116
- // (e.g. a capability revoked on another device) on the next render.
117
63
  useEffect(() => {
118
- if (!isNative || !Device) return;
119
- const enabled = CAPABILITIES
120
- .map((definition) => definition.capability)
121
- .filter((capability) => capabilityTokens?.[capability] === activeClientToken);
122
- Device.setEnabledCapabilities({ capabilities: enabled }).catch(() => {});
123
- }, [capabilityTokens, activeClientToken]);
124
-
125
- async function toggleCapability(definition: CapabilityDefinition, bypassConfirmation = false) {
126
- const enabled = isCapabilityEnabled(definition.capability);
127
-
128
- if (enabled && !bypassConfirmation) {
129
- setConfirmingDisableCapability(definition);
130
- return;
131
- }
132
-
133
- const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
134
- && capabilityTokens[definition.capability] !== activeClientToken;
135
- if (ownedByOther && !bypassConfirmation) {
136
- setConfirmingSwitchCapability(definition);
137
- return;
138
- }
64
+ if (!isNative) return;
65
+ const listener = CapacitorApp.addListener("resume", refresh);
66
+ return () => { listener.then((h) => h.remove()); };
67
+ }, [refresh]);
139
68
 
140
- setTogglingCapability(definition.capability);
69
+ async function toggleCapability(definition: CapabilityDefinition) {
70
+ if (!Device) return;
71
+ const status = statuses.get(definition.capability);
72
+ if (!status) return;
73
+ setBusyCapability(definition.capability);
141
74
  try {
142
- if (enabled) {
143
- const method = definition.disableMethod ?? "device.capability.disable";
144
- const params = definition.disableParams?.() ?? { capability: definition.capability };
145
- await request(method, params);
146
- setCapabilityEnabled(definition.capability, false);
147
- return;
148
- }
149
-
150
- if (Device && definition.capability === "send-email") {
151
- try {
152
- const result = await Device.hasEmailClient();
153
- if (result.supported && !result.available) {
154
- alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Sending Email.");
155
- return;
156
- }
157
- } catch { /* older APK: fall through */ }
158
- }
159
-
160
- if (Device && definition.permission) {
161
- const check = await Device.checkPermission({ type: definition.permission });
162
- if (!check.supported) {
163
- console.warn(`Native build does not support permission '${definition.permission}'`);
164
- return;
165
- }
166
- if (!check.granted) {
167
- const result = await Device.requestPermission({ type: definition.permission });
168
- if (!result.granted) return;
169
- }
170
- }
171
- if (Device && definition.needsFullScreenIntent) {
172
- const check = await Device.checkPermission({ type: "fullScreenIntent" });
173
- if (!check.supported) {
174
- console.warn("Native build does not support fullScreenIntent");
175
- return;
176
- }
177
- if (!check.granted) {
178
- const result = await Device.requestPermission({ type: "fullScreenIntent" });
179
- if (!result.granted) return;
180
- }
75
+ const result = await Device.setCapabilityEnabled({
76
+ capability: definition.capability,
77
+ enabled: !status.enabled,
78
+ });
79
+ if (!result.enabled && result.reason === "no-email-client") {
80
+ alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Send Email.");
181
81
  }
182
-
183
- if (!Device) return;
184
- const { token: fcmToken } = await Device.getFcmToken();
185
- if (!fcmToken) { console.warn("No FCM token available"); return; }
186
-
187
- // Whitelist the capability natively before enabling on the host, so an FCM
188
- // from the host can't arrive in the gap before our useEffect syncs.
189
- const enabledNow = CAPABILITIES
190
- .map((c) => c.capability)
191
- .filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
192
- await Device.setEnabledCapabilities({ capabilities: enabledNow });
193
-
194
- const method = definition.enableMethod ?? "device.capability.enable";
195
- const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
196
- await request(method, params);
197
- setCapabilityEnabled(definition.capability, true);
82
+ await refresh();
198
83
  } catch (err) {
199
84
  console.error(`Failed to toggle ${definition.capability}:`, err);
200
85
  } finally {
201
- setTogglingCapability(null);
86
+ setBusyCapability(null);
202
87
  }
203
88
  }
204
89
 
205
90
  const visibleGroups = CAPABILITY_GROUPS
206
- .map((group) => ({ group, items: CAPABILITIES.filter((d) => d.group === group && isCapabilityVisible(d)) }))
91
+ .map((group) => ({
92
+ group,
93
+ items: CAPABILITIES.filter((d) => {
94
+ const s = statuses.get(d.capability);
95
+ return d.group === group && s?.supported;
96
+ }),
97
+ }))
207
98
  .filter((g) => g.items.length > 0);
208
99
 
209
100
  if (!isNative || visibleGroups.length === 0) return null;
210
101
 
211
- const switchModal = confirmingSwitchCapability && createPortal(
212
- <div className="confirm-modal-overlay" onClick={() => setConfirmingSwitchCapability(null)}>
213
- <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
214
- <h2 className="confirm-modal-title">Switch {confirmingSwitchCapability.label} to this device?</h2>
215
- <p className="confirm-modal-message">
216
- {confirmingSwitchCapability.label} is currently enabled on another device. Switching will make this device the one the host uses for {confirmingSwitchCapability.label}, and it will be disabled on the other device.
217
- </p>
218
- <div className="confirm-modal-actions">
219
- <button className="btn btn-secondary" onClick={() => setConfirmingSwitchCapability(null)}>Cancel</button>
220
- <button
221
- className="btn btn-primary"
222
- onClick={() => {
223
- const d = confirmingSwitchCapability;
224
- setConfirmingSwitchCapability(null);
225
- toggleCapability(d, true);
226
- }}
227
- >
228
- Switch
229
- </button>
230
- </div>
231
- </div>
232
- </div>,
233
- document.body,
234
- );
235
-
236
- const disableModal = confirmingDisableCapability && createPortal(
237
- <div className="confirm-modal-overlay" onClick={() => setConfirmingDisableCapability(null)}>
238
- <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
239
- <h2 className="confirm-modal-title">Disable {confirmingDisableCapability.label}?</h2>
240
- <p className="confirm-modal-message">
241
- Agents running on the host will no longer be able to use {confirmingDisableCapability.label} until it is re-enabled on a device.
242
- </p>
243
- <div className="confirm-modal-actions">
244
- <button className="btn btn-secondary" onClick={() => setConfirmingDisableCapability(null)}>Cancel</button>
245
- <button
246
- className="btn btn-danger"
247
- onClick={() => {
248
- const d = confirmingDisableCapability;
249
- setConfirmingDisableCapability(null);
250
- toggleCapability(d, true);
251
- }}
252
- >
253
- Disable
254
- </button>
255
- </div>
256
- </div>
257
- </div>,
258
- document.body,
259
- );
260
-
261
102
  return (
262
103
  <>
263
104
  {visibleGroups.map(({ group, items }, index) => (
264
105
  <div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
265
106
  {items.map((definition) => {
266
- const enabled = isCapabilityEnabled(definition.capability);
107
+ const status = statuses.get(definition.capability);
108
+ const on = !!status?.enabled;
267
109
  return (
268
110
  <label key={definition.capability} className="drawer-toggle">
269
111
  <span className="drawer-toggle-label">{definition.label}</span>
270
112
  <button
271
- className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
113
+ className={`toggle-switch ${on ? "toggle-switch-on" : ""}`}
272
114
  onClick={() => toggleCapability(definition)}
273
- disabled={togglingCapability === definition.capability}
115
+ disabled={busyCapability === definition.capability}
274
116
  role="switch"
275
- aria-checked={enabled}
117
+ aria-checked={on}
276
118
  >
277
119
  <span className="toggle-switch-thumb" />
278
120
  </button>
@@ -281,8 +123,6 @@ export default function CapabilityToggles({ capabilityTokens, activeClientToken,
281
123
  })}
282
124
  </div>
283
125
  ))}
284
- {switchModal}
285
- {disableModal}
286
126
  </>
287
127
  );
288
128
  }
@@ -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
  );