palmier 0.7.3 → 0.7.6

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.
@@ -47,7 +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
+ const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
51
51
 
52
52
  // Register push subscription for the active host
53
53
  usePushSubscription();
@@ -88,11 +88,11 @@ export default function Dashboard() {
88
88
 
89
89
  return (
90
90
  <div className="dashboard">
91
- {isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
91
+ {isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
92
92
 
93
93
  <div className="dashboard-content">
94
94
  <div className="tab-bar">
95
- {!isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
95
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
96
96
  <TabBar />
97
97
  </div>
98
98
 
@@ -141,7 +141,7 @@ export default function Dashboard() {
141
141
  onViewRun={handleViewRun}
142
142
  onUpdateRequired={setUpdateRequired}
143
143
  onVersion={setDaemonVersion}
144
- onLocationClientToken={setLocationClientToken}
144
+ onCapabilityTokens={setCapabilityTokens}
145
145
  />
146
146
  </div>
147
147
  {isRunDetail ? (
@@ -391,19 +391,21 @@ async function main(): Promise<void> {
391
391
  }
392
392
  })();
393
393
 
394
- // Subscribe to alarm requests from hosts
394
+ // Subscribe to alert requests from hosts
395
395
  (async () => {
396
396
  try {
397
397
  const conn = await getNatsConnection();
398
- const sub = conn.subscribe("host.*.fcm.alarm");
399
- console.log("Listening for FCM alarm requests");
398
+ const sub = conn.subscribe("host.*.fcm.alert");
399
+ console.log("Listening for FCM alert requests");
400
400
 
401
401
  for await (const msg of sub) {
402
402
  try {
403
403
  const data = JSON.parse(sc.decode(msg.data)) as {
404
404
  hostId: string;
405
405
  requestId: string;
406
- [key: string]: string;
406
+ fcmToken?: string;
407
+ title?: string;
408
+ description?: string;
407
409
  };
408
410
 
409
411
  const subjectHostId = msg.subject.split(".")[1];
@@ -415,29 +417,32 @@ async function main(): Promise<void> {
415
417
  }
416
418
 
417
419
  const fcmPayload: Record<string, string> = {
418
- type: "set-alarm",
420
+ type: "send-alert",
419
421
  requestId: data.requestId,
420
422
  hostId: data.hostId,
421
423
  };
422
- for (const key of ["hour", "minutes", "label", "days"]) {
423
- if (data[key]) fcmPayload[key] = data[key];
424
- }
424
+ if (data.title) fcmPayload.title = data.title;
425
+ if (data.description) fcmPayload.description = data.description;
425
426
 
426
- console.log(`[FCM] Sending alarm request for host ${data.hostId}`);
427
- await sendFcmToClients(data.hostId, fcmPayload);
427
+ console.log(`[FCM] Sending alert request for host ${data.hostId}`);
428
+ if (data.fcmToken) {
429
+ await sendFcmToDevice(data.fcmToken, fcmPayload);
430
+ } else {
431
+ await sendFcmToClients(data.hostId, fcmPayload);
432
+ }
428
433
 
429
434
  if (msg.reply) {
430
435
  msg.respond(sc.encode(JSON.stringify({ ok: true })));
431
436
  }
432
437
  } catch (err) {
433
- console.error("[FCM] Error handling alarm request:", err);
438
+ console.error("[FCM] Error handling alert request:", err);
434
439
  if (msg.reply) {
435
440
  msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
436
441
  }
437
442
  }
438
443
  }
439
444
  } catch (err) {
440
- console.error("Failed to subscribe to FCM alarm requests:", err);
445
+ console.error("Failed to subscribe to FCM alert requests:", err);
441
446
  }
442
447
  })();
443
448
 
@@ -125,8 +125,8 @@ router.post("/sms-response", async (req: Request, res: Response) => {
125
125
  }
126
126
  });
127
127
 
128
- // POST /api/device/alarm-response - Receive alarm response from Android, relay to host via NATS
129
- router.post("/alarm-response", async (req: Request, res: Response) => {
128
+ // POST /api/device/alert-response - Receive alert response from Android, relay to host via NATS
129
+ router.post("/alert-response", async (req: Request, res: Response) => {
130
130
  try {
131
131
  const { requestId, hostId, result } = req.body;
132
132
 
@@ -138,13 +138,13 @@ router.post("/alarm-response", async (req: Request, res: Response) => {
138
138
  const conn = await getNatsConnection();
139
139
  const sc = StringCodec();
140
140
  conn.publish(
141
- `host.${hostId}.alarm.${requestId}`,
141
+ `host.${hostId}.alert.${requestId}`,
142
142
  sc.encode(JSON.stringify(result)),
143
143
  );
144
144
 
145
145
  res.json({ ok: true });
146
146
  } catch (err) {
147
- console.error("Device alarm response relay error:", err);
147
+ console.error("Device alert response relay error:", err);
148
148
  res.status(500).json({ error: "Internal server error" });
149
149
  }
150
150
  });
@@ -12,7 +12,7 @@ The host supports **Linux** (systemd) and **Windows** (Task Scheduler for both d
12
12
 
13
13
  ### 1.2 Components
14
14
 
15
- * **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms`, `set-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
15
+ * **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms-message`, `set-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms-messages://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
16
16
 
17
17
  * **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Subscribes to `host.*.fcm.contacts`, `host.*.fcm.calendar`, `host.*.fcm.sms`, `host.*.fcm.alarm`, `host.*.fcm.battery`, and `host.*.fcm.ringer` to relay device capability requests via FCM. Provides HTTP endpoints for Android to post responses back (`/api/device/contacts-response`, `/api/device/calendar-response`, `/api/device/sms-response`, `/api/device/alarm-response`, `/api/device/battery-response`, `/api/device/ringer-response`). Co-located with the NATS server on the same machine.
18
18
 
@@ -400,7 +400,7 @@ Resource REST endpoints are auto-generated from the `ResourceDefinition[]` regis
400
400
 
401
401
  * **`GET /notifications`** — Returns recent notifications from the user's Android device as a JSON array. Each notification contains `{ id, packageName, appName, title, text, timestamp, receivedAt }`. The host maintains a bounded in-memory collection (last 50 notifications) fed by NATS subscription to `host.<host_id>.device.notifications`.
402
402
 
403
- * **`GET /sms`** — Returns recent SMS messages from the user's Android device as a JSON array. Each message contains `{ id, sender, body, timestamp, receivedAt }`. The host maintains a bounded in-memory collection (last 50 messages) fed by NATS subscription to `host.<host_id>.device.sms`.
403
+ * **`GET /sms-messages`** — Returns recent SMS messages from the user's Android device as a JSON array. Each message contains `{ id, sender, body, timestamp, receivedAt }`. The host maintains a bounded in-memory collection (last 50 messages) fed by NATS subscription to `host.<host_id>.device.sms`.
404
404
 
405
405
  ## 7. Database Schema (PostgreSQL)
406
406
 
@@ -0,0 +1,55 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+
5
+ const CAPABILITIES_FILE = path.join(CONFIG_DIR, "device-capabilities.json");
6
+
7
+ export interface RegisteredDevice {
8
+ clientToken: string;
9
+ fcmToken: string;
10
+ }
11
+
12
+ export type DeviceCapability =
13
+ | "location"
14
+ | "notifications"
15
+ | "sms"
16
+ | "contacts"
17
+ | "calendar"
18
+ | "alert"
19
+ | "battery"
20
+ | "dnd";
21
+
22
+ type CapabilityMap = Partial<Record<DeviceCapability, RegisteredDevice>>;
23
+
24
+ function readAll(): CapabilityMap {
25
+ try {
26
+ if (!fs.existsSync(CAPABILITIES_FILE)) return {};
27
+ return JSON.parse(fs.readFileSync(CAPABILITIES_FILE, "utf-8")) as CapabilityMap;
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ function writeAll(map: CapabilityMap): void {
34
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
35
+ fs.writeFileSync(CAPABILITIES_FILE, JSON.stringify(map, null, 2), "utf-8");
36
+ }
37
+
38
+ export function getCapabilityDevice(capability: DeviceCapability): RegisteredDevice | null {
39
+ const map = readAll();
40
+ const device = map[capability];
41
+ if (!device?.clientToken || !device?.fcmToken) return null;
42
+ return device;
43
+ }
44
+
45
+ export function setCapabilityDevice(capability: DeviceCapability, clientToken: string, fcmToken: string): void {
46
+ const map = readAll();
47
+ map[capability] = { clientToken, fcmToken };
48
+ writeAll(map);
49
+ }
50
+
51
+ export function clearCapabilityDevice(capability: DeviceCapability): void {
52
+ const map = readAll();
53
+ delete map[capability];
54
+ writeAll(map);
55
+ }
package/src/mcp-tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StringCodec, type NatsConnection } from "nats";
2
2
  import { registerPending } from "./pending-requests.js";
3
- import { getLocationDevice } from "./location-device.js";
3
+ import { getCapabilityDevice } from "./device-capabilities.js";
4
4
  import { getNotifications } from "./notification-store.js";
5
5
  import { getSmsMessages } from "./sms-store.js";
6
6
  import type { HostConfig } from "./types.js";
@@ -170,14 +170,14 @@ const deviceGeolocationTool: ToolDefinition = {
170
170
  async handler(_args, ctx) {
171
171
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
172
172
 
173
- const locDevice = getLocationDevice();
174
- if (!locDevice) throw new ToolError("No device has location access enabled", 400);
173
+ const device = getCapabilityDevice("location");
174
+ if (!device) throw new ToolError("No device has location access enabled", 400);
175
175
 
176
176
  const sc = StringCodec();
177
177
 
178
178
  const ackReply = await ctx.nc.request(
179
179
  `host.${ctx.config.hostId}.fcm.geolocation`,
180
- sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })),
180
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })),
181
181
  { timeout: 5_000 },
182
182
  );
183
183
  const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
@@ -218,11 +218,14 @@ const readContactsTool: ToolDefinition = {
218
218
  async handler(_args, ctx) {
219
219
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
220
220
 
221
+ const device = getCapabilityDevice("contacts");
222
+ if (!device) throw new ToolError("No device has contacts access enabled", 400);
223
+
221
224
  const sc = StringCodec();
222
225
 
223
226
  const ackReply = await ctx.nc.request(
224
227
  `host.${ctx.config.hostId}.fcm.contacts`,
225
- sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, action: "read" })),
228
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, action: "read" })),
226
229
  { timeout: 5_000 },
227
230
  );
228
231
  const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
@@ -268,6 +271,9 @@ const createContactTool: ToolDefinition = {
268
271
  async handler(args, ctx) {
269
272
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
270
273
 
274
+ const device = getCapabilityDevice("contacts");
275
+ if (!device) throw new ToolError("No device has contacts access enabled", 400);
276
+
271
277
  const { name, phone, email } = args as { name: string; phone?: string; email?: string };
272
278
  if (!name) throw new ToolError("name is required", 400);
273
279
 
@@ -276,7 +282,7 @@ const createContactTool: ToolDefinition = {
276
282
  const ackReply = await ctx.nc.request(
277
283
  `host.${ctx.config.hostId}.fcm.contacts`,
278
284
  sc.encode(JSON.stringify({
279
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
285
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
280
286
  action: "create", name, phone, email,
281
287
  })),
282
288
  { timeout: 5_000 },
@@ -323,13 +329,16 @@ const readCalendarTool: ToolDefinition = {
323
329
  async handler(args, ctx) {
324
330
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
325
331
 
332
+ const device = getCapabilityDevice("calendar");
333
+ if (!device) throw new ToolError("No device has calendar access enabled", 400);
334
+
326
335
  const { startDate, endDate } = args as { startDate?: number; endDate?: number };
327
336
  const sc = StringCodec();
328
337
 
329
338
  const ackReply = await ctx.nc.request(
330
339
  `host.${ctx.config.hostId}.fcm.calendar`,
331
340
  sc.encode(JSON.stringify({
332
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
341
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
333
342
  action: "read",
334
343
  ...(startDate ? { startDate: String(startDate) } : {}),
335
344
  ...(endDate ? { endDate: String(endDate) } : {}),
@@ -381,6 +390,9 @@ const createCalendarEventTool: ToolDefinition = {
381
390
  async handler(args, ctx) {
382
391
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
383
392
 
393
+ const device = getCapabilityDevice("calendar");
394
+ if (!device) throw new ToolError("No device has calendar access enabled", 400);
395
+
384
396
  const { title, startTime, endTime, location, description } = args as {
385
397
  title: string; startTime: number; endTime: number; location?: string; description?: string;
386
398
  };
@@ -391,7 +403,7 @@ const createCalendarEventTool: ToolDefinition = {
391
403
  const ackReply = await ctx.nc.request(
392
404
  `host.${ctx.config.hostId}.fcm.calendar`,
393
405
  sc.encode(JSON.stringify({
394
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
406
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
395
407
  action: "create",
396
408
  title, startTime: String(startTime), endTime: String(endTime),
397
409
  ...(location ? { location } : {}),
@@ -424,7 +436,7 @@ const createCalendarEventTool: ToolDefinition = {
424
436
  };
425
437
 
426
438
  const sendSmsTool: ToolDefinition = {
427
- name: "send-sms",
439
+ name: "send-sms-message",
428
440
  description: [
429
441
  "Send an SMS message from the user's mobile device.",
430
442
  "Blocks until the device responds (up to 30 seconds).",
@@ -441,6 +453,9 @@ const sendSmsTool: ToolDefinition = {
441
453
  async handler(args, ctx) {
442
454
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
443
455
 
456
+ const device = getCapabilityDevice("sms");
457
+ if (!device) throw new ToolError("No device has SMS access enabled", 400);
458
+
444
459
  const { to, body } = args as { to: string; body: string };
445
460
  if (!to || !body) throw new ToolError("to and body are required", 400);
446
461
 
@@ -449,7 +464,7 @@ const sendSmsTool: ToolDefinition = {
449
464
  const ackReply = await ctx.nc.request(
450
465
  `host.${ctx.config.hostId}.fcm.sms`,
451
466
  sc.encode(JSON.stringify({
452
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
467
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
453
468
  action: "send", to, body,
454
469
  })),
455
470
  { timeout: 5_000 },
@@ -478,44 +493,41 @@ const sendSmsTool: ToolDefinition = {
478
493
  },
479
494
  };
480
495
 
481
- const setAlarmTool: ToolDefinition = {
482
- name: "set-alarm",
496
+ const sendAlertTool: ToolDefinition = {
497
+ name: "send-alert",
483
498
  description: [
484
- "Set an alarm on the user's mobile device.",
499
+ "Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
500
+ "Use this to urgently get the user's attention. The device will play an alarm sound and show a full-screen dialog even on the lock screen.",
485
501
  "Blocks until the device responds (up to 30 seconds).",
486
502
  'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
487
503
  ],
488
504
  inputSchema: {
489
505
  type: "object",
490
506
  properties: {
491
- hour: { type: "number", description: "Hour (0-23)" },
492
- minutes: { type: "number", description: "Minutes (0-59)" },
493
- label: { type: "string", description: "Alarm label" },
494
- days: {
495
- type: "array",
496
- items: { type: "number" },
497
- description: "Recurring days (1=Sun, 2=Mon, ..., 7=Sat). Omit for one-time.",
498
- },
507
+ title: { type: "string", description: "Alert title" },
508
+ description: { type: "string", description: "Alert description/details" },
499
509
  },
500
- required: ["hour", "minutes"],
510
+ required: ["title"],
501
511
  },
502
512
  async handler(args, ctx) {
503
513
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
504
514
 
505
- const { hour, minutes, label, days } = args as { hour: number; minutes: number; label?: string; days?: number[] };
506
- if (hour == null || minutes == null) throw new ToolError("hour and minutes are required", 400);
515
+ const device = getCapabilityDevice("alert");
516
+ if (!device) throw new ToolError("No device has alert access enabled", 400);
517
+
518
+ const { title, description } = args as { title: string; description?: string };
519
+ if (!title) throw new ToolError("title is required", 400);
507
520
 
508
521
  const sc = StringCodec();
509
522
 
510
- const payload: Record<string, unknown> = {
511
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
512
- action: "set", hour: String(hour), minutes: String(minutes),
523
+ const payload: Record<string, string> = {
524
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
525
+ title,
513
526
  };
514
- if (label) payload.label = label;
515
- if (days?.length) payload.days = days.join(",");
527
+ if (description) payload.description = description;
516
528
 
517
529
  const ackReply = await ctx.nc.request(
518
- `host.${ctx.config.hostId}.fcm.alarm`,
530
+ `host.${ctx.config.hostId}.fcm.alert`,
519
531
  sc.encode(JSON.stringify(payload)),
520
532
  { timeout: 5_000 },
521
533
  );
@@ -523,7 +535,7 @@ const setAlarmTool: ToolDefinition = {
523
535
  if (ack.error) throw new ToolError(ack.error, 502);
524
536
 
525
537
  const responsePromise = new Promise<string>((resolve, reject) => {
526
- const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.sessionId}`, { max: 1 });
538
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
527
539
  const timer = setTimeout(() => {
528
540
  sub.unsubscribe();
529
541
  reject(new ToolError("Device did not respond within 30 seconds", 504));
@@ -557,11 +569,14 @@ const readBatteryTool: ToolDefinition = {
557
569
  async handler(_args, ctx) {
558
570
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
559
571
 
572
+ const device = getCapabilityDevice("battery");
573
+ if (!device) throw new ToolError("No device has battery access enabled", 400);
574
+
560
575
  const sc = StringCodec();
561
576
 
562
577
  const ackReply = await ctx.nc.request(
563
578
  `host.${ctx.config.hostId}.fcm.battery`,
564
- sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId })),
579
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })),
565
580
  { timeout: 5_000 },
566
581
  );
567
582
  const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
@@ -605,6 +620,9 @@ const setRingerModeTool: ToolDefinition = {
605
620
  async handler(args, ctx) {
606
621
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
607
622
 
623
+ const device = getCapabilityDevice("dnd");
624
+ if (!device) throw new ToolError("No device has Do Not Disturb control enabled", 400);
625
+
608
626
  const { mode } = args as { mode: string };
609
627
  if (!["normal", "vibrate", "silent"].includes(mode)) throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
610
628
 
@@ -612,7 +630,7 @@ const setRingerModeTool: ToolDefinition = {
612
630
 
613
631
  const ackReply = await ctx.nc.request(
614
632
  `host.${ctx.config.hostId}.fcm.ringer`,
615
- sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, mode })),
633
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, mode })),
616
634
  { timeout: 5_000 },
617
635
  );
618
636
  const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
@@ -639,7 +657,7 @@ const setRingerModeTool: ToolDefinition = {
639
657
  },
640
658
  };
641
659
 
642
- export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, setAlarmTool, readBatteryTool, setRingerModeTool];
660
+ export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendAlertTool, readBatteryTool, setRingerModeTool];
643
661
  export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
644
662
 
645
663
  // ── MCP Resources ─────────────────────────────────────────────────────
@@ -671,14 +689,14 @@ const deviceNotificationsResource: ResourceDefinition = {
671
689
  };
672
690
 
673
691
  const deviceSmsResource: ResourceDefinition = {
674
- uri: "sms://device",
692
+ uri: "sms-messages://device",
675
693
  name: "Device SMS",
676
694
  description: [
677
695
  "Get recent SMS messages from the user's Android device.",
678
696
  "Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
679
697
  ],
680
698
  mimeType: "application/json",
681
- restPath: "/sms",
699
+ restPath: "/sms-messages",
682
700
  read: getSmsMessages,
683
701
  };
684
702
 
@@ -11,7 +11,7 @@ import crossSpawn from "cross-spawn";
11
11
  import { getAgent } from "./agents/agent.js";
12
12
  import { validateClient } from "./client-store.js";
13
13
  import { publishHostEvent } from "./events.js";
14
- import { getLocationDevice, setLocationDevice, clearLocationDevice } from "./location-device.js";
14
+ import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type DeviceCapability } from "./device-capabilities.js";
15
15
  import { currentVersion, performUpdate } from "./update-checker.js";
16
16
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
17
17
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
@@ -163,13 +163,17 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
163
163
  switch (request.method) {
164
164
  case "task.list": {
165
165
  const tasks = listTasks(config.projectRoot);
166
- const locDevice = getLocationDevice();
166
+ const capabilities: Record<string, string | null> = {};
167
+ for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd"] as const) {
168
+ capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
169
+ }
167
170
  return {
168
171
  tasks: tasks.map((task) => flattenTask(task)),
169
172
  agents: config.agents ?? [],
170
173
  version: currentVersion,
171
174
  host_platform: process.platform,
172
- location_client_token: locDevice?.clientToken ?? null,
175
+ location_client_token: capabilities.location,
176
+ capability_tokens: capabilities,
173
177
  };
174
178
  }
175
179
 
@@ -652,12 +656,27 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
652
656
  const params = request.params as { fcmToken: string };
653
657
  if (!params.fcmToken) return { error: "fcmToken is required" };
654
658
  const clientToken = request.clientToken ?? "";
655
- setLocationDevice(clientToken, params.fcmToken);
659
+ setCapabilityDevice("location", clientToken, params.fcmToken);
656
660
  return { ok: true };
657
661
  }
658
662
 
659
663
  case "device.location.disable": {
660
- clearLocationDevice();
664
+ clearCapabilityDevice("location");
665
+ return { ok: true };
666
+ }
667
+
668
+ case "device.capability.enable": {
669
+ const params = request.params as { capability: DeviceCapability; fcmToken: string };
670
+ if (!params.capability || !params.fcmToken) return { error: "capability and fcmToken are required" };
671
+ const clientToken = request.clientToken ?? "";
672
+ setCapabilityDevice(params.capability, clientToken, params.fcmToken);
673
+ return { ok: true };
674
+ }
675
+
676
+ case "device.capability.disable": {
677
+ const params = request.params as { capability: DeviceCapability };
678
+ if (!params.capability) return { error: "capability is required" };
679
+ clearCapabilityDevice(params.capability);
661
680
  return { ok: true };
662
681
  }
663
682
 
@@ -125,7 +125,7 @@ export async function startHttpTransport(
125
125
 
126
126
  // Wire up resource change listeners
127
127
  onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
128
- onSmsChanged(() => broadcastResourceUpdated("sms://device"));
128
+ onSmsChanged(() => broadcastResourceUpdated("sms-messages://device"));
129
129
 
130
130
  // If a pairing code is provided, pre-register it
131
131
  if (pairingCode) {
@@ -1,8 +0,0 @@
1
- export interface LocationDevice {
2
- clientToken: string;
3
- fcmToken: string;
4
- }
5
- export declare function getLocationDevice(): LocationDevice | null;
6
- export declare function setLocationDevice(clientToken: string, fcmToken: string): void;
7
- export declare function clearLocationDevice(): void;
8
- //# sourceMappingURL=location-device.d.ts.map
@@ -1,32 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { CONFIG_DIR } from "./config.js";
4
- const LOCATION_FILE = path.join(CONFIG_DIR, "location-device.json");
5
- export function getLocationDevice() {
6
- try {
7
- if (!fs.existsSync(LOCATION_FILE))
8
- return null;
9
- const raw = fs.readFileSync(LOCATION_FILE, "utf-8");
10
- const data = JSON.parse(raw);
11
- if (!data.clientToken || !data.fcmToken)
12
- return null;
13
- return data;
14
- }
15
- catch {
16
- return null;
17
- }
18
- }
19
- export function setLocationDevice(clientToken, fcmToken) {
20
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
- fs.writeFileSync(LOCATION_FILE, JSON.stringify({ clientToken, fcmToken }, null, 2), "utf-8");
22
- }
23
- export function clearLocationDevice() {
24
- try {
25
- if (fs.existsSync(LOCATION_FILE))
26
- fs.unlinkSync(LOCATION_FILE);
27
- }
28
- catch {
29
- // ignore
30
- }
31
- }
32
- //# sourceMappingURL=location-device.js.map