palmier 0.8.1 → 0.8.4

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 (133) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +16 -14
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/commands/info.d.ts +0 -3
  17. package/dist/commands/info.js +0 -5
  18. package/dist/commands/init.d.ts +0 -3
  19. package/dist/commands/init.js +2 -11
  20. package/dist/commands/pair.d.ts +1 -4
  21. package/dist/commands/pair.js +3 -12
  22. package/dist/commands/restart.d.ts +0 -3
  23. package/dist/commands/restart.js +0 -3
  24. package/dist/commands/run.d.ts +1 -14
  25. package/dist/commands/run.js +18 -61
  26. package/dist/commands/serve.d.ts +0 -3
  27. package/dist/commands/serve.js +29 -27
  28. package/dist/config.d.ts +0 -8
  29. package/dist/config.js +0 -8
  30. package/dist/device-capabilities.d.ts +1 -1
  31. package/dist/event-queues.d.ts +6 -21
  32. package/dist/event-queues.js +6 -21
  33. package/dist/events.d.ts +0 -6
  34. package/dist/events.js +1 -9
  35. package/dist/index.js +0 -1
  36. package/dist/mcp-handler.js +1 -2
  37. package/dist/mcp-tools.d.ts +0 -3
  38. package/dist/mcp-tools.js +12 -16
  39. package/dist/nats-client.d.ts +0 -3
  40. package/dist/nats-client.js +1 -4
  41. package/dist/pending-requests.d.ts +4 -18
  42. package/dist/pending-requests.js +4 -18
  43. package/dist/platform/index.d.ts +1 -4
  44. package/dist/platform/index.js +8 -7
  45. package/dist/platform/linux.d.ts +3 -9
  46. package/dist/platform/linux.js +9 -20
  47. package/dist/platform/macos.d.ts +32 -0
  48. package/dist/platform/macos.js +287 -0
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-499vYQvR.js +120 -0
  53. package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
  54. package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
  55. package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +14 -47
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +7 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/pwa/src/App.css +325 -22
  73. package/palmier-server/pwa/src/App.tsx +2 -0
  74. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  78. package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
  79. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  80. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  81. package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
  82. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  83. package/palmier-server/pwa/src/constants.ts +1 -1
  84. package/palmier-server/pwa/src/native/Device.ts +18 -2
  85. package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
  86. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  87. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  88. package/palmier-server/server/src/index.ts +7 -7
  89. package/palmier-server/server/src/routes/device.ts +4 -4
  90. package/palmier-server/spec.md +38 -7
  91. package/src/agents/agent.ts +0 -4
  92. package/src/agents/claude.ts +1 -1
  93. package/src/agents/codex.ts +2 -2
  94. package/src/agents/cursor.ts +1 -1
  95. package/src/agents/deepagents.ts +1 -1
  96. package/src/agents/gemini.ts +3 -2
  97. package/src/agents/goose.ts +1 -1
  98. package/src/agents/hermes.ts +1 -1
  99. package/src/agents/kiro.ts +1 -1
  100. package/src/agents/opencode.ts +1 -1
  101. package/src/agents/qoder.ts +1 -1
  102. package/src/agents/shared-prompt.ts +0 -3
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +3 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +28 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +3 -2
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +12 -18
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +5 -7
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/macos.ts +310 -0
  121. package/src/platform/platform.ts +1 -4
  122. package/src/platform/windows.ts +19 -40
  123. package/src/rpc-handler.ts +14 -47
  124. package/src/spawn-command.ts +11 -27
  125. package/src/task.ts +7 -70
  126. package/src/transports/http-transport.ts +7 -39
  127. package/src/transports/nats-transport.ts +3 -9
  128. package/src/types.ts +3 -10
  129. package/src/update-checker.ts +2 -5
  130. package/test/macos-plist.test.ts +112 -0
  131. package/test/task-parsing.test.ts +2 -3
  132. package/test/windows-xml.test.ts +11 -12
  133. package/dist/pwa/assets/index-DQfOEB03.js +0 -120
