palmier 0.9.6 → 0.9.8

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 (255) hide show
  1. package/README.md +28 -13
  2. package/dist/agents/agent.d.ts +0 -1
  3. package/dist/agents/agent.js +0 -1
  4. package/dist/agents/aider.d.ts +0 -1
  5. package/dist/agents/aider.js +0 -1
  6. package/dist/agents/claude.d.ts +0 -1
  7. package/dist/agents/claude.js +0 -1
  8. package/dist/agents/cline.d.ts +0 -1
  9. package/dist/agents/cline.js +0 -1
  10. package/dist/agents/codex.d.ts +0 -1
  11. package/dist/agents/codex.js +0 -1
  12. package/dist/agents/copilot.d.ts +0 -1
  13. package/dist/agents/copilot.js +0 -1
  14. package/dist/agents/cursor.d.ts +0 -1
  15. package/dist/agents/cursor.js +0 -1
  16. package/dist/agents/deepagents.d.ts +0 -1
  17. package/dist/agents/deepagents.js +0 -1
  18. package/dist/agents/droid.d.ts +0 -1
  19. package/dist/agents/droid.js +0 -1
  20. package/dist/agents/gemini.d.ts +0 -1
  21. package/dist/agents/gemini.js +0 -1
  22. package/dist/agents/goose.d.ts +0 -1
  23. package/dist/agents/goose.js +0 -1
  24. package/dist/agents/hermes.d.ts +0 -1
  25. package/dist/agents/hermes.js +0 -1
  26. package/dist/agents/kimi.d.ts +0 -1
  27. package/dist/agents/kimi.js +0 -1
  28. package/dist/agents/kiro.d.ts +0 -1
  29. package/dist/agents/kiro.js +0 -1
  30. package/dist/agents/openclaw.d.ts +0 -1
  31. package/dist/agents/openclaw.js +0 -1
  32. package/dist/agents/opencode.d.ts +0 -1
  33. package/dist/agents/opencode.js +0 -1
  34. package/dist/agents/qoder.d.ts +0 -1
  35. package/dist/agents/qoder.js +0 -1
  36. package/dist/agents/qwen.d.ts +0 -1
  37. package/dist/agents/qwen.js +0 -1
  38. package/dist/agents/shared-prompt.d.ts +0 -1
  39. package/dist/agents/shared-prompt.js +0 -1
  40. package/dist/client-store.d.ts +0 -1
  41. package/dist/client-store.js +0 -1
  42. package/dist/commands/clients.d.ts +0 -1
  43. package/dist/commands/clients.js +0 -1
  44. package/dist/commands/info.d.ts +0 -1
  45. package/dist/commands/info.js +0 -1
  46. package/dist/commands/init.d.ts +0 -1
  47. package/dist/commands/init.js +1 -2
  48. package/dist/commands/pair.d.ts +0 -1
  49. package/dist/commands/pair.js +0 -1
  50. package/dist/commands/restart.d.ts +0 -1
  51. package/dist/commands/restart.js +0 -1
  52. package/dist/commands/run.d.ts +0 -1
  53. package/dist/commands/run.js +19 -3
  54. package/dist/commands/serve.d.ts +0 -1
  55. package/dist/commands/serve.js +0 -1
  56. package/dist/commands/uninstall.d.ts +0 -1
  57. package/dist/commands/uninstall.js +0 -1
  58. package/dist/config.d.ts +0 -1
  59. package/dist/config.js +0 -1
  60. package/dist/event-queues.d.ts +0 -1
  61. package/dist/event-queues.js +0 -1
  62. package/dist/events.d.ts +0 -1
  63. package/dist/events.js +0 -1
  64. package/dist/index.d.ts +0 -1
  65. package/dist/index.js +0 -1
  66. package/dist/linked-device.d.ts +0 -1
  67. package/dist/linked-device.js +0 -1
  68. package/dist/mcp-handler.d.ts +0 -1
  69. package/dist/mcp-handler.js +0 -1
  70. package/dist/mcp-tools.d.ts +0 -1
  71. package/dist/mcp-tools.js +0 -1
  72. package/dist/nats-client.d.ts +0 -1
  73. package/dist/nats-client.js +0 -1
  74. package/dist/network.d.ts +0 -1
  75. package/dist/network.js +0 -1
  76. package/dist/notification-store.d.ts +0 -1
  77. package/dist/notification-store.js +0 -1
  78. package/dist/pending-requests.d.ts +0 -1
  79. package/dist/pending-requests.js +0 -1
  80. package/dist/platform/index.d.ts +0 -1
  81. package/dist/platform/index.js +0 -1
  82. package/dist/platform/linux.d.ts +0 -1
  83. package/dist/platform/linux.js +0 -1
  84. package/dist/platform/macos.d.ts +0 -1
  85. package/dist/platform/macos.js +0 -1
  86. package/dist/platform/platform.d.ts +0 -1
  87. package/dist/platform/platform.js +0 -1
  88. package/dist/platform/windows.d.ts +0 -1
  89. package/dist/platform/windows.js +0 -1
  90. package/dist/pwa/assets/{index-MLEFUP3r.js → index-DWvRAUiy.js} +31 -31
  91. package/dist/pwa/assets/{web-B1sKCc7e.js → web-C4iZbqTC.js} +1 -1
  92. package/dist/pwa/assets/{web-ETD-8ZHd.js → web-CBFqJGX6.js} +1 -1
  93. package/dist/pwa/assets/{web-B4xEa6WO.js → web-DL4uXOpS.js} +1 -1
  94. package/dist/pwa/index.html +2 -2
  95. package/dist/rpc-handler.d.ts +0 -1
  96. package/dist/rpc-handler.js +0 -1
  97. package/dist/sms-store.d.ts +0 -1
  98. package/dist/sms-store.js +0 -1
  99. package/dist/spawn-command.d.ts +0 -1
  100. package/dist/spawn-command.js +0 -1
  101. package/dist/task.d.ts +0 -1
  102. package/dist/task.js +0 -1
  103. package/dist/transports/http-transport.d.ts +0 -1
  104. package/dist/transports/http-transport.js +0 -1
  105. package/dist/transports/nats-transport.d.ts +0 -1
  106. package/dist/transports/nats-transport.js +0 -1
  107. package/dist/types.d.ts +0 -1
  108. package/dist/types.js +0 -1
  109. package/dist/update-checker.d.ts +0 -1
  110. package/dist/update-checker.js +0 -1
  111. package/package.json +11 -1
  112. package/.github/workflows/ci.yml +0 -16
  113. package/.github/workflows/publish.yml +0 -37
  114. package/CLAUDE.md +0 -22
  115. package/dist/pwa/apple-touch-icon.png +0 -0
  116. package/dist/pwa/manifest.webmanifest +0 -1
  117. package/dist/pwa/pwa-192x192.png +0 -0
  118. package/dist/pwa/pwa-512x512.png +0 -0
  119. package/dist/pwa/registerSW.js +0 -1
  120. package/dist/pwa/service-worker.js +0 -2
  121. package/palmier-server/.github/workflows/ci.yml +0 -21
  122. package/palmier-server/.github/workflows/deploy.yml +0 -38
  123. package/palmier-server/CLAUDE.md +0 -17
  124. package/palmier-server/PRODUCTION.md +0 -358
  125. package/palmier-server/README.md +0 -231
  126. package/palmier-server/nats.conf +0 -19
  127. package/palmier-server/package.json +0 -15
  128. package/palmier-server/pnpm-lock.yaml +0 -7639
  129. package/palmier-server/pnpm-workspace.yaml +0 -3
  130. package/palmier-server/pwa/index.html +0 -16
  131. package/palmier-server/pwa/logo/logo_20260421.png +0 -0
  132. package/palmier-server/pwa/package.json +0 -34
  133. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  134. package/palmier-server/pwa/public/favicon.ico +0 -0
  135. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  136. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  137. package/palmier-server/pwa/src/App.css +0 -3012
  138. package/palmier-server/pwa/src/App.tsx +0 -59
  139. package/palmier-server/pwa/src/agentLabels.ts +0 -11
  140. package/palmier-server/pwa/src/api.ts +0 -67
  141. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +0 -170
  142. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +0 -113
  143. package/palmier-server/pwa/src/components/HostMenu.tsx +0 -429
  144. package/palmier-server/pwa/src/components/PermissionsDialog.tsx +0 -34
  145. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +0 -46
  146. package/palmier-server/pwa/src/components/RunDetailView.tsx +0 -343
  147. package/palmier-server/pwa/src/components/SessionComposer.tsx +0 -157
  148. package/palmier-server/pwa/src/components/SessionsView.tsx +0 -326
  149. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +0 -170
  150. package/palmier-server/pwa/src/components/TabBar.tsx +0 -40
  151. package/palmier-server/pwa/src/components/TaskCard.tsx +0 -255
  152. package/palmier-server/pwa/src/components/TaskForm.tsx +0 -766
  153. package/palmier-server/pwa/src/components/TasksView.tsx +0 -179
  154. package/palmier-server/pwa/src/constants.ts +0 -2
  155. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +0 -432
  156. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +0 -124
  157. package/palmier-server/pwa/src/draftGuard.ts +0 -24
  158. package/palmier-server/pwa/src/formatTime.ts +0 -44
  159. package/palmier-server/pwa/src/hooks/useBackClose.ts +0 -75
  160. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +0 -17
  161. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +0 -102
  162. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +0 -77
  163. package/palmier-server/pwa/src/main.tsx +0 -14
  164. package/palmier-server/pwa/src/native/Device.ts +0 -49
  165. package/palmier-server/pwa/src/pages/Dashboard.tsx +0 -542
  166. package/palmier-server/pwa/src/pages/PairHost.tsx +0 -232
  167. package/palmier-server/pwa/src/pages/PairSetup.tsx +0 -134
  168. package/palmier-server/pwa/src/service-worker.ts +0 -142
  169. package/palmier-server/pwa/src/types.ts +0 -75
  170. package/palmier-server/pwa/src/vite-env.d.ts +0 -11
  171. package/palmier-server/pwa/tsconfig.json +0 -21
  172. package/palmier-server/pwa/tsconfig.node.json +0 -19
  173. package/palmier-server/pwa/vite.config.ts +0 -47
  174. package/palmier-server/server/.env.example +0 -20
  175. package/palmier-server/server/package.json +0 -36
  176. package/palmier-server/server/src/db.ts +0 -44
  177. package/palmier-server/server/src/fcm.ts +0 -74
  178. package/palmier-server/server/src/index.ts +0 -688
  179. package/palmier-server/server/src/nats-jwt.ts +0 -299
  180. package/palmier-server/server/src/nats-setup.ts +0 -48
  181. package/palmier-server/server/src/nats.ts +0 -33
  182. package/palmier-server/server/src/notify.ts +0 -34
  183. package/palmier-server/server/src/push.ts +0 -68
  184. package/palmier-server/server/src/routes/device.ts +0 -224
  185. package/palmier-server/server/src/routes/fcm.ts +0 -64
  186. package/palmier-server/server/src/routes/hosts.ts +0 -56
  187. package/palmier-server/server/src/routes/push.ts +0 -101
  188. package/palmier-server/server/tsconfig.json +0 -20
  189. package/palmier-server/spec.md +0 -533
  190. package/src/agents/agent-instructions.md +0 -28
  191. package/src/agents/agent.ts +0 -114
  192. package/src/agents/aider.ts +0 -35
  193. package/src/agents/claude.ts +0 -39
  194. package/src/agents/cline.ts +0 -35
  195. package/src/agents/codex.ts +0 -40
  196. package/src/agents/copilot.ts +0 -37
  197. package/src/agents/cursor.ts +0 -36
  198. package/src/agents/deepagents.ts +0 -36
  199. package/src/agents/droid.ts +0 -35
  200. package/src/agents/gemini.ts +0 -43
  201. package/src/agents/goose.ts +0 -33
  202. package/src/agents/hermes.ts +0 -36
  203. package/src/agents/kimi.ts +0 -35
  204. package/src/agents/kiro.ts +0 -36
  205. package/src/agents/openclaw.ts +0 -29
  206. package/src/agents/opencode.ts +0 -36
  207. package/src/agents/qoder.ts +0 -36
  208. package/src/agents/qwen.ts +0 -32
  209. package/src/agents/shared-prompt.ts +0 -30
  210. package/src/client-store.ts +0 -68
  211. package/src/commands/clients.ts +0 -29
  212. package/src/commands/info.ts +0 -29
  213. package/src/commands/init.ts +0 -165
  214. package/src/commands/pair.ts +0 -137
  215. package/src/commands/restart.ts +0 -6
  216. package/src/commands/run.ts +0 -608
  217. package/src/commands/serve.ts +0 -211
  218. package/src/commands/uninstall.ts +0 -9
  219. package/src/config.ts +0 -36
  220. package/src/cross-spawn.d.ts +0 -5
  221. package/src/event-queues.ts +0 -41
  222. package/src/events.ts +0 -29
  223. package/src/index.ts +0 -111
  224. package/src/linked-device.ts +0 -52
  225. package/src/mcp-handler.ts +0 -200
  226. package/src/mcp-tools.ts +0 -839
  227. package/src/nats-client.ts +0 -19
  228. package/src/network.ts +0 -96
  229. package/src/notification-store.ts +0 -30
  230. package/src/pending-requests.ts +0 -73
  231. package/src/platform/index.ts +0 -20
  232. package/src/platform/linux.ts +0 -296
  233. package/src/platform/macos.ts +0 -329
  234. package/src/platform/platform.ts +0 -31
  235. package/src/platform/windows.ts +0 -299
  236. package/src/rpc-handler.ts +0 -691
  237. package/src/sms-store.ts +0 -28
  238. package/src/spawn-command.ts +0 -123
  239. package/src/task.ts +0 -343
  240. package/src/transports/http-transport.ts +0 -478
  241. package/src/transports/nats-transport.ts +0 -76
  242. package/src/types.ts +0 -89
  243. package/src/update-checker.ts +0 -40
  244. package/test/agent-instructions.test.ts +0 -209
  245. package/test/agent-output-parsing.test.ts +0 -74
  246. package/test/linux-cron.test.ts +0 -41
  247. package/test/macos-plist.test.ts +0 -112
  248. package/test/notification-store.test.ts +0 -57
  249. package/test/pairing.test.ts +0 -35
  250. package/test/result-state.test.ts +0 -110
  251. package/test/task-parsing.test.ts +0 -82
  252. package/test/taskrun-messages.test.ts +0 -224
  253. package/test/tsconfig.json +0 -9
  254. package/test/windows-xml.test.ts +0 -89
  255. package/tsconfig.json +0 -19
