palmier 0.8.3 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +6 -4
  2. package/dist/commands/pair.js +2 -0
  3. package/dist/commands/serve.js +0 -4
  4. package/dist/platform/index.js +7 -3
  5. package/dist/platform/macos.d.ts +32 -0
  6. package/dist/platform/macos.js +298 -0
  7. package/dist/pwa/assets/index-BiAE5qeC.js +120 -0
  8. package/dist/pwa/assets/{index-B0F9mtid.css → index-UaZFu6XL.css} +1 -1
  9. package/dist/pwa/assets/{web-C6lkQj9J.js → web-DYwZE4qa.js} +1 -1
  10. package/dist/pwa/assets/{web-Z1623me-.js → web-nSzKzI8x.js} +1 -1
  11. package/dist/pwa/index.html +2 -2
  12. package/dist/pwa/service-worker.js +1 -1
  13. package/dist/rpc-handler.js +0 -4
  14. package/dist/transports/http-transport.js +1 -0
  15. package/package.json +1 -1
  16. package/palmier-server/pwa/src/App.css +191 -33
  17. package/palmier-server/pwa/src/App.tsx +2 -0
  18. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  19. package/palmier-server/pwa/src/components/HostMenu.tsx +15 -312
  20. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  21. package/palmier-server/pwa/src/components/SessionsView.tsx +3 -1
  22. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  23. package/palmier-server/pwa/src/components/TaskForm.tsx +126 -74
  24. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  25. package/palmier-server/pwa/src/constants.ts +1 -1
  26. package/palmier-server/pwa/src/native/Device.ts +0 -2
  27. package/palmier-server/pwa/src/pages/Dashboard.tsx +2 -0
  28. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  29. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  30. package/palmier-server/spec.md +3 -3
  31. package/src/commands/pair.ts +2 -0
  32. package/src/commands/serve.ts +0 -3
  33. package/src/platform/index.ts +4 -3
  34. package/src/platform/macos.ts +319 -0
  35. package/src/rpc-handler.ts +0 -5
  36. package/src/transports/http-transport.ts +1 -0
  37. package/test/macos-plist.test.ts +112 -0
  38. package/dist/app-registry.d.ts +0 -10
  39. package/dist/app-registry.js +0 -44
  40. package/dist/pwa/assets/index-SYs3mcdJ.js +0 -120
  41. package/src/app-registry.ts +0 -52
package/README.md CHANGED
@@ -29,12 +29,12 @@ It runs on your machine as a background daemon and connects to a mobile-friendly
29
29
  ### Prerequisites
30
30
 
31
31
  - **Node.js 24+**
32
- - **Linux with systemd** or **Windows 10/11** (macOS coming soon)
32
+ - **Linux with systemd**, **macOS 13+**, or **Windows 10/11**
33
33
  - At least one supported agent CLI
34
34
 
35
35
  ## How It Works
36
36
 
