palmier 0.7.9 → 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.
Files changed (41) hide show
  1. package/dist/commands/run.js +55 -0
  2. package/dist/commands/serve.js +22 -2
  3. package/dist/device-capabilities.d.ts +1 -1
  4. package/dist/event-queues.d.ts +36 -0
  5. package/dist/event-queues.js +53 -0
  6. package/dist/mcp-tools.js +2 -2
  7. package/dist/platform/windows.js +5 -2
  8. package/dist/pwa/assets/index-CQxcuDhM.css +1 -0
  9. package/dist/pwa/assets/index-DQfOEB03.js +120 -0
  10. package/dist/pwa/assets/{web-CF-N8Di6.js → web-D7Kq3Nvk.js} +1 -1
  11. package/dist/pwa/assets/{web-BpM3fNCn.js → web-DOyOiwsW.js} +1 -1
  12. package/dist/pwa/index.html +2 -2
  13. package/dist/pwa/service-worker.js +1 -1
  14. package/dist/rpc-handler.js +6 -5
  15. package/dist/transports/http-transport.js +15 -0
  16. package/dist/types.d.ts +6 -5
  17. package/package.json +1 -1
  18. package/palmier-server/README.md +1 -1
  19. package/palmier-server/pwa/src/App.css +5 -0
  20. package/palmier-server/pwa/src/App.tsx +15 -1
  21. package/palmier-server/pwa/src/components/HostMenu.tsx +155 -456
  22. package/palmier-server/pwa/src/components/SessionsView.tsx +9 -3
  23. package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
  24. package/palmier-server/pwa/src/components/TaskForm.tsx +79 -32
  25. package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
  26. package/palmier-server/pwa/src/constants.ts +1 -1
  27. package/palmier-server/pwa/src/native/Device.ts +48 -0
  28. package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
  29. package/palmier-server/pwa/src/types.ts +1 -1
  30. package/palmier-server/spec.md +12 -2
  31. package/src/commands/run.ts +61 -0
  32. package/src/commands/serve.ts +22 -2
  33. package/src/device-capabilities.ts +1 -1
  34. package/src/event-queues.ts +56 -0
  35. package/src/mcp-tools.ts +2 -2
  36. package/src/platform/windows.ts +5 -2
  37. package/src/rpc-handler.ts +8 -7
  38. package/src/transports/http-transport.ts +14 -0
  39. package/src/types.ts +6 -5
  40. package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
  41. package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
