palmier 0.7.2 → 0.7.4

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 (36) hide show
  1. package/README.md +43 -22
  2. package/dist/commands/serve.js +14 -1
  3. package/dist/device-capabilities.d.ts +9 -0
  4. package/dist/device-capabilities.js +36 -0
  5. package/dist/mcp-handler.js +4 -1
  6. package/dist/mcp-tools.js +414 -7
  7. package/dist/pwa/assets/{index-C6Lz09EY.css → index-B-ByUHPS.css} +1 -1
  8. package/dist/pwa/assets/index-BirmfPUC.js +118 -0
  9. package/dist/pwa/assets/{web-HDs03L2B.js → web-Dc9-IiRD.js} +1 -1
  10. package/dist/pwa/assets/{web-CBI458eN.js → web-_b3Dvcvz.js} +1 -1
  11. package/dist/pwa/index.html +2 -2
  12. package/dist/pwa/service-worker.js +1 -1
  13. package/dist/rpc-handler.js +19 -4
  14. package/dist/sms-store.d.ts +11 -0
  15. package/dist/sms-store.js +19 -0
  16. package/dist/transports/http-transport.js +16 -1
  17. package/package.json +1 -1
  18. package/palmier-server/README.md +11 -3
  19. package/palmier-server/pwa/src/App.css +3 -0
  20. package/palmier-server/pwa/src/components/HostMenu.tsx +465 -0
  21. package/palmier-server/pwa/src/constants.ts +1 -1
  22. package/palmier-server/server/src/index.ts +306 -0
  23. package/palmier-server/server/src/routes/device.ts +168 -0
  24. package/palmier-server/spec.md +32 -3
  25. package/src/commands/serve.ts +14 -1
  26. package/src/device-capabilities.ts +55 -0
  27. package/src/mcp-handler.ts +4 -1
  28. package/src/mcp-tools.ts +473 -7
  29. package/src/rpc-handler.ts +19 -4
  30. package/src/sms-store.ts +28 -0
  31. package/src/transports/http-transport.ts +16 -1
  32. package/test/agent-instructions.test.ts +1 -1
  33. package/dist/location-device.d.ts +0 -8
  34. package/dist/location-device.js +0 -32
  35. package/dist/pwa/assets/index-DLxrL0hR.js +0 -118
  36. package/src/location-device.ts +0 -35
package/README.md CHANGED
@@ -34,7 +34,7 @@ It runs on your machine as a background daemon and connects to a mobile-friendly
34
34
 
35
35
  ## How It Works
36
36
 
37
- Palmier runs as a background daemon (systemd on Linux, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications, fetching GPS location, and reading device notifications.
37
+ Palmier runs as a background daemon (systemd on Linux, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications, reading SMS/notifications, managing contacts and calendar, setting alarms, and more.
38
38
 
39
39
  ### MCP Server
40
40
 
@@ -43,36 +43,57 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
43
43
  **MCP server URL:** `http://localhost:<port>/mcp`
44
44
 
45
45
  **Available tools:**
46
- | Tool | Description |
47
- |------|-------------|
48
- | `notify` | Send a push notification to the user's device |
49
- | `request-input` | Request input from the user (blocks until response) |
50
- | `request-confirmation` | Request confirmation from the user (blocks until response) |
51
- | `device-geolocation` | Get GPS location of the user's mobile device |
46
+ | Tool | Description | Permission |
47
+ |------|-------------|------------|
48
+ | `notify` | Send a push notification to the user's device | None |
49
+ | `request-input` | Request input from the user (blocks until response) | None |
50
+ | `request-confirmation` | Request confirmation from the user (blocks until response) | None |
51
+ | `device-geolocation` | Get GPS location of the user's mobile device | Location Access |
52
+ | `read-contacts` | Read the contact list from the user's device | Contacts Access |
53
+ | `create-contact` | Create a new contact on the user's device | Contacts Access |
54
+ | `read-calendar` | Read calendar events (with time range filter) | Calendar Access |
55
+ | `create-calendar-event` | Create a calendar event on the user's device | Calendar Access |
56
+ | `send-sms-message` | Send an SMS message from the user's device | SMS Access |
57
+ | `set-alarm` | Set an alarm on the user's device | None |
58
+ | `read-battery` | Get battery level and charging status | None |
59
+ | `set-ringer-mode` | Set ringer mode (normal/vibrate/silent) | Do Not Disturb Control |
52
60
 
53
61
  **Available resources:**
54
- | Resource | URI | REST | Description |
55
- |----------|-----|------|-------------|
56
- | Device Notifications | `notifications://device` | `GET /notifications` | Recent notifications from the user's Android device |
62
+ | Resource | URI | Permission | Description |
63
+ |----------|-----|------------|-------------|
64
+ | Device Notifications | `notifications://device` | Notification Access | Recent notifications from the user's Android device |
65
+ | Device SMS | `sms-messages://device` | SMS Access | Recent SMS messages from the user's Android device |
57
66
 
58
- Resources support MCP subscriptions — clients can subscribe via `resources/subscribe` and receive real-time `notifications/resources/updated` events via the streamable HTTP transport when the resource changes. The Android app requires notification listener access to be enabled in system settings.
67
+ Resources support MCP subscriptions — clients can subscribe via `resources/subscribe` and receive real-time `notifications/resources/updated` events via the streamable HTTP transport when the resource changes.
68
+
69
+ All device tools work while the Palmier Android app is in the background — they communicate via FCM data messages which wake the app's service even when it's not in the foreground. Permissions listed above must be granted via toggles in the Android app's settings menu.
70
+
71
+ ### Architecture
59
72
 
60
73
  ```
61
74
  ┌──────────────┐ HTTP ┌──────────────────┐
62
75
  │ │◄──────────────────────│ │
63
76
  │ Host Daemon │ │ PWA (Browser) │
64
- │◄──────┐ │ │
65
- └──────┬───────┘ │ └──────────────────┘
66
- │ │
67
- │ NATS (TLS) │ NATS (TLS)
68
- ┌──────────────┐ │ ┌────────┴─────────┐
69
- Agent CLIs └───────────────│ Relay Server │
70
- (Claude, │ │ (passthrough, │
71
- Gemini, │ push notify)
72
- │ Codex ...) │ └──────────────────┘
73
- └──────────────┘
77
+ (MCP Server)│◄──────┐ │ │
78
+ └──┬────────┬──┘ │ └──────────────────┘
79
+
80
+ │ NATS (TLS) │ NATS (TLS)
81
+ ┌──────┐ ┌──────┐ │ ┌────────┴─────────┐
82
+ │Agent │ │Agent │ └───────────────│ Relay Server │
83
+ CLIs │Tools/│ │ (passthrough, │
84
+ Rsrcs │◄──── FCM ───────────│ push, FCM)
85
+ └──────┘ └──────┘ └──────────────────┘
86
+
87
+ FCM │
88
+
89
+ ┌──────────────────┐
90
+ │ Android Device │
91
+ │ (notifications, │
92
+ │ SMS, contacts, │
93
+ │ calendar, GPS) │
94
+ └──────────────────┘
74
95
  Local / LAN: direct HTTP
75
- Server mode: via relay server
96
+ Server mode: via relay server + FCM
76
97
  ```
77
98
 
78
99
  ## Access Modes
@@ -13,6 +13,7 @@ import { saveConfig } from "../config.js";
13
13
  import { CONFIG_DIR } from "../config.js";
14
14
  import { StringCodec } from "nats";
15
15
  import { addNotification } from "../notification-store.js";
16
+ import { addSmsMessage } from "../sms-store.js";
16
17
  const POLL_INTERVAL_MS = 30_000;
17
18
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
18
19
  /**
@@ -116,7 +117,7 @@ export async function serveCommand() {
116
117
  // Start NATS transport (loops forever, fire-and-forget)
117
118
  if (nc) {
118
119
  startNatsTransport(config, handleRpc, nc);
119
- // Subscribe to device notifications from Android
120
+ // Subscribe to device notifications and SMS from Android
120
121
  const sc = StringCodec();
121
122
  const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
122
123
  (async () => {
@@ -130,6 +131,18 @@ export async function serveCommand() {
130
131
  }
131
132
  }
132
133
  })();
134
+ const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
135
+ (async () => {
136
+ for await (const msg of smsSub) {
137
+ try {
138
+ const data = JSON.parse(sc.decode(msg.data));
139
+ addSmsMessage({ ...data, receivedAt: Date.now() });
140
+ }
141
+ catch (err) {
142
+ console.error("[nats] Failed to parse device SMS:", err);
143
+ }
144
+ }
145
+ })();
133
146
  }
134
147
  // Start HTTP transport (loops forever)
135
148
  await startHttpTransport(config, handleRpc, httpPort, nc);
@@ -0,0 +1,9 @@
1
+ export interface RegisteredDevice {
2
+ clientToken: string;
3
+ fcmToken: string;
4
+ }
5
+ export type DeviceCapability = "location" | "notifications" | "sms" | "contacts" | "calendar" | "alert" | "battery" | "dnd";
6
+ export declare function getCapabilityDevice(capability: DeviceCapability): RegisteredDevice | null;
7
+ export declare function setCapabilityDevice(capability: DeviceCapability, clientToken: string, fcmToken: string): void;
8
+ export declare function clearCapabilityDevice(capability: DeviceCapability): void;
9
+ //# sourceMappingURL=device-capabilities.d.ts.map
@@ -0,0 +1,36 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+ const CAPABILITIES_FILE = path.join(CONFIG_DIR, "device-capabilities.json");
5
+ function readAll() {
6
+ try {
7
+ if (!fs.existsSync(CAPABILITIES_FILE))
8
+ return {};
9
+ return JSON.parse(fs.readFileSync(CAPABILITIES_FILE, "utf-8"));
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ function writeAll(map) {
16
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
17
+ fs.writeFileSync(CAPABILITIES_FILE, JSON.stringify(map, null, 2), "utf-8");
18
+ }
19
+ export function getCapabilityDevice(capability) {
20
+ const map = readAll();
21
+ const device = map[capability];
22
+ if (!device?.clientToken || !device?.fcmToken)
23
+ return null;
24
+ return device;
25
+ }
26
+ export function setCapabilityDevice(capability, clientToken, fcmToken) {
27
+ const map = readAll();
28
+ map[capability] = { clientToken, fcmToken };
29
+ writeAll(map);
30
+ }
31
+ export function clearCapabilityDevice(capability) {
32
+ const map = readAll();
33
+ delete map[capability];
34
+ writeAll(map);
35
+ }
36
+ //# sourceMappingURL=device-capabilities.js.map
@@ -127,12 +127,15 @@ export async function handleMcpRequest(body, sessionId, ctx) {
127
127
  if (!resource) {
128
128
  return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
129
129
  }
130
+ console.log(`${logPrefix} resources/read ${uri}`);
131
+ const content = resource.read();
132
+ console.log(`${logPrefix} resources/read ${uri} done: ${JSON.stringify(content).slice(0, 200)}`);
130
133
  return {
131
134
  body: rpcResult(id, {
132
135
  contents: [{
133
136
  uri: resource.uri,
134
137
  mimeType: resource.mimeType,
135
- text: JSON.stringify(resource.read()),
138
+ text: JSON.stringify(content),
136
139
  }],
137
140
  }),
138
141
  };
package/dist/mcp-tools.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { StringCodec } 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
+ import { getSmsMessages } from "./sms-store.js";
5
6
  export class ToolError extends Error {
6
7
  statusCode;
7
8
  constructor(message, statusCode = 500) {
@@ -144,11 +145,11 @@ const deviceGeolocationTool = {
144
145
  async handler(_args, ctx) {
145
146
  if (!ctx.nc)
146
147
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
147
- const locDevice = getLocationDevice();
148
- if (!locDevice)
148
+ const device = getCapabilityDevice("location");
149
+ if (!device)
149
150
  throw new ToolError("No device has location access enabled", 400);
150
151
  const sc = StringCodec();
151
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })), { timeout: 5_000 });
152
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })), { timeout: 5_000 });
152
153
  const ack = JSON.parse(sc.decode(ackReply.data));
153
154
  if (ack.error)
154
155
  throw new ToolError(ack.error, 502);
@@ -171,7 +172,402 @@ const deviceGeolocationTool = {
171
172
  return locationData;
172
173
  },
173
174
  };
174
- export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
175
+ const readContactsTool = {
176
+ name: "read-contacts",
177
+ description: [
178
+ "Read the contact list from the user's mobile device.",
179
+ "Blocks until the device responds (up to 30 seconds).",
180
+ 'Response: `{"contacts": [{"id": ..., "name": ..., "phone": ...}]}` on success, or `{"error": "..."}` on failure.',
181
+ ],
182
+ inputSchema: {
183
+ type: "object",
184
+ properties: {},
185
+ },
186
+ async handler(_args, ctx) {
187
+ if (!ctx.nc)
188
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
189
+ const device = getCapabilityDevice("contacts");
190
+ if (!device)
191
+ throw new ToolError("No device has contacts access enabled", 400);
192
+ const sc = StringCodec();
193
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, action: "read" })), { timeout: 5_000 });
194
+ const ack = JSON.parse(sc.decode(ackReply.data));
195
+ if (ack.error)
196
+ throw new ToolError(ack.error, 502);
197
+ const responsePromise = new Promise((resolve, reject) => {
198
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
199
+ const timer = setTimeout(() => {
200
+ sub.unsubscribe();
201
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
202
+ }, 30_000);
203
+ (async () => {
204
+ for await (const msg of sub) {
205
+ clearTimeout(timer);
206
+ resolve(sc.decode(msg.data));
207
+ }
208
+ })();
209
+ });
210
+ const result = JSON.parse(await responsePromise);
211
+ if (result.error)
212
+ return { error: result.error };
213
+ return result;
214
+ },
215
+ };
216
+ const createContactTool = {
217
+ name: "create-contact",
218
+ description: [
219
+ "Create a new contact on the user's mobile device.",
220
+ "Blocks until the device responds (up to 30 seconds).",
221
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
222
+ ],
223
+ inputSchema: {
224
+ type: "object",
225
+ properties: {
226
+ name: { type: "string", description: "Contact display name" },
227
+ phone: { type: "string", description: "Phone number" },
228
+ email: { type: "string", description: "Email address" },
229
+ },
230
+ required: ["name"],
231
+ },
232
+ async handler(args, ctx) {
233
+ if (!ctx.nc)
234
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
235
+ const device = getCapabilityDevice("contacts");
236
+ if (!device)
237
+ throw new ToolError("No device has contacts access enabled", 400);
238
+ const { name, phone, email } = args;
239
+ if (!name)
240
+ throw new ToolError("name is required", 400);
241
+ const sc = StringCodec();
242
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({
243
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
244
+ action: "create", name, phone, email,
245
+ })), { timeout: 5_000 });
246
+ const ack = JSON.parse(sc.decode(ackReply.data));
247
+ if (ack.error)
248
+ throw new ToolError(ack.error, 502);
249
+ const responsePromise = new Promise((resolve, reject) => {
250
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
251
+ const timer = setTimeout(() => {
252
+ sub.unsubscribe();
253
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
254
+ }, 30_000);
255
+ (async () => {
256
+ for await (const msg of sub) {
257
+ clearTimeout(timer);
258
+ resolve(sc.decode(msg.data));
259
+ }
260
+ })();
261
+ });
262
+ const result = JSON.parse(await responsePromise);
263
+ if (result.error)
264
+ return { error: result.error };
265
+ return result;
266
+ },
267
+ };
268
+ const readCalendarTool = {
269
+ name: "read-calendar",
270
+ description: [
271
+ "Read calendar events from the user's mobile device.",
272
+ "Blocks until the device responds (up to 30 seconds).",
273
+ "Pass startDate and endDate as Unix timestamps in milliseconds. Defaults to next 7 days.",
274
+ 'Response: `{"events": [{"id": ..., "title": ..., "startTime": ..., "endTime": ..., "location": ..., "description": ..., "allDay": ..., "calendar": ...}]}` on success.',
275
+ ],
276
+ inputSchema: {
277
+ type: "object",
278
+ properties: {
279
+ startDate: { type: "number", description: "Start of range (Unix ms). Defaults to now." },
280
+ endDate: { type: "number", description: "End of range (Unix ms). Defaults to 7 days from start." },
281
+ },
282
+ },
283
+ async handler(args, ctx) {
284
+ if (!ctx.nc)
285
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
286
+ const device = getCapabilityDevice("calendar");
287
+ if (!device)
288
+ throw new ToolError("No device has calendar access enabled", 400);
289
+ const { startDate, endDate } = args;
290
+ const sc = StringCodec();
291
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
292
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
293
+ action: "read",
294
+ ...(startDate ? { startDate: String(startDate) } : {}),
295
+ ...(endDate ? { endDate: String(endDate) } : {}),
296
+ })), { timeout: 5_000 });
297
+ const ack = JSON.parse(sc.decode(ackReply.data));
298
+ if (ack.error)
299
+ throw new ToolError(ack.error, 502);
300
+ const responsePromise = new Promise((resolve, reject) => {
301
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
302
+ const timer = setTimeout(() => {
303
+ sub.unsubscribe();
304
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
305
+ }, 30_000);
306
+ (async () => {
307
+ for await (const msg of sub) {
308
+ clearTimeout(timer);
309
+ resolve(sc.decode(msg.data));
310
+ }
311
+ })();
312
+ });
313
+ const result = JSON.parse(await responsePromise);
314
+ if (result.error)
315
+ return { error: result.error };
316
+ return result;
317
+ },
318
+ };
319
+ const createCalendarEventTool = {
320
+ name: "create-calendar-event",
321
+ description: [
322
+ "Create a calendar event on the user's mobile device.",
323
+ "Blocks until the device responds (up to 30 seconds).",
324
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
325
+ ],
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ title: { type: "string", description: "Event title" },
330
+ startTime: { type: "number", description: "Start time (Unix ms)" },
331
+ endTime: { type: "number", description: "End time (Unix ms)" },
332
+ location: { type: "string", description: "Event location" },
333
+ description: { type: "string", description: "Event description" },
334
+ },
335
+ required: ["title", "startTime", "endTime"],
336
+ },
337
+ async handler(args, ctx) {
338
+ if (!ctx.nc)
339
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
340
+ const device = getCapabilityDevice("calendar");
341
+ if (!device)
342
+ throw new ToolError("No device has calendar access enabled", 400);
343
+ const { title, startTime, endTime, location, description } = args;
344
+ if (!title || !startTime || !endTime)
345
+ throw new ToolError("title, startTime, and endTime are required", 400);
346
+ const sc = StringCodec();
347
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
348
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
349
+ action: "create",
350
+ title, startTime: String(startTime), endTime: String(endTime),
351
+ ...(location ? { location } : {}),
352
+ ...(description ? { description } : {}),
353
+ })), { timeout: 5_000 });
354
+ const ack = JSON.parse(sc.decode(ackReply.data));
355
+ if (ack.error)
356
+ throw new ToolError(ack.error, 502);
357
+ const responsePromise = new Promise((resolve, reject) => {
358
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
359
+ const timer = setTimeout(() => {
360
+ sub.unsubscribe();
361
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
362
+ }, 30_000);
363
+ (async () => {
364
+ for await (const msg of sub) {
365
+ clearTimeout(timer);
366
+ resolve(sc.decode(msg.data));
367
+ }
368
+ })();
369
+ });
370
+ const result = JSON.parse(await responsePromise);
371
+ if (result.error)
372
+ return { error: result.error };
373
+ return result;
374
+ },
375
+ };
376
+ const sendSmsTool = {
377
+ name: "send-sms-message",
378
+ description: [
379
+ "Send an SMS message from the user's mobile device.",
380
+ "Blocks until the device responds (up to 30 seconds).",
381
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
382
+ ],
383
+ inputSchema: {
384
+ type: "object",
385
+ properties: {
386
+ to: { type: "string", description: "Recipient phone number" },
387
+ body: { type: "string", description: "Message text" },
388
+ },
389
+ required: ["to", "body"],
390
+ },
391
+ async handler(args, ctx) {
392
+ if (!ctx.nc)
393
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
394
+ const device = getCapabilityDevice("sms");
395
+ if (!device)
396
+ throw new ToolError("No device has SMS access enabled", 400);
397
+ const { to, body } = args;
398
+ if (!to || !body)
399
+ throw new ToolError("to and body are required", 400);
400
+ const sc = StringCodec();
401
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.sms`, sc.encode(JSON.stringify({
402
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
403
+ action: "send", to, body,
404
+ })), { timeout: 5_000 });
405
+ const ack = JSON.parse(sc.decode(ackReply.data));
406
+ if (ack.error)
407
+ throw new ToolError(ack.error, 502);
408
+ const responsePromise = new Promise((resolve, reject) => {
409
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.sms.${ctx.sessionId}`, { max: 1 });
410
+ const timer = setTimeout(() => {
411
+ sub.unsubscribe();
412
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
413
+ }, 30_000);
414
+ (async () => {
415
+ for await (const msg of sub) {
416
+ clearTimeout(timer);
417
+ resolve(sc.decode(msg.data));
418
+ }
419
+ })();
420
+ });
421
+ const result = JSON.parse(await responsePromise);
422
+ if (result.error)
423
+ return { error: result.error };
424
+ return result;
425
+ },
426
+ };
427
+ const sendAlertTool = {
428
+ name: "send-alert",
429
+ description: [
430
+ "Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
431
+ "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.",
432
+ "Blocks until the device responds (up to 30 seconds).",
433
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
434
+ ],
435
+ inputSchema: {
436
+ type: "object",
437
+ properties: {
438
+ title: { type: "string", description: "Alert title" },
439
+ description: { type: "string", description: "Alert description/details" },
440
+ },
441
+ required: ["title"],
442
+ },
443
+ async handler(args, ctx) {
444
+ if (!ctx.nc)
445
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
446
+ const device = getCapabilityDevice("alert");
447
+ if (!device)
448
+ throw new ToolError("No device has alert access enabled", 400);
449
+ const { title, description } = args;
450
+ if (!title)
451
+ throw new ToolError("title is required", 400);
452
+ const sc = StringCodec();
453
+ const payload = {
454
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
455
+ title,
456
+ };
457
+ if (description)
458
+ payload.description = description;
459
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alert`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
460
+ const ack = JSON.parse(sc.decode(ackReply.data));
461
+ if (ack.error)
462
+ throw new ToolError(ack.error, 502);
463
+ const responsePromise = new Promise((resolve, reject) => {
464
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
465
+ const timer = setTimeout(() => {
466
+ sub.unsubscribe();
467
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
468
+ }, 30_000);
469
+ (async () => {
470
+ for await (const msg of sub) {
471
+ clearTimeout(timer);
472
+ resolve(sc.decode(msg.data));
473
+ }
474
+ })();
475
+ });
476
+ const result = JSON.parse(await responsePromise);
477
+ if (result.error)
478
+ return { error: result.error };
479
+ return result;
480
+ },
481
+ };
482
+ const readBatteryTool = {
483
+ name: "read-battery",
484
+ description: [
485
+ "Get the battery level and charging status of the user's mobile device.",
486
+ "Blocks until the device responds (up to 30 seconds).",
487
+ 'Response: `{"level": 85, "charging": true}` on success, or `{"error": "..."}` on failure.',
488
+ ],
489
+ inputSchema: {
490
+ type: "object",
491
+ properties: {},
492
+ },
493
+ async handler(_args, ctx) {
494
+ if (!ctx.nc)
495
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
496
+ const device = getCapabilityDevice("battery");
497
+ if (!device)
498
+ throw new ToolError("No device has battery access enabled", 400);
499
+ const sc = StringCodec();
500
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })), { timeout: 5_000 });
501
+ const ack = JSON.parse(sc.decode(ackReply.data));
502
+ if (ack.error)
503
+ throw new ToolError(ack.error, 502);
504
+ const responsePromise = new Promise((resolve, reject) => {
505
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.battery.${ctx.sessionId}`, { max: 1 });
506
+ const timer = setTimeout(() => {
507
+ sub.unsubscribe();
508
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
509
+ }, 30_000);
510
+ (async () => {
511
+ for await (const msg of sub) {
512
+ clearTimeout(timer);
513
+ resolve(sc.decode(msg.data));
514
+ }
515
+ })();
516
+ });
517
+ const result = JSON.parse(await responsePromise);
518
+ if (result.error)
519
+ return { error: result.error };
520
+ return result;
521
+ },
522
+ };
523
+ const setRingerModeTool = {
524
+ name: "set-ringer-mode",
525
+ description: [
526
+ "Set the phone's ringer mode. Requires Do Not Disturb access on the device.",
527
+ "Blocks until the device responds (up to 30 seconds).",
528
+ 'Response: `{"ok": true, "mode": "silent"}` on success, or `{"error": "..."}` on failure.',
529
+ ],
530
+ inputSchema: {
531
+ type: "object",
532
+ properties: {
533
+ mode: { type: "string", description: "Ringer mode: 'normal', 'vibrate', or 'silent'" },
534
+ },
535
+ required: ["mode"],
536
+ },
537
+ async handler(args, ctx) {
538
+ if (!ctx.nc)
539
+ throw new ToolError("Not connected to server (NATS unavailable)", 503);
540
+ const device = getCapabilityDevice("dnd");
541
+ if (!device)
542
+ throw new ToolError("No device has Do Not Disturb control enabled", 400);
543
+ const { mode } = args;
544
+ if (!["normal", "vibrate", "silent"].includes(mode))
545
+ throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
546
+ const sc = StringCodec();
547
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, mode })), { timeout: 5_000 });
548
+ const ack = JSON.parse(sc.decode(ackReply.data));
549
+ if (ack.error)
550
+ throw new ToolError(ack.error, 502);
551
+ const responsePromise = new Promise((resolve, reject) => {
552
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.ringer.${ctx.sessionId}`, { max: 1 });
553
+ const timer = setTimeout(() => {
554
+ sub.unsubscribe();
555
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
556
+ }, 30_000);
557
+ (async () => {
558
+ for await (const msg of sub) {
559
+ clearTimeout(timer);
560
+ resolve(sc.decode(msg.data));
561
+ }
562
+ })();
563
+ });
564
+ const result = JSON.parse(await responsePromise);
565
+ if (result.error)
566
+ return { error: result.error };
567
+ return result;
568
+ },
569
+ };
570
+ export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendAlertTool, readBatteryTool, setRingerModeTool];
175
571
  export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
176
572
  const deviceNotificationsResource = {
177
573
  uri: "notifications://device",
@@ -184,7 +580,18 @@ const deviceNotificationsResource = {
184
580
  restPath: "/notifications",
185
581
  read: getNotifications,
186
582
  };
187
- export const agentResources = [deviceNotificationsResource];
583
+ const deviceSmsResource = {
584
+ uri: "sms-messages://device",
585
+ name: "Device SMS",
586
+ description: [
587
+ "Get recent SMS messages from the user's Android device.",
588
+ "Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
589
+ ],
590
+ mimeType: "application/json",
591
+ restPath: "/sms-messages",
592
+ read: getSmsMessages,
593
+ };
594
+ export const agentResources = [deviceNotificationsResource, deviceSmsResource];
188
595
  export const agentResourceMap = new Map(agentResources.map((r) => [r.uri, r]));
189
596
  /**
190
597
  * Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
@@ -229,7 +636,7 @@ export function generateEndpointDocs(port, taskId, tools = agentTools, resources
229
636
  }
230
637
  for (const resource of resources) {
231
638
  const [header, ...details] = resource.description;
232
- lines.push(`**\`GET ${baseUrl}${resource.restPath}\`** — ${header}`);
639
+ lines.push(`**\`GET ${baseUrl}${resource.restPath}?taskId=${taskId}\`** — ${header}`);
233
640
  for (const detail of details) {
234
641
  lines.push(`- ${detail}`);
235
642
  }