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.
- package/dist/commands/run.js +55 -0
- package/dist/commands/serve.js +22 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +36 -0
- package/dist/event-queues.js +53 -0
- package/dist/mcp-tools.js +2 -2
- package/dist/platform/windows.js +5 -2
- package/dist/pwa/assets/index-CQxcuDhM.css +1 -0
- package/dist/pwa/assets/index-DQfOEB03.js +120 -0
- package/dist/pwa/assets/{web-CF-N8Di6.js → web-D7Kq3Nvk.js} +1 -1
- package/dist/pwa/assets/{web-BpM3fNCn.js → web-DOyOiwsW.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +6 -5
- package/dist/transports/http-transport.js +15 -0
- package/dist/types.d.ts +6 -5
- package/package.json +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/App.css +5 -0
- package/palmier-server/pwa/src/App.tsx +15 -1
- package/palmier-server/pwa/src/components/HostMenu.tsx +155 -456
- package/palmier-server/pwa/src/components/SessionsView.tsx +9 -3
- package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
- package/palmier-server/pwa/src/components/TaskForm.tsx +79 -32
- package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +48 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
- package/palmier-server/pwa/src/types.ts +1 -1
- package/palmier-server/spec.md +12 -2
- package/src/commands/run.ts +61 -0
- package/src/commands/serve.ts +22 -2
- package/src/device-capabilities.ts +1 -1
- package/src/event-queues.ts +56 -0
- package/src/mcp-tools.ts +2 -2
- package/src/platform/windows.ts +5 -2
- package/src/rpc-handler.ts +8 -7
- package/src/transports/http-transport.ts +14 -0
- package/src/types.ts +6 -5
- package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
- 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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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(
|
|
130
|
-
|
|
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 && (
|
|
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:
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
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 (
|
|
117
|
-
|
|
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
|
|
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:
|
|
220
|
-
schedule_values:
|
|
221
|
-
schedule_enabled:
|
|
222
|
-
requires_confirmation:
|
|
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
|
-
//
|
|
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 “the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}”.
|
|
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.
|
|
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
|
-
|
|
89
|
-
|
|
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;
|
package/palmier-server/spec.md
CHANGED
|
@@ -223,7 +223,14 @@ requires_confirmation: true
|
|
|
223
223
|
---
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
-
`schedule_type` is
|
|
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`).
|
package/src/commands/run.ts
CHANGED
|
@@ -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,
|
package/src/commands/serve.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}
|
|
@@ -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
|
+
}
|