palmier 0.8.0 → 0.8.1

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.
@@ -256,9 +256,15 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
256
256
  <div className="sessions-card-body">
257
257
  <h3 className="sessions-card-name">{entry.task_name || entry.task_id}</h3>
258
258
  <div className="sessions-card-meta">
259
- <span style={{ color: stateColor(entry.running_state) }}>
260
- {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
261
- </span>
259
+ {entry.running_state === "started" ? (
260
+ <span className="status-spinner" aria-label="Running">
261
+ <span />
262
+ </span>
263
+ ) : (
264
+ <span style={{ color: stateColor(entry.running_state) }}>
265
+ {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
266
+ </span>
267
+ )}
262
268
  {entry.end_time && <span>{formatTime(entry.end_time)}</span>}
263
269
  {entry.start_time && entry.end_time && (
264
270
  <span style={{ color: "var(--color-muted)" }}>
@@ -23,7 +23,9 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
23
23
  const menuRef = useRef<HTMLDivElement>(null);
24
24
 
25
25
  const isRunning = lastEvent?.running_state === "started";
26
- const scheduleActive = !!task.schedule_enabled && !!task.schedule_type && (task.schedule_values?.length ?? 0) > 0;
26
+ const hasScheduleValues = (task.schedule_values?.length ?? 0) > 0;
27
+ const isEventSchedule = task.schedule_type === "on_new_notification" || task.schedule_type === "on_new_sms";
28
+ const scheduleActive = !!task.schedule_enabled && !!task.schedule_type && (hasScheduleValues || isEventSchedule);
27
29
  const stateColor =
28
30
  !scheduleActive
29
31
  ? "var(--color-text-secondary)"
@@ -126,8 +128,14 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
126
128
  return `${c.kind.charAt(0).toUpperCase() + c.kind.slice(1)}: ${c.detail}`;
127
129
  }
128
130
 
129
- function formatScheduleGrouped(scheduleType: "crons" | "specific_times" | undefined, values: string[] | undefined): string {
130
- if (!scheduleType || !values || values.length === 0) return "";
131
+ function formatScheduleGrouped(
132
+ scheduleType: "crons" | "specific_times" | "on_new_notification" | "on_new_sms" | undefined,
133
+ values: string[] | undefined,
134
+ ): string {
135
+ if (!scheduleType) return "";
136
+ if (scheduleType === "on_new_notification") return "On new push notification";
137
+ if (scheduleType === "on_new_sms") return "On new SMS";
138
+ if (!values || values.length === 0) return "";
131
139
  if (values.length === 1) return formatSingleValue(scheduleType, values[0]);
132
140
 
133
141
  const classified = values.map((v) => classifyValue(scheduleType, v));
@@ -214,7 +222,7 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
214
222
  {" "}{formatTime(lastEvent.time_stamp)}
215
223
  </span>
216
224
  )}
217
- {task.schedule_type && (task.schedule_values?.length ?? 0) > 0 && (
225
+ {task.schedule_type && (hasScheduleValues || isEventSchedule) && (
218
226
  <span className="task-card-triggers">
219
227
  {task.schedule_enabled ? scheduleText : "Schedule disabled"}
220
228
  </span>
@@ -4,19 +4,24 @@ import PlanDialog from "./PlanDialog";
4
4
  import { useBackClose } from "../hooks/useBackClose";
5
5
  import type { AgentInfo, Task } from "../types";
6
6
 
7
- type ScheduleType = "crons" | "specific_times";
7
+ type ScheduleType = "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
8
+ type EventMode = "on_new_notification" | "on_new_sms";
8
9
 
9
10
 
10
11
  const DAYS_OF_WEEK = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
11
12
 
12
13
  type ScheduleSlot = "specific_times" | "hourly" | "daily" | "weekly" | "monthly";
13
- type ScheduleMode = "ondemand" | "command" | ScheduleSlot;
14
+ type ScheduleMode = "ondemand" | "command" | EventMode | ScheduleSlot;
14
15
 
15
16
  const SCHEDULE_SLOTS: readonly ScheduleMode[] = ["specific_times", "hourly", "daily", "weekly", "monthly"];
16
17
  function isScheduleSlot(mode: ScheduleMode): mode is ScheduleSlot {
17
18
  return (SCHEDULE_SLOTS as readonly string[]).includes(mode);
18
19
  }
19
20
 
21
+ function isEventMode(mode: ScheduleMode): mode is EventMode {
22
+ return mode === "on_new_notification" || mode === "on_new_sms";
23
+ }
24
+
20
25
  interface TriggerRow {
21
26
  schedule: ScheduleSlot;
22
27
  time: string;
@@ -41,7 +46,7 @@ function cronToRow(cron: string): TriggerRow {
41
46
  return { ...newRow("daily"), time };
42
47
  }
43
48
 
44
- function valueToRow(scheduleType: ScheduleType, value: string): TriggerRow {
49
+ function valueToRow(scheduleType: "crons" | "specific_times", value: string): TriggerRow {
45
50
  if (scheduleType === "specific_times") {
46
51
  const [datePart, timePart] = value.split("T");
47
52
  return { ...newRow("specific_times"), onceDate: datePart ?? "", onceTime: (timePart ?? "09:00").slice(0, 5) };
@@ -67,15 +72,21 @@ function rowToValue(row: TriggerRow): string | null {
67
72
  return rowToCron(row);
68
73
  }
69
74
 
70
- function modeToScheduleType(mode: ScheduleSlot): ScheduleType {
71
- return mode === "specific_times" ? "specific_times" : "crons";
75
+ function modeToScheduleType(mode: ScheduleSlot | EventMode): ScheduleType {
76
+ if (mode === "specific_times") return "specific_times";
77
+ if (mode === "on_new_notification") return "on_new_notification";
78
+ if (mode === "on_new_sms") return "on_new_sms";
79
+ return "crons";
72
80
  }
73
81
 
74
82
  function initialScheduleMode(initial?: Task): ScheduleMode {
75
- if (initial?.command) return "command";
76
- const type = initial?.schedule_type;
77
- const first = initial?.schedule_values?.[0];
78
- if (!type || !first) return "ondemand";
83
+ if (!initial) return "daily";
84
+ if (initial.command) return "command";
85
+ const type = initial.schedule_type;
86
+ if (type === "on_new_notification" || type === "on_new_sms") return type;
87
+ if (type !== "crons" && type !== "specific_times") return "ondemand";
88
+ const first = initial.schedule_values?.[0];
89
+ if (!first) return "ondemand";
79
90
  return valueToRow(type, first).schedule;
80
91
  }
81
92
 
@@ -113,13 +124,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
113
124
  () => {
114
125
  const type = initial?.schedule_type;
115
126
  const values = initial?.schedule_values;
116
- if (!type || !values) return [];
117
- return values.map((v) => valueToRow(type, v));
127
+ if (values && (type === "crons" || type === "specific_times")) {
128
+ return values.map((v) => valueToRow(type, v));
129
+ }
130
+ const mode = initialScheduleMode(initial);
131
+ return isScheduleSlot(mode) ? [newRow(mode)] : [];
118
132
  }
119
133
  );
120
134
  const [requiresConfirmation, setRequiresConfirmation] = useState(
121
135
  initial?.requires_confirmation ?? false
122
136
  );
137
+ const [scheduleEnabled, setScheduleEnabled] = useState(initial?.schedule_enabled ?? true);
123
138
  const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
124
139
  const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
125
140
  const [command, setCommand] = useState(initial?.command ?? "");
@@ -129,6 +144,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
129
144
 
130
145
  const modeIsScheduled = isScheduleSlot(scheduleMode);
131
146
  const modeIsCommand = scheduleMode === "command";
147
+ const modeIsEvent = isEventMode(scheduleMode);
132
148
 
133
149
  const selectedAgent = agents.find((a) => a.key === agent);
134
150
  const agentSupportsYolo = !!selectedAgent?.supportsYolo;
@@ -145,13 +161,15 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
145
161
  || agent !== (initial?.agent ?? "")
146
162
  || scheduleMode !== initialMode
147
163
  || requiresConfirmation !== (initial?.requires_confirmation ?? false)
164
+ || scheduleEnabled !== (initial?.schedule_enabled ?? true)
148
165
  || yoloMode !== (initial?.yolo_mode ?? false)
149
166
  || foregroundMode !== (initial?.foreground_mode ?? false)
150
167
  || (modeIsCommand && command !== (initial?.command ?? ""))
151
168
  || (modeIsScheduled && (
152
169
  JSON.stringify(collectScheduleValues()) !== JSON.stringify(initial?.schedule_values ?? [])
153
170
  || modeToScheduleType(scheduleMode as ScheduleSlot) !== (initial?.schedule_type ?? undefined)
154
- ));
171
+ ))
172
+ || (modeIsEvent && scheduleMode !== initial?.schedule_type);
155
173
 
156
174
  const hasInvalidTrigger = modeIsScheduled && triggerRows.some((r) =>
157
175
  r.schedule === "specific_times" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
@@ -172,7 +190,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
172
190
 
173
191
  function addRow() {
174
192
  if (!isScheduleSlot(scheduleMode)) return;
175
- setTriggerRows((prev) => [...prev, newRow(scheduleMode)]);
193
+ setTriggerRows((prev) => [...prev, prev.length > 0 ? { ...prev[prev.length - 1] } : newRow(scheduleMode)]);
176
194
  }
177
195
 
178
196
  function changeScheduleMode(next: ScheduleMode) {
@@ -212,14 +230,18 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
212
230
  setError(null);
213
231
  try {
214
232
  const scheduleValues = modeIsScheduled ? collectScheduleValues() : [];
215
- const hasSchedule = scheduleValues.length > 0;
233
+ const scheduleType: ScheduleType | null = modeIsScheduled
234
+ ? modeToScheduleType(scheduleMode as ScheduleSlot)
235
+ : modeIsEvent
236
+ ? modeToScheduleType(scheduleMode as EventMode)
237
+ : null;
216
238
  const payload: Record<string, unknown> = {
217
239
  user_prompt: userPrompt,
218
240
  agent,
219
- schedule_type: hasSchedule ? modeToScheduleType(scheduleMode as ScheduleSlot) : null,
220
- schedule_values: hasSchedule ? scheduleValues : null,
221
- schedule_enabled: hasSchedule,
222
- requires_confirmation: hasSchedule ? requiresConfirmation : false,
241
+ schedule_type: scheduleType,
242
+ schedule_values: scheduleValues.length > 0 ? scheduleValues : null,
243
+ schedule_enabled: scheduleMode !== "ondemand" && scheduleEnabled,
244
+ requires_confirmation: modeIsScheduled ? requiresConfirmation : false,
223
245
  yolo_mode: yoloMode,
224
246
  foreground_mode: foregroundMode,
225
247
  command: modeIsCommand ? command : "",
@@ -235,7 +257,9 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
235
257
  }
236
258
  if (!isEdit) localStorage.setItem("palmier:lastAgent", agent);
237
259
 
238
- // Reactive on create: save the task, then start it and navigate to the run.
260
+ // Command-triggered on create: save the task, then start it and navigate
261
+ // to the run. Event-triggered tasks are started by the daemon in response
262
+ // to the next NATS event, so we just save.
239
263
  if (modeIsCommand && !isEdit) {
240
264
  onSaved(result);
241
265
  try {
@@ -257,7 +281,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
257
281
  const saveButtonLabel = (() => {
258
282
  if (isEdit) return "Save";
259
283
  if (modeIsCommand) return "Run";
260
- if (modeIsScheduled) return "Schedule";
284
+ if ((modeIsScheduled || modeIsEvent) && scheduleEnabled) return "Schedule";
261
285
  return "Save";
262
286
  })();
263
287
 
@@ -325,18 +349,6 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
325
349
  </div>
326
350
 
327
351
  <div className="toggles-group">
328
- {hostPlatform === "win32" && (
329
- <label className="toggle-label">
330
- <input
331
- type="checkbox"
332
- checked={foregroundMode}
333
- onChange={(e) => setForegroundMode(e.target.checked)}
334
- disabled={saving}
335
- />
336
- Run in the foreground (host must login to Windows)
337
- </label>
338
- )}
339
-
340
352
  <div className="schedule-section">
341
353
  <h3 className="schedule-section-title">Schedule</h3>
342
354
  <select
@@ -351,9 +363,22 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
351
363
  <option value="daily">Daily</option>
352
364
  <option value="weekly">Weekly</option>
353
365
  <option value="monthly">Monthly</option>
366
+ <option value="on_new_notification">On New Push Notification</option>
367
+ <option value="on_new_sms">On New SMS</option>
354
368
  <option value="command">Command-triggered</option>
355
369
  </select>
356
370
 
371
+ {modeIsEvent && (
372
+ <div className="schedule-reactive">
373
+ <p className="command-help-text">
374
+ {scheduleMode === "on_new_notification"
375
+ ? "Runs each time a new notification arrives on the paired Android device."
376
+ : "Runs each time a new SMS arrives on the paired Android device."}
377
+ {" "}The triggering payload is spliced into your task prompt — reference it as &ldquo;the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}&rdquo;.
378
+ </p>
379
+ </div>
380
+ )}
381
+
357
382
  {modeIsCommand && (
358
383
  <div className="schedule-reactive">
359
384
  <p className="command-help-text">
@@ -461,6 +486,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
461
486
  </>
462
487
  )}
463
488
 
489
+ {hostPlatform === "win32" && (
490
+ <label className="toggle-label">
491
+ <input
492
+ type="checkbox"
493
+ checked={foregroundMode}
494
+ onChange={(e) => setForegroundMode(e.target.checked)}
495
+ disabled={saving}
496
+ />
497
+ Run in the foreground (host must login to Windows)
498
+ </label>
499
+ )}
464
500
  {modeIsScheduled && (
465
501
  <label className="toggle-label">
466
502
  <input
@@ -472,6 +508,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
472
508
  Confirm before each run
473
509
  </label>
474
510
  )}
511
+ {scheduleMode !== "ondemand" && (
512
+ <label className="toggle-label">
513
+ <input
514
+ type="checkbox"
515
+ checked={scheduleEnabled}
516
+ onChange={(e) => setScheduleEnabled(e.target.checked)}
517
+ disabled={saving}
518
+ />
519
+ Enabled
520
+ </label>
521
+ )}
475
522
  </div>
476
523
  </div>
477
524
 
@@ -122,6 +122,11 @@ export default function TasksView({ connected, hostId, request, subscribeEvents,
122
122
  </div>
123
123
  ))}
124
124
  </div>
125
+ ) : tasks.length === 0 ? (
126
+ <div className="empty-state">
127
+ <p className="empty-state-text">No tasks yet</p>
128
+ <p className="empty-state-hint">Tap the + button to create your first task.</p>
129
+ </div>
125
130
  ) : (
126
131
  <div className="task-list">
127
132
  {tasks.map((task) => (
@@ -1,2 +1,2 @@
1
1
  /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.7.8";
2
+ export const MIN_HOST_VERSION = "0.8.0";
@@ -0,0 +1,48 @@
1
+ import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core";
2
+
3
+ export type PermissionType =
4
+ | "location"
5
+ | "sms"
6
+ | "contacts"
7
+ | "calendar"
8
+ | "notificationListener"
9
+ | "dnd"
10
+ | "fullScreenIntent";
11
+
12
+ export interface PermissionResult {
13
+ granted: boolean;
14
+ /**
15
+ * False when the native build doesn't recognize this permission type — the PWA
16
+ * is served remotely and may ship ahead of the installed APK. Callers should
17
+ * treat unsupported types as "cannot enable" rather than as a hard error.
18
+ */
19
+ supported: boolean;
20
+ }
21
+
22
+ export interface DeepLinkEvent {
23
+ path: string;
24
+ }
25
+
26
+ interface DevicePlugin {
27
+ getFcmToken(): Promise<{ token: string }>;
28
+ /** Returns the set of PermissionType strings this native build understands. */
29
+ getSupportedPermissions(): Promise<{ types: PermissionType[] }>;
30
+ checkPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
31
+ requestPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
32
+ /**
33
+ * Authoritative list of capabilities the user has enabled on this device.
34
+ * Native receivers + handlers consult this as a local kill-switch. Unknown
35
+ * capability names are stored but ignored — safe for PWAs that ship new caps
36
+ * ahead of the installed APK.
37
+ */
38
+ setEnabledCapabilities(opts: { capabilities: string[] }): Promise<void>;
39
+ addListener(
40
+ event: "deepLink",
41
+ handler: (ev: DeepLinkEvent) => void
42
+ ): Promise<PluginListenerHandle>;
43
+ }
44
+
45
+ /** Null on web — callers must guard with Capacitor.isNativePlatform(). */
46
+ export const Device = Capacitor.isNativePlatform()
47
+ ? registerPlugin<DevicePlugin>("Device")
48
+ : null;
@@ -4,6 +4,7 @@ import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
4
4
  import { Capacitor } from "@capacitor/core";
5
5
  import { Preferences } from "@capacitor/preferences";
6
6
  import { useHostStore } from "../contexts/HostStoreContext";
7
+ import { Device } from "../native/Device";
7
8
  import { SERVER_URL } from "../api";
8
9
  import type { PairedHost } from "../types";
9
10
 
@@ -85,9 +86,24 @@ export default function PairHost() {
85
86
 
86
87
  addPairedHost(host);
87
88
 
88
- // Write hostId to native SharedPreferences for FCM token registration
89
- if (Capacitor.isNativePlatform()) {
89
+ if (Capacitor.isNativePlatform() && Device) {
90
+ // Native receivers (SmsBroadcastReceiver, DeviceNotificationListenerService)
91
+ // read hostId to address relay messages.
90
92
  await Preferences.set({ key: "hostId", value: response.hostId });
93
+
94
+ // Register this device's FCM token with the relay server so it can wake
95
+ // the device on the paired host's behalf. Moved here from native so the
96
+ // APK no longer needs to read hostId or trigger registration itself.
97
+ try {
98
+ const { token: fcmToken } = await Device.getFcmToken();
99
+ await fetch(`${SERVER_URL}/api/fcm/register`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ hostId: response.hostId, fcmToken }),
103
+ });
104
+ } catch (err) {
105
+ console.warn("FCM token registration failed:", err);
106
+ }
91
107
  }
92
108
 
93
109
  navigate("/");
@@ -11,7 +11,7 @@ export interface Task {
11
11
  name: string;
12
12
  user_prompt: string;
13
13
  agent?: string;
14
- schedule_type?: "crons" | "specific_times";
14
+ schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
15
15
  schedule_values?: string[];
16
16
  schedule_enabled: boolean;
17
17
  requires_confirmation: boolean;
@@ -223,7 +223,14 @@ requires_confirmation: true
223
223
  ---
224
224
  ```
225
225
 
226
- `schedule_type` is either `"crons"` (cron expressions) or `"specific_times"` (local datetime strings like `"2026-04-20T09:00"`). `schedule_values` is a homogeneous array whose elements match the type. Both fields are present together or absent together; absence means the task has no schedule and can only be run manually.
226
+ `schedule_type` is one of:
227
+
228
+ * `"crons"` — `schedule_values` holds cron expressions.
229
+ * `"specific_times"` — `schedule_values` holds local datetime strings (e.g. `"2026-04-20T09:00"`).
230
+ * `"on_new_notification"` — fires once per new Android notification relayed over NATS. No `schedule_values`.
231
+ * `"on_new_sms"` — fires once per new SMS relayed over NATS. No `schedule_values`.
232
+
233
+ For `crons` / `specific_times` the schedule is installed as an OS timer (systemd / Task Scheduler). For the `on_new_*` types the `palmier run` process subscribes directly to the corresponding NATS subject (`host.<hostId>.device.notifications` or `...device.sms`), drains events through a bounded FIFO queue, and invokes the agent once per event with the event payload spliced into the user prompt (mirroring command-triggered mode). These event-driven tasks are not started on save — they launch via `task.run` (the PWA auto-runs them on create, matching command-triggered UX).
227
234
 
228
235
  The `name` field is auto-generated by spawning the configured agent CLI with a short prompt (for prompts > 50 chars). For shorter prompts, the `user_prompt` is used directly as the name.
229
236
 
@@ -236,6 +243,7 @@ The optional `command` field stores a shell command for command-triggered tasks.
236
243
  * **`schedule_enabled`:** Controls whether systemd timers are installed for the task's schedule. When `false`, all timers are removed; when toggled back to `true`, timers are reinstalled. Defaults to `true`. The task can still be run manually via "Run Now" regardless of this setting. The "Enable Schedule" checkbox only appears in the UI when the task has a schedule.
237
244
  * **`crons` schedules:** Persist indefinitely. The systemd timer remains active until the task is deleted or the schedule is disabled.
238
245
  * **`specific_times` schedules:** After a value fires, it is removed from `schedule_values` and its corresponding systemd timer/service files are cleaned up. Once all values have fired the schedule is cleared, and the task remains in the `tasks/` directory as a manual task (can still be executed on-demand via the PWA or CLI, but will not fire automatically again).
246
+ * **`on_new_notification` / `on_new_sms` schedules:** No OS timers are installed. Instead, the serve daemon subscribes to the matching NATS subject (`host.<host_id>.device.notifications` / `.sms`), maintains a per-task in-memory FIFO queue (max 100 entries; overflow drops the oldest), and spawns `palmier run <id>` via the OS scheduler when the task transitions from idle to active (tracked by an `active_run` flag flipped atomically on the empty-pop). The run process drains the queue by calling `POST /task-event/pop?taskId=<id>` on the localhost daemon: each non-empty response is spliced into the user prompt (`user_prompt + "\n\nProcess this new notification:\n" + <raw JSON payload>` or `...new SMS...`) and invoked through the standard agent retry loop. When the endpoint returns `{ empty: true }` the active flag is cleared and the run exits; the next incoming NATS event will start a fresh run. These tasks are never auto-run on save — they launch only in response to NATS events. Requires server mode (NATS).
239
247
 
240
248
  ### Task Events
241
249
 
@@ -305,7 +313,7 @@ Dashboard owns the always-on NATS event subscription and renders pending `confir
305
313
 
306
314
  4. For updates: if the user changes the `user_prompt` or `agent`, the name is regenerated. If neither changed, the existing name is preserved. Existing tasks with granted permissions show a clickable "Granted Permissions" link to view them.
307
315
 
308
- 5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. `schedule_type` and `schedule_values` are omitted when the task has no schedule.
316
+ 5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. `schedule_type` and `schedule_values` are omitted when the task has no schedule; `schedule_values` is also omitted for the `on_new_notification` / `on_new_sms` types.
309
317
 
310
318
  6. Host creates/updates the `tasks/<task-id>/TASK.md` file and returns the **full flat task object** (all frontmatter fields at the top level). The PWA uses this response directly to update the UI.
311
319
 
@@ -407,6 +415,8 @@ The serve daemon exposes localhost-only HTTP endpoints that agents call during t
407
415
 
408
416
  * **`POST /request-permission`** — Requests permission grants. Body: `{ taskId, taskName, permissions }`. Called by `palmier run` (not agents). Returns `{ response: "granted" | "granted_all" | "aborted" }`.
409
417
 
418
+ * **`POST /task-event/pop?taskId=<id>`** — Drains one queued event for an event-triggered task. Called by `palmier run` (not agents). Atomically: if the task's in-memory FIFO queue is non-empty, returns `{ event: "<raw JSON payload>" }` and keeps `active_run = true`; if empty, clears `active_run` and returns `{ empty: true }`. Used only for `schedule_type: "on_new_notification" | "on_new_sms"` tasks.
419
+
410
420
  ### 6.2 Resource Endpoints
411
421
 
412
422
  Resource REST endpoints are auto-generated from the `ResourceDefinition[]` registry in `mcp-tools.ts`. Each resource exposes a GET endpoint at its `restPath`. These are also available via the MCP protocol (`resources/list`, `resources/read`).
@@ -17,7 +17,7 @@ export type DeviceCapability =
17
17
  | "calendar"
18
18
  | "alert"
19
19
  | "battery"
20
- | "email"
20
+ | "send-email"
21
21
  | "dnd";
22
22
 
23
23
  type CapabilityMap = Partial<Record<DeviceCapability, RegisteredDevice>>;
package/src/mcp-tools.ts CHANGED
@@ -687,8 +687,8 @@ const sendEmailTool: ToolDefinition = {
687
687
  async handler(args, ctx) {
688
688
  if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
689
689
 
690
- const device = getCapabilityDevice("email");
691
- if (!device) throw new ToolError("No device has email access enabled", 400);
690
+ const device = getCapabilityDevice("send-email");
691
+ if (!device) throw new ToolError("No device has send-email access enabled", 400);
692
692
 
693
693
  const { to, subject, body, cc, bcc } = args as { to: string; subject?: string; body?: string; cc?: string; bcc?: string };
694
694
  if (!to) throw new ToolError("to is required", 400);
@@ -163,8 +163,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
163
163
  // is active. Includes any prompts already waiting so a reconnecting
164
164
  // PWA can render their modals without replaying events.
165
165
  const capabilities: Record<string, string | null> = {};
166
- for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "email"] as const) {
167
- capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
166
+ for (const capability of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "send-email"] as const) {
167
+ capabilities[capability] = getCapabilityDevice(capability)?.clientToken ?? null;
168
168
  }
169
169
  return {
170
170
  agents: config.agents ?? [],