palmier 0.6.5 → 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 (57) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent-instructions.md +28 -6
  3. package/dist/agents/agent.js +6 -3
  4. package/dist/agents/hermes.d.ts +9 -0
  5. package/dist/agents/hermes.js +35 -0
  6. package/dist/commands/plan-generation.md +1 -0
  7. package/dist/commands/run.js +3 -3
  8. package/dist/location-device.d.ts +8 -0
  9. package/dist/location-device.js +32 -0
  10. package/dist/mcp-handler.d.ts +8 -0
  11. package/dist/mcp-handler.js +110 -0
  12. package/dist/mcp-tools.d.ts +22 -0
  13. package/dist/mcp-tools.js +152 -0
  14. package/dist/pwa/assets/{index-DhvJN8ie.css → index-DAI3J-jU.css} +1 -1
  15. package/dist/pwa/assets/index-RrJvjqz9.js +118 -0
  16. package/dist/pwa/assets/web-DQteXlI7.js +1 -0
  17. package/dist/pwa/assets/web-EzNEHXEh.js +1 -0
  18. package/dist/pwa/index.html +3 -3
  19. package/dist/pwa/service-worker.js +2 -2
  20. package/dist/rpc-handler.js +23 -15
  21. package/dist/transports/http-transport.js +61 -129
  22. package/package.json +1 -1
  23. package/palmier-server/README.md +6 -1
  24. package/palmier-server/package.json +7 -1
  25. package/palmier-server/pnpm-lock.yaml +1025 -1
  26. package/palmier-server/pwa/index.html +1 -1
  27. package/palmier-server/pwa/package.json +3 -0
  28. package/palmier-server/pwa/src/App.css +55 -0
  29. package/palmier-server/pwa/src/api.ts +8 -2
  30. package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
  31. package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
  32. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
  33. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
  34. package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
  35. package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
  36. package/palmier-server/pwa/src/service-worker.ts +7 -7
  37. package/palmier-server/server/.env.example +4 -0
  38. package/palmier-server/server/package.json +1 -0
  39. package/palmier-server/server/src/db.ts +10 -0
  40. package/palmier-server/server/src/fcm.ts +74 -0
  41. package/palmier-server/server/src/index.ts +101 -21
  42. package/palmier-server/server/src/notify.ts +34 -0
  43. package/palmier-server/server/src/push.ts +1 -1
  44. package/palmier-server/server/src/routes/fcm.ts +64 -0
  45. package/palmier-server/server/src/routes/push.ts +6 -5
  46. package/palmier-server/spec.md +4 -2
  47. package/src/agents/agent-instructions.md +28 -6
  48. package/src/agents/agent.ts +6 -3
  49. package/src/agents/hermes.ts +38 -0
  50. package/src/commands/plan-generation.md +1 -0
  51. package/src/commands/run.ts +3 -3
  52. package/src/location-device.ts +35 -0
  53. package/src/mcp-handler.ts +133 -0
  54. package/src/mcp-tools.ts +182 -0
  55. package/src/rpc-handler.ts +24 -15
  56. package/src/transports/http-transport.ts +58 -128
  57. package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