@@ -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`).
@@ -267,6 +267,14 @@ export async function runCommand(taskId: string): Promise<void> {
267
267
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
268
268
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
269
269
  console.log(`Task ${taskId} completed (command-triggered).`);
270
+ } else if (task.frontmatter.schedule_type === "on_new_notification"
271
+ || task.frontmatter.schedule_type === "on_new_sms") {
272
+ // Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
273
+ const result = await runEventTriggeredMode(ctx);
274
+ const outcome = resolveOutcome(taskDir, result.outcome);
275
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
276
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
277
+ console.log(`Task ${taskId} completed (event-triggered).`);
270
278
  } else {
271
279
  // Standard execution — add user prompt as first message
272
280
  await appendAndNotify(ctx, {
@@ -455,6 +463,59 @@ async function runCommandTriggeredMode(
455
463
  return { outcome: "finished", endTime };
456
464
  }
457
465
 
466
+ /**
467
+ * Event-triggered execution mode.
468
+ *
469
+ * Drains the daemon-owned per-task event queue via the local /task-event/pop
470
+ * HTTP endpoint, invoking the agent once per event with the payload spliced
471
+ * into the user prompt. The run process itself holds no NATS subscription;
472
+ * the daemon handles that and atomically clears the active flag when we see
473
+ * an empty pop, so it can fire up a fresh run on the next incoming event.
474
+ */
475
+ async function runEventTriggeredMode(
476
+ ctx: InvocationContext,
477
+ ): Promise<{ outcome: TaskRunningState; endTime: number }> {
478
+ const scheduleType = ctx.task.frontmatter.schedule_type!;
479
+ const label = scheduleType === "on_new_notification" ? "notification" : "SMS";
480
+ const port = ctx.config.httpPort ?? 7256;
481
+ const popUrl = `http://localhost:${port}/task-event/pop?taskId=${encodeURIComponent(ctx.taskId)}`;
482
+
483
+ console.log(`[event-triggered] Draining ${label} queue`);
484
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
485
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
486
+
487
+ let eventsProcessed = 0;
488
+ try {
489
+ // eslint-disable-next-line no-constant-condition
490
+ while (true) {
491
+ const res = await fetch(popUrl, { method: "POST" });
492
+ if (!res.ok) throw new Error(`pop-event failed: ${res.status} ${res.statusText}`);
493
+ const body = await res.json() as { event?: string; empty?: true };
494
+ if (body.empty || !body.event) break;
495
+
496
+ eventsProcessed++;
497
+ console.log(`[event-triggered] Processing ${label} #${eventsProcessed}`);
498
+
499
+ const perEventPrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
500
+ const perEventTask: ParsedTask = {
501
+ frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
502
+ };
503
+
504
+ await invokeAgentWithRetries(ctx, perEventTask);
505
+
506
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
507
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
508
+ }
509
+ } catch (err) {
510
+ const errorMsg = err instanceof Error ? err.message : String(err);
511
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: errorMsg, type: "error" });
512
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
513
+ return { outcome: "failed", endTime: Date.now() };
514
+ }
515
+
516
+ return { outcome: "finished", endTime: Date.now() };
517
+ }
518
+
458
519
  async function publishTaskEvent(
459
520
  nc: NatsConnection | undefined,
460
521
  config: HostConfig,
@@ -15,6 +15,7 @@ import { CONFIG_DIR } from "../config.js";
15
15
  import { StringCodec, type NatsConnection } from "nats";
16
16
  import { addNotification } from "../notification-store.js";
17
17
  import { addSmsMessage } from "../sms-store.js";
18
+ import { enqueueEvent } from "../event-queues.js";
18
19
 
19
20
  const POLL_INTERVAL_MS = 30_000;
20
21
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
@@ -135,27 +136,46 @@ export async function serveCommand(): Promise<void> {
135
136
 
136
137
  // Subscribe to device notifications and SMS from Android
137
138
  const sc = StringCodec();
139
+
140
+ // Dispatch a raw event payload to every task whose schedule matches.
141
+ function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string): void {
142
+ for (const task of listTasks(config.projectRoot)) {
143
+ if (task.frontmatter.schedule_type !== scheduleType) continue;
144
+ if (!task.frontmatter.schedule_enabled) continue;
145
+ const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
146
+ if (shouldStart) {
147
+ platform.startTask(task.frontmatter.id).catch((err) => {
148
+ console.error(`[event-trigger] Failed to start ${task.frontmatter.id}:`, err);
149
+ });
150
+ }
151
+ }
152
+ }
153
+
138
154
  const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
139
155
  (async () => {
140
156
  for await (const msg of notifSub) {
157
+ const raw = sc.decode(msg.data);
141
158
  try {
142
- const data = JSON.parse(sc.decode(msg.data));
159
+ const data = JSON.parse(raw);
143
160
  addNotification({ ...data, receivedAt: Date.now() });
144
161
  } catch (err) {
145
162
  console.error("[nats] Failed to parse device notification:", err);
146
163
  }
164
+ dispatchDeviceEvent("on_new_notification", raw);
147
165
  }
148
166
  })();
149
167
 
150
168
  const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
151
169
  (async () => {
152
170
  for await (const msg of smsSub) {
171
+ const raw = sc.decode(msg.data);
153
172
  try {
154
- const data = JSON.parse(sc.decode(msg.data));
173
+ const data = JSON.parse(raw);
155
174
  addSmsMessage({ ...data, receivedAt: Date.now() });
156
175
  } catch (err) {
157
176
  console.error("[nats] Failed to parse device SMS:", err);
158
177
  }
178
+ dispatchDeviceEvent("on_new_sms", raw);
159
179
  }
160
180
  })();
161
181
  }
@@ -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>>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-task in-memory event queues for event-triggered schedules
3
+ * (schedule_type: "on_new_notification" | "on_new_sms").
4
+ *
5
+ * The daemon owns the NATS subscription and populates these queues; the
6
+ * `palmier run` process drains them via the localhost /task-event/pop HTTP
7
+ * endpoint. `activeRuns` tracks whether a run process is currently draining,
8
+ * so we don't race a fresh startTask with a teardown-phase run.
9
+ *
10
+ * Lifecycle invariants:
11
+ * - activeRuns is cleared atomically inside popEvent when the queue is
12
+ * drained. At that point the calling run has already finished its last
13
+ * agent invocation and is only tearing down.
14
+ * - enqueueEvent returns shouldStart=true only if the task transitioned
15
+ * from idle (no active run) to active — callers must then startTask.
16
+ */
17
+
18
+ const MAX_QUEUE_SIZE = 100;
19
+
20
+ const queues = new Map<string, string[]>();
21
+ const activeRuns = new Set<string>();
22
+
23
+ /**
24
+ * Queue a raw (JSON-string) event payload for a task. Returns whether the
25
+ * caller should now start the run process.
26
+ */
27
+ export function enqueueEvent(taskId: string, payload: string): { shouldStart: boolean } {
28
+ const queue = queues.get(taskId) ?? [];
29
+ if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
30
+ queue.push(payload);
31
+ queues.set(taskId, queue);
32
+
33
+ if (activeRuns.has(taskId)) return { shouldStart: false };
34
+ activeRuns.add(taskId);
35
+ return { shouldStart: true };
36
+ }
37
+
38
+ /**
39
+ * Pop the oldest queued event for a task. Returns `{ event }` when one is
40
+ * available (keeps the task marked active), or `{ empty: true }` after
41
+ * clearing the active flag atomically.
42
+ */
43
+ export function popEvent(taskId: string): { event: string } | { empty: true } {
44
+ const queue = queues.get(taskId);
45
+ if (queue && queue.length > 0) {
46
+ return { event: queue.shift()! };
47
+ }
48
+ activeRuns.delete(taskId);
49
+ return { empty: true };
50
+ }
51
+
52
+ /** Remove any state for a task (called from task.delete). */
53
+ export function clearTaskQueue(taskId: string): void {
54
+ queues.delete(taskId);
55
+ activeRuns.delete(taskId);
56
+ }