palmier 0.6.6 → 0.6.7

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent-instructions.md +28 -6
  3. package/dist/commands/plan-generation.md +1 -0
  4. package/dist/commands/run.js +3 -3
  5. package/dist/location-device.d.ts +8 -0
  6. package/dist/location-device.js +32 -0
  7. package/dist/mcp-handler.d.ts +8 -0
  8. package/dist/mcp-handler.js +110 -0
  9. package/dist/mcp-tools.d.ts +22 -0
  10. package/dist/mcp-tools.js +152 -0
  11. package/dist/pwa/assets/{index-DhvJN8ie.css → index-DAI3J-jU.css} +1 -1
  12. package/dist/pwa/assets/index-RrJvjqz9.js +118 -0
  13. package/dist/pwa/assets/web-DQteXlI7.js +1 -0
  14. package/dist/pwa/assets/web-EzNEHXEh.js +1 -0
  15. package/dist/pwa/index.html +3 -3
  16. package/dist/pwa/service-worker.js +2 -2
  17. package/dist/rpc-handler.js +20 -7
  18. package/dist/transports/http-transport.js +61 -129
  19. package/package.json +1 -1
  20. package/palmier-server/README.md +6 -1
  21. package/palmier-server/package.json +7 -1
  22. package/palmier-server/pnpm-lock.yaml +1025 -1
  23. package/palmier-server/pwa/index.html +1 -1
  24. package/palmier-server/pwa/package.json +3 -0
  25. package/palmier-server/pwa/src/App.css +55 -0
  26. package/palmier-server/pwa/src/api.ts +8 -2
  27. package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
  28. package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
  29. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
  30. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
  31. package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
  32. package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
  33. package/palmier-server/pwa/src/service-worker.ts +7 -7
  34. package/palmier-server/server/.env.example +4 -0
  35. package/palmier-server/server/package.json +1 -0
  36. package/palmier-server/server/src/db.ts +10 -0
  37. package/palmier-server/server/src/fcm.ts +74 -0
  38. package/palmier-server/server/src/index.ts +101 -21
  39. package/palmier-server/server/src/notify.ts +34 -0
  40. package/palmier-server/server/src/push.ts +1 -1
  41. package/palmier-server/server/src/routes/fcm.ts +64 -0
  42. package/palmier-server/server/src/routes/push.ts +6 -5
  43. package/palmier-server/spec.md +4 -2
  44. package/src/agents/agent-instructions.md +28 -6
  45. package/src/commands/plan-generation.md +1 -0
  46. package/src/commands/run.ts +3 -3
  47. package/src/location-device.ts +35 -0
  48. package/src/mcp-handler.ts +133 -0
  49. package/src/mcp-tools.ts +182 -0
  50. package/src/rpc-handler.ts +21 -7
  51. package/src/transports/http-transport.ts +58 -128
  52. package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
@@ -7,7 +7,7 @@
7
7
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
8
8
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
9
9
  <title>Palmier</title>
10
- <meta name="description" content="Control AI agents running on your machine from any device. Schedule tasks, monitor runs, and stay in control." />
10
+ <meta name="description" content="Remote control for AI agents running on your own machine. Schedule tasks, approve permissions, and get push notifications." />
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -9,6 +9,9 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
+ "@capacitor/app": "^8.1.0",
13
+ "@capacitor/core": "^8.3.0",
14
+ "@capacitor/preferences": "^8.0.1",
12
15
  "@fontsource-variable/plus-jakarta-sans": "^5.2.8",
13
16
  "nats.ws": "^1.30.0",
14
17
  "react": "^19.0.0",
@@ -1308,6 +1308,12 @@ body {
1308
1308
  font-size: 1.125rem;
1309
1309
  font-weight: 700;
1310
1310
  letter-spacing: -0.02em;
1311
+ margin-bottom: var(--space-xs);
1312
+ }
1313
+
1314
+ .confirm-modal-subtitle {
1315
+ font-size: 0.8rem;
1316
+ color: var(--color-muted);
1311
1317
  margin-bottom: var(--space-sm);
1312
1318
  }
1313
1319
 
@@ -1681,6 +1687,55 @@ body {
1681
1687
  margin-bottom: var(--space-sm);
1682
1688
  }
1683
1689
 
