palmier 0.8.1 → 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.
- package/CLAUDE.md +13 -0
- package/README.md +11 -11
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/app-registry.d.ts +10 -0
- package/dist/app-registry.js +44 -0
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +1 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +33 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +12 -16
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +1 -4
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/{index-CQxcuDhM.css → index-B0F9mtid.css} +1 -1
- package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
- package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-C6lkQj9J.js} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.js → web-Z1623me-.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.d.ts +0 -6
- package/dist/rpc-handler.js +18 -47
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +6 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +165 -20
- package/palmier-server/pwa/src/components/HostMenu.tsx +159 -49
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionsView.tsx +57 -31
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskForm.tsx +152 -2
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +20 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +38 -7
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/app-registry.ts +52 -0
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +1 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +31 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +3 -2
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +12 -18
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +1 -4
- package/src/platform/linux.ts +9 -20
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +19 -47
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +6 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- package/dist/pwa/assets/index-DQfOEB03.js +0 -120
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode, type PointerEvent } from "react";
|
|
2
|
+
|
|
3
|
+
interface SwipeToDeleteRowProps {
|
|
4
|
+
/** Unique id used to coordinate "at most one row revealed" with the parent. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** The id of the currently-revealed row (or null). Set to this row's id to reveal it. */
|
|
7
|
+
revealedId: string | null;
|
|
8
|
+
setRevealedId(id: string | null): void;
|
|
9
|
+
onDelete(): void;
|
|
10
|
+
onClick?(): void;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/** Label for the action button (default "Delete"). */
|
|
13
|
+
actionLabel?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const REVEAL_WIDTH = 88; // px width of the action button
|
|
17
|
+
const OPEN_THRESHOLD = REVEAL_WIDTH / 2;
|
|
18
|
+
const AXIS_LOCK_THRESHOLD = 6; // px of horizontal travel before we claim the gesture
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wraps a row with swipe-left to reveal a destructive action button.
|
|
22
|
+
* Tap the button to confirm, tap elsewhere to dismiss the reveal.
|
|
23
|
+
*
|
|
24
|
+
* Uses pointer events so the same code works for touch and mouse. A short
|
|
25
|
+
* axis-lock period at the start of a drag decides whether the user is
|
|
26
|
+
* scrolling vertically (let it through) or swiping horizontally (capture).
|
|
27
|
+
*/
|
|
28
|
+
export default function SwipeToDeleteRow({
|
|
29
|
+
id,
|
|
30
|
+
revealedId,
|
|
31
|
+
setRevealedId,
|
|
32
|
+
onDelete,
|
|
33
|
+
onClick,
|
|
34
|
+
children,
|
|
35
|
+
actionLabel = "Delete",
|
|
36
|
+
}: SwipeToDeleteRowProps) {
|
|
37
|
+
const revealed = revealedId === id;
|
|
38
|
+
const [dragOffset, setDragOffset] = useState(0);
|
|
39
|
+
const [dragging, setDragging] = useState(false);
|
|
40
|
+
|
|
41
|
+
const startX = useRef(0);
|
|
42
|
+
const startY = useRef(0);
|
|
43
|
+
const axis = useRef<"x" | "y" | null>(null);
|
|
44
|
+
const baseOffset = useRef(0); // translateX when the gesture started
|
|
45
|
+
const movedEnough = useRef(false); // whether we should suppress the click that follows
|
|
46
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
|
|
48
|
+
// Reset local drag offset whenever parent closes this row.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!revealed) setDragOffset(0);
|
|
51
|
+
}, [revealed]);
|
|
52
|
+
|
|
53
|
+
// Close when the user taps elsewhere in the document.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!revealed) return;
|
|
56
|
+
function onDocPointerDown(e: Event) {
|
|
57
|
+
if (rowRef.current?.contains(e.target as Node)) return;
|
|
58
|
+
setRevealedId(null);
|
|
59
|
+
}
|
|
60
|
+
document.addEventListener("pointerdown", onDocPointerDown);
|
|
61
|
+
return () => document.removeEventListener("pointerdown", onDocPointerDown);
|
|
62
|
+
}, [revealed, setRevealedId]);
|
|
63
|
+
|
|
64
|
+
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
|
|
65
|
+
// Ignore non-primary buttons (right-click etc.) so we don't steal them.
|
|
66
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
67
|
+
startX.current = e.clientX;
|
|
68
|
+
startY.current = e.clientY;
|
|
69
|
+
axis.current = null;
|
|
70
|
+
baseOffset.current = revealed ? -REVEAL_WIDTH : 0;
|
|
71
|
+
movedEnough.current = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
|
75
|
+
if (e.pointerType === "mouse" && e.buttons === 0) return; // not dragging
|
|
76
|
+
const dx = e.clientX - startX.current;
|
|
77
|
+
const dy = e.clientY - startY.current;
|
|
78
|
+
|
|
79
|
+
if (axis.current === null) {
|
|
80
|
+
if (Math.abs(dx) < AXIS_LOCK_THRESHOLD && Math.abs(dy) < AXIS_LOCK_THRESHOLD) return;
|
|
81
|
+
axis.current = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
|
82
|
+
if (axis.current === "x") {
|
|
83
|
+
try { (e.currentTarget as Element).setPointerCapture(e.pointerId); } catch { /* unsupported */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (axis.current !== "x") return;
|
|
87
|
+
|
|
88
|
+
movedEnough.current = true;
|
|
89
|
+
if (!dragging) setDragging(true);
|
|
90
|
+
// Clamp: can swipe left to reveal fully, a bit of rubber-band on the right.
|
|
91
|
+
let next = baseOffset.current + dx;
|
|
92
|
+
if (next > 0) next = next / 4;
|
|
93
|
+
if (next < -REVEAL_WIDTH) next = -REVEAL_WIDTH + (next + REVEAL_WIDTH) / 4;
|
|
94
|
+
setDragOffset(next);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function handlePointerUp() {
|
|
98
|
+
if (axis.current !== "x") {
|
|
99
|
+
setDragging(false);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
axis.current = null;
|
|
103
|
+
setDragging(false);
|
|
104
|
+
|
|
105
|
+
const finalOffset = dragOffset;
|
|
106
|
+
const openNow = finalOffset <= -OPEN_THRESHOLD;
|
|
107
|
+
if (openNow) {
|
|
108
|
+
setDragOffset(-REVEAL_WIDTH);
|
|
109
|
+
setRevealedId(id);
|
|
110
|
+
} else {
|
|
111
|
+
setDragOffset(0);
|
|
112
|
+
if (revealed) setRevealedId(null);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleClickCapture(e: React.MouseEvent) {
|
|
117
|
+
// If the gesture translated the row, treat it as a swipe — not a click.
|
|
118
|
+
// Also absorb the click that re-hides a revealed row.
|
|
119
|
+
if (movedEnough.current) {
|
|
120
|
+
movedEnough.current = false;
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (revealed) {
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
setRevealedId(null);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const currentOffset = dragOffset !== 0 ? dragOffset : (revealed ? -REVEAL_WIDTH : 0);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div ref={rowRef} className="swipe-row">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className="swipe-row-action"
|
|
139
|
+
style={{ width: REVEAL_WIDTH }}
|
|
140
|
+
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
|
141
|
+
tabIndex={revealed ? 0 : -1}
|
|
142
|
+
aria-hidden={!revealed}
|
|
143
|
+
>
|
|
144
|
+
{actionLabel}
|
|
145
|
+
</button>
|
|
146
|
+
<div
|
|
147
|
+
className={`swipe-row-content ${dragging ? "swipe-row-content-dragging" : ""}`}
|
|
148
|
+
style={{ transform: `translateX(${currentOffset}px)` }}
|
|
149
|
+
onPointerDown={handlePointerDown}
|
|
150
|
+
onPointerMove={handlePointerMove}
|
|
151
|
+
onPointerUp={handlePointerUp}
|
|
152
|
+
onPointerCancel={handlePointerUp}
|
|
153
|
+
onClickCapture={handleClickCapture}
|
|
154
|
+
onClick={onClick}
|
|
155
|
+
>
|
|
156
|
+
{children}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
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
9
|
type ScheduleType = "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
|
|
@@ -140,6 +142,71 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
140
142
|
const [command, setCommand] = useState(initial?.command ?? "");
|
|
141
143
|
const [saving, setSaving] = useState(false);
|
|
142
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
|
+
|
|
143
210
|
const commandInputRef = useRef<HTMLInputElement>(null);
|
|
144
211
|
|
|
145
212
|
const modeIsScheduled = isScheduleSlot(scheduleMode);
|
|
@@ -169,7 +236,9 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
169
236
|
JSON.stringify(collectScheduleValues()) !== JSON.stringify(initial?.schedule_values ?? [])
|
|
170
237
|
|| modeToScheduleType(scheduleMode as ScheduleSlot) !== (initial?.schedule_type ?? undefined)
|
|
171
238
|
))
|
|
172
|
-
|| (modeIsEvent && scheduleMode !== initial?.schedule_type)
|
|
239
|
+
|| (modeIsEvent && scheduleMode !== initial?.schedule_type)
|
|
240
|
+
|| (scheduleMode === "on_new_notification" && notificationApp.trim() !== initialNotificationApp.trim())
|
|
241
|
+
|| (scheduleMode === "on_new_sms" && smsSender.trim() !== initialSmsSender.trim());
|
|
173
242
|
|
|
174
243
|
const hasInvalidTrigger = modeIsScheduled && triggerRows.some((r) =>
|
|
175
244
|
r.schedule === "specific_times" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
|
|
@@ -209,6 +278,12 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
209
278
|
} else {
|
|
210
279
|
setCommand("");
|
|
211
280
|
}
|
|
281
|
+
if (next !== "on_new_notification") {
|
|
282
|
+
setNotificationApp(initialNotificationApp);
|
|
283
|
+
}
|
|
284
|
+
if (next !== "on_new_sms") {
|
|
285
|
+
setSmsSender(initialSmsSender);
|
|
286
|
+
}
|
|
212
287
|
}
|
|
213
288
|
|
|
214
289
|
function collectScheduleValues(): string[] {
|
|
@@ -229,7 +304,13 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
229
304
|
setSaving(true);
|
|
230
305
|
setError(null);
|
|
231
306
|
try {
|
|
232
|
-
const scheduleValues = modeIsScheduled
|
|
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
|
+
: [];
|
|
233
314
|
const scheduleType: ScheduleType | null = modeIsScheduled
|
|
234
315
|
? modeToScheduleType(scheduleMode as ScheduleSlot)
|
|
235
316
|
: modeIsEvent
|
|
@@ -376,6 +457,75 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
376
457
|
: "Runs each time a new SMS arrives on the paired Android device."}
|
|
377
458
|
{" "}The triggering payload is spliced into your task prompt — reference it as “the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}”.
|
|
378
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
|
+
)}
|
|
379
529
|
</div>
|
|
380
530
|
)}
|
|
381
531
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.8.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.8.3";
|
|
@@ -2,12 +2,14 @@ import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor
|
|
|
2
2
|
|
|
3
3
|
export type PermissionType =
|
|
4
4
|
| "location"
|
|
5
|
-
| "
|
|
5
|
+
| "smsRead"
|
|
6
|
+
| "smsSend"
|
|
6
7
|
| "contacts"
|
|
7
8
|
| "calendar"
|
|
8
9
|
| "notificationListener"
|
|
9
10
|
| "dnd"
|
|
10
|
-
| "fullScreenIntent"
|
|
11
|
+
| "fullScreenIntent"
|
|
12
|
+
| "postNotifications";
|
|
11
13
|
|
|
12
14
|
export interface PermissionResult {
|
|
13
15
|
granted: boolean;
|
|
@@ -23,6 +25,13 @@ export interface DeepLinkEvent {
|
|
|
23
25
|
path: string;
|
|
24
26
|
}
|
|
25
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
|
+
|
|
26
35
|
interface DevicePlugin {
|
|
27
36
|
getFcmToken(): Promise<{ token: string }>;
|
|
28
37
|
/** Returns the set of PermissionType strings this native build understands. */
|
|
@@ -36,6 +45,15 @@ interface DevicePlugin {
|
|
|
36
45
|
* ahead of the installed APK.
|
|
37
46
|
*/
|
|
38
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 }>;
|
|
39
57
|
addListener(
|
|
40
58
|
event: "deepLink",
|
|
41
59
|
handler: (ev: DeepLinkEvent) => void
|
|
@@ -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(),
|
|
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
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
377
|
+
<p>{hasHosts ? "Connecting to host..." : "No host computer paired yet."}</p>
|
|
373
378
|
{!hasHosts && (
|
|
374
379
|
<button
|
|
375
380
|
className="btn btn-primary"
|
|
@@ -392,12 +392,12 @@ async function main(): Promise<void> {
|
|
|
392
392
|
}
|
|
393
393
|
})();
|
|
394
394
|
|
|
395
|
-
// Subscribe to
|
|
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.
|
|
400
|
-
console.log("Listening for FCM
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
129
|
-
router.post("/
|
|
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}.
|
|
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
|
|
147
|
+
console.error("Device alarm response relay error:", err);
|
|
148
148
|
res.status(500).json({ error: "Internal server error" });
|
|
149
149
|
}
|
|
150
150
|
});
|
package/palmier-server/spec.md
CHANGED
|
@@ -12,18 +12,18 @@ The host supports **Linux** (systemd) and **Windows** (Task Scheduler for both d
|
|
|
12
12
|
|
|
13
13
|
### 1.2 Components
|
|
14
14
|
|
|
15
|
-
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms-message`, `
|
|
15
|
+
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms-message`, `send-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms-messages://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
|
|
16
16
|
|
|
17
17
|
* **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Subscribes to `host.*.fcm.contacts`, `host.*.fcm.calendar`, `host.*.fcm.sms`, `host.*.fcm.alarm`, `host.*.fcm.battery`, and `host.*.fcm.ringer` to relay device capability requests via FCM. Provides HTTP endpoints for Android to post responses back (`/api/device/contacts-response`, `/api/device/calendar-response`, `/api/device/sms-response`, `/api/device/alarm-response`, `/api/device/battery-response`, `/api/device/ringer-response`). Co-located with the NATS server on the same machine.
|
|
18
18
|
|
|
19
19
|
* **Android App (Capacitor):** Native Android wrapper for the PWA. Provides FCM push messaging for receiving data messages in the background, `FusedLocationProviderClient` for GPS access, `NotificationListenerService` for capturing device notifications, `BroadcastReceiver` for incoming SMS, and handlers for contacts, calendar, alarms, battery, and ringer mode. All device tools work while the app is in the background via FCM data messages. When a request arrives via FCM, the appropriate handler executes the action and POSTs the result back to the Web Server. Device capabilities and their permissions:
|
|
20
20
|
- **Notifications**: `NotificationListenerService` — requires notification listener access (system settings toggle)
|
|
21
|
-
- **SMS
|
|
22
|
-
- **SMS
|
|
21
|
+
- **SMS Read**: `SmsBroadcastReceiver` — requires `RECEIVE_SMS` runtime permission
|
|
22
|
+
- **SMS Send**: `SmsHandler` — requires `SEND_SMS` runtime permission
|
|
23
23
|
- **Contacts**: `ContactsHandler` — requires `READ_CONTACTS` + `WRITE_CONTACTS` runtime permissions
|
|
24
24
|
- **Calendar**: `CalendarHandler` — requires `READ_CALENDAR` + `WRITE_CALENDAR` runtime permissions
|
|
25
25
|
- **Geolocation**: `GeolocationForegroundService` — requires `ACCESS_FINE_LOCATION` runtime permission
|
|
26
|
-
- **Alarm**: `AlarmHandler` — requires `
|
|
26
|
+
- **Alarm**: `AlarmHandler` + `AlarmActivity` — triggers a full-screen alarm popup with looping ringtone; requires `USE_FULL_SCREEN_INTENT` (Android 14+ requires user grant in settings)
|
|
27
27
|
- **Battery**: `BatteryHandler` — no permission required
|
|
28
28
|
- **Ringer mode**: `RingerHandler` — requires Do Not Disturb access (system settings toggle)
|
|
29
29
|
|
|
@@ -49,6 +49,37 @@ The project is split across two repositories:
|
|
|
49
49
|
|
|
50
50
|
* **`palmier`**: The host binary. A standalone Node.js CLI that runs on the user's machine.
|
|
51
51
|
* **`palmier-server`**: Contains both the Web Server (`server/`) and the PWA (`pwa/`, built with Vite + React). Uses **pnpm** for package management with a pnpm workspace.
|
|
52
|
+
* **`palmier-android`**: Capacitor-based Android wrapper that loads the PWA from `app.palmier.me` in a WebView and exposes native device capabilities via a custom plugin.
|
|
53
|
+
|
|
54
|
+
### 1.5 Android Implementation Details
|
|
55
|
+
|
|
56
|
+
The Android app is a thin native shell over the remotely-hosted PWA. The design decisions below are non-obvious enough that changing them silently would break assumptions the rest of the system makes.
|
|
57
|
+
|
|
58
|
+
**Remote-first WebView.** `capacitor.config.json` sets `server.url` to `https://app.palmier.me`; the WebView fetches the PWA from the cloud on every launch. This means PWA updates ship instantly with no APK rebuild, and release builds run `npx cap sync` only (no PWA build step). Consequences:
|
|
59
|
+
|
|
60
|
+
- **Server mode only.** LAN and Local modes are browser-only. The WebView blocks cleartext `http://<host-ip>:<port>` requests as mixed content, so LAN users must open the PWA from Chrome/Safari directly.
|
|
61
|
+
- **Offline fallback.** When `app.palmier.me` is unreachable, the WebView loads `www/offline.html` (configured via `server.errorPath`), which auto-reloads when connectivity returns.
|
|
62
|
+
- **PWA ships ahead of the APK.** The PWA may reference permission types or native methods that the installed APK doesn't implement yet. `Device.getSupportedPermissions()` returns the set the APK understands; `checkPermission` / `requestPermission` resolve with `{ granted: false, supported: false }` for unknown types rather than throwing. The PWA uses this to hide toggles it can't fulfill.
|
|
63
|
+
|
|
64
|
+
**Unified `Device` Capacitor plugin.** A single plugin (`DevicePlugin.kt`) exposes the entire native surface — FCM token, permission gate, capability whitelist, installed-app enumeration, email-client availability, deep-link events. This replaces seven per-capability permission plugins. Methods: `getFcmToken`, `getSupportedPermissions`, `checkPermission({type})`, `requestPermission({type})`, `setEnabledCapabilities({capabilities})`, `getInstalledApps`, `hasEmailClient`, `addListener("deepLink", ...)`. Permission types: `location`, `smsRead`, `smsSend`, `contacts`, `calendar`, `notificationListener`, `dnd`, `fullScreenIntent`, `postNotifications`.
|
|
65
|
+
|
|
66
|
+
**Capability kill-switch (`CapabilityState`).** Local whitelist persisted as a JSON-array string under `enabledCapabilities` in `CapacitorStorage` SharedPreferences, written only by `DevicePlugin.setEnabledCapabilities` from the PWA's derived state. Native receivers (`SmsBroadcastReceiver`, `DeviceNotificationListenerService`) and all FCM handlers consult this before acting — a second line of defense beyond the server-side capability token. If the user disables a capability in the drawer, the native side refuses to relay events or respond to requests even if the server still asks.
|
|
67
|
+
|
|
68
|
+
**FCM token flow.** The PWA reads the current token on demand via `Device.getFcmToken()`; no cached copy in SharedPreferences. `PalmierFirebaseMessagingService.onNewToken` still re-registers with the relay server itself (using the stored `hostId`) because background token refreshes can fire while the PWA isn't running.
|
|
69
|
+
|
|
70
|
+
**Deep links.** FCM notification taps pass a relative path (e.g. `/runs/:taskId/:runId`) via an `Intent` extra named `deepLink`. `MainActivity.handleDeepLink` forwards the path to `DevicePlugin.emitDeepLink`, which emits a `deepLink` event the PWA's router handles client-side. If the plugin isn't ready yet (intent arrives before `onPostCreate`), `MainActivity` buffers the path and flushes in `onPostCreate`. No external `intent-filter` is registered — Android 11+ `<queries>` entries are declared so `hasEmailClient` and installed-app enumeration work without `QUERY_ALL_PACKAGES`.
|
|
71
|
+
|
|
72
|
+
**Notification listener filtering and debounce.** `DeviceNotificationListenerService` drops notifications from (a) Palmier's own `palmier_tasks` channel to avoid feedback loops and (b) the default SMS app (SMS is captured separately via `SmsBroadcastReceiver`, which arrives before the SMS app's notification). Empty-title+body notifications are skipped. A 2-second debounce per `packageName:title` key dedupes rapid updates; the debounce map is LRU-capped at 200 entries.
|
|
73
|
+
|
|
74
|
+
**SMS capture.** Multi-part SMS arrives as multiple PDUs in a single `SMS_RECEIVED` broadcast. `SmsBroadcastReceiver` groups parts by `displayOriginatingAddress` and concatenates bodies before relaying, otherwise long messages would arrive as fragments.
|
|
75
|
+
|
|
76
|
+
**Alarm capability.** `AlarmHandler` posts a `CATEGORY_ALARM` notification on the DND-bypassing `palmier_alarms` channel with a full-screen intent targeting `AlarmActivity`. `AlarmActivity` extends `AppCompatActivity`, shows over the lock screen (`setShowWhenLocked`/`setTurnScreenOn` on O_MR1+, legacy window flags below), and plays the default alarm ringtone on the alarm audio stream via `RingtoneManager`. Requires `USE_FULL_SCREEN_INTENT`; Android 14+ requires the user to grant it in per-app settings.
|
|
77
|
+
|
|
78
|
+
**Email capability.** FCM `send-email` messages post a "Pending email" notification whose tap launches `EmailActivity` — a translucent `Activity` (not `AppCompatActivity`, so `Theme.Translucent` applies) that builds a `mailto:` URI and starts the email app with `ACTION_SENDTO`. The activity auto-finishes on result, returning the user to the previous screen. `hasEmailClient` gates the toggle in the PWA to avoid enabling a capability no installed app can fulfill.
|
|
79
|
+
|
|
80
|
+
**Location capability.** `GeolocationForegroundService` briefly starts as a foreground service (`FOREGROUND_SERVICE_TYPE_LOCATION` on U+) and uses `FusedLocationProviderClient.getCurrentLocation(PRIORITY_HIGH_ACCURACY)` for a single fix before stopping itself. Requires both `ACCESS_FINE_LOCATION` and `ACCESS_BACKGROUND_LOCATION` on Q+; the plugin requests them sequentially.
|
|
81
|
+
|
|
82
|
+
**Releases.** GitHub Actions builds a signed APK and creates a release when a `v*` tag is pushed. The workflow runs `npm ci && npx cap sync` — no PWA build step since the WebView loads remotely.
|
|
52
83
|
|
|
53
84
|
## 2. Host Provisioning & Device Pairing
|
|
54
85
|
|
|
@@ -227,8 +258,8 @@ requires_confirmation: true
|
|
|
227
258
|
|
|
228
259
|
* `"crons"` — `schedule_values` holds cron expressions.
|
|
229
260
|
* `"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.
|
|
231
|
-
* `"on_new_sms"` — fires once per new SMS relayed over NATS.
|
|
261
|
+
* `"on_new_notification"` — fires once per new Android notification relayed over NATS. Optional `schedule_values` holds a single-entry packageName filter; empty/unset matches any app.
|
|
262
|
+
* `"on_new_sms"` — fires once per new SMS relayed over NATS. Optional `schedule_values` holds a single-entry sender filter (phone number or alphanumeric shortcode); compared after normalizing away spaces, dashes, parens, plus sign, and case. Empty/unset matches any sender.
|
|
232
263
|
|
|
233
264
|
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).
|
|
234
265
|
|
|
@@ -313,7 +344,7 @@ Dashboard owns the always-on NATS event subscription and renders pending `confir
|
|
|
313
344
|
|
|
314
345
|
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.
|
|
315
346
|
|
|
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
|
|
347
|
+
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. For `on_new_notification` / `on_new_sms` types, `schedule_values` is sent only when a filter is configured (single packageName for notifications, single sender for SMS).
|
|
317
348
|
|
|
318
349
|
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.
|
|
319
350
|
|
package/src/agents/agent.ts
CHANGED
|
@@ -26,10 +26,6 @@ export interface CommandLine {
|
|
|
26
26
|
env?: Record<string, string>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Interface that each agent tool must implement.
|
|
31
|
-
* Abstracts how plans are generated and tasks are executed across different AI agents.
|
|
32
|
-
*/
|
|
33
29
|
export interface AgentTool {
|
|
34
30
|
/** Return the command and args for a short, non-interactive prompt (e.g. generating a task name). */
|
|
35
31
|
getPromptCommandLine(prompt: string): CommandLine;
|
package/src/agents/claude.ts
CHANGED
package/src/agents/codex.ts
CHANGED
|
@@ -23,8 +23,8 @@ export class CodexAgent implements AgentTool {
|
|
|
23
23
|
args.push(`apps.${p.name}.default_tools_approval_mode="approve"`);
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
if (followupPrompt) {args.push("resume", "--last");}
|
|
27
|
-
args.push("-");
|
|
26
|
+
if (followupPrompt) {args.push("resume", "--last");}
|
|
27
|
+
args.push("-");
|
|
28
28
|
|
|
29
29
|
return { command: "codex", args, stdin: prompt };
|
|
30
30
|
}
|