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
@@ -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
@@ -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
 
@@ -140,6 +140,17 @@ Client tokens are stored on the host in `~/.config/palmier/clients.json`. Each t
140
140
 
141
141
  If no clients exist, the host skips client validation (backward compatibility for unpaired hosts).
142
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
+
143
154
  ### 2.4 NATS Communication
144
155
 
145
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.
@@ -152,7 +163,10 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
152
163
 
153
164
  | Method | Params | Description |
154
165
  |---|---|---|
155
- | `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. |
156
170
  | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
157
171
  | `task.get` | `id` | Get a single task with frontmatter and current status. |
158
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. |
@@ -294,7 +308,7 @@ Task lifecycle status is persisted to a `status.json` file in the task directory
294
308
 
295
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.
296
310
 
297
- 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.
298
312
 
299
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.
300
314
 
@@ -320,7 +334,7 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
320
334
 
321
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.
322
336
 
323
- 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.
324
338
 
325
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.
326
340
 
@@ -330,12 +344,24 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
330
344
 
331
345
  ### 4.2 UI Layout: Sessions & Tasks Tabs
332
346
 
333
- 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.
334
360
 
335
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.
336
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).
337
363
 
338
- 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.
339
365
 
340
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.
341
367
 
@@ -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
+ }