@@ -22,10 +22,9 @@ export interface PendingRequest {
22
22
  const pending = new Map<string, PendingRequest>();
23
23
 
24
24
  /**
25
- * Register a pending request keyed by either a sessionId (confirmation / input)
26
- * or a taskId (permission). The `meta` is surfaced to PWAs that connect after
27
- * the request was opened, so their modals can render without replaying events.
28
- * Only one pending request per key at a time.
25
+ * Key is sessionId for confirmation/input, taskId for permission. Only one
26
+ * pending request per key at a time. `meta` is surfaced via host.info so a
27
+ * freshly-connected PWA can render the modal without replaying events.
29
28
  */
30
29
  export function registerPending(
31
30
  key: string,
@@ -42,10 +41,6 @@ export function registerPending(
42
41
  });
43
42
  }
44
43
 
45
- /**
46
- * Resolve a pending request with the user's response.
47
- * Returns true if a pending request was found and resolved.
48
- */
49
44
  export function resolvePending(key: string, value: string[]): boolean {
50
45
  const entry = pending.get(key);
51
46
  if (!entry) return false;
@@ -54,24 +49,15 @@ export function resolvePending(key: string, value: string[]): boolean {
54
49
  return true;
55
50
  }
56
51
 
57
- /**
58
- * Get the current pending request for a key (if any).
59
- */
60
52
  export function getPending(key: string): PendingRequest | undefined {
61
53
  return pending.get(key);
62
54
  }
63
55
 
64
- /**
65
- * Remove a pending request without resolving it.
66
- */
67
56
  export function removePending(key: string): void {
68
57
  pending.delete(key);
69
58
  }
70
59
 
71
- /**
72
- * List all currently-pending requests, stripped of the unserializable `resolve`
73
- * callback. Used by `host.info` so the PWA can seed its modal state on connect.
74
- */
60
+ /** Pending requests stripped of the unserializable `resolve` callback. */
75
61
  export function listPending(): Array<{
76
62
  key: string;
77
63
  type: PendingRequest["type"];
@@ -1,20 +1,18 @@
1
1
  import type { PlatformService } from "./platform.js";
2
2
  import { LinuxPlatform } from "./linux.js";
3
3
  import { WindowsPlatform } from "./windows.js";
4
+ import { MacOsPlatform } from "./macos.js";
4
5
 
5
- /**
6
- * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
7
- * On Unix, undefined lets Node use the default shell.
8
- */
6
+ /** Windows needs an explicit shell for execSync to resolve .cmd shims. */
9
7
  export const SHELL: string | undefined = process.platform === "win32" ? "cmd.exe" : undefined;
10
8
 
11
9
  let _instance: PlatformService | undefined;
12
10
 
13
11
  export function getPlatform(): PlatformService {
14
12
  if (!_instance) {
15
- _instance = process.platform === "win32"
16
- ? new WindowsPlatform()
17
- : new LinuxPlatform();
13
+ if (process.platform === "win32") _instance = new WindowsPlatform();
14
+ else if (process.platform === "darwin") _instance = new MacOsPlatform();
15
+ else _instance = new LinuxPlatform();
18
16
  }
19
17
  return _instance;
20
18
  }
@@ -22,15 +22,9 @@ function getServiceName(taskId: string): string {
22
22
  }
23
23
 
24
24
  /**
25
- * Convert a cron expression to a systemd OnCalendar string.
26
- *
27
- * Only the 4 cron patterns the PWA UI can produce are supported:
28
- * hourly: "0 * * * *"
29
- * daily: "MM HH * * *"
30
- * weekly: "MM HH * * D"
31
- * monthly: "MM HH D * *"
32
- * Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
33
- * handled because the UI never generates them.
25
+ * Only the 4 cron patterns the PWA UI produces are supported:
26
+ * hourly "0 * * * *", daily "MM HH * * *", weekly "MM HH * * D", monthly "MM HH D * *".
27
+ * Arbitrary expressions (ranges, lists, sub-hour steps) are not handled.
34
28
  */
35
29
  export function cronToOnCalendar(cron: string): string {
36
30
  const parts = cron.trim().split(/\s+/);
@@ -40,7 +34,6 @@ export function cronToOnCalendar(cron: string): string {
40
34
 
41
35
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
42
36
 
43
- // Map cron day-of-week numbers to systemd abbreviated names
44
37
  const dowMap: Record<string, string> = {
45
38
  "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
46
39
  "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
@@ -73,8 +66,8 @@ export class LinuxPlatform implements PlatformService {
73
66
  fs.mkdirSync(UNIT_DIR, { recursive: true });
74
67
 
75
68
  const palmierBin = process.argv[1] || "palmier";
76
- // Save the user's shell PATH so restartDaemon can use it later
77
- // (the daemon itself runs under systemd with a limited PATH).
69
+ // Save the user's shell PATH so restartDaemon can reuse it later — under
70
+ // systemd the daemon itself runs with a limited PATH.
78
71
  const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
79
72
  fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
80
73
  fs.writeFileSync(PATH_FILE, userPath, "utf-8");
@@ -110,7 +103,7 @@ WantedBy=default.target
110
103
  console.error("You may need to start it manually: systemctl --user enable --now palmier.service");
111
104
  }
112
105
 
113
- // Enable lingering so service runs without active login session
106
+ // Lingering lets the service run without an active login session.
114
107
  try {
115
108
  execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
116
109
  console.log("Login lingering enabled.");
@@ -127,11 +120,9 @@ WantedBy=default.target
127
120
  execSync("systemctl --user disable palmier.service 2>/dev/null", { stdio: "pipe" });
128
121
  } catch { /* service may not exist */ }
129
122
 
130
- // Remove daemon service file
131
123
  const servicePath = path.join(UNIT_DIR, "palmier.service");
132
124
  try { fs.unlinkSync(servicePath); } catch { /* ignore */ }
133
125
 
134
- // Remove all task timers and services
135
126
  try {
136
127
  const files = fs.readdirSync(UNIT_DIR).filter((f) => f.startsWith("palmier-task-"));
137
128
  for (const f of files) {
@@ -148,8 +139,8 @@ WantedBy=default.target
148
139
  }
149
140
 
150
141
  async restartDaemon(): Promise<void> {
151
- // If called from a user's terminal, save the current PATH for future use.
152
- // If called from the daemon (auto-update), read the saved PATH instead.
142
+ // From a TTY, snapshot the current PATH; from the daemon (auto-update),
143
+ // reuse whatever was last saved.
153
144
  if (process.stdin.isTTY) {
154
145
  fs.mkdirSync(path.dirname(PATH_FILE), { recursive: true });
155
146
  fs.writeFileSync(PATH_FILE, process.env.PATH || "", "utf-8");
@@ -196,7 +187,6 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
196
187
  fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
197
188
  daemonReload();
198
189
 
199
- // Only create and enable a timer if the schedule exists and is enabled
200
190
  if (!task.frontmatter.schedule_enabled) return;
201
191
  const scheduleType = task.frontmatter.schedule_type;
202
192
  const scheduleValues = task.frontmatter.schedule_values;
@@ -260,7 +250,6 @@ WantedBy=timers.target
260
250
  }
261
251
 
262
252
  isTaskRunning(taskId: string): boolean {
263
- // Check systemd first (for scheduled/on-demand runs)
264
253
  const serviceName = getServiceName(taskId);
265
254
  try {
266
255
  const out = execSync(
@@ -271,7 +260,7 @@ WantedBy=timers.target
271
260
  if (state === "active" || state === "activating") return true;
272
261
  } catch { /* service may not exist */ }
273
262
 
274
- // Fall back to PID check (for follow-up runs spawned directly)
263
+ // Follow-up runs are spawned directly, so check PID too.
275
264
  try {
276
265
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
277
266
  const status = readTaskStatus(taskDir);
@@ -0,0 +1,310 @@
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 type { PlatformService } from "./platform.js";
7
+ import type { HostConfig, ParsedTask } from "../types.js";
8
+ import { CONFIG_DIR, loadConfig } from "../config.js";
9
+ import { getTaskDir, readTaskStatus } from "../task.js";
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ const AGENT_DIR = path.join(homedir(), "Library", "LaunchAgents");
14
+ const PATH_FILE = path.join(CONFIG_DIR, "user-path");
15
+ const DAEMON_LABEL = "me.palmier.host";
16
+ const TASK_LABEL_PREFIX = "me.palmier.task.";
17
+
18
+ function daemonPlistPath(): string {
19
+ return path.join(AGENT_DIR, `${DAEMON_LABEL}.plist`);
20
+ }
21
+
22
+ function taskLabel(taskId: string): string {
23
+ return `${TASK_LABEL_PREFIX}${taskId}`;
24
+ }
25
+
26
+ function taskPlistPath(taskId: string): string {
27
+ return path.join(AGENT_DIR, `${taskLabel(taskId)}.plist`);
28
+ }
29
+
30
+ function guiDomain(): string {
31
+ const uid = process.getuid?.();
32
+ if (uid === undefined) throw new Error("getuid() unavailable — macOS platform requires POSIX uid");
33
+ return `gui/${uid}`;
34
+ }
35
+
36
+ /**
37
+ * Convert one of the four PWA-produced cron patterns to a launchd
38
+ * `StartCalendarInterval` dict.
39
+ * hourly "0 * * * *" → { Minute: 0 }
40
+ * daily "MM HH * * *" → { Minute, Hour }
41
+ * weekly "MM HH * * D" → { Minute, Hour, Weekday }
42
+ * monthly "MM HH D * *" → { Minute, Hour, Day }
43
+ * launchd Weekday: Sunday is 0 (cron 7 → 0).
44
+ */
45
+ export function cronToCalendarInterval(cron: string): Record<string, number> {
46
+ const parts = cron.trim().split(/\s+/);
47
+ if (parts.length !== 5) {
48
+ throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
49
+ }
50
+ const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
51
+ const result: Record<string, number> = {};
52
+
53
+ if (minute !== "*") result.Minute = Number(minute);
54
+ if (hour !== "*") result.Hour = Number(hour);
55
+ if (dayOfMonth !== "*") result.Day = Number(dayOfMonth);
56
+ if (dayOfWeek !== "*") {
57
+ const dow = Number(dayOfWeek);
58
+ result.Weekday = dow === 7 ? 0 : dow;
59
+ }
60
+
61
+ for (const [k, v] of Object.entries(result)) {
62
+ if (!Number.isInteger(v) || v < 0) {
63
+ throw new Error(`Invalid cron field ${k}=${v} in ${cron}`);
64
+ }
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /**
70
+ * Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
71
+ * to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
72
+ * so we omit Year — the task fires yearly on the same date and time. Sufficient
73
+ * because the PWA regenerates/removes one-off tasks after they run.
74
+ */
75
+ export function specificTimeToCalendarInterval(iso: string): Record<string, number> {
76
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
77
+ if (!m) throw new Error(`Invalid specific_times value: ${iso}`);
78
+ return {
79
+ Month: Number(m[2]),
80
+ Day: Number(m[3]),
81
+ Hour: Number(m[4]),
82
+ Minute: Number(m[5]),
83
+ };
84
+ }
85
+
86
+ function escapeXml(s: string): string {
87
+ return s
88
+ .replace(/&/g, "&amp;")
89
+ .replace(/</g, "&lt;")
90
+ .replace(/>/g, "&gt;");
91
+ }
92
+
93
+ /** Serialize a JS value to a plist XML fragment. Supports string/number/boolean/array/plain object. */
94
+ function plistValue(value: unknown, indent: string): string {
95
+ if (typeof value === "string") return `${indent}<string>${escapeXml(value)}</string>`;
96
+ if (typeof value === "boolean") return `${indent}<${value ? "true" : "false"}/>`;
97
+ if (typeof value === "number") {
98
+ return Number.isInteger(value)
99
+ ? `${indent}<integer>${value}</integer>`
100
+ : `${indent}<real>${value}</real>`;
101
+ }
102
+ if (Array.isArray(value)) {
103
+ if (value.length === 0) return `${indent}<array/>`;
104
+ const inner = value.map((v) => plistValue(v, indent + " ")).join("\n");
105
+ return `${indent}<array>\n${inner}\n${indent}</array>`;
106
+ }
107
+ if (value && typeof value === "object") {
108
+ const entries = Object.entries(value as Record<string, unknown>);
109
+ if (entries.length === 0) return `${indent}<dict/>`;
110
+ const inner = entries
111
+ .map(([k, v]) => `${indent} <key>${escapeXml(k)}</key>\n${plistValue(v, indent + " ")}`)
112
+ .join("\n");
113
+ return `${indent}<dict>\n${inner}\n${indent}</dict>`;
114
+ }
115
+ throw new Error(`Unsupported plist value type: ${typeof value}`);
116
+ }
117
+
118
+ export function buildPlist(dict: Record<string, unknown>): string {
119
+ return [
120
+ `<?xml version="1.0" encoding="UTF-8"?>`,
121
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
122
+ `<plist version="1.0">`,
123
+ plistValue(dict, ""),
124
+ `</plist>`,
125
+ ``,
126
+ ].join("\n");
127
+ }
128
+
129
+ function runLaunchctl(args: string[], opts: { ignoreFailure?: boolean } = {}): void {
130
+ try {
131
+ execSync(`launchctl ${args.join(" ")}`, { stdio: "pipe", encoding: "utf-8" });
132
+ } catch (err: unknown) {
133
+ if (opts.ignoreFailure) return;
134
+ const e = err as { stderr?: string };
135
+ console.error(`launchctl ${args[0]} failed: ${e.stderr || err}`);
136
+ }
137
+ }
138
+
139
+ export class MacOsPlatform implements PlatformService {
140
+ installDaemon(config: HostConfig): void {
141
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
142
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
143
+
144
+ const palmierBin = process.argv[1] || "palmier";
145
+ const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
146
+ fs.writeFileSync(PATH_FILE, userPath, "utf-8");
147
+
148
+ const logPath = path.join(CONFIG_DIR, "daemon.log");
149
+ const plist = buildPlist({
150
+ Label: DAEMON_LABEL,
151
+ ProgramArguments: [process.execPath, palmierBin, "serve"],
152
+ WorkingDirectory: config.projectRoot,
153
+ RunAtLoad: true,
154
+ KeepAlive: { SuccessfulExit: false },
155
+ EnvironmentVariables: { PATH: userPath },
156
+ StandardOutPath: logPath,
157
+ StandardErrorPath: logPath,
158
+ });
159
+
160
+ const plistPath = daemonPlistPath();
161
+ fs.writeFileSync(plistPath, plist, "utf-8");
162
+ console.log("LaunchAgent installed at:", plistPath);
163
+
164
+ const domain = guiDomain();
165
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
166
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
167
+ runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
168
+
169
+ console.log("Palmier host LaunchAgent loaded and started.");
170
+ console.log(
171
+ "Note: LaunchAgents only run while you are logged into the GUI session. " +
172
+ "After reboot, tasks remain dormant until you log in at least once.",
173
+ );
174
+
175
+ console.log("\nHost initialization complete!");
176
+ }
177
+
178
+ uninstallDaemon(): void {
179
+ const domain = guiDomain();
180
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
181
+ try { fs.unlinkSync(daemonPlistPath()); } catch { /* may not exist */ }
182
+
183
+ try {
184
+ const entries = fs.readdirSync(AGENT_DIR).filter((f) => f.startsWith(TASK_LABEL_PREFIX) && f.endsWith(".plist"));
185
+ for (const f of entries) {
186
+ const label = f.slice(0, -".plist".length);
187
+ runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
188
+ try { fs.unlinkSync(path.join(AGENT_DIR, f)); } catch { /* ignore */ }
189
+ }
190
+ } catch { /* AGENT_DIR may not exist */ }
191
+
192
+ console.log("Palmier daemon and tasks uninstalled.");
193
+ }
194
+
195
+ async restartDaemon(): Promise<void> {
196
+ const plistPath = daemonPlistPath();
197
+ const domain = guiDomain();
198
+
199
+ if (process.stdin.isTTY && fs.existsSync(plistPath)) {
200
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
201
+ const userPath = process.env.PATH || "";
202
+ fs.writeFileSync(PATH_FILE, userPath, "utf-8");
203
+
204
+ const content = fs.readFileSync(plistPath, "utf-8");
205
+ const updated = content.replace(
206
+ /(<key>PATH<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/,
207
+ `$1${escapeXml(userPath)}$2`,
208
+ );
209
+ if (updated !== content) {
210
+ fs.writeFileSync(plistPath, updated, "utf-8");
211
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
212
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
213
+ }
214
+ }
215
+
216
+ runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
217
+ console.log("Palmier daemon restarted.");
218
+ }
219
+
220
+ installTaskTimer(config: HostConfig, task: ParsedTask): void {
221
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
222
+
223
+ const taskId = task.frontmatter.id;
224
+ const label = taskLabel(taskId);
225
+ const plistPath = taskPlistPath(taskId);
226
+ const palmierBin = process.argv[1] || "palmier";
227
+
228
+ const dict: Record<string, unknown> = {
229
+ Label: label,
230
+ ProgramArguments: [process.execPath, palmierBin, "run", taskId],
231
+ WorkingDirectory: config.projectRoot,
232
+ RunAtLoad: false,
233
+ EnvironmentVariables: { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" },
234
+ };
235
+
236
+ const scheduleType = task.frontmatter.schedule_type;
237
+ const scheduleValues = task.frontmatter.schedule_values;
238
+ const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
239
+ if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
240
+ const intervals: Record<string, number>[] = [];
241
+ for (const value of scheduleValues) {
242
+ try {
243
+ intervals.push(
244
+ scheduleType === "crons"
245
+ ? cronToCalendarInterval(value)
246
+ : specificTimeToCalendarInterval(value),
247
+ );
248
+ } catch (err) {
249
+ console.error(`Invalid schedule value: ${err}`);
250
+ }
251
+ }
252
+ if (intervals.length > 0) dict.StartCalendarInterval = intervals;
253
+ }
254
+
255
+ fs.writeFileSync(plistPath, buildPlist(dict), "utf-8");
256
+
257
+ const domain = guiDomain();
258
+ runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
259
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
260
+ }
261
+
262
+ removeTaskTimer(taskId: string): void {
263
+ const domain = guiDomain();
264
+ runLaunchctl(["bootout", `${domain}/${taskLabel(taskId)}`], { ignoreFailure: true });
265
+ try { fs.unlinkSync(taskPlistPath(taskId)); } catch { /* ignore */ }
266
+ }
267
+
268
+ async startTask(taskId: string): Promise<void> {
269
+ await execAsync(`launchctl kickstart ${guiDomain()}/${taskLabel(taskId)}`);
270
+ }
271
+
272
+ async stopTask(taskId: string): Promise<void> {
273
+ try {
274
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
275
+ const status = readTaskStatus(taskDir);
276
+ if (status?.pid) {
277
+ process.kill(status.pid, "SIGTERM");
278
+ return;
279
+ }
280
+ } catch { /* fall through */ }
281
+
282
+ await execAsync(`launchctl kill SIGTERM ${guiDomain()}/${taskLabel(taskId)}`);
283
+ }
284
+
285
+ isTaskRunning(taskId: string): boolean {
286
+ try {
287
+ const out = execSync(`launchctl print ${guiDomain()}/${taskLabel(taskId)}`, {
288
+ encoding: "utf-8",
289
+ stdio: ["ignore", "pipe", "ignore"],
290
+ });
291
+ // Running services show a numeric `pid = N`; idle ones show `state = not running`.
292
+ if (/^\s*pid\s*=\s*\d+/m.test(out)) return true;
293
+ } catch { /* service may not be loaded */ }
294
+
295
+ try {
296
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
297
+ const status = readTaskStatus(taskDir);
298
+ if (status?.pid) {
299
+ process.kill(status.pid, 0);
300
+ return true;
301
+ }
302
+ } catch { /* process not running or config unavailable */ }
303
+
304
+ return false;
305
+ }
306
+
307
+ getGuiEnv(): Record<string, string> {
308
+ return {};
309
+ }
310
+ }
@@ -1,9 +1,6 @@
1
1
  import type { HostConfig, ParsedTask } from "../types.js";
2
2
 
3
- /**
4
- * Abstracts OS-specific daemon, scheduling, and process management.
5
- * Linux uses systemd; Windows uses Task Scheduler; macOS will use launchd.
6
- */
3
+ /** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
7
4
  export interface PlatformService {
8
5
  /** Install the main `palmier serve` daemon to start at boot. */
9
6
  installDaemon(config: HostConfig): void;