1690
+ .drawer-toggle {
1691
+ display: flex;
1692
+ align-items: center;
1693
+ justify-content: space-between;
1694
+ gap: var(--space-sm);
1695
+ }
1696
+
1697
+ .drawer-toggle-label {
1698
+ font-size: 0.85rem;
1699
+ color: var(--color-text);
1700
+ }
1701
+
1702
+ .toggle-switch {
1703
+ position: relative;
1704
+ width: 40px;
1705
+ height: 22px;
1706
+ border-radius: 11px;
1707
+ border: none;
1708
+ background: var(--color-border);
1709
+ cursor: pointer;
1710
+ padding: 0;
1711
+ transition: background 0.2s;
1712
+ flex-shrink: 0;
1713
+ }
1714
+
1715
+ .toggle-switch-on {
1716
+ background: var(--color-primary);
1717
+ }
1718
+
1719
+ .toggle-switch-thumb {
1720
+ position: absolute;
1721
+ top: 2px;
1722
+ left: 2px;
1723
+ width: 18px;
1724
+ height: 18px;
1725
+ border-radius: 50%;
1726
+ background: white;
1727
+ transition: transform 0.2s;
1728
+ }
1729
+
1730
+ .toggle-switch-on .toggle-switch-thumb {
1731
+ transform: translateX(18px);
1732
+ }
1733
+
1734
+ .toggle-switch:disabled {
1735
+ opacity: 0.5;
1736
+ cursor: not-allowed;
1737
+ }
1738
+
1684
1739
  .drawer-footer {
1685
1740
  margin-top: auto;
1686
1741
  padding: var(--space-md);
@@ -1,17 +1,23 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+
3
+ /** On native platforms, API calls go to the production server. On web, they're relative (same-origin). */
4
+ export const SERVER_URL = Capacitor.isNativePlatform() ? "https://app.palmier.me" : "";
5
+
1
6
  async function request<T>(
2
7
  method: string,
3
8
  path: string,
4
9
  body?: unknown,
5
10
  token?: string
6
11
  ): Promise<T> {
7
- console.log(`[API] ${method} ${path}`);
12
+ const url = `${SERVER_URL}${path}`;
13
+ console.log(`[API] ${method} ${url}`);
8
14
  const headers: Record<string, string> = {
9
15
  "Content-Type": "application/json",
10
16
  };
11
17
  if (token) {
12
18
  headers["Authorization"] = `Bearer ${token}`;
13
19
  }
14
- const res = await fetch(path, {
20
+ const res = await fetch(url, {
15
21
  method,
16
22
  headers,
17
23
  body: body != null ? JSON.stringify(body) : undefined,
@@ -1,17 +1,39 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useNavigate } from "react-router-dom";
4
+ import { Capacitor, registerPlugin } from "@capacitor/core";
5
+ import { App as CapApp } from "@capacitor/app";
6
+ import { Preferences } from "@capacitor/preferences";
7
+
8
+ interface LocationPermissionResult {
9
+ fine: boolean;
10
+ background: boolean;
11
+ }
12
+
13
+ interface LocationPermissionPlugin {
14
+ request(): Promise<LocationPermissionResult>;
15
+ check(): Promise<LocationPermissionResult>;
16
+ }
17
+
18
+ const LocationPermission = Capacitor.isNativePlatform()
19
+ ? registerPlugin<LocationPermissionPlugin>("LocationPermission")
20
+ : null;
4
21
  import { useHostStore } from "../contexts/HostStoreContext";
5
22
  import { useMediaQuery } from "../hooks/useMediaQuery";
6
23
 
7
24
  /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
8
25
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
26
+ const isNative = Capacitor.isNativePlatform();
9
27
 
10
28
  interface HostMenuProps {
11
29
  daemonVersion?: string | null;
30
+ locationClientToken?: string | null;
31
+ activeClientToken?: string | null;
32
+ request?<T = unknown>(method: string, params?: unknown): Promise<T>;
33
+ onLocationClientTokenChange?(token: string | null): void;
12
34
  }
13
35
 
14
- export default function HostMenu({ daemonVersion }: HostMenuProps) {
36
+ export default function HostMenu({ daemonVersion, locationClientToken, activeClientToken, request, onLocationClientTokenChange }: HostMenuProps) {
15
37
  const { pairedHosts, activeHostId, setActiveHostId, removePairedHost, renamePairedHost } = useHostStore();
16
38
  const navigate = useNavigate();
17
39
  const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -21,6 +43,65 @@ export default function HostMenu({ daemonVersion }: HostMenuProps) {
21
43
  const [renamingId, setRenamingId] = useState<string | null>(null);
22
44
  const [renameValue, setRenameValue] = useState("");
23
45
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
46
+ const [togglingLocation, setTogglingLocation] = useState(false);
47
+
48
+ const locationEnabled = !!(activeClientToken && locationClientToken === activeClientToken);
49
+
50
+ // Sync location toggle with permission state — on mount and when app resumes from background
51
+ useEffect(() => {
52
+ if (!isNative || !LocationPermission || !request) return;
53
+
54
+ function syncPermissionState() {
55
+ if (!locationEnabled) return;
56
+ LocationPermission!.check().then(({ fine }) => {
57
+ if (!fine) {
58
+ // Permission revoked — disable on host
59
+ request!("device.location.disable").then(() => {
60
+ onLocationClientTokenChange?.(null);
61
+ }).catch(() => {});
62
+ }
63
+ });
64
+ }
65
+
66
+ syncPermissionState();
67
+
68
+ const listener = CapApp.addListener("resume", () => {
69
+ syncPermissionState();
70
+ });
71
+
72
+ return () => { listener.then((h) => h.remove()); };
73
+ }, [locationEnabled, activeClientToken]);
74
+
75
+ async function handleLocationToggle() {
76
+ if (!request) return;
77
+ setTogglingLocation(true);
78
+ try {
79
+ if (locationEnabled) {
80
+ await request("device.location.disable");
81
+ onLocationClientTokenChange?.(null);
82
+ } else {
83
+ // Request location permissions before enabling
84
+ if (LocationPermission) {
85
+ const result = await LocationPermission.request();
86
+ if (!result.fine) {
87
+ return; // User denied permission
88
+ }
89
+ }
90
+
91
+ const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
92
+ if (!fcmToken) {
93
+ console.warn("No FCM token available");
94
+ return;
95
+ }
96
+ await request("device.location.enable", { fcmToken });
97
+ onLocationClientTokenChange?.(activeClientToken ?? null);
98
+ }
99
+ } catch (err) {
100
+ console.error("Failed to toggle location:", err);
101
+ } finally {
102
+ setTogglingLocation(false);
103
+ }
104
+ }
24
105
  const drawerRef = useRef<HTMLDivElement>(null);
25
106
  const renameInputRef = useRef<HTMLInputElement>(null);
26
107
 
@@ -197,6 +278,26 @@ export default function HostMenu({ daemonVersion }: HostMenuProps) {
197
278
  </div>
198
279
  </>)}
199
280
 
281
+ {isNative && (
282
+ <>
283
+ <div className="drawer-divider" />
284
+ <div className="drawer-section">
285
+ <label className="drawer-toggle">
286
+ <span className="drawer-toggle-label">Location Access</span>
287
+ <button
288
+ className={`toggle-switch ${locationEnabled ? "toggle-switch-on" : ""}`}
289
+ onClick={handleLocationToggle}
290
+ disabled={togglingLocation}
291
+ role="switch"
292
+ aria-checked={locationEnabled}
293
+ >
294
+ <span className="toggle-switch-thumb" />
295
+ </button>
296
+ </label>
297
+ </div>
298
+ </>
299
+ )}
300
+
200
301
  <div className="drawer-footer">
201
302
  {daemonVersion && (
202
303
  <div className="drawer-version">
@@ -26,16 +26,17 @@ interface TaskListViewProps {
26
26
  onViewRun(taskId: string, runId?: string): void;
27
27
  onUpdateRequired?(required: boolean): void;
28
28
  onVersion?(version: string | null): void;
29
+ onLocationClientToken?(token: string | null): void;
29
30
  }
30
31
 
31
- export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion }: TaskListViewProps) {
32
+ export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onLocationClientToken }: TaskListViewProps) {
32
33
  const [tasks, setTasks] = useState<Task[]>([]);
33
34
  const [loadingTasks, setLoadingTasks] = useState(false);
34
35
  const [taskError, setTaskError] = useState<string | null>(null);
35
36
  const [taskEvents, setTaskStatuss] = useState<Map<string, TaskStatus>>(new Map());
36
- const [pendingConfirms, setPendingConfirms] = useState<Set<string>>(new Set());
37
+ const [pendingConfirms, setPendingConfirms] = useState<Map<string, { description: string; agentName?: string }>>(new Map());
37
38
  const [pendingPermissions, setPendingPermissions] = useState<Map<string, RequiredPermission[]>>(new Map());
38
- const [pendingInputs, setPendingInputs] = useState<Map<string, string[]>>(new Map());
39
+ const [pendingInputs, setPendingInputs] = useState<Map<string, { questions: string[]; description?: string; agentName?: string }>>(new Map());
39
40
  const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
40
41
 
41
42
  const [showForm, setShowForm] = useState(false);
@@ -52,20 +53,20 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
52
53
  setLoadingTasks(true);
53
54
  setTaskError(null);
54
55
  try {
55
- const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string }>("task.list");
56
+ const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null }>("task.list");
56
57
  const taskList = result.tasks ?? [];
57
58
  const initialEvents = new Map<string, TaskStatus>();
58
- const initialConfirms = new Set<string>();
59
+ const initialConfirms = new Map<string, { description: string; agentName?: string }>();
59
60
  const initialPerms = new Map<string, RequiredPermission[]>();
60
- const initialInputs = new Map<string, string[]>();
61
+ const initialInputs = new Map<string, { questions: string[]; description?: string; agentName?: string }>();
61
62
  const initialInputVals = new Map<string, string[]>();
62
63
  for (const t of taskList) {
63
64
  if (t.status) {
64
65
  initialEvents.set(t.id, t.status);
65
- if (t.status.pending_confirmation) initialConfirms.add(t.id);
66
+ // pending_confirmation no longer comes from task.list (confirmation is sessionId-based now)
66
67
  if (t.status.pending_permission?.length) initialPerms.set(t.id, t.status.pending_permission);
67
68
  if (t.status.pending_input?.length) {
68
- initialInputs.set(t.id, t.status.pending_input);
69
+ initialInputs.set(t.id, { questions: t.status.pending_input });
69
70
  initialInputVals.set(t.id, new Array(t.status.pending_input.length).fill(""));
70
71
  }
71
72
  }
@@ -81,6 +82,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
81
82
  setAgentLabels(result.agents ?? []);
82
83
  const version = result.version ?? null;
83
84
  onVersion?.(version);
85
+ onLocationClientToken?.(result.location_client_token ?? null);
84
86
  onUpdateRequired?.(!!version && isOlderThan(version, MIN_HOST_VERSION));
85
87
  } catch (err) {
86
88
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -109,43 +111,81 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
109
111
  if (tokens.length < 3) return;
110
112
  const taskId = tokens.slice(2).join(".");
111
113
 
112
- let eventType: string | undefined;
114
+ let parsed: Record<string, unknown> = {};
113
115
  try {
114
- const parsed = JSON.parse(sc.decode(msg.data)) as { event_type?: string };
115
- eventType = parsed.event_type;
116
+ parsed = JSON.parse(sc.decode(msg.data)) as Record<string, unknown>;
116
117
  } catch { return; }
118
+ const eventType = parsed.event_type as string | undefined;
119
+ const sessionId = parsed.session_id as string | undefined;
117
120
 
118
- // Handle confirm-resolved: close pending confirmation dialog
119
- if (eventType === "confirm-resolved") {
120
- setPendingConfirms((prev) => {
121
- if (!prev.has(taskId)) return prev;
122
- const next = new Set(prev);
123
- next.delete(taskId);
124
- return next;
125
- });
121
+ // Handle input-request: show standalone input dialog (keyed by sessionId)
122
+ if (eventType === "input-request" && sessionId) {
123
+ const questions = parsed.input_questions as string[];
124
+ const agentName = parsed.agent_name as string | undefined;
125
+ const description = parsed.description as string | undefined;
126
+ if (questions?.length) {
127
+ setPendingInputs((prev) => {
128
+ if (prev.has(sessionId)) return prev;
129
+ const next = new Map(prev);
130
+ next.set(sessionId, { questions, description, agentName });
131
+ return next;
132
+ });
133
+ setInputValues((prev) => {
134
+ if (prev.has(sessionId)) return prev;
135
+ const next = new Map(prev);
136
+ next.set(sessionId, new Array(questions.length).fill(""));
137
+ return next;
138
+ });
139
+ }
126
140
  return;
127
141
  }
128
142
 
129
- // Handle permission-resolved: close pending permission dialog
130
- if (eventType === "permission-resolved") {
131
- setPendingPermissions((prev) => {
132
- if (!prev.has(taskId)) return prev;
143
+ // Handle input-resolved: close pending input dialog
144
+ if (eventType === "input-resolved" && sessionId) {
145
+ setPendingInputs((prev) => {
146
+ if (!prev.has(sessionId)) return prev;
133
147
  const next = new Map(prev);
134
- next.delete(taskId);
148
+ next.delete(sessionId);
149
+ return next;
150
+ });
151
+ setInputValues((prev) => {
152
+ const next = new Map(prev);
153
+ next.delete(sessionId);
135
154
  return next;
136
155
  });
137
156
  return;
138
157
  }
139
158
 
140
- // Handle input-resolved: close pending input dialog
141
- if (eventType === "input-resolved") {
142
- setPendingInputs((prev) => {
143
- if (!prev.has(taskId)) return prev;
159
+ // Handle confirm-request: show standalone confirmation dialog (keyed by sessionId)
160
+ if (eventType === "confirm-request" && sessionId) {
161
+ const description = parsed.description as string;
162
+ const agentName = parsed.agent_name as string | undefined;
163
+ if (description) {
164
+ setPendingConfirms((prev) => {
165
+ if (prev.has(sessionId)) return prev;
166
+ const next = new Map(prev);
167
+ next.set(sessionId, { description, agentName });
168
+ return next;
169
+ });
170
+ }
171
+ return;
172
+ }
173
+
174
+ // Handle confirm-resolved: close pending confirmation dialog
175
+ if (eventType === "confirm-resolved" && sessionId) {
176
+ setPendingConfirms((prev) => {
177
+ if (!prev.has(sessionId)) return prev;
144
178
  const next = new Map(prev);
145
- next.delete(taskId);
179
+ next.delete(sessionId);
146
180
  return next;
147
181
  });
148
- setInputValues((prev) => {
182
+ return;
183
+ }
184
+
185
+ // Handle permission-resolved: close pending permission dialog
186
+ if (eventType === "permission-resolved") {
187
+ setPendingPermissions((prev) => {
188
+ if (!prev.has(taskId)) return prev;
149
189
  const next = new Map(prev);
150
190
  next.delete(taskId);
151
191
  return next;
@@ -157,14 +197,6 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
157
197
  const status = await request<TaskStatus & { error?: string }>("task.status", { id: taskId }, { timeout: 5000 });
158
198
  if (status.error) return;
159
199
  setTaskStatuss((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
160
- if (status.pending_confirmation) {
161
- setPendingConfirms((prev) => {
162
- if (prev.has(taskId)) return prev;
163
- const next = new Set(prev);
164
- next.add(taskId);
165
- return next;
166
- });
167
- }
168
200
  if (status.pending_permission?.length) {
169
201
  setPendingPermissions((prev) => {
170
202
  if (prev.has(taskId)) return prev;
@@ -173,28 +205,14 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
173
205
  return next;
174
206
  });
175
207
  }
176
- if (status.pending_input?.length) {
177
- setPendingInputs((prev) => {
178
- if (prev.has(taskId)) return prev;
179
- const next = new Map(prev);
180
- next.set(taskId, status.pending_input!);
181
- return next;
182
- });
183
- setInputValues((prev) => {
184
- if (prev.has(taskId)) return prev;
185
- const next = new Map(prev);
186
- next.set(taskId, new Array(status.pending_input!.length).fill(""));
187
- return next;
188
- });
189
- }
190
208
  } catch { /* skip */ }
191
209
  });
192
210
  return unsubscribe;
193
211
  }, [connected, hostId, subscribeEvents, request]);
194
212
 
195
- async function respondToConfirm(taskId: string, response: "confirmed" | "aborted") {
213
+ async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
196
214
  try {
197
- await request("task.user_input", { id: taskId, value: [response] });
215
+ await request("task.user_input", { id: sessionId, value: [response] });
198
216
  } catch (err) {
199
217
  console.error("[TaskListView] Failed to respond to confirmation:", err);
200
218
  }
@@ -208,9 +226,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
208
226
  }
209
227
  }
210
228
 
211
- async function respondToInput(taskId: string, values: string[]) {
229
+ async function respondToInput(sessionId: string, values: string[]) {
212
230
  try {
213
- await request("task.user_input", { id: taskId, value: values });
231
+ await request("task.user_input", { id: sessionId, value: values });
214
232
  } catch (err) {
215
233
  console.error("[TaskListView] Failed to respond to input request:", err);
216
234
  }
@@ -300,20 +318,19 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
300
318
  )}
301
319
 
302
320
  {createPortal(<>
303
- {[...pendingConfirms].map((taskId) => {
304
- const task = tasks.find((t) => t.id === taskId);
321
+ {[...pendingConfirms.entries()].map(([sessionId, { description, agentName }]) => {
322
+ const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
305
323
  return (
306
- <div key={taskId} className="confirm-modal-overlay">
324
+ <div key={sessionId} className="confirm-modal-overlay">
307
325
  <div className="confirm-modal">
308
- <h2 className="confirm-modal-title">Task Confirmation</h2>
309
- <p className="confirm-modal-message">
310
- Run task "<strong>{task?.name || task?.user_prompt || taskId}</strong>"?
311
- </p>
326
+ <h2 className="confirm-modal-title">Confirmation Required</h2>
327
+ {subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
328
+ <p className="confirm-modal-message">{description}</p>
312
329
  <div className="confirm-modal-actions">
313
- <button className="btn btn-primary" onClick={() => respondToConfirm(taskId, "confirmed")}>
330
+ <button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
314
331
  Confirm
315
332
  </button>
316
- <button className="btn btn-secondary" onClick={() => respondToConfirm(taskId, "aborted")}>
333
+ <button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
317
334
  Abort
318
335
  </button>
319
336
  </div>
@@ -358,18 +375,17 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
358
375
  );
359
376
  })}
360
377
 
361
- {[...pendingInputs.entries()].map(([taskId, descriptions]) => {
362
- const task = tasks.find((t) => t.id === taskId);
363
- const values = inputValues.get(taskId) ?? new Array(descriptions.length).fill("");
378
+ {[...pendingInputs.entries()].map(([sessionId, { questions, description, agentName }]) => {
379
+ const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
380
+ const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
364
381
  return (
365
- <div key={taskId} className="confirm-modal-overlay">
382
+ <div key={sessionId} className="confirm-modal-overlay">
366
383
  <div className="confirm-modal input-modal">
367
384
  <h2 className="confirm-modal-title">Input Required</h2>
368
- <p className="confirm-modal-message">
369
- <strong>{task?.name || task?.user_prompt || taskId}</strong>
370
- </p>
385
+ {subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
386
+ {description && <p className="confirm-modal-message">{description}</p>}
371
387
  <div className="input-list">
372
- {descriptions.map((desc, i) => (
388
+ {questions.map((desc: string, i: number) => (
373
389
  <div key={i} className="input-item">
374
390
  <label className="input-label">{desc}</label>
375
391
  <input
@@ -379,9 +395,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
379
395
  onChange={(e) => {
380
396
  setInputValues((prev) => {
381
397
  const next = new Map(prev);
382
- const arr = [...(next.get(taskId) ?? [])];
398
+ const arr = [...(next.get(sessionId) ?? [])];
383
399
  arr[i] = e.target.value;
384
- next.set(taskId, arr);
400
+ next.set(sessionId, arr);
385
401
  return next;
386
402
  });
387
403
  }}
@@ -394,16 +410,16 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
394
410
  <button
395
411
  className="btn btn-primary"
396
412
  disabled={values.some((v) => !v.trim())}
397
- onClick={() => respondToInput(taskId, values)}
413
+ onClick={() => respondToInput(sessionId, values)}
398
414
  >
399
415
  Submit
400
416
  </button>
401
417
  </div>
402
418
  <button
403
419
  className="permission-abort-link"
404
- onClick={() => respondToInput(taskId, ["aborted"])}
420
+ onClick={() => respondToInput(sessionId, ["aborted"])}
405
421
  >
406
- Cancel & Abort Task
422
+ Cancel
407
423
  </button>
408
424
  </div>
409
425
  </div>
@@ -8,6 +8,7 @@ import {
8
8
  type ReactNode,
9
9
  } from "react";
10
10
  import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
11
+ import { SERVER_URL } from "../api";
11
12
  import { useHostStore } from "./HostStoreContext";
12
13
  import type { PairedHost } from "../types";
13
14
 
@@ -89,7 +90,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
89
90
 
90
91
  async function init() {
91
92
  try {
92
- const res = await fetch("/api/config");
93
+ const res = await fetch(`${SERVER_URL}/api/config`);
93
94
  if (!res.ok) {
94
95
  console.error("[NATS] Failed to fetch config");
95
96
  return;
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef } from "react";
2
+ import { Capacitor } from "@capacitor/core";
2
3
  import { useHostStore } from "../contexts/HostStoreContext";
3
4
  import { apiPost, apiGet } from "../api";
4
5
 
@@ -8,6 +9,8 @@ export function usePushSubscription() {
8
9
  const subscribedRef = useRef<string | null>(null);
9
10
 
10
11
  useEffect(() => {
12
+ // Native app uses FCM for notifications, not Web Push
13
+ if (Capacitor.isNativePlatform()) return;
11
14
  // Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
12
15
  if (!activeHost || activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
13
16
 
@@ -47,6 +47,7 @@ export default function Dashboard() {
47
47
  const [updating, setUpdating] = useState(false);
48
48
  const [updateError, setUpdateError] = useState<string | null>(null);
49
49
  const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
50
+ const [locationClientToken, setLocationClientToken] = useState<string | null>(null);
50
51
 
51
52
  // Register push subscription for the active host
52
53
  usePushSubscription();
@@ -83,14 +84,15 @@ export default function Dashboard() {
83
84
 
84
85
  const hasHosts = pairedHosts.length > 0;
85
86
  const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
87
+ const activeClientToken = pairedHosts.find((h) => h.hostId === activeHostId)?.clientToken ?? null;
86
88
 
87
89
  return (
88
90
  <div className="dashboard">
89
- {isDesktop && <HostMenu daemonVersion={daemonVersion} />}
91
+ {isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
90
92
 
91
93
  <div className="dashboard-content">
92
94
  <div className="tab-bar">
93
- {!isDesktop && <HostMenu daemonVersion={daemonVersion} />}
95
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
94
96
  <TabBar />
95
97
  </div>
96
98
 
@@ -139,6 +141,7 @@ export default function Dashboard() {
139
141
  onViewRun={handleViewRun}
140
142
  onUpdateRequired={setUpdateRequired}
141
143
  onVersion={setDaemonVersion}
144
+ onLocationClientToken={setLocationClientToken}
142
145
  />
143
146
  </div>
144
147
  {isRunDetail ? (
@@ -1,7 +1,10 @@
1
1
  import { useState } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
3
  import { connect, StringCodec } from "nats.ws";
4
+ import { Capacitor } from "@capacitor/core";
5
+ import { Preferences } from "@capacitor/preferences";
4
6
  import { useHostStore } from "../contexts/HostStoreContext";
7
+ import { SERVER_URL } from "../api";
5
8
  import type { PairedHost } from "../types";
6
9
 
7
10
  interface PairResponse {
@@ -49,7 +52,7 @@ export default function PairHost() {
49
52
  response = await res.json() as PairResponse;
50
53
  } else {
51
54
  // Server mode — pair via NATS
52
- const configRes = await fetch("/api/config");
55
+ const configRes = await fetch(`${SERVER_URL}/api/config`);
53
56
  if (!configRes.ok) throw new Error("Failed to fetch server config");
54
57
  const config = await configRes.json() as { natsWsUrl: string; natsToken: string };
55
58
  if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
@@ -78,6 +81,12 @@ export default function PairHost() {
78
81
  };
79
82
 
80
83
  addPairedHost(host);
84
+
85
+ // Write hostId to native SharedPreferences for FCM token registration
86
+ if (Capacitor.isNativePlatform()) {
87
+ await Preferences.set({ key: "hostId", value: response.hostId });
88
+ }
89
+
81
90
  navigate("/");
82
91
  } catch (err) {
83
92
  const message = err instanceof Error ? err.message : String(err);