@@ -1,329 +0,0 @@
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 taskLogPath(taskId: string): string {
31
- return path.join(CONFIG_DIR, `task-${taskId}.log`);
32
- }
33
-
34
- function guiDomain(): string {
35
- const uid = process.getuid?.();
36
- if (uid === undefined) throw new Error("getuid() unavailable — macOS platform requires POSIX uid");
37
- return `gui/${uid}`;
38
- }
39
-
40
- /**
41
- * Convert one of the four PWA-produced cron patterns to a launchd
42
- * `StartCalendarInterval` dict.
43
- * hourly "0 * * * *" → { Minute: 0 }
44
- * daily "MM HH * * *" → { Minute, Hour }
45
- * weekly "MM HH * * D" → { Minute, Hour, Weekday }
46
- * monthly "MM HH D * *" → { Minute, Hour, Day }
47
- * launchd Weekday: Sunday is 0 (cron 7 → 0).
48
- */
49
- export function cronToCalendarInterval(cron: string): Record<string, number> {
50
- const parts = cron.trim().split(/\s+/);
51
- if (parts.length !== 5) {
52
- throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
53
- }
54
- const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
55
- const result: Record<string, number> = {};
56
-
57
- if (minute !== "*") result.Minute = Number(minute);
58
- if (hour !== "*") result.Hour = Number(hour);
59
- if (dayOfMonth !== "*") result.Day = Number(dayOfMonth);
60
- if (dayOfWeek !== "*") {
61
- const dow = Number(dayOfWeek);
62
- result.Weekday = dow === 7 ? 0 : dow;
63
- }
64
-
65
- for (const [k, v] of Object.entries(result)) {
66
- if (!Number.isInteger(v) || v < 0) {
67
- throw new Error(`Invalid cron field ${k}=${v} in ${cron}`);
68
- }
69
- }
70
- return result;
71
- }
72
-
73
- /**
74
- * Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
75
- * to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
76
- * so we omit Year — the task fires yearly on the same date and time. Sufficient
77
- * because the PWA regenerates/removes one-off tasks after they run.
78
- */
79
- export function specificTimeToCalendarInterval(iso: string): Record<string, number> {
80
- const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
81
- if (!m) throw new Error(`Invalid specific_times value: ${iso}`);
82
- return {
83
- Month: Number(m[2]),
84
- Day: Number(m[3]),
85
- Hour: Number(m[4]),
86
- Minute: Number(m[5]),
87
- };
88
- }
89
-
90
- function escapeXml(s: string): string {
91
- return s
92
- .replace(/&/g, "&amp;")
93
- .replace(/</g, "&lt;")
94
- .replace(/>/g, "&gt;");
95
- }
96
-
97
- /** Serialize a JS value to a plist XML fragment. Supports string/number/boolean/array/plain object. */
98
- function plistValue(value: unknown, indent: string): string {
99
- if (typeof value === "string") return `${indent}<string>${escapeXml(value)}</string>`;
100
- if (typeof value === "boolean") return `${indent}<${value ? "true" : "false"}/>`;
101
- if (typeof value === "number") {
102
- return Number.isInteger(value)
103
- ? `${indent}<integer>${value}</integer>`
104
- : `${indent}<real>${value}</real>`;
105
- }
106
- if (Array.isArray(value)) {
107
- if (value.length === 0) return `${indent}<array/>`;
108
- const inner = value.map((v) => plistValue(v, indent + " ")).join("\n");
109
- return `${indent}<array>\n${inner}\n${indent}</array>`;
110
- }
111
- if (value && typeof value === "object") {
112
- const entries = Object.entries(value as Record<string, unknown>);
113
- if (entries.length === 0) return `${indent}<dict/>`;
114
- const inner = entries
115
- .map(([k, v]) => `${indent} <key>${escapeXml(k)}</key>\n${plistValue(v, indent + " ")}`)
116
- .join("\n");
117
- return `${indent}<dict>\n${inner}\n${indent}</dict>`;
118
- }
119
- throw new Error(`Unsupported plist value type: ${typeof value}`);
120
- }
121
-
122
- export function buildPlist(dict: Record<string, unknown>): string {
123
- return [
124
- `<?xml version="1.0" encoding="UTF-8"?>`,
125
- `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
126
- `<plist version="1.0">`,
127
- plistValue(dict, ""),
128
- `</plist>`,
129
- ``,
130
- ].join("\n");
131
- }
132
-
133
- function runLaunchctl(args: string[], opts: { ignoreFailure?: boolean } = {}): void {
134
- try {
135
- execSync(`launchctl ${args.join(" ")}`, { stdio: "pipe", encoding: "utf-8" });
136
- } catch (err: unknown) {
137
- if (opts.ignoreFailure) return;
138
- const e = err as { stderr?: string };
139
- console.error(`launchctl ${args[0]} failed: ${e.stderr || err}`);
140
- }
141
- }
142
-
143
- /**
144
- * Reload a LaunchAgent plist. The `enable` call is essential: after `bootout`
145
- * macOS can leave the service in a *disabled* state (tracked in
146
- * /var/db/com.apple.xpc.launchd/disabled.<uid>.plist). A subsequent bootstrap
147
- * then fails with "Bootstrap failed: 5: Input/output error".
148
- */
149
- function reloadAgent(domain: string, label: string, plistPath: string): void {
150
- runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
151
- runLaunchctl(["enable", `${domain}/${label}`], { ignoreFailure: true });
152
- runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
153
- }
154
-
155
- export class MacOsPlatform implements PlatformService {
156
- installDaemon(config: HostConfig): void {
157
- fs.mkdirSync(AGENT_DIR, { recursive: true });
158
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
159
-
160
- const palmierBin = process.argv[1] || "palmier";
161
- const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
162
- fs.writeFileSync(PATH_FILE, userPath, "utf-8");
163
-
164
- const logPath = path.join(CONFIG_DIR, "daemon.log");
165
- const plist = buildPlist({
166
- Label: DAEMON_LABEL,
167
- ProgramArguments: [process.execPath, palmierBin, "serve"],
168
- WorkingDirectory: config.projectRoot,
169
- RunAtLoad: true,
170
- KeepAlive: { SuccessfulExit: false },
171
- EnvironmentVariables: { PATH: userPath },
172
- StandardOutPath: logPath,
173
- StandardErrorPath: logPath,
174
- });
175
-
176
- const plistPath = daemonPlistPath();
177
- fs.writeFileSync(plistPath, plist, "utf-8");
178
- console.log("LaunchAgent installed at:", plistPath);
179
-
180
- const domain = guiDomain();
181
- reloadAgent(domain, DAEMON_LABEL, plistPath);
182
- runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
183
-
184
- console.log("Palmier host LaunchAgent loaded and started.");
185
- console.log(
186
- "Note: LaunchAgents only run while you are logged into the GUI session. " +
187
- "After reboot, tasks remain dormant until you log in at least once.",
188
- );
189
-
190
- console.log("\nHost initialization complete!");
191
- }
192
-
193
- uninstallDaemon(): void {
194
- const domain = guiDomain();
195
- runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
196
- try { fs.unlinkSync(daemonPlistPath()); } catch { /* may not exist */ }
197
-
198
- try {
199
- const entries = fs.readdirSync(AGENT_DIR).filter((f) => f.startsWith(TASK_LABEL_PREFIX) && f.endsWith(".plist"));
200
- for (const f of entries) {
201
- const label = f.slice(0, -".plist".length);
202
- runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
203
- try { fs.unlinkSync(path.join(AGENT_DIR, f)); } catch { /* ignore */ }
204
- }
205
- } catch { /* AGENT_DIR may not exist */ }
206
-
207
- console.log("Palmier daemon and tasks uninstalled.");
208
- }
209
-
210
- async restartDaemon(): Promise<void> {
211
- const plistPath = daemonPlistPath();
212
- const domain = guiDomain();
213
-
214
- if (process.stdin.isTTY && fs.existsSync(plistPath)) {
215
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
216
- const userPath = process.env.PATH || "";
217
- fs.writeFileSync(PATH_FILE, userPath, "utf-8");
218
-
219
- const content = fs.readFileSync(plistPath, "utf-8");
220
- const updated = content.replace(
221
- /(<key>PATH<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/,
222
- `$1${escapeXml(userPath)}$2`,
223
- );
224
- if (updated !== content) {
225
- fs.writeFileSync(plistPath, updated, "utf-8");
226
- reloadAgent(domain, DAEMON_LABEL, plistPath);
227
- }
228
- }
229
-
230
- runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
231
- console.log("Palmier daemon restarted.");
232
- }
233
-
234
- installTaskTimer(config: HostConfig, task: ParsedTask): void {
235
- fs.mkdirSync(AGENT_DIR, { recursive: true });
236
-
237
- const taskId = task.frontmatter.id;
238
- const label = taskLabel(taskId);
239
- const plistPath = taskPlistPath(taskId);
240
- const palmierBin = process.argv[1] || "palmier";
241
-
242
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
243
- const logPath = taskLogPath(taskId);
244
- const dict: Record<string, unknown> = {
245
- Label: label,
246
- ProgramArguments: [process.execPath, palmierBin, "run", taskId],
247
- WorkingDirectory: config.projectRoot,
248
- RunAtLoad: false,
249
- EnvironmentVariables: { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" },
250
- StandardOutPath: logPath,
251
- StandardErrorPath: logPath,
252
- };
253
-
254
- const scheduleType = task.frontmatter.schedule_type;
255
- const scheduleValues = task.frontmatter.schedule_values;
256
- const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
257
- if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
258
- const intervals: Record<string, number>[] = [];
259
- for (const value of scheduleValues) {
260
- try {
261
- intervals.push(
262
- scheduleType === "crons"
263
- ? cronToCalendarInterval(value)
264
- : specificTimeToCalendarInterval(value),
265
- );
266
- } catch (err) {
267
- console.error(`Invalid schedule value: ${err}`);
268
- }
269
- }
270
- if (intervals.length > 0) dict.StartCalendarInterval = intervals;
271
- }
272
-
273
- fs.writeFileSync(plistPath, buildPlist(dict), "utf-8");
274
-
275
- const domain = guiDomain();
276
- reloadAgent(domain, label, plistPath);
277
- }
278
-
279
- removeTaskTimer(taskId: string): void {
280
- const domain = guiDomain();
281
- runLaunchctl(["bootout", `${domain}/${taskLabel(taskId)}`], { ignoreFailure: true });
282
- try { fs.unlinkSync(taskPlistPath(taskId)); } catch { /* ignore */ }
283
- // Keep the log file — parity with journald retention on Linux, and
284
- // needed to debug the last fire of one-shot specific_times tasks.
285
- }
286
-
287
- async startTask(taskId: string): Promise<void> {
288
- await execAsync(`launchctl kickstart ${guiDomain()}/${taskLabel(taskId)}`);
289
- }
290
-
291
- async stopTask(taskId: string): Promise<void> {
292
- try {
293
- const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
294
- const status = readTaskStatus(taskDir);
295
- if (status?.pid) {
296
- process.kill(status.pid, "SIGTERM");
297
- return;
298
- }
299
- } catch { /* fall through */ }
300
-
301
- await execAsync(`launchctl kill SIGTERM ${guiDomain()}/${taskLabel(taskId)}`);
302
- }
303
-
304
- isTaskRunning(taskId: string): boolean {
305
- try {
306
- const out = execSync(`launchctl print ${guiDomain()}/${taskLabel(taskId)}`, {
307
- encoding: "utf-8",
308
- stdio: ["ignore", "pipe", "ignore"],
309
- });
310
- // Running services show a numeric `pid = N`; idle ones show `state = not running`.
311
- if (/^\s*pid\s*=\s*\d+/m.test(out)) return true;
312
- } catch { /* service may not be loaded */ }
313
-
314
- try {
315
- const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
316
- const status = readTaskStatus(taskDir);
317
- if (status?.pid) {
318
- process.kill(status.pid, 0);
319
- return true;
320
- }
321
- } catch { /* process not running or config unavailable */ }
322
-
323
- return false;
324
- }
325
-
326
- getGuiEnv(): Record<string, string> {
327
- return {};
328
- }
329
- }
@@ -1,31 +0,0 @@
1
- import type { HostConfig, ParsedTask } from "../types.js";
2
-
3
- /** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
4
- export interface PlatformService {
5
- /** Install the main `palmier serve` daemon to start at boot. */
6
- installDaemon(config: HostConfig): void;
7
-
8
- /** Restart the `palmier serve` daemon. */
9
- restartDaemon(): Promise<void>;
10
-
11
- /** Stop the daemon and remove all scheduled tasks/timers. */
12
- uninstallDaemon(): void;
13
-
14
- /** Install a scheduled trigger (timer) for a task. */
15
- installTaskTimer(config: HostConfig, task: ParsedTask): void;
16
-
17
- /** Remove a task's scheduled trigger and service files. */
18
- removeTaskTimer(taskId: string): void;
19
-
20
- /** Start a task execution (non-blocking). */
21
- startTask(taskId: string): Promise<void>;
22
-
23
- /** Abort/stop a running task. */
24
- stopTask(taskId: string): Promise<void>;
25
-
26
- /** Check if a task is currently running via the system scheduler. */
27
- isTaskRunning(taskId: string): boolean;
28
-
29
- /** Return env vars needed for GUI access (Linux: DISPLAY, etc.). */
30
- getGuiEnv(): Record<string, string>;
31
- }
@@ -1,299 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { execFileSync } from "child_process";
4
- import type { PlatformService } from "./platform.js";
5
- import type { HostConfig, ParsedTask } from "../types.js";
6
- import { CONFIG_DIR, loadConfig } from "../config.js";
7
- import { getTaskDir, readTaskStatus } from "../task.js";
8
-
9
-
10
- const TASK_PREFIX = "\\Palmier\\PalmierTask-";
11
- const DAEMON_TASK_NAME = "PalmierDaemon";
12
-
13
-
14
- const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
15
-
16
- /**
17
- * Convert a single schedule value to a Task Scheduler XML trigger element.
18
- *
19
- * `specific_times` values are ISO datetime strings like "2026-03-28T09:00".
20
- *
21
- * `crons` values are cron expressions. Only these patterns (produced by the PWA UI) are handled:
22
- * hourly: "0 * * * *"
23
- * daily: "MM HH * * *"
24
- * weekly: "MM HH * * D"
25
- * monthly: "MM HH D * *"
26
- */
27
- export function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string {
28
- if (scheduleType === "specific_times") {
29
- return `<TimeTrigger><StartBoundary>${value}:00</StartBoundary></TimeTrigger>`;
30
- }
31
-
32
- const parts = value.trim().split(/\s+/);
33
- if (parts.length !== 5) throw new Error(`Invalid cron expression: ${value}`);
34
- const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
35
- const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
36
- // StartBoundary needs a full date; anchor to a past one.
37
- const base = `2000-01-01T${st}`;
38
-
39
- if (hour === "*") {
40
- return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
41
- }
42
-
43
- if (dayOfMonth === "*" && dayOfWeek !== "*") {
44
- const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
45
- return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
46
- }
47
-
48
- if (dayOfMonth !== "*" && dayOfWeek === "*") {
49
- return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByMonth><DaysOfMonth><Day>${dayOfMonth}</Day></DaysOfMonth><Months><January /><February /><March /><April /><May /><June /><July /><August /><September /><October /><November /><December /></Months></ScheduleByMonth></CalendarTrigger>`;
50
- }
51
-
52
- return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
53
- }
54
-
55
- export function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string {
56
- const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
57
- const commandStr = command?.replace(/"/g, "") ?? "";
58
- const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
59
-
60
- return [
61
- `<?xml version="1.0" encoding="UTF-16"?>`,
62
- `<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
63
- ` <Principals>`,
64
- ` <Principal>`,
65
- ` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
66
- ` <RunLevel>LeastPrivilege</RunLevel>`,
67
- ` </Principal>`,
68
- ` </Principals>`,
69
- ` <Settings>`,
70
- ` <MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>`,
71
- ` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
72
- ` <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`,
73
- ` <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>`,
74
- ` </Settings>`,
75
- ` <Triggers>${triggers.join("")}</Triggers>`,
76
- ` <Actions>`,
77
- ` <Exec>`,
78
- ` <Command>${commandStr}</Command>`,
79
- ` <Arguments>${argsStr}</Arguments>`,
80
- ` </Exec>`,
81
- ` </Actions>`,
82
- `</Task>`,
83
- ].join("\n");
84
- }
85
-
86
- function schtasksTaskName(taskId: string): string {
87
- return `${TASK_PREFIX}${taskId}`;
88
- }
89
-
90
- export class WindowsPlatform implements PlatformService {
91
- installDaemon(config: HostConfig): void {
92
- const script = process.argv[1] || "palmier";
93
-
94
- this.ensureDaemonTask(script);
95
- this.startDaemonTask();
96
-
97
- console.log("\nHost initialization complete!");
98
- }
99
-
100
- uninstallDaemon(): void {
101
- const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
102
-
103
- try {
104
- execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
105
- } catch { /* task may not be running */ }
106
-
107
- // Deleting an S4U task requires elevation.
108
- try {
109
- execFileSync("powershell", [
110
- "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
111
- ], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
112
- console.log("Daemon task removed.");
113
- } catch { /* task may not exist */ }
114
-
115
- try {
116
- const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
117
- for (const line of out.split("\n")) {
118
- const match = line.match(/"(\\Palmier\\PalmierTask-[^"]+)"/);
119
- if (match) {
120
- try { execFileSync("schtasks", ["/end", "/tn", match[1]], { encoding: "utf-8", windowsHide: true, stdio: "pipe" }); } catch { /* ignore */ }
121
- try { execFileSync("schtasks", ["/delete", "/tn", match[1], "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" }); } catch { /* ignore */ }
122
- }
123
- }
124
- console.log("Task timers removed.");
125
- } catch { /* ignore */ }
126
-
127
- console.log("Palmier daemon and tasks uninstalled.");
128
- }
129
-
130
- async restartDaemon(): Promise<void> {
131
- const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
132
-
133
- try {
134
- execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
135
- } catch { /* task may not be running */ }
136
-
137
- this.startDaemonTask();
138
- }
139
-
140
- /** S4U LogonType requires elevation to create. */
141
- private ensureDaemonTask(script: string): void {
142
- const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
143
- const tr = `"${process.execPath}" "${script}" serve`;
144
- const xml = buildTaskXml(tr, [`<BootTrigger><Enabled>true</Enabled></BootTrigger>`]);
145
- const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
146
- try {
147
- const bom = Buffer.from([0xFF, 0xFE]);
148
- fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
149
- // S4U requires elevation — spawn schtasks via RunAs.
150
- const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
151
- execFileSync("powershell", [
152
- "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
153
- ], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
154
- } catch (err: unknown) {
155
- const e = err as { stderr?: string };
156
- console.error(`Failed to create daemon task: ${e.stderr || err}`);
157
- } finally {
158
- try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
159
- }
160
-
161
- }
162
-
163
- /** Starting via Task Scheduler runs the daemon outside any session's job object. */
164
- private startDaemonTask(): void {
165
- const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
166
- try {
167
- execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
168
- } catch (err: unknown) {
169
- const e = err as { stderr?: string };
170
- console.error(`Failed to start daemon via Task Scheduler: ${e.stderr || err}`);
171
- }
172
- console.log("Palmier daemon started.");
173
- }
174
-
175
- installTaskTimer(config: HostConfig, task: ParsedTask): void {
176
- const taskId = task.frontmatter.id;
177
- const tn = schtasksTaskName(taskId);
178
- const script = process.argv[1] || "palmier";
179
- const tr = `"${process.execPath}" "${script}" run ${taskId}`;
180
-
181
- // Event-based schedule types (on_new_notification/on_new_sms) are driven by
182
- // the run process, not the OS scheduler — they fall through to the dummy trigger.
183
- const triggerElements: string[] = [];
184
- const scheduleType = task.frontmatter.schedule_type;
185
- const scheduleValues = task.frontmatter.schedule_values;
186
- const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
187
- if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
188
- for (const value of scheduleValues) {
189
- try {
190
- triggerElements.push(scheduleValueToXml(scheduleType, value));
191
- } catch (err) {
192
- console.error(`Invalid schedule value: ${err}`);
193
- }
194
- }
195
- }
196
- // Dummy trigger so schtasks /run still works.
197
- if (triggerElements.length === 0) {
198
- triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
199
- }
200
-
201
- // XML registration (vs schtasks flags) gives us access to settings like
202
- // MultipleInstancesPolicy. S4U keeps the console hidden unless
203
- // foreground_mode is set. Works unelevated because the caller (daemon)
204
- // runs elevated.
205
- const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
206
- const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
207
- try {
208
- // schtasks /xml requires UTF-16LE with BOM.
209
- const bom = Buffer.from([0xFF, 0xFE]);
210
- fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
211
- execFileSync("schtasks", [
212
- "/create", "/tn", tn, "/xml", xmlPath, "/f",
213
- ], { encoding: "utf-8", windowsHide: true });
214
- } catch (err: unknown) {
215
- const e = err as { stderr?: string };
216
- console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
217
- } finally {
218
- try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
219
- }
220
-
221
- }
222
-
223
- removeTaskTimer(taskId: string): void {
224
- const tn = schtasksTaskName(taskId);
225
- try {
226
- execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
227
- } catch { /* task may not exist */ }
228
- }
229
-
230
- async startTask(taskId: string): Promise<void> {
231
- const tn = schtasksTaskName(taskId);
232
- try {
233
- execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true });
234
- } catch (err: unknown) {
235
- const e = err as { stderr?: string; message?: string };
236
- throw new Error(`Failed to start task via schtasks: ${e.stderr || e.message}`);
237
- }
238
- }
239
-
240
- async stopTask(taskId: string): Promise<void> {
241
- // schtasks /end leaves agent children orphaned, so kill the process tree
242
- // via the PID recorded in status.json first.
243
- try {
244
- const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
245
- const status = readTaskStatus(taskDir);
246
- if (status?.pid) {
247
- execFileSync("taskkill", ["/pid", String(status.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
248
- return;
249
- }
250
- } catch {
251
- // PID may be stale or config unavailable; fall through to schtasks /end.
252
- }
253
-
254
- const tn = schtasksTaskName(taskId);
255
- try {
256
- execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
257
- } catch (err: unknown) {
258
- const e = err as { stderr?: string; message?: string };
259
- throw new Error(`Failed to stop task via schtasks: ${e.stderr || e.message}`);
260
- }
261
- }
262
-
263
- isTaskRunning(taskId: string): boolean {
264
- const tn = schtasksTaskName(taskId);
265
- try {
266
- const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
267
- encoding: "utf-8",
268
- windowsHide: true,
269
- });
270
- if (out.includes('"Running"')) return true;
271
- } catch { /* task may not exist in scheduler */ }
272
-
273
- // Follow-up runs are spawned directly (not via schtasks), so check PID too.
274
- try {
275
- const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
276
- const status = readTaskStatus(taskDir);
277
- if (status?.pid) {
278
- execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
279
- encoding: "utf-8",
280
- windowsHide: true,
281
- stdio: "pipe",
282
- });
283
- // tasklist always exits 0, so match the output for the PID.
284
- const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
285
- encoding: "utf-8",
286
- windowsHide: true,
287
- stdio: "pipe",
288
- });
289
- if (out.includes(`"${status.pid}"`)) return true;
290
- }
291
- } catch { /* ignore */ }
292
-
293
- return false;
294
- }
295
-
296
- getGuiEnv(): Record<string, string> {
297
- return {};
298
- }
299
- }