37
- Palmier runs as a background daemon (systemd on Linux, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications and full-screen alarms, reading SMS/notifications, managing contacts and calendar, and more.
37
+ Palmier runs as a background daemon (systemd on Linux, launchd on macOS, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications and full-screen alarms, reading SMS/notifications, managing contacts and calendar, and more.
38
38
 
39
39
  ### MCP Server
40
40
 
@@ -148,11 +148,13 @@ The wizard:
148
148
  - Configures access modes (HTTP port, LAN access)
149
149
  - Shows a summary (including any existing scheduled tasks to recover) and asks for confirmation
150
150
  - Registers with the Palmier server, saves configuration to `~/.config/palmier/host.json`
151
- - Installs a background daemon (systemd user service on Linux, Task Scheduler on Windows)
151
+ - Installs a background daemon (systemd user service on Linux, LaunchAgent on macOS, Task Scheduler on Windows)
152
152
  - Auto-enters pair mode to connect your first device
153
153
 
154
154
  The daemon automatically recovers existing tasks by reinstalling their system timers on startup.
155
155
 
156
+ > **macOS note:** Palmier installs as a user-level LaunchAgent, so it runs without `sudo`. LaunchAgents only run while the user is logged into the GUI session — after a reboot, scheduled tasks stay dormant until you log in at least once. Enable auto-login in System Settings → Users & Groups if you need unattended operation across reboots.
157
+
156
158
  Agents are re-detected on every daemon start. Run `palmier restart` after installing or removing a CLI.
157
159
 
158
160
  ## CLI Reference
@@ -190,7 +192,7 @@ To fully remove Palmier from a machine:
190
192
 
191
193
  4. **(Optional) Remove configuration and task data:**
192
194
 
193
- **Linux:**
195
+ **Linux / macOS:**
194
196
  ```bash
195
197
  rm -rf ~/.config/palmier
196
198
  rm -rf ~/palmier # or wherever your Palmier root directory is
@@ -1,4 +1,5 @@
1
1
  import * as http from "node:http";
2
+ import * as os from "node:os";
2
3
  import { StringCodec } from "nats";
3
4
  import { loadConfig } from "../config.js";
4
5
  import { connectNats } from "../nats-client.js";
@@ -16,6 +17,7 @@ function buildPairResponse(config, label) {
16
17
  return {
17
18
  hostId: config.hostId,
18
19
  clientToken: client.token,
20
+ hostName: os.hostname(),
19
21
  };
20
22
  }
21
23
  function httpPairRegister(port, code) {
@@ -15,7 +15,6 @@ import { StringCodec } from "nats";
15
15
  import { addNotification } from "../notification-store.js";
16
16
  import { addSmsMessage } from "../sms-store.js";
17
17
  import { enqueueEvent } from "../event-queues.js";
18
- import { recordApp } from "../app-registry.js";
19
18
  const POLL_INTERVAL_MS = 30_000;
20
19
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
21
20
  /**
@@ -144,10 +143,7 @@ export async function serveCommand() {
144
143
  let parsed;
145
144
  try {
146
145
  parsed = JSON.parse(raw);
147
- const data = parsed;
148
146
  addNotification({ ...parsed, receivedAt: Date.now() });
149
- if (data.packageName && data.appName)
150
- recordApp(data.packageName, data.appName);
151
147
  }
152
148
  catch (err) {
153
149
  console.error("[nats] Failed to parse device notification:", err);
@@ -1,13 +1,17 @@
1
1
  import { LinuxPlatform } from "./linux.js";
2
2
  import { WindowsPlatform } from "./windows.js";
3
+ import { MacOsPlatform } from "./macos.js";
3
4
  /** Windows needs an explicit shell for execSync to resolve .cmd shims. */
4
5
  export const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
5
6
  let _instance;
6
7
  export function getPlatform() {
7
8
  if (!_instance) {
8
- _instance = process.platform === "win32"
9
- ? new WindowsPlatform()
10
- : new LinuxPlatform();
9
+ if (process.platform === "win32")
10
+ _instance = new WindowsPlatform();
11
+ else if (process.platform === "darwin")
12
+ _instance = new MacOsPlatform();
13
+ else
14
+ _instance = new LinuxPlatform();
11
15
  }
12
16
  return _instance;
13
17
  }
@@ -0,0 +1,32 @@
1
+ import type { PlatformService } from "./platform.js";
2
+ import type { HostConfig, ParsedTask } from "../types.js";
3
+ /**
4
+ * Convert one of the four PWA-produced cron patterns to a launchd
5
+ * `StartCalendarInterval` dict.
6
+ * hourly "0 * * * *" → { Minute: 0 }
7
+ * daily "MM HH * * *" → { Minute, Hour }
8
+ * weekly "MM HH * * D" → { Minute, Hour, Weekday }
9
+ * monthly "MM HH D * *" → { Minute, Hour, Day }
10
+ * launchd Weekday: Sunday is 0 (cron 7 → 0).
11
+ */
12
+ export declare function cronToCalendarInterval(cron: string): Record<string, number>;
13
+ /**
14
+ * Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
15
+ * to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
16
+ * so we omit Year — the task fires yearly on the same date and time. Sufficient
17
+ * because the PWA regenerates/removes one-off tasks after they run.
18
+ */
19
+ export declare function specificTimeToCalendarInterval(iso: string): Record<string, number>;
20
+ export declare function buildPlist(dict: Record<string, unknown>): string;
21
+ export declare class MacOsPlatform implements PlatformService {
22
+ installDaemon(config: HostConfig): void;
23
+ uninstallDaemon(): void;
24
+ restartDaemon(): Promise<void>;
25
+ installTaskTimer(config: HostConfig, task: ParsedTask): void;
26
+ removeTaskTimer(taskId: string): void;
27
+ startTask(taskId: string): Promise<void>;
28
+ stopTask(taskId: string): Promise<void>;
29
+ isTaskRunning(taskId: string): boolean;
30
+ getGuiEnv(): Record<string, string>;
31
+ }
32
+ //# sourceMappingURL=macos.d.ts.map
@@ -0,0 +1,298 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { homedir } from "os";
4
+ import { execSync, exec } from "child_process";
5
+ import { promisify } from "util";
6
+ import { CONFIG_DIR, loadConfig } from "../config.js";
7
+ import { getTaskDir, readTaskStatus } from "../task.js";
8
+ const execAsync = promisify(exec);
9
+ const AGENT_DIR = path.join(homedir(), "Library", "LaunchAgents");
10
+ const PATH_FILE = path.join(CONFIG_DIR, "user-path");
11
+ const DAEMON_LABEL = "me.palmier.host";
12
+ const TASK_LABEL_PREFIX = "me.palmier.task.";
13
+ function daemonPlistPath() {
14
+ return path.join(AGENT_DIR, `${DAEMON_LABEL}.plist`);
15
+ }
16
+ function taskLabel(taskId) {
17
+ return `${TASK_LABEL_PREFIX}${taskId}`;
18
+ }
19
+ function taskPlistPath(taskId) {
20
+ return path.join(AGENT_DIR, `${taskLabel(taskId)}.plist`);
21
+ }
22
+ function taskLogPath(taskId) {
23
+ return path.join(CONFIG_DIR, `task-${taskId}.log`);
24
+ }
25
+ function guiDomain() {
26
+ const uid = process.getuid?.();
27
+ if (uid === undefined)
28
+ throw new Error("getuid() unavailable — macOS platform requires POSIX uid");
29
+ return `gui/${uid}`;
30
+ }
31
+ /**
32
+ * Convert one of the four PWA-produced cron patterns to a launchd
33
+ * `StartCalendarInterval` dict.
34
+ * hourly "0 * * * *" → { Minute: 0 }
35
+ * daily "MM HH * * *" → { Minute, Hour }
36
+ * weekly "MM HH * * D" → { Minute, Hour, Weekday }
37
+ * monthly "MM HH D * *" → { Minute, Hour, Day }
38
+ * launchd Weekday: Sunday is 0 (cron 7 → 0).
39
+ */
40
+ export function cronToCalendarInterval(cron) {
41
+ const parts = cron.trim().split(/\s+/);
42
+ if (parts.length !== 5) {
43
+ throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
44
+ }
45
+ const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
46
+ const result = {};
47
+ if (minute !== "*")
48
+ result.Minute = Number(minute);
49
+ if (hour !== "*")
50
+ result.Hour = Number(hour);
51
+ if (dayOfMonth !== "*")
52
+ result.Day = Number(dayOfMonth);
53
+ if (dayOfWeek !== "*") {
54
+ const dow = Number(dayOfWeek);
55
+ result.Weekday = dow === 7 ? 0 : dow;
56
+ }
57
+ for (const [k, v] of Object.entries(result)) {
58
+ if (!Number.isInteger(v) || v < 0) {
59
+ throw new Error(`Invalid cron field ${k}=${v} in ${cron}`);
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ /**
65
+ * Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
66
+ * to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
67
+ * so we omit Year — the task fires yearly on the same date and time. Sufficient
68
+ * because the PWA regenerates/removes one-off tasks after they run.
69
+ */
70
+ export function specificTimeToCalendarInterval(iso) {
71
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
72
+ if (!m)
73
+ throw new Error(`Invalid specific_times value: ${iso}`);
74
+ return {
75
+ Month: Number(m[2]),
76
+ Day: Number(m[3]),
77
+ Hour: Number(m[4]),
78
+ Minute: Number(m[5]),
79
+ };
80
+ }
81
+ function escapeXml(s) {
82
+ return s
83
+ .replace(/&/g, "&amp;")
84
+ .replace(/</g, "&lt;")
85
+ .replace(/>/g, "&gt;");
86
+ }
87
+ /** Serialize a JS value to a plist XML fragment. Supports string/number/boolean/array/plain object. */
88
+ function plistValue(value, indent) {
89
+ if (typeof value === "string")
90
+ return `${indent}<string>${escapeXml(value)}</string>`;
91
+ if (typeof value === "boolean")
92
+ return `${indent}<${value ? "true" : "false"}/>`;
93
+ if (typeof value === "number") {
94
+ return Number.isInteger(value)
95
+ ? `${indent}<integer>${value}</integer>`
96
+ : `${indent}<real>${value}</real>`;
97
+ }
98
+ if (Array.isArray(value)) {
99
+ if (value.length === 0)
100
+ return `${indent}<array/>`;
101
+ const inner = value.map((v) => plistValue(v, indent + " ")).join("\n");
102
+ return `${indent}<array>\n${inner}\n${indent}</array>`;
103
+ }
104
+ if (value && typeof value === "object") {
105
+ const entries = Object.entries(value);
106
+ if (entries.length === 0)
107
+ return `${indent}<dict/>`;
108
+ const inner = entries
109
+ .map(([k, v]) => `${indent} <key>${escapeXml(k)}</key>\n${plistValue(v, indent + " ")}`)
110
+ .join("\n");
111
+ return `${indent}<dict>\n${inner}\n${indent}</dict>`;
112
+ }
113
+ throw new Error(`Unsupported plist value type: ${typeof value}`);
114
+ }
115
+ export function buildPlist(dict) {
116
+ return [
117
+ `<?xml version="1.0" encoding="UTF-8"?>`,
118
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
119
+ `<plist version="1.0">`,
120
+ plistValue(dict, ""),
121
+ `</plist>`,
122
+ ``,
123
+ ].join("\n");
124
+ }
125
+ function runLaunchctl(args, opts = {}) {
126
+ try {
127
+ execSync(`launchctl ${args.join(" ")}`, { stdio: "pipe", encoding: "utf-8" });
128
+ }
129
+ catch (err) {
130
+ if (opts.ignoreFailure)
131
+ return;
132
+ const e = err;
133
+ console.error(`launchctl ${args[0]} failed: ${e.stderr || err}`);
134
+ }
135
+ }
136
+ export class MacOsPlatform {
137
+ installDaemon(config) {
138
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
139
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
140
+ const palmierBin = process.argv[1] || "palmier";
141
+ const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
142
+ fs.writeFileSync(PATH_FILE, userPath, "utf-8");
143
+ const logPath = path.join(CONFIG_DIR, "daemon.log");
144
+ const plist = buildPlist({
145
+ Label: DAEMON_LABEL,
146
+ ProgramArguments: [process.execPath, palmierBin, "serve"],
147
+ WorkingDirectory: config.projectRoot,
148
+ RunAtLoad: true,
149
+ KeepAlive: { SuccessfulExit: false },
150
+ EnvironmentVariables: { PATH: userPath },
151
+ StandardOutPath: logPath,
152
+ StandardErrorPath: logPath,
153
+ });
154
+ const plistPath = daemonPlistPath();
155
+ fs.writeFileSync(plistPath, plist, "utf-8");
156
+ console.log("LaunchAgent installed at:", plistPath);
157
+ const domain = guiDomain();
158
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
159
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
160
+ runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
161
+ console.log("Palmier host LaunchAgent loaded and started.");
162
+ console.log("Note: LaunchAgents only run while you are logged into the GUI session. " +
163
+ "After reboot, tasks remain dormant until you log in at least once.");
164
+ console.log("\nHost initialization complete!");
165
+ }
166
+ uninstallDaemon() {
167
+ const domain = guiDomain();
168
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
169
+ try {
170
+ fs.unlinkSync(daemonPlistPath());
171
+ }
172
+ catch { /* may not exist */ }
173
+ try {
174
+ const entries = fs.readdirSync(AGENT_DIR).filter((f) => f.startsWith(TASK_LABEL_PREFIX) && f.endsWith(".plist"));
175
+ for (const f of entries) {
176
+ const label = f.slice(0, -".plist".length);
177
+ runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
178
+ try {
179
+ fs.unlinkSync(path.join(AGENT_DIR, f));
180
+ }
181
+ catch { /* ignore */ }
182
+ }
183
+ }
184
+ catch { /* AGENT_DIR may not exist */ }
185
+ console.log("Palmier daemon and tasks uninstalled.");
186
+ }
187
+ async restartDaemon() {
188
+ const plistPath = daemonPlistPath();
189
+ const domain = guiDomain();
190
+ if (process.stdin.isTTY && fs.existsSync(plistPath)) {
191
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
192
+ const userPath = process.env.PATH || "";
193
+ fs.writeFileSync(PATH_FILE, userPath, "utf-8");
194
+ const content = fs.readFileSync(plistPath, "utf-8");
195
+ const updated = content.replace(/(<key>PATH<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/, `$1${escapeXml(userPath)}$2`);
196
+ if (updated !== content) {
197
+ fs.writeFileSync(plistPath, updated, "utf-8");
198
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
199
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
200
+ }
201
+ }
202
+ runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
203
+ console.log("Palmier daemon restarted.");
204
+ }
205
+ installTaskTimer(config, task) {
206
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
207
+ const taskId = task.frontmatter.id;
208
+ const label = taskLabel(taskId);
209
+ const plistPath = taskPlistPath(taskId);
210
+ const palmierBin = process.argv[1] || "palmier";
211
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
212
+ const logPath = taskLogPath(taskId);
213
+ const dict = {
214
+ Label: label,
215
+ ProgramArguments: [process.execPath, palmierBin, "run", taskId],
216
+ WorkingDirectory: config.projectRoot,
217
+ RunAtLoad: false,
218
+ EnvironmentVariables: { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" },
219
+ StandardOutPath: logPath,
220
+ StandardErrorPath: logPath,
221
+ };
222
+ const scheduleType = task.frontmatter.schedule_type;
223
+ const scheduleValues = task.frontmatter.schedule_values;
224
+ const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
225
+ if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
226
+ const intervals = [];
227
+ for (const value of scheduleValues) {
228
+ try {
229
+ intervals.push(scheduleType === "crons"
230
+ ? cronToCalendarInterval(value)
231
+ : specificTimeToCalendarInterval(value));
232
+ }
233
+ catch (err) {
234
+ console.error(`Invalid schedule value: ${err}`);
235
+ }
236
+ }
237
+ if (intervals.length > 0)
238
+ dict.StartCalendarInterval = intervals;
239
+ }
240
+ fs.writeFileSync(plistPath, buildPlist(dict), "utf-8");
241
+ const domain = guiDomain();
242
+ runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
243
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
244
+ }
245
+ removeTaskTimer(taskId) {
246
+ const domain = guiDomain();
247
+ runLaunchctl(["bootout", `${domain}/${taskLabel(taskId)}`], { ignoreFailure: true });
248
+ try {
249
+ fs.unlinkSync(taskPlistPath(taskId));
250
+ }
251
+ catch { /* ignore */ }
252
+ try {
253
+ fs.unlinkSync(taskLogPath(taskId));
254
+ }
255
+ catch { /* ignore */ }
256
+ }
257
+ async startTask(taskId) {
258
+ await execAsync(`launchctl kickstart ${guiDomain()}/${taskLabel(taskId)}`);
259
+ }
260
+ async stopTask(taskId) {
261
+ try {
262
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
263
+ const status = readTaskStatus(taskDir);
264
+ if (status?.pid) {
265
+ process.kill(status.pid, "SIGTERM");
266
+ return;
267
+ }
268
+ }
269
+ catch { /* fall through */ }
270
+ await execAsync(`launchctl kill SIGTERM ${guiDomain()}/${taskLabel(taskId)}`);
271
+ }
272
+ isTaskRunning(taskId) {
273
+ try {
274
+ const out = execSync(`launchctl print ${guiDomain()}/${taskLabel(taskId)}`, {
275
+ encoding: "utf-8",
276
+ stdio: ["ignore", "pipe", "ignore"],
277
+ });
278
+ // Running services show a numeric `pid = N`; idle ones show `state = not running`.
279
+ if (/^\s*pid\s*=\s*\d+/m.test(out))
280
+ return true;
281
+ }
282
+ catch { /* service may not be loaded */ }
283
+ try {
284
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
285
+ const status = readTaskStatus(taskDir);
286
+ if (status?.pid) {
287
+ process.kill(status.pid, 0);
288
+ return true;
289
+ }
290
+ }
291
+ catch { /* process not running or config unavailable */ }
292
+ return false;
293
+ }
294
+ getGuiEnv() {
295
+ return {};
296
+ }
297
+ }
298
+ //# sourceMappingURL=macos.js.map