palmier 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +11 -11
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/app-registry.d.ts +10 -0
  17. package/dist/app-registry.js +44 -0
  18. package/dist/commands/info.d.ts +0 -3
  19. package/dist/commands/info.js +0 -5
  20. package/dist/commands/init.d.ts +0 -3
  21. package/dist/commands/init.js +2 -11
  22. package/dist/commands/pair.d.ts +1 -4
  23. package/dist/commands/pair.js +1 -12
  24. package/dist/commands/restart.d.ts +0 -3
  25. package/dist/commands/restart.js +0 -3
  26. package/dist/commands/run.d.ts +1 -14
  27. package/dist/commands/run.js +18 -61
  28. package/dist/commands/serve.d.ts +0 -3
  29. package/dist/commands/serve.js +33 -27
  30. package/dist/config.d.ts +0 -8
  31. package/dist/config.js +0 -8
  32. package/dist/device-capabilities.d.ts +1 -1
  33. package/dist/event-queues.d.ts +6 -21
  34. package/dist/event-queues.js +6 -21
  35. package/dist/events.d.ts +0 -6
  36. package/dist/events.js +1 -9
  37. package/dist/index.js +0 -1
  38. package/dist/mcp-handler.js +1 -2
  39. package/dist/mcp-tools.d.ts +0 -3
  40. package/dist/mcp-tools.js +14 -18
  41. package/dist/nats-client.d.ts +0 -3
  42. package/dist/nats-client.js +1 -4
  43. package/dist/pending-requests.d.ts +4 -18
  44. package/dist/pending-requests.js +4 -18
  45. package/dist/platform/index.d.ts +1 -4
  46. package/dist/platform/index.js +1 -4
  47. package/dist/platform/linux.d.ts +3 -9
  48. package/dist/platform/linux.js +9 -20
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-B0F9mtid.css +1 -0
  53. package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
  54. package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
  55. package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +19 -48
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +6 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/README.md +1 -1
  73. package/palmier-server/pwa/src/App.css +170 -20
  74. package/palmier-server/pwa/src/App.tsx +15 -1
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
  78. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  79. package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
  80. package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
  81. package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
  82. package/palmier-server/pwa/src/constants.ts +1 -1
  83. package/palmier-server/pwa/src/native/Device.ts +66 -0
  84. package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
  85. package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
  86. package/palmier-server/pwa/src/types.ts +1 -1
  87. package/palmier-server/server/src/index.ts +7 -7
  88. package/palmier-server/server/src/routes/device.ts +4 -4
  89. package/palmier-server/spec.md +47 -6
  90. package/src/agents/agent.ts +0 -4
  91. package/src/agents/claude.ts +1 -1
  92. package/src/agents/codex.ts +2 -2
  93. package/src/agents/cursor.ts +1 -1
  94. package/src/agents/deepagents.ts +1 -1
  95. package/src/agents/gemini.ts +3 -2
  96. package/src/agents/goose.ts +1 -1
  97. package/src/agents/hermes.ts +1 -1
  98. package/src/agents/kiro.ts +1 -1
  99. package/src/agents/opencode.ts +1 -1
  100. package/src/agents/qoder.ts +1 -1
  101. package/src/agents/shared-prompt.ts +0 -3
  102. package/src/app-registry.ts +52 -0
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +1 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +31 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +4 -3
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +14 -20
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +1 -4
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/platform.ts +1 -4
  121. package/src/platform/windows.ts +19 -40
  122. package/src/rpc-handler.ts +20 -48
  123. package/src/spawn-command.ts +11 -27
  124. package/src/task.ts +7 -70
  125. package/src/transports/http-transport.ts +6 -39
  126. package/src/transports/nats-transport.ts +3 -9
  127. package/src/types.ts +3 -10
  128. package/src/update-checker.ts +2 -5
  129. package/test/task-parsing.test.ts +2 -3
  130. package/test/windows-xml.test.ts +11 -12
  131. package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
  132. package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
@@ -1,22 +1,29 @@
1
1
  import { useEffect, useState, useCallback, useRef } from "react";
2
+ import { Capacitor } from "@capacitor/core";
2
3
  import { useHostConnection } from "../contexts/HostConnectionContext";
3
4
  import PlanDialog from "./PlanDialog";