@@ -70,7 +70,7 @@ async function invokeAgentWithRetries(
70
70
  console.log(`[invoke] ${command} ${displayArgs.join(" ")}${stdin ? ` (stdin: ${truncate(stdin, 100)})` : ""}`);
71
71
  const result = await spawnCommand(command, args, {
72
72
  cwd: getRunDir(ctx.taskDir, ctx.runId),
73
- env: { ...ctx.guiEnv, ...agentEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
73
+ env: { ...ctx.guiEnv, ...agentEnv, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
74
74
  echoStdout: true,
75
75
  resolveOnFailure: true,
76
76
  stdin,
@@ -324,7 +324,7 @@ async function runCommandTriggeredMode(
324
324
 
325
325
  const child = spawnStreamingCommand(commandStr, {
326
326
  cwd: getRunDir(ctx.taskDir, ctx.runId),
327
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
327
+ env: { ...ctx.guiEnv, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 9966) },
328
328
  });
329
329
 
330
330
  let linesProcessed = 0;
@@ -519,7 +519,7 @@ async function requestConfirmation(
519
519
  const res = await fetch(`http://localhost:${port}/request-confirmation`, {
520
520
  method: "POST",
521
521
  headers: { "Content-Type": "application/json" },
522
- body: JSON.stringify({ taskId: task.frontmatter.id, taskName: task.frontmatter.name }),
522
+ body: JSON.stringify({ taskId: task.frontmatter.id, description: `Run task "${task.frontmatter.name || task.frontmatter.id}"?` }),
523
523
  });
524
524
  const body = await res.json() as { confirmed?: boolean; error?: string };
525
525
  if (typeof body.confirmed !== "boolean") {
@@ -0,0 +1,35 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+
5
+ const LOCATION_FILE = path.join(CONFIG_DIR, "location-device.json");
6
+
7
+ export interface LocationDevice {
8
+ clientToken: string;
9
+ fcmToken: string;
10
+ }
11
+
12
+ export function getLocationDevice(): LocationDevice | null {
13
+ try {
14
+ if (!fs.existsSync(LOCATION_FILE)) return null;
15
+ const raw = fs.readFileSync(LOCATION_FILE, "utf-8");
16
+ const data = JSON.parse(raw) as LocationDevice;
17
+ if (!data.clientToken || !data.fcmToken) return null;
18
+ return data;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function setLocationDevice(clientToken: string, fcmToken: string): void {
25
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ fs.writeFileSync(LOCATION_FILE, JSON.stringify({ clientToken, fcmToken }, null, 2), "utf-8");
27
+ }
28
+
29
+ export function clearLocationDevice(): void {
30
+ try {
31
+ if (fs.existsSync(LOCATION_FILE)) fs.unlinkSync(LOCATION_FILE);
32
+ } catch {
33
+ // ignore
34
+ }
35
+ }
@@ -0,0 +1,133 @@
1
+ import { randomUUID } from "crypto";
2
+ import { agentTools, agentToolMap, ToolError, type ToolContext } from "./mcp-tools.js";
3
+
4
+ interface JsonRpcRequest {
5
+ jsonrpc: string;
6
+ id?: string | number | null;
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }
10
+
11
+ export interface McpResponse {
12
+ body: object;
13
+ sessionId?: string;
14
+ }
15
+
16
+ // Session-to-agent name map with 24h TTL
17
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
18
+ const sessionAgents = new Map<string, { agentName: string; expiresAt: number }>();
19
+
20
+ export function getAgentName(sessionId: string): string | undefined {
21
+ const entry = sessionAgents.get(sessionId);
22
+ if (!entry) return undefined;
23
+ if (Date.now() > entry.expiresAt) {
24
+ sessionAgents.delete(sessionId);
25
+ return undefined;
26
+ }
27
+ return entry.agentName;
28
+ }
29
+
30
+ function pruneExpiredSessions(): void {
31
+ const now = Date.now();
32
+ for (const [id, entry] of sessionAgents) {
33
+ if (now > entry.expiresAt) sessionAgents.delete(id);
34
+ }
35
+ }
36
+
37
+ function rpcError(id: string | number | null, code: number, message: string): object {
38
+ return { jsonrpc: "2.0", id, error: { code, message } };
39
+ }
40
+
41
+ function rpcResult(id: string | number | null, result: unknown): object {
42
+ return { jsonrpc: "2.0", id, result };
43
+ }
44
+
45
+ export async function handleMcpRequest(body: string, sessionId: string | undefined, ctx: ToolContext): Promise<McpResponse> {
46
+ let req: JsonRpcRequest;
47
+ try {
48
+ req = JSON.parse(body);
49
+ } catch {
50
+ return { body: rpcError(null, -32700, "Parse error") };
51
+ }
52
+
53
+ const id = req.id ?? null;
54
+
55
+ if (req.jsonrpc !== "2.0") {
56
+ return { body: rpcError(id, -32600, "Invalid Request: missing jsonrpc 2.0") };
57
+ }
58
+
59
+ const agent = sessionId ? getAgentName(sessionId) : undefined;
60
+ const sid = sessionId?.slice(0, 8) ?? "none";
61
+ const logPrefix = agent ? `[mcp] [${sid}] [${agent}]` : `[mcp] [${sid}]`;
62
+ console.log(`${logPrefix} ${req.method}${req.method === "tools/call" ? ` → ${req.params?.name}` : ""}`);
63
+
64
+ switch (req.method) {
65
+ case "initialize": {
66
+ const newSessionId = randomUUID();
67
+ const clientInfo = req.params?.clientInfo as { name?: string; version?: string } | undefined;
68
+ const agentName = clientInfo
69
+ ? `${clientInfo.name || "unknown"}${clientInfo.version ? ` ${clientInfo.version}` : ""}`
70
+ : undefined;
71
+
72
+ if (agentName) {
73
+ sessionAgents.set(newSessionId, { agentName, expiresAt: Date.now() + SESSION_TTL_MS });
74
+ pruneExpiredSessions();
75
+ }
76
+
77
+ console.log(`[mcp] [${newSessionId.slice(0, 8)}] Session initialized${agentName ? ` (${agentName})` : ""}`);
78
+ return {
79
+ body: rpcResult(id, {
80
+ protocolVersion: "2025-03-26",
81
+ capabilities: { tools: {} },
82
+ serverInfo: { name: "palmier", version: "1.0.0" },
83
+ }),
84
+ sessionId: newSessionId,
85
+ };
86
+ }
87
+
88
+ case "tools/list": {
89
+ return {
90
+ body: rpcResult(id, {
91
+ tools: agentTools.map((t) => ({
92
+ name: t.name,
93
+ description: t.description,
94
+ inputSchema: t.inputSchema,
95
+ })),
96
+ }),
97
+ };
98
+ }
99
+
100
+ case "tools/call": {
101
+ const name = req.params?.name as string | undefined;
102
+ const args = (req.params?.arguments ?? {}) as Record<string, unknown>;
103
+
104
+ if (!name) return { body: rpcError(id, -32602, "Missing params.name") };
105
+
106
+ const tool = agentToolMap.get(name);
107
+ if (!tool) return { body: rpcError(id, -32602, `Unknown tool: ${name}`) };
108
+
109
+ try {
110
+ const result = await tool.handler(args, ctx);
111
+ console.log(`${logPrefix} tools/call ${name} done:`, JSON.stringify(result).slice(0, 200));
112
+ return {
113
+ body: rpcResult(id, {
114
+ content: [{ type: "text", text: JSON.stringify(result) }],
115
+ }),
116
+ };
117
+ } catch (err: any) {
118
+ const message = err instanceof ToolError ? err.message : String(err);
119
+ console.error(`${logPrefix} tools/call ${name} error:`, message);
120
+ return {
121
+ body: rpcResult(id, {
122
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
123
+ isError: true,
124
+ }),
125
+ };
126
+ }
127
+ }
128
+
129
+ default:
130
+ console.warn(`${logPrefix} Unknown method: ${req.method}`);
131
+ return { body: rpcError(id, -32601, `Method not found: ${req.method}`) };
132
+ }
133
+ }
@@ -0,0 +1,182 @@
1
+ import { StringCodec, type NatsConnection } from "nats";
2
+ import { registerPending } from "./pending-requests.js";
3
+ import { getLocationDevice } from "./location-device.js";
4
+ import type { HostConfig } from "./types.js";
5
+
6
+ export class ToolError extends Error {
7
+ constructor(message: string, public statusCode: number = 500) {
8
+ super(message);
9
+ }
10
+ }
11
+
12
+ export interface ToolContext {
13
+ config: HostConfig;
14
+ nc: NatsConnection | undefined;
15
+ publishEvent: (id: string, payload: Record<string, unknown>) => Promise<void>;
16
+ sessionId: string;
17
+ agentName?: string;
18
+ }
19
+
20
+ export interface ToolDefinition {
21
+ name: string;
22
+ description: string;
23
+ inputSchema: object;
24
+ handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
25
+ }
26
+
27
+ const notifyTool: ToolDefinition = {
28
+ name: "notify",
29
+ description: "Send a push notification to the user's device.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ title: { type: "string", description: "Notification title" },
34
+ body: { type: "string", description: "Notification body" },
35
+ },
36
+ required: ["title", "body"],
37
+ },
38
+ async handler(args, ctx) {
39
+ const { title, body } = args as { title: string; body: string };
40
+ if (!title || !body) throw new ToolError("title and body are required", 400);
41
+ if (!ctx.nc) throw new ToolError("NATS not connected — push notifications require server mode", 503);
42
+
43
+ const sc = StringCodec();
44
+ const payload: Record<string, string> = { hostId: ctx.config.hostId, title, body };
45
+ if (ctx.sessionId) payload.session_id = ctx.sessionId;
46
+ if (ctx.agentName) payload.agent_name = ctx.agentName;
47
+ const subject = `host.${ctx.config.hostId}.push.send`;
48
+ const reply = await ctx.nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
49
+ const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
50
+
51
+ if (result.ok) return { ok: true };
52
+ throw new ToolError(result.error ?? "Push notification failed", 502);
53
+ },
54
+ };
55
+
56
+ const requestInputTool: ToolDefinition = {
57
+ name: "request-input",
58
+ description: "Request input from the user. The request blocks until the user responds.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ description: { type: "string", description: "Context or heading for the input request" },
63
+ questions: {
64
+ type: "array",
65
+ items: { type: "string" },
66
+ description: "Questions to present to the user",
67
+ minItems: 1,
68
+ },
69
+ },
70
+ required: ["questions"],
71
+ },
72
+ async handler(args, ctx) {
73
+ const { description, questions } = args as { description?: string; questions: string[] };
74
+ if (!questions?.length) throw new ToolError("questions is required", 400);
75
+
76
+ const pendingPromise = registerPending(ctx.sessionId, "input", questions);
77
+
78
+ await ctx.publishEvent("_input", {
79
+ event_type: "input-request",
80
+ host_id: ctx.config.hostId,
81
+ session_id: ctx.sessionId,
82
+ agent_name: ctx.agentName,
83
+ description,
84
+ input_questions: questions,
85
+ });
86
+
87
+ const response = await pendingPromise;
88
+
89
+ if (response.length === 1 && response[0] === "aborted") {
90
+ await ctx.publishEvent("_input", { event_type: "input-resolved", host_id: ctx.config.hostId, session_id: ctx.sessionId, status: "aborted" });
91
+ return { aborted: true };
92
+ }
93
+
94
+ await ctx.publishEvent("_input", { event_type: "input-resolved", host_id: ctx.config.hostId, session_id: ctx.sessionId, status: "provided" });
95
+ return { values: response };
96
+ },
97
+ };
98
+
99
+ const requestConfirmationTool: ToolDefinition = {
100
+ name: "request-confirmation",
101
+ description: "Request confirmation from the user. The request blocks until the user confirms or aborts.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ description: { type: "string", description: "What the user is confirming" },
106
+ },
107
+ required: ["description"],
108
+ },
109
+ async handler(args, ctx) {
110
+ const { description } = args as { description: string };
111
+ if (!description) throw new ToolError("description is required", 400);
112
+
113
+ const pendingPromise = registerPending(ctx.sessionId, "confirmation");
114
+
115
+ await ctx.publishEvent("_confirm", {
116
+ event_type: "confirm-request",
117
+ host_id: ctx.config.hostId,
118
+ session_id: ctx.sessionId,
119
+ agent_name: ctx.agentName,
120
+ description,
121
+ });
122
+
123
+ const response = await pendingPromise;
124
+ const confirmed = response[0] === "confirmed";
125
+
126
+ await ctx.publishEvent("_confirm", {
127
+ event_type: "confirm-resolved",
128
+ host_id: ctx.config.hostId,
129
+ session_id: ctx.sessionId,
130
+ status: confirmed ? "confirmed" : "aborted",
131
+ });
132
+
133
+ return { confirmed };
134
+ },
135
+ };
136
+
137
+ const deviceGeolocationTool: ToolDefinition = {
138
+ name: "device-geolocation",
139
+ description: "Get the GPS location of the user's mobile device. Blocks until the device responds (up to 30 seconds).",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {},
143
+ },
144
+ async handler(_args, ctx) {
145
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
146
+
147
+ const locDevice = getLocationDevice();
148
+ if (!locDevice) throw new ToolError("No device has location access enabled", 400);
149
+
150
+ const sc = StringCodec();
151
+
152
+ const ackReply = await ctx.nc.request(
153
+ `host.${ctx.config.hostId}.fcm.geolocation`,
154
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })),
155
+ { timeout: 5_000 },
156
+ );
157
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
158
+ if (ack.error) throw new ToolError(ack.error, 502);
159
+
160
+ const locationPromise = new Promise<string>((resolve, reject) => {
161
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.geolocation.${ctx.sessionId}`, { max: 1 });
162
+ const timer = setTimeout(() => {
163
+ sub.unsubscribe();
164
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
165
+ }, 30_000);
166
+
167
+ (async () => {
168
+ for await (const msg of sub) {
169
+ clearTimeout(timer);
170
+ resolve(sc.decode(msg.data));
171
+ }
172
+ })();
173
+ });
174
+
175
+ const locationData = JSON.parse(await locationPromise);
176
+ if (locationData.error) return { error: locationData.error };
177
+ return locationData;
178
+ },
179
+ };
180
+
181
+ export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
182
+ export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
@@ -13,6 +13,7 @@ import crossSpawn from "cross-spawn";
13
13
  import { getAgent } from "./agents/agent.js";
14
14
  import { validateClient } from "./client-store.js";
15
15
  import { publishHostEvent } from "./events.js";
16
+ import { getLocationDevice, setLocationDevice, clearLocationDevice } from "./location-device.js";
16
17
  import { currentVersion, performUpdate } from "./update-checker.js";
17
18
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
18
19
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
@@ -166,27 +167,29 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
166
167
  body: task.body,
167
168
  status: status ? {
168
169
  ...status,
169
- ...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
170
170
  ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
171
- ...(pending?.type === "input" ? { pending_input: pending.params } : {}),
172
171
  } : undefined,
173
172
  };
174
173
  }
175
174
 
176
175
  async function handleRpc(request: RpcMessage): Promise<unknown> {
177
- // Client token validation: skip for trusted localhost requests
178
- if (!request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
176
+ // Client token validation: skip for trusted localhost requests and
177
+ // task.user_input (server-originated push responses; gated by getPending instead)
178
+ const skipAuth = request.method === "task.user_input";
179
+ if (!skipAuth && !request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
179
180
  return { error: "Unauthorized" };
180
181
  }
181
182
 
182
183
  switch (request.method) {
183
184
  case "task.list": {
184
185
  const tasks = listTasks(config.projectRoot);
186
+ const locDevice = getLocationDevice();
185
187
  return {
186
188
  tasks: tasks.map((task) => flattenTask(task)),
187
189
  agents: config.agents ?? [],
188
190
  version: currentVersion,
189
191
  host_platform: process.platform,
192
+ location_client_token: locDevice?.clientToken ?? null,
190
193
  };
191
194
  }
192
195
 
@@ -382,15 +385,10 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
382
385
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
383
386
  const platform = getPlatform();
384
387
 
385
- // Check if the task is already running
388
+ // If the task is already running, kill the stale process and start fresh
386
389
  if (platform.isTaskRunning(params.id)) {
387
- // Ensure status reflects reality
388
- const currentStatus = readTaskStatus(runTaskDir);
389
- if (currentStatus?.running_state !== "started") {
390
- writeTaskStatus(runTaskDir, { running_state: "started", time_stamp: Date.now() });
391
- await publishHostEvent(nc, config.hostId, params.id, { event_type: "running-state", running_state: "started" });
392
- }
393
- return { error: "Task is already running" };
390
+ console.log(`[task.run] Task ${params.id} is already running, killing stale process`);
391
+ await platform.stopTask(params.id);
394
392
  }
395
393
 
396
394
  // Create initial result file so it appears in runs list immediately
@@ -445,7 +443,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
445
443
  const child = crossSpawn(cmd, cmdArgs, {
446
444
  cwd: followupRunDir,
447
445
  stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
448
- env: { ...process.env, ...followupAgentEnv, PALMIER_TASK_ID: params.id },
446
+ env: { ...process.env, ...followupAgentEnv },
449
447
  windowsHide: true,
450
448
  });
451
449
  if (stdin != null) child.stdin!.end(stdin);
@@ -582,9 +580,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
582
580
  return {
583
581
  task_id: params.id,
584
582
  ...status,
585
- ...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
586
583
  ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
587
- ...(pending?.type === "input" ? { pending_input: pending.params } : {}),
588
584
  };
589
585
  }
590
586
 
@@ -695,6 +691,19 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
695
691
  return { ok: true };
696
692
  }
697
693
 
694
+ case "device.location.enable": {
695
+ const params = request.params as { fcmToken: string };
696
+ if (!params.fcmToken) return { error: "fcmToken is required" };
697
+ const clientToken = request.clientToken ?? "";
698
+ setLocationDevice(clientToken, params.fcmToken);
699
+ return { ok: true };
700
+ }
701
+
702
+ case "device.location.disable": {
703
+ clearLocationDevice();
704
+ return { ok: true };
705
+ }
706
+
698
707
  default:
699
708
  return { error: `Unknown method: ${request.method}` };
700
709
  }