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/src/mcp-tools.ts CHANGED
@@ -1,7 +1,8 @@
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
+ import { getSmsMessages } from "./sms-store.js";
5
6
  import type { HostConfig } from "./types.js";
6
7
 
7
8
  export class ToolError extends Error {
@@ -169,14 +170,14 @@ const deviceGeolocationTool: ToolDefinition = {
169
170
  async handler(_args, ctx) {
170
171
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
171
172
 
172
- const locDevice = getLocationDevice();
173
- 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);
174
175
 
175
176
  const sc = StringCodec();
176
177
 
177
178
  const ackReply = await ctx.nc.request(
178
179
  `host.${ctx.config.hostId}.fcm.geolocation`,
179
- 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 })),
180
181
  { timeout: 5_000 },
181
182
  );
182
183
  const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
@@ -203,7 +204,460 @@ const deviceGeolocationTool: ToolDefinition = {
203
204
  },
204
205
  };
205
206
 
206
- export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
207
+ const readContactsTool: ToolDefinition = {
208
+ name: "read-contacts",
209
+ description: [
210
+ "Read the contact list from the user's mobile device.",
211
+ "Blocks until the device responds (up to 30 seconds).",
212
+ 'Response: `{"contacts": [{"id": ..., "name": ..., "phone": ...}]}` on success, or `{"error": "..."}` on failure.',
213
+ ],
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {},
217
+ },
218
+ async handler(_args, ctx) {
219
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
220
+
221
+ const device = getCapabilityDevice("contacts");
222
+ if (!device) throw new ToolError("No device has contacts access enabled", 400);
223
+
224
+ const sc = StringCodec();
225
+
226
+ const ackReply = await ctx.nc.request(
227
+ `host.${ctx.config.hostId}.fcm.contacts`,
228
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, action: "read" })),
229
+ { timeout: 5_000 },
230
+ );
231
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
232
+ if (ack.error) throw new ToolError(ack.error, 502);
233
+
234
+ const responsePromise = new Promise<string>((resolve, reject) => {
235
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
236
+ const timer = setTimeout(() => {
237
+ sub.unsubscribe();
238
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
239
+ }, 30_000);
240
+
241
+ (async () => {
242
+ for await (const msg of sub) {
243
+ clearTimeout(timer);
244
+ resolve(sc.decode(msg.data));
245
+ }
246
+ })();
247
+ });
248
+
249
+ const result = JSON.parse(await responsePromise);
250
+ if (result.error) return { error: result.error };
251
+ return result;
252
+ },
253
+ };
254
+
255
+ const createContactTool: ToolDefinition = {
256
+ name: "create-contact",
257
+ description: [
258
+ "Create a new contact on the user's mobile device.",
259
+ "Blocks until the device responds (up to 30 seconds).",
260
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
261
+ ],
262
+ inputSchema: {
263
+ type: "object",
264
+ properties: {
265
+ name: { type: "string", description: "Contact display name" },
266
+ phone: { type: "string", description: "Phone number" },
267
+ email: { type: "string", description: "Email address" },
268
+ },
269
+ required: ["name"],
270
+ },
271
+ async handler(args, ctx) {
272
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
273
+
274
+ const device = getCapabilityDevice("contacts");
275
+ if (!device) throw new ToolError("No device has contacts access enabled", 400);
276
+
277
+ const { name, phone, email } = args as { name: string; phone?: string; email?: string };
278
+ if (!name) throw new ToolError("name is required", 400);
279
+
280
+ const sc = StringCodec();
281
+
282
+ const ackReply = await ctx.nc.request(
283
+ `host.${ctx.config.hostId}.fcm.contacts`,
284
+ sc.encode(JSON.stringify({
285
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
286
+ action: "create", name, phone, email,
287
+ })),
288
+ { timeout: 5_000 },
289
+ );
290
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
291
+ if (ack.error) throw new ToolError(ack.error, 502);
292
+
293
+ const responsePromise = new Promise<string>((resolve, reject) => {
294
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
295
+ const timer = setTimeout(() => {
296
+ sub.unsubscribe();
297
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
298
+ }, 30_000);
299
+
300
+ (async () => {
301
+ for await (const msg of sub) {
302
+ clearTimeout(timer);
303
+ resolve(sc.decode(msg.data));
304
+ }
305
+ })();
306
+ });
307
+
308
+ const result = JSON.parse(await responsePromise);
309
+ if (result.error) return { error: result.error };
310
+ return result;
311
+ },
312
+ };
313
+
314
+ const readCalendarTool: ToolDefinition = {
315
+ name: "read-calendar",
316
+ description: [
317
+ "Read calendar events from the user's mobile device.",
318
+ "Blocks until the device responds (up to 30 seconds).",
319
+ "Pass startDate and endDate as Unix timestamps in milliseconds. Defaults to next 7 days.",
320
+ 'Response: `{"events": [{"id": ..., "title": ..., "startTime": ..., "endTime": ..., "location": ..., "description": ..., "allDay": ..., "calendar": ...}]}` on success.',
321
+ ],
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: {
325
+ startDate: { type: "number", description: "Start of range (Unix ms). Defaults to now." },
326
+ endDate: { type: "number", description: "End of range (Unix ms). Defaults to 7 days from start." },
327
+ },
328
+ },
329
+ async handler(args, ctx) {
330
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
331
+
332
+ const device = getCapabilityDevice("calendar");
333
+ if (!device) throw new ToolError("No device has calendar access enabled", 400);
334
+
335
+ const { startDate, endDate } = args as { startDate?: number; endDate?: number };
336
+ const sc = StringCodec();
337
+
338
+ const ackReply = await ctx.nc.request(
339
+ `host.${ctx.config.hostId}.fcm.calendar`,
340
+ sc.encode(JSON.stringify({
341
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
342
+ action: "read",
343
+ ...(startDate ? { startDate: String(startDate) } : {}),
344
+ ...(endDate ? { endDate: String(endDate) } : {}),
345
+ })),
346
+ { timeout: 5_000 },
347
+ );
348
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
349
+ if (ack.error) throw new ToolError(ack.error, 502);
350
+
351
+ const responsePromise = new Promise<string>((resolve, reject) => {
352
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
353
+ const timer = setTimeout(() => {
354
+ sub.unsubscribe();
355
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
356
+ }, 30_000);
357
+
358
+ (async () => {
359
+ for await (const msg of sub) {
360
+ clearTimeout(timer);
361
+ resolve(sc.decode(msg.data));
362
+ }
363
+ })();
364
+ });
365
+
366
+ const result = JSON.parse(await responsePromise);
367
+ if (result.error) return { error: result.error };
368
+ return result;
369
+ },
370
+ };
371
+
372
+ const createCalendarEventTool: ToolDefinition = {
373
+ name: "create-calendar-event",
374
+ description: [
375
+ "Create a calendar event on the user's mobile device.",
376
+ "Blocks until the device responds (up to 30 seconds).",
377
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
378
+ ],
379
+ inputSchema: {
380
+ type: "object",
381
+ properties: {
382
+ title: { type: "string", description: "Event title" },
383
+ startTime: { type: "number", description: "Start time (Unix ms)" },
384
+ endTime: { type: "number", description: "End time (Unix ms)" },
385
+ location: { type: "string", description: "Event location" },
386
+ description: { type: "string", description: "Event description" },
387
+ },
388
+ required: ["title", "startTime", "endTime"],
389
+ },
390
+ async handler(args, ctx) {
391
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
392
+
393
+ const device = getCapabilityDevice("calendar");
394
+ if (!device) throw new ToolError("No device has calendar access enabled", 400);
395
+
396
+ const { title, startTime, endTime, location, description } = args as {
397
+ title: string; startTime: number; endTime: number; location?: string; description?: string;
398
+ };
399
+ if (!title || !startTime || !endTime) throw new ToolError("title, startTime, and endTime are required", 400);
400
+
401
+ const sc = StringCodec();
402
+
403
+ const ackReply = await ctx.nc.request(
404
+ `host.${ctx.config.hostId}.fcm.calendar`,
405
+ sc.encode(JSON.stringify({
406
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
407
+ action: "create",
408
+ title, startTime: String(startTime), endTime: String(endTime),
409
+ ...(location ? { location } : {}),
410
+ ...(description ? { description } : {}),
411
+ })),
412
+ { timeout: 5_000 },
413
+ );
414
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
415
+ if (ack.error) throw new ToolError(ack.error, 502);
416
+
417
+ const responsePromise = new Promise<string>((resolve, reject) => {
418
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
419
+ const timer = setTimeout(() => {
420
+ sub.unsubscribe();
421
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
422
+ }, 30_000);
423
+
424
+ (async () => {
425
+ for await (const msg of sub) {
426
+ clearTimeout(timer);
427
+ resolve(sc.decode(msg.data));
428
+ }
429
+ })();
430
+ });
431
+
432
+ const result = JSON.parse(await responsePromise);
433
+ if (result.error) return { error: result.error };
434
+ return result;
435
+ },
436
+ };
437
+
438
+ const sendSmsTool: ToolDefinition = {
439
+ name: "send-sms-message",
440
+ description: [
441
+ "Send an SMS message from the user's mobile device.",
442
+ "Blocks until the device responds (up to 30 seconds).",
443
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
444
+ ],
445
+ inputSchema: {
446
+ type: "object",
447
+ properties: {
448
+ to: { type: "string", description: "Recipient phone number" },
449
+ body: { type: "string", description: "Message text" },
450
+ },
451
+ required: ["to", "body"],
452
+ },
453
+ async handler(args, ctx) {
454
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
455
+
456
+ const device = getCapabilityDevice("sms");
457
+ if (!device) throw new ToolError("No device has SMS access enabled", 400);
458
+
459
+ const { to, body } = args as { to: string; body: string };
460
+ if (!to || !body) throw new ToolError("to and body are required", 400);
461
+
462
+ const sc = StringCodec();
463
+
464
+ const ackReply = await ctx.nc.request(
465
+ `host.${ctx.config.hostId}.fcm.sms`,
466
+ sc.encode(JSON.stringify({
467
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
468
+ action: "send", to, body,
469
+ })),
470
+ { timeout: 5_000 },
471
+ );
472
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
473
+ if (ack.error) throw new ToolError(ack.error, 502);
474
+
475
+ const responsePromise = new Promise<string>((resolve, reject) => {
476
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.sms.${ctx.sessionId}`, { max: 1 });
477
+ const timer = setTimeout(() => {
478
+ sub.unsubscribe();
479
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
480
+ }, 30_000);
481
+
482
+ (async () => {
483
+ for await (const msg of sub) {
484
+ clearTimeout(timer);
485
+ resolve(sc.decode(msg.data));
486
+ }
487
+ })();
488
+ });
489
+
490
+ const result = JSON.parse(await responsePromise);
491
+ if (result.error) return { error: result.error };
492
+ return result;
493
+ },
494
+ };
495
+
496
+ const sendAlertTool: ToolDefinition = {
497
+ name: "send-alert",
498
+ description: [
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.",
501
+ "Blocks until the device responds (up to 30 seconds).",
502
+ 'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
503
+ ],
504
+ inputSchema: {
505
+ type: "object",
506
+ properties: {
507
+ title: { type: "string", description: "Alert title" },
508
+ description: { type: "string", description: "Alert description/details" },
509
+ },
510
+ required: ["title"],
511
+ },
512
+ async handler(args, ctx) {
513
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
514
+
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);
520
+
521
+ const sc = StringCodec();
522
+
523
+ const payload: Record<string, string> = {
524
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
525
+ title,
526
+ };
527
+ if (description) payload.description = description;
528
+
529
+ const ackReply = await ctx.nc.request(
530
+ `host.${ctx.config.hostId}.fcm.alert`,
531
+ sc.encode(JSON.stringify(payload)),
532
+ { timeout: 5_000 },
533
+ );
534
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
535
+ if (ack.error) throw new ToolError(ack.error, 502);
536
+
537
+ const responsePromise = new Promise<string>((resolve, reject) => {
538
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
539
+ const timer = setTimeout(() => {
540
+ sub.unsubscribe();
541
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
542
+ }, 30_000);
543
+
544
+ (async () => {
545
+ for await (const msg of sub) {
546
+ clearTimeout(timer);
547
+ resolve(sc.decode(msg.data));
548
+ }
549
+ })();
550
+ });
551
+
552
+ const result = JSON.parse(await responsePromise);
553
+ if (result.error) return { error: result.error };
554
+ return result;
555
+ },
556
+ };
557
+
558
+ const readBatteryTool: ToolDefinition = {
559
+ name: "read-battery",
560
+ description: [
561
+ "Get the battery level and charging status of the user's mobile device.",
562
+ "Blocks until the device responds (up to 30 seconds).",
563
+ 'Response: `{"level": 85, "charging": true}` on success, or `{"error": "..."}` on failure.',
564
+ ],
565
+ inputSchema: {
566
+ type: "object",
567
+ properties: {},
568
+ },
569
+ async handler(_args, ctx) {
570
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
571
+
572
+ const device = getCapabilityDevice("battery");
573
+ if (!device) throw new ToolError("No device has battery access enabled", 400);
574
+
575
+ const sc = StringCodec();
576
+
577
+ const ackReply = await ctx.nc.request(
578
+ `host.${ctx.config.hostId}.fcm.battery`,
579
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })),
580
+ { timeout: 5_000 },
581
+ );
582
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
583
+ if (ack.error) throw new ToolError(ack.error, 502);
584
+
585
+ const responsePromise = new Promise<string>((resolve, reject) => {
586
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.battery.${ctx.sessionId}`, { max: 1 });
587
+ const timer = setTimeout(() => {
588
+ sub.unsubscribe();
589
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
590
+ }, 30_000);
591
+
592
+ (async () => {
593
+ for await (const msg of sub) {
594
+ clearTimeout(timer);
595
+ resolve(sc.decode(msg.data));
596
+ }
597
+ })();
598
+ });
599
+
600
+ const result = JSON.parse(await responsePromise);
601
+ if (result.error) return { error: result.error };
602
+ return result;
603
+ },
604
+ };
605
+
606
+ const setRingerModeTool: ToolDefinition = {
607
+ name: "set-ringer-mode",
608
+ description: [
609
+ "Set the phone's ringer mode. Requires Do Not Disturb access on the device.",
610
+ "Blocks until the device responds (up to 30 seconds).",
611
+ 'Response: `{"ok": true, "mode": "silent"}` on success, or `{"error": "..."}` on failure.',
612
+ ],
613
+ inputSchema: {
614
+ type: "object",
615
+ properties: {
616
+ mode: { type: "string", description: "Ringer mode: 'normal', 'vibrate', or 'silent'" },
617
+ },
618
+ required: ["mode"],
619
+ },
620
+ async handler(args, ctx) {
621
+ if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
622
+
623
+ const device = getCapabilityDevice("dnd");
624
+ if (!device) throw new ToolError("No device has Do Not Disturb control enabled", 400);
625
+
626
+ const { mode } = args as { mode: string };
627
+ if (!["normal", "vibrate", "silent"].includes(mode)) throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
628
+
629
+ const sc = StringCodec();
630
+
631
+ const ackReply = await ctx.nc.request(
632
+ `host.${ctx.config.hostId}.fcm.ringer`,
633
+ sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, mode })),
634
+ { timeout: 5_000 },
635
+ );
636
+ const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
637
+ if (ack.error) throw new ToolError(ack.error, 502);
638
+
639
+ const responsePromise = new Promise<string>((resolve, reject) => {
640
+ const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.ringer.${ctx.sessionId}`, { max: 1 });
641
+ const timer = setTimeout(() => {
642
+ sub.unsubscribe();
643
+ reject(new ToolError("Device did not respond within 30 seconds", 504));
644
+ }, 30_000);
645
+
646
+ (async () => {
647
+ for await (const msg of sub) {
648
+ clearTimeout(timer);
649
+ resolve(sc.decode(msg.data));
650
+ }
651
+ })();
652
+ });
653
+
654
+ const result = JSON.parse(await responsePromise);
655
+ if (result.error) return { error: result.error };
656
+ return result;
657
+ },
658
+ };
659
+
660
+ export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendAlertTool, readBatteryTool, setRingerModeTool];
207
661
  export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
208
662
 
209
663
  // ── MCP Resources ─────────────────────────────────────────────────────
@@ -234,7 +688,19 @@ const deviceNotificationsResource: ResourceDefinition = {
234
688
  read: getNotifications,
235
689
  };
236
690
 
237
- export const agentResources: ResourceDefinition[] = [deviceNotificationsResource];
691
+ const deviceSmsResource: ResourceDefinition = {
692
+ uri: "sms-messages://device",
693
+ name: "Device SMS",
694
+ description: [
695
+ "Get recent SMS messages from the user's Android device.",
696
+ "Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
697
+ ],
698
+ mimeType: "application/json",
699
+ restPath: "/sms-messages",
700
+ read: getSmsMessages,
701
+ };
702
+
703
+ export const agentResources: ResourceDefinition[] = [deviceNotificationsResource, deviceSmsResource];
238
704
  export const agentResourceMap = new Map<string, ResourceDefinition>(agentResources.map((r) => [r.uri, r]));
239
705
 
240
706
  /**
@@ -288,7 +754,7 @@ export function generateEndpointDocs(
288
754
 
289
755
  for (const resource of resources) {
290
756
  const [header, ...details] = resource.description;
291
- lines.push(`**\`GET ${baseUrl}${resource.restPath}\`** — ${header}`);
757
+ lines.push(`**\`GET ${baseUrl}${resource.restPath}?taskId=${taskId}\`** — ${header}`);
292
758
  for (const detail of details) {
293
759
  lines.push(`- ${detail}`);
294
760
  }
@@ -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,7 +163,7 @@ 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 locDevice = getCapabilityDevice("location");
167
167
  return {
168
168
  tasks: tasks.map((task) => flattenTask(task)),
169
169
  agents: config.agents ?? [],
@@ -652,12 +652,27 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
652
652
  const params = request.params as { fcmToken: string };
653
653
  if (!params.fcmToken) return { error: "fcmToken is required" };
654
654
  const clientToken = request.clientToken ?? "";
655
- setLocationDevice(clientToken, params.fcmToken);
655
+ setCapabilityDevice("location", clientToken, params.fcmToken);
656
656
  return { ok: true };
657
657
  }
658
658
 
659
659
  case "device.location.disable": {
660
- clearLocationDevice();
660
+ clearCapabilityDevice("location");
661
+ return { ok: true };
662
+ }
663
+
664
+ case "device.capability.enable": {
665
+ const params = request.params as { capability: DeviceCapability; fcmToken: string };
666
+ if (!params.capability || !params.fcmToken) return { error: "capability and fcmToken are required" };
667
+ const clientToken = request.clientToken ?? "";
668
+ setCapabilityDevice(params.capability, clientToken, params.fcmToken);
669
+ return { ok: true };
670
+ }
671
+
672
+ case "device.capability.disable": {
673
+ const params = request.params as { capability: DeviceCapability };
674
+ if (!params.capability) return { error: "capability is required" };
675
+ clearCapabilityDevice(params.capability);
661
676
  return { ok: true };
662
677
  }
663
678
 
@@ -0,0 +1,28 @@
1
+ export interface SmsMessage {
2
+ id: string;
3
+ sender: string;
4
+ body: string;
5
+ timestamp: number;
6
+ receivedAt: number;
7
+ }
8
+
9
+ const MAX_MESSAGES = 50;
10
+ const messages: SmsMessage[] = [];
11
+ const listeners = new Set<() => void>();
12
+
13
+ export function addSmsMessage(m: SmsMessage): void {
14
+ messages.push(m);
15
+ if (messages.length > MAX_MESSAGES) {
16
+ messages.shift();
17
+ }
18
+ for (const cb of listeners) cb();
19
+ }
20
+
21
+ export function getSmsMessages(): SmsMessage[] {
22
+ return [...messages];
23
+ }
24
+
25
+ export function onSmsChanged(cb: () => void): () => void {
26
+ listeners.add(cb);
27
+ return () => { listeners.delete(cb); };
28
+ }
@@ -10,6 +10,7 @@ import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mc
10
10
  import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
11
11
  import { getTaskDir } from "../task.js";
12
12
  import { onNotificationsChanged } from "../notification-store.js";
13
+ import { onSmsChanged } from "../sms-store.js";
13
14
 
14
15
  // ── Bundled PWA asset serving ───────────────────────────────────────────
15
16
 
@@ -124,6 +125,7 @@ export async function startHttpTransport(
124
125
 
125
126
  // Wire up resource change listeners
126
127
  onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
128
+ onSmsChanged(() => broadcastResourceUpdated("sms-messages://device"));
127
129
 
128
130
  // If a pairing code is provided, pre-register it
129
131
  if (pairingCode) {
@@ -262,7 +264,20 @@ export async function startHttpTransport(
262
264
  const matchedResource = req.method === "GET" && agentResources.find((r) => r.restPath === pathname);
263
265
  if (matchedResource) {
264
266
  if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
265
- sendJson(res, 200, matchedResource.read());
267
+ const taskId = url.searchParams.get("taskId");
268
+ if (!taskId) {
269
+ sendJson(res, 400, { error: "taskId query parameter is required" });
270
+ return;
271
+ }
272
+ const taskDir = getTaskDir(config.projectRoot, taskId);
273
+ if (!fs.existsSync(taskDir)) {
274
+ sendJson(res, 404, { error: `Task not found: ${taskId}` });
275
+ return;
276
+ }
277
+ console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${matchedResource.name}`);
278
+ const result = matchedResource.read();
279
+ console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${matchedResource.name} done: ${JSON.stringify(result).slice(0, 200)}`);
280
+ sendJson(res, 200, result);
266
281
  return;
267
282
  }
268
283
 
@@ -141,7 +141,7 @@ describe("generateEndpointDocs", () => {
141
141
  "- Blocks until the device responds.",
142
142
  '- Response: `{"data": ...}` on success.',
143
143
  "",
144
- "**`GET http://localhost:9966/mock-data`** — Get mock data from the device.",
144
+ "**`GET http://localhost:9966/mock-data?taskId=test-id`** — Get mock data from the device.",
145
145
  "- Response: JSON array of data objects.",
146
146
  ].join("\n");
147
147
  assert.equal(docs, expected);
@@ -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