4
5
  import { useBackClose } from "../hooks/useBackClose";
6
+ import { Device } from "../native/Device";
5
7
  import type { AgentInfo, Task } from "../types";
6
8
 
7
- type ScheduleType = "crons" | "specific_times";
9
+ type ScheduleType = "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
10
+ type EventMode = "on_new_notification" | "on_new_sms";
8
11
 
9
12
 
10
13
  const DAYS_OF_WEEK = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
11
14
 
12
15
  type ScheduleSlot = "specific_times" | "hourly" | "daily" | "weekly" | "monthly";
13
- type ScheduleMode = "ondemand" | "command" | ScheduleSlot;
16
+ type ScheduleMode = "ondemand" | "command" | EventMode | ScheduleSlot;
14
17
 
15
18
  const SCHEDULE_SLOTS: readonly ScheduleMode[] = ["specific_times", "hourly", "daily", "weekly", "monthly"];
16
19
  function isScheduleSlot(mode: ScheduleMode): mode is ScheduleSlot {
17
20
  return (SCHEDULE_SLOTS as readonly string[]).includes(mode);
18
21
  }
19
22
 
23
+ function isEventMode(mode: ScheduleMode): mode is EventMode {
24
+ return mode === "on_new_notification" || mode === "on_new_sms";
25
+ }
26
+
20
27
  interface TriggerRow {
21
28
  schedule: ScheduleSlot;
22
29
  time: string;
@@ -41,7 +48,7 @@ function cronToRow(cron: string): TriggerRow {
41
48
  return { ...newRow("daily"), time };
42
49
  }
43
50
 
44
- function valueToRow(scheduleType: ScheduleType, value: string): TriggerRow {
51
+ function valueToRow(scheduleType: "crons" | "specific_times", value: string): TriggerRow {
45
52
  if (scheduleType === "specific_times") {
46
53
  const [datePart, timePart] = value.split("T");
47
54
  return { ...newRow("specific_times"), onceDate: datePart ?? "", onceTime: (timePart ?? "09:00").slice(0, 5) };
@@ -67,15 +74,21 @@ function rowToValue(row: TriggerRow): string | null {
67
74
  return rowToCron(row);
68
75
  }
69
76
 
70
- function modeToScheduleType(mode: ScheduleSlot): ScheduleType {
71
- return mode === "specific_times" ? "specific_times" : "crons";
77
+ function modeToScheduleType(mode: ScheduleSlot | EventMode): ScheduleType {
78
+ if (mode === "specific_times") return "specific_times";
79
+ if (mode === "on_new_notification") return "on_new_notification";
80
+ if (mode === "on_new_sms") return "on_new_sms";
81
+ return "crons";
72
82
  }
73
83
 
74
84
  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";
85
+ if (!initial) return "daily";
86
+ if (initial.command) return "command";
87
+ const type = initial.schedule_type;
88
+ if (type === "on_new_notification" || type === "on_new_sms") return type;
89
+ if (type !== "crons" && type !== "specific_times") return "ondemand";
90
+ const first = initial.schedule_values?.[0];
91
+ if (!first) return "ondemand";
79
92
  return valueToRow(type, first).schedule;
80
93
  }
81
94
 
@@ -113,22 +126,92 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
113
126
  () => {
114
127
  const type = initial?.schedule_type;
115
128
  const values = initial?.schedule_values;
116
- if (!type || !values) return [];
117
- return values.map((v) => valueToRow(type, v));
129
+ if (values && (type === "crons" || type === "specific_times")) {
130
+ return values.map((v) => valueToRow(type, v));
131
+ }
132
+ const mode = initialScheduleMode(initial);
133
+ return isScheduleSlot(mode) ? [newRow(mode)] : [];
118
134
  }
119
135
  );
120
136
  const [requiresConfirmation, setRequiresConfirmation] = useState(
121
137
  initial?.requires_confirmation ?? false
122
138
  );
139
+ const [scheduleEnabled, setScheduleEnabled] = useState(initial?.schedule_enabled ?? true);
123
140
  const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
124
141
  const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
125
142
  const [command, setCommand] = useState(initial?.command ?? "");
126
143
  const [saving, setSaving] = useState(false);
127
144
 
145
+ // Single optional app filter for on_new_notification tasks. Empty = any app;
146
+ // non-empty = single packageName filter (stored as a one-entry schedule_values
147
+ // array on save). Datalist options come from the host-side app registry.
148
+ // No migration: pre-existing multi-app tasks truncate to the first entry on
149
+ // first edit.
150
+ const initialNotificationApp: string = (() => {
151
+ if (initial?.schedule_type === "on_new_notification" && initial.schedule_values && initial.schedule_values.length > 0) {
152
+ return initial.schedule_values[0];
153
+ }
154
+ return "";
155
+ })();
156
+ const [notificationApp, setNotificationApp] = useState<string>(initialNotificationApp);
157
+ const [knownApps, setKnownApps] = useState<Array<{ packageName: string; appName: string; icon?: string }>>([]);
158
+ const [appDropdownOpen, setAppDropdownOpen] = useState(false);
159
+
160
+ // Merge native launcher enum (complete, native-only) with host registry
161
+ // (apps already seen — fills the gap on non-native clients). Dedup by
162
+ // packageName; free-form typing still works if neither knows the package.
163
+ useEffect(() => {
164
+ if (scheduleMode !== "on_new_notification") return;
165
+ let cancelled = false;
166
+
167
+ const merged = new Map<string, { packageName: string; appName: string; icon?: string }>();
168
+ const PALMIER_PACKAGE = "com.palmier.app";
169
+ const flush = () => { if (!cancelled) setKnownApps(Array.from(merged.values()).sort((a, b) => (a.appName || a.packageName).localeCompare(b.appName || b.packageName))); };
170
+
171
+ if (Capacitor.isNativePlatform() && Device) {
172
+ Device.getInstalledApps()
173
+ .then(({ apps }) => {
174
+ if (cancelled) return;
175
+ for (const a of apps) {
176
+ if (a.packageName === PALMIER_PACKAGE) continue;
177
+ merged.set(a.packageName, { packageName: a.packageName, appName: a.appName, icon: a.icon });
178
+ }
179
+ flush();
180
+ })
181
+ .catch(() => {});
182
+ }
183
+
184
+ request<{ apps?: Array<{ packageName: string; appName: string }> }>("device.notifications.apps")
185
+ .then((res) => {
186
+ if (cancelled || !res.apps) return;
187
+ for (const a of res.apps) {
188
+ if (a.packageName === PALMIER_PACKAGE) continue;
189
+ if (!merged.has(a.packageName)) merged.set(a.packageName, a);
190
+ }
191
+ flush();
192
+ })
193
+ .catch(() => {});
194
+
195
+ return () => { cancelled = true; };
196
+ }, [scheduleMode]);
197
+
198
+ // Sender filter for on_new_sms tasks. Empty string = any sender; non-empty =
199
+ // whitelist a single sender (stored as a single-entry schedule_values array
200
+ // on save). Matching is normalized on the host — users don't need to worry
201
+ // about exact phone-number formatting.
202
+ const initialSmsSender: string = (() => {
203
+ if (initial?.schedule_type === "on_new_sms" && initial.schedule_values && initial.schedule_values.length > 0) {
204
+ return initial.schedule_values[0];
205
+ }
206
+ return "";
207
+ })();
208
+ const [smsSender, setSmsSender] = useState<string>(initialSmsSender);
209
+
128
210
  const commandInputRef = useRef<HTMLInputElement>(null);
129
211
 
130
212
  const modeIsScheduled = isScheduleSlot(scheduleMode);
131
213
  const modeIsCommand = scheduleMode === "command";
214
+ const modeIsEvent = isEventMode(scheduleMode);
132
215
 
133
216
  const selectedAgent = agents.find((a) => a.key === agent);
134
217
  const agentSupportsYolo = !!selectedAgent?.supportsYolo;
@@ -145,13 +228,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
145
228
  || agent !== (initial?.agent ?? "")
146
229
  || scheduleMode !== initialMode
147
230
  || requiresConfirmation !== (initial?.requires_confirmation ?? false)
231
+ || scheduleEnabled !== (initial?.schedule_enabled ?? true)
148
232
  || yoloMode !== (initial?.yolo_mode ?? false)
149
233
  || foregroundMode !== (initial?.foreground_mode ?? false)
150
234
  || (modeIsCommand && command !== (initial?.command ?? ""))
151
235
  || (modeIsScheduled && (
152
236
  JSON.stringify(collectScheduleValues()) !== JSON.stringify(initial?.schedule_values ?? [])
153
237
  || modeToScheduleType(scheduleMode as ScheduleSlot) !== (initial?.schedule_type ?? undefined)
154
- ));
238
+ ))
239
+ || (modeIsEvent && scheduleMode !== initial?.schedule_type)
240
+ || (scheduleMode === "on_new_notification" && notificationApp.trim() !== initialNotificationApp.trim())
241
+ || (scheduleMode === "on_new_sms" && smsSender.trim() !== initialSmsSender.trim());
155
242
 
156
243
  const hasInvalidTrigger = modeIsScheduled && triggerRows.some((r) =>
157
244
  r.schedule === "specific_times" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
@@ -172,7 +259,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
172
259
 
173
260
  function addRow() {
174
261
  if (!isScheduleSlot(scheduleMode)) return;
175
- setTriggerRows((prev) => [...prev, newRow(scheduleMode)]);
262
+ setTriggerRows((prev) => [...prev, prev.length > 0 ? { ...prev[prev.length - 1] } : newRow(scheduleMode)]);
176
263
  }
177
264
 
178
265
  function changeScheduleMode(next: ScheduleMode) {
@@ -191,6 +278,12 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
191
278
  } else {
192
279
  setCommand("");
193
280
  }
281
+ if (next !== "on_new_notification") {
282
+ setNotificationApp(initialNotificationApp);
283
+ }
284
+ if (next !== "on_new_sms") {
285
+ setSmsSender(initialSmsSender);
286
+ }
194
287
  }
195
288
 
196
289
  function collectScheduleValues(): string[] {
@@ -211,15 +304,25 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
211
304
  setSaving(true);
212
305
  setError(null);
213
306
  try {
214
- const scheduleValues = modeIsScheduled ? collectScheduleValues() : [];
215
- const hasSchedule = scheduleValues.length > 0;
307
+ const scheduleValues = modeIsScheduled
308
+ ? collectScheduleValues()
309
+ : scheduleMode === "on_new_notification" && notificationApp.trim()
310
+ ? [notificationApp.trim()]
311
+ : scheduleMode === "on_new_sms" && smsSender.trim()
312
+ ? [smsSender.trim()]
313
+ : [];
314
+ const scheduleType: ScheduleType | null = modeIsScheduled
315
+ ? modeToScheduleType(scheduleMode as ScheduleSlot)
316
+ : modeIsEvent
317
+ ? modeToScheduleType(scheduleMode as EventMode)
318
+ : null;
216
319
  const payload: Record<string, unknown> = {
217
320
  user_prompt: userPrompt,
218
321
  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,
322
+ schedule_type: scheduleType,
323
+ schedule_values: scheduleValues.length > 0 ? scheduleValues : null,
324
+ schedule_enabled: scheduleMode !== "ondemand" && scheduleEnabled,
325
+ requires_confirmation: modeIsScheduled ? requiresConfirmation : false,
223
326
  yolo_mode: yoloMode,
224
327
  foreground_mode: foregroundMode,
225
328
  command: modeIsCommand ? command : "",
@@ -235,7 +338,9 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
235
338
  }
236
339
  if (!isEdit) localStorage.setItem("palmier:lastAgent", agent);
237
340
 
238
- // Reactive on create: save the task, then start it and navigate to the run.
341
+ // Command-triggered on create: save the task, then start it and navigate
342
+ // to the run. Event-triggered tasks are started by the daemon in response
343
+ // to the next NATS event, so we just save.
239
344
  if (modeIsCommand && !isEdit) {
240
345
  onSaved(result);
241
346
  try {
@@ -257,7 +362,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
257
362
  const saveButtonLabel = (() => {
258
363
  if (isEdit) return "Save";
259
364
  if (modeIsCommand) return "Run";
260
- if (modeIsScheduled) return "Schedule";
365
+ if ((modeIsScheduled || modeIsEvent) && scheduleEnabled) return "Schedule";
261
366
  return "Save";
262
367
  })();
263
368
 
@@ -325,18 +430,6 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
325
430
  </div>
326
431
 
327
432
  <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
433
  <div className="schedule-section">
341
434
  <h3 className="schedule-section-title">Schedule</h3>
342
435
  <select
@@ -351,9 +444,91 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
351
444
  <option value="daily">Daily</option>
352
445
  <option value="weekly">Weekly</option>
353
446
  <option value="monthly">Monthly</option>
447
+ <option value="on_new_notification">On New Push Notification</option>
448
+ <option value="on_new_sms">On New SMS</option>
354
449
  <option value="command">Command-triggered</option>
355
450
  </select>
356
451
 
452
+ {modeIsEvent && (
453
+ <div className="schedule-reactive">
454
+ <p className="command-help-text">
455
+ {scheduleMode === "on_new_notification"
456
+ ? "Runs each time a new notification arrives on the paired Android device."
457
+ : "Runs each time a new SMS arrives on the paired Android device."}
458
+ {" "}The triggering payload is spliced into your task prompt — reference it as &ldquo;the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}&rdquo;.
459
+ </p>
460
+ {scheduleMode === "on_new_notification" && (() => {
461
+ const q = notificationApp.trim().toLowerCase();
462
+ const filtered = q
463
+ ? knownApps.filter((a) => a.packageName.toLowerCase().includes(q) || a.appName.toLowerCase().includes(q))
464
+ : knownApps;
465
+ return (
466
+ <>
467
+ <div className="app-combobox">
468
+ <input
469
+ className="form-input"
470
+ type="text"
471
+ value={notificationApp}
472
+ onChange={(e) => { setNotificationApp(e.target.value); setAppDropdownOpen(true); }}
473
+ onFocus={() => setAppDropdownOpen(true)}
474
+ onBlur={() => setAppDropdownOpen(false)}
475
+ placeholder={Capacitor.isNativePlatform() ? "App (optional)" : "App (optional, e.g. com.google.android.gm)"}
476
+ disabled={saving}
477
+ />
478
+ {appDropdownOpen && filtered.length > 0 && (
479
+ <ul className="app-combobox-list">
480
+ {filtered.map((a) => (
481
+ <li
482
+ key={a.packageName}
483
+ className="app-combobox-row"
484
+ // onMouseDown instead of onClick so the input's blur doesn't close
485
+ // the dropdown before the selection registers.
486
+ onMouseDown={(e) => {
487
+ e.preventDefault();
488
+ setNotificationApp(a.packageName);
489
+ setAppDropdownOpen(false);
490
+ }}
491
+ >
492
+ {a.icon
493
+ ? <img src={a.icon} alt="" className="app-combobox-icon" />
494
+ : <div className="app-combobox-icon app-combobox-icon-placeholder" />}
495
+ <div className="app-combobox-labels">
496
+ <div className="app-combobox-name">{a.appName || a.packageName}</div>
497
+ {a.appName && <div className="app-combobox-pkg">{a.packageName}</div>}
498
+ </div>
499
+ </li>
500
+ ))}
501
+ </ul>
502
+ )}
503
+ </div>
504
+ <p className="command-help-text app-filter-help">
505
+ {notificationApp.trim()
506
+ ? `Only notifications from ${notificationApp.trim()} will trigger this task.`
507
+ : "Every notification from your device triggers this task."}
508
+ </p>
509
+ </>
510
+ );
511
+ })()}
512
+ {scheduleMode === "on_new_sms" && (
513
+ <>
514
+ <input
515
+ className="form-input"
516
+ type="text"
517
+ value={smsSender}
518
+ onChange={(e) => setSmsSender(e.target.value)}
519
+ placeholder="From (optional), e.g. +1 555-1234)"
520
+ disabled={saving}
521
+ />
522
+ <p className="command-help-text app-filter-help">
523
+ {smsSender.trim()
524
+ ? `Only SMS from ${smsSender.trim()} will trigger this task. Formatting (spaces, dashes, parens) is ignored.`
525
+ : "Every SMS that arrives on your device triggers this task."}
526
+ </p>
527
+ </>
528
+ )}
529
+ </div>
530
+ )}
531
+
357
532
  {modeIsCommand && (
358
533
  <div className="schedule-reactive">
359
534
  <p className="command-help-text">
@@ -461,6 +636,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
461
636
  </>
462
637
  )}
463
638
 
639
+ {hostPlatform === "win32" && (
640
+ <label className="toggle-label">
641
+ <input
642
+ type="checkbox"
643
+ checked={foregroundMode}
644
+ onChange={(e) => setForegroundMode(e.target.checked)}
645
+ disabled={saving}
646
+ />
647
+ Run in the foreground (host must login to Windows)
648
+ </label>
649
+ )}
464
650
  {modeIsScheduled && (
465
651
  <label className="toggle-label">
466
652
  <input
@@ -472,6 +658,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
472
658
  Confirm before each run
473
659
  </label>
474
660
  )}
661
+ {scheduleMode !== "ondemand" && (
662
+ <label className="toggle-label">
663
+ <input
664
+ type="checkbox"
665
+ checked={scheduleEnabled}
666
+ onChange={(e) => setScheduleEnabled(e.target.checked)}
667
+ disabled={saving}
668
+ />
669
+ Enabled
670
+ </label>
671
+ )}
475
672
  </div>
476
673
  </div>
477
674
 
@@ -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.3";
@@ -0,0 +1,66 @@
1
+ import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core";
2
+
3
+ export type PermissionType =
4
+ | "location"
5
+ | "smsRead"
6
+ | "smsSend"
7
+ | "contacts"
8
+ | "calendar"
9
+ | "notificationListener"
10
+ | "dnd"
11
+ | "fullScreenIntent"
12
+ | "postNotifications";
13
+
14
+ export interface PermissionResult {
15
+ granted: boolean;
16
+ /**
17
+ * False when the native build doesn't recognize this permission type — the PWA
18
+ * is served remotely and may ship ahead of the installed APK. Callers should
19
+ * treat unsupported types as "cannot enable" rather than as a hard error.
20
+ */
21
+ supported: boolean;
22
+ }
23
+
24
+ export interface DeepLinkEvent {
25
+ path: string;
26
+ }
27
+
28
+ export interface InstalledApp {
29
+ packageName: string;
30
+ appName: string;
31
+ /** data:image/png;base64 URL, 96x96. May be absent if the icon couldn't be rendered. */
32
+ icon?: string;
33
+ }
34
+
35
+ interface DevicePlugin {
36
+ getFcmToken(): Promise<{ token: string }>;
37
+ /** Returns the set of PermissionType strings this native build understands. */
38
+ getSupportedPermissions(): Promise<{ types: PermissionType[] }>;
39
+ checkPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
40
+ requestPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
41
+ /**
42
+ * Authoritative list of capabilities the user has enabled on this device.
43
+ * Native receivers + handlers consult this as a local kill-switch. Unknown
44
+ * capability names are stored but ignored — safe for PWAs that ship new caps
45
+ * ahead of the installed APK.
46
+ */
47
+ setEnabledCapabilities(opts: { capabilities: string[] }): Promise<void>;
48
+ /** Returns user-visible (launcher) apps on the device, with 96x96 PNG icons. */
49
+ getInstalledApps(): Promise<{ apps: InstalledApp[] }>;
50
+ /**
51
+ * Returns whether the device has at least one app that can handle a mailto:
52
+ * intent. Used to gate the Sending Email capability — silent PackageManager
53
+ * lookup, no side effects. `supported: false` on older APKs that don't expose
54
+ * this method; treat as "cannot enable" rather than as a hard error.
55
+ */
56
+ hasEmailClient(): Promise<{ available: boolean; supported: boolean }>;
57
+ addListener(
58
+ event: "deepLink",
59
+ handler: (ev: DeepLinkEvent) => void
60
+ ): Promise<PluginListenerHandle>;
61
+ }
62
+
63
+ /** Null on web — callers must guard with Capacitor.isNativePlatform(). */
64
+ export const Device = Capacitor.isNativePlatform()
65
+ ? registerPlugin<DevicePlugin>("Device")
66
+ : null;
@@ -282,7 +282,7 @@ export default function Dashboard() {
282
282
  } catch {
283
283
  // Expected: connection drops during daemon restart
284
284
  }
285
- setTimeout(() => window.location.reload(), 10000);
285
+ setTimeout(() => window.location.reload(), 15000);
286
286
  }
287
287
 
288
288
  const hasHosts = pairedHosts.length > 0;
@@ -294,10 +294,15 @@ export default function Dashboard() {
294
294
  {isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
295
295
 
296
296
  <div className="dashboard-content">
297
- <div className="tab-bar">
298
- {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
299
- <TabBar />
300
- </div>
297
+ <header className="app-header">
298
+ <div className="app-title-bar">
299
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
300
+ <h1 className="app-title">Palmier</h1>
301
+ </div>
302
+ <div className="tab-bar">
303
+ <TabBar />
304
+ </div>
305
+ </header>
301
306
 
302
307
  <main className="dashboard-main">
303
308
  {unauthorized ? (
@@ -369,7 +374,7 @@ export default function Dashboard() {
369
374
  </>
370
375
  ) : (
371
376
  <div className="empty-state">
372
- <p>{hasHosts ? "Connecting to host..." : "No hosts paired yet."}</p>
377
+ <p>{hasHosts ? "Connecting to host..." : "No host computer paired yet."}</p>
373
378
  {!hasHosts && (
374
379
  <button
375
380
  className="btn btn-primary"
@@ -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;
@@ -392,12 +392,12 @@ async function main(): Promise<void> {
392
392
  }
393
393
  })();
394
394
 
395
- // Subscribe to alert requests from hosts
395
+ // Subscribe to alarm requests from hosts
396
396
  (async () => {
397
397
  try {
398
398
  const conn = await getNatsConnection();
399
- const sub = conn.subscribe("host.*.fcm.alert");
400
- console.log("Listening for FCM alert requests");
399
+ const sub = conn.subscribe("host.*.fcm.alarm");
400
+ console.log("Listening for FCM alarm requests");
401
401
 
402
402
  for await (const msg of sub) {
403
403
  try {
@@ -418,14 +418,14 @@ async function main(): Promise<void> {
418
418
  }
419
419
 
420
420
  const fcmPayload: Record<string, string> = {
421
- type: "send-alert",
421
+ type: "send-alarm",
422
422
  requestId: data.requestId,
423
423
  hostId: data.hostId,
424
424
  };
425
425
  if (data.title) fcmPayload.title = data.title;
426
426
  if (data.description) fcmPayload.description = data.description;
427
427
 
428
- console.log(`[FCM] Sending alert request for host ${data.hostId}`);
428
+ console.log(`[FCM] Sending alarm request for host ${data.hostId}`);
429
429
  if (data.fcmToken) {
430
430
  await sendFcmToDevice(data.fcmToken, fcmPayload);
431
431
  } else {
@@ -436,14 +436,14 @@ async function main(): Promise<void> {
436
436
  msg.respond(sc.encode(JSON.stringify({ ok: true })));
437
437
  }
438
438
  } catch (err) {
439
- console.error("[FCM] Error handling alert request:", err);
439
+ console.error("[FCM] Error handling alarm request:", err);
440
440
  if (msg.reply) {
441
441
  msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
442
442
  }
443
443
  }
444
444
  }
445
445
  } catch (err) {
446
- console.error("Failed to subscribe to FCM alert requests:", err);
446
+ console.error("Failed to subscribe to FCM alarm requests:", err);
447
447
  }
448
448
  })();
449
449
 
@@ -125,8 +125,8 @@ router.post("/sms-response", async (req: Request, res: Response) => {
125
125
  }
126
126
  });
127
127
 
128
- // POST /api/device/alert-response - Receive alert response from Android, relay to host via NATS
129
- router.post("/alert-response", async (req: Request, res: Response) => {
128
+ // POST /api/device/alarm-response - Receive alarm response from Android, relay to host via NATS
129
+ router.post("/alarm-response", async (req: Request, res: Response) => {
130
130
  try {
131
131
  const { requestId, hostId, result } = req.body;
132
132
 
@@ -138,13 +138,13 @@ router.post("/alert-response", async (req: Request, res: Response) => {
138
138
  const conn = await getNatsConnection();
139
139
  const sc = StringCodec();
140
140
  conn.publish(
141
- `host.${hostId}.alert.${requestId}`,
141
+ `host.${hostId}.alarm.${requestId}`,
142
142
  sc.encode(JSON.stringify(result)),
143
143
  );
144
144
 
145
145
  res.json({ ok: true });
146
146
  } catch (err) {
147
- console.error("Device alert response relay error:", err);
147
+ console.error("Device alarm response relay error:", err);
148
148
  res.status(500).json({ error: "Internal server error" });
149
149
  }
150
150
  });