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
@@ -0,0 +1,287 @@
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 guiDomain() {
23
+ const uid = process.getuid?.();
24
+ if (uid === undefined)
25
+ throw new Error("getuid() unavailable — macOS platform requires POSIX uid");
26
+ return `gui/${uid}`;
27
+ }
28
+ /**
29
+ * Convert one of the four PWA-produced cron patterns to a launchd
30
+ * `StartCalendarInterval` dict.
31
+ * hourly "0 * * * *" → { Minute: 0 }
32
+ * daily "MM HH * * *" → { Minute, Hour }
33
+ * weekly "MM HH * * D" → { Minute, Hour, Weekday }
34
+ * monthly "MM HH D * *" → { Minute, Hour, Day }
35
+ * launchd Weekday: Sunday is 0 (cron 7 → 0).
36
+ */
37
+ export function cronToCalendarInterval(cron) {
38
+ const parts = cron.trim().split(/\s+/);
39
+ if (parts.length !== 5) {
40
+ throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
41
+ }
42
+ const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
43
+ const result = {};
44
+ if (minute !== "*")
45
+ result.Minute = Number(minute);
46
+ if (hour !== "*")
47
+ result.Hour = Number(hour);
48
+ if (dayOfMonth !== "*")
49
+ result.Day = Number(dayOfMonth);
50
+ if (dayOfWeek !== "*") {
51
+ const dow = Number(dayOfWeek);
52
+ result.Weekday = dow === 7 ? 0 : dow;
53
+ }
54
+ for (const [k, v] of Object.entries(result)) {
55
+ if (!Number.isInteger(v) || v < 0) {
56
+ throw new Error(`Invalid cron field ${k}=${v} in ${cron}`);
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+ /**
62
+ * Convert a PWA `specific_times` value (ISO local datetime like "2026-04-20T09:00")
63
+ * to a `StartCalendarInterval` dict. launchd has no "one-shot at date X" trigger,
64
+ * so we omit Year — the task fires yearly on the same date and time. Sufficient
65
+ * because the PWA regenerates/removes one-off tasks after they run.
66
+ */
67
+ export function specificTimeToCalendarInterval(iso) {
68
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
69
+ if (!m)
70
+ throw new Error(`Invalid specific_times value: ${iso}`);
71
+ return {
72
+ Month: Number(m[2]),
73
+ Day: Number(m[3]),
74
+ Hour: Number(m[4]),
75
+ Minute: Number(m[5]),
76
+ };
77
+ }
78
+ function escapeXml(s) {
79
+ return s
80
+ .replace(/&/g, "&amp;")
81
+ .replace(/</g, "&lt;")
82
+ .replace(/>/g, "&gt;");
83
+ }
84
+ /** Serialize a JS value to a plist XML fragment. Supports string/number/boolean/array/plain object. */
85
+ function plistValue(value, indent) {
86
+ if (typeof value === "string")
87
+ return `${indent}<string>${escapeXml(value)}</string>`;
88
+ if (typeof value === "boolean")
89
+ return `${indent}<${value ? "true" : "false"}/>`;
90
+ if (typeof value === "number") {
91
+ return Number.isInteger(value)
92
+ ? `${indent}<integer>${value}</integer>`
93
+ : `${indent}<real>${value}</real>`;
94
+ }
95
+ if (Array.isArray(value)) {
96
+ if (value.length === 0)
97
+ return `${indent}<array/>`;
98
+ const inner = value.map((v) => plistValue(v, indent + " ")).join("\n");
99
+ return `${indent}<array>\n${inner}\n${indent}</array>`;
100
+ }
101
+ if (value && typeof value === "object") {
102
+ const entries = Object.entries(value);
103
+ if (entries.length === 0)
104
+ return `${indent}<dict/>`;
105
+ const inner = entries
106
+ .map(([k, v]) => `${indent} <key>${escapeXml(k)}</key>\n${plistValue(v, indent + " ")}`)
107
+ .join("\n");
108
+ return `${indent}<dict>\n${inner}\n${indent}</dict>`;
109
+ }
110
+ throw new Error(`Unsupported plist value type: ${typeof value}`);
111
+ }
112
+ export function buildPlist(dict) {
113
+ return [
114
+ `<?xml version="1.0" encoding="UTF-8"?>`,
115
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
116
+ `<plist version="1.0">`,
117
+ plistValue(dict, ""),
118
+ `</plist>`,
119
+ ``,
120
+ ].join("\n");
121
+ }
122
+ function runLaunchctl(args, opts = {}) {
123
+ try {
124
+ execSync(`launchctl ${args.join(" ")}`, { stdio: "pipe", encoding: "utf-8" });
125
+ }
126
+ catch (err) {
127
+ if (opts.ignoreFailure)
128
+ return;
129
+ const e = err;
130
+ console.error(`launchctl ${args[0]} failed: ${e.stderr || err}`);
131
+ }
132
+ }
133
+ export class MacOsPlatform {
134
+ installDaemon(config) {
135
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
136
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
137
+ const palmierBin = process.argv[1] || "palmier";
138
+ const userPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
139
+ fs.writeFileSync(PATH_FILE, userPath, "utf-8");
140
+ const logPath = path.join(CONFIG_DIR, "daemon.log");
141
+ const plist = buildPlist({
142
+ Label: DAEMON_LABEL,
143
+ ProgramArguments: [process.execPath, palmierBin, "serve"],
144
+ WorkingDirectory: config.projectRoot,
145
+ RunAtLoad: true,
146
+ KeepAlive: { SuccessfulExit: false },
147
+ EnvironmentVariables: { PATH: userPath },
148
+ StandardOutPath: logPath,
149
+ StandardErrorPath: logPath,
150
+ });
151
+ const plistPath = daemonPlistPath();
152
+ fs.writeFileSync(plistPath, plist, "utf-8");
153
+ console.log("LaunchAgent installed at:", plistPath);
154
+ const domain = guiDomain();
155
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
156
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
157
+ runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
158
+ console.log("Palmier host LaunchAgent loaded and started.");
159
+ console.log("Note: LaunchAgents only run while you are logged into the GUI session. " +
160
+ "After reboot, tasks remain dormant until you log in at least once.");
161
+ console.log("\nHost initialization complete!");
162
+ }
163
+ uninstallDaemon() {
164
+ const domain = guiDomain();
165
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
166
+ try {
167
+ fs.unlinkSync(daemonPlistPath());
168
+ }
169
+ catch { /* may not exist */ }
170
+ try {
171
+ const entries = fs.readdirSync(AGENT_DIR).filter((f) => f.startsWith(TASK_LABEL_PREFIX) && f.endsWith(".plist"));
172
+ for (const f of entries) {
173
+ const label = f.slice(0, -".plist".length);
174
+ runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
175
+ try {
176
+ fs.unlinkSync(path.join(AGENT_DIR, f));
177
+ }
178
+ catch { /* ignore */ }
179
+ }
180
+ }
181
+ catch { /* AGENT_DIR may not exist */ }
182
+ console.log("Palmier daemon and tasks uninstalled.");
183
+ }
184
+ async restartDaemon() {
185
+ const plistPath = daemonPlistPath();
186
+ const domain = guiDomain();
187
+ if (process.stdin.isTTY && fs.existsSync(plistPath)) {
188
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
189
+ const userPath = process.env.PATH || "";
190
+ fs.writeFileSync(PATH_FILE, userPath, "utf-8");
191
+ const content = fs.readFileSync(plistPath, "utf-8");
192
+ const updated = content.replace(/(<key>PATH<\/key>\s*\n\s*<string>)[^<]*(<\/string>)/, `$1${escapeXml(userPath)}$2`);
193
+ if (updated !== content) {
194
+ fs.writeFileSync(plistPath, updated, "utf-8");
195
+ runLaunchctl(["bootout", `${domain}/${DAEMON_LABEL}`], { ignoreFailure: true });
196
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
197
+ }
198
+ }
199
+ runLaunchctl(["kickstart", "-k", `${domain}/${DAEMON_LABEL}`]);
200
+ console.log("Palmier daemon restarted.");
201
+ }
202
+ installTaskTimer(config, task) {
203
+ fs.mkdirSync(AGENT_DIR, { recursive: true });
204
+ const taskId = task.frontmatter.id;
205
+ const label = taskLabel(taskId);
206
+ const plistPath = taskPlistPath(taskId);
207
+ const palmierBin = process.argv[1] || "palmier";
208
+ const dict = {
209
+ Label: label,
210
+ ProgramArguments: [process.execPath, palmierBin, "run", taskId],
211
+ WorkingDirectory: config.projectRoot,
212
+ RunAtLoad: false,
213
+ EnvironmentVariables: { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" },
214
+ };
215
+ const scheduleType = task.frontmatter.schedule_type;
216
+ const scheduleValues = task.frontmatter.schedule_values;
217
+ const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
218
+ if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
219
+ const intervals = [];
220
+ for (const value of scheduleValues) {
221
+ try {
222
+ intervals.push(scheduleType === "crons"
223
+ ? cronToCalendarInterval(value)
224
+ : specificTimeToCalendarInterval(value));
225
+ }
226
+ catch (err) {
227
+ console.error(`Invalid schedule value: ${err}`);
228
+ }
229
+ }
230
+ if (intervals.length > 0)
231
+ dict.StartCalendarInterval = intervals;
232
+ }
233
+ fs.writeFileSync(plistPath, buildPlist(dict), "utf-8");
234
+ const domain = guiDomain();
235
+ runLaunchctl(["bootout", `${domain}/${label}`], { ignoreFailure: true });
236
+ runLaunchctl(["bootstrap", domain, `"${plistPath}"`]);
237
+ }
238
+ removeTaskTimer(taskId) {
239
+ const domain = guiDomain();
240
+ runLaunchctl(["bootout", `${domain}/${taskLabel(taskId)}`], { ignoreFailure: true });
241
+ try {
242
+ fs.unlinkSync(taskPlistPath(taskId));
243
+ }
244
+ catch { /* ignore */ }
245
+ }
246
+ async startTask(taskId) {
247
+ await execAsync(`launchctl kickstart ${guiDomain()}/${taskLabel(taskId)}`);
248
+ }
249
+ async stopTask(taskId) {
250
+ try {
251
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
252
+ const status = readTaskStatus(taskDir);
253
+ if (status?.pid) {
254
+ process.kill(status.pid, "SIGTERM");
255
+ return;
256
+ }
257
+ }
258
+ catch { /* fall through */ }
259
+ await execAsync(`launchctl kill SIGTERM ${guiDomain()}/${taskLabel(taskId)}`);
260
+ }
261
+ isTaskRunning(taskId) {
262
+ try {
263
+ const out = execSync(`launchctl print ${guiDomain()}/${taskLabel(taskId)}`, {
264
+ encoding: "utf-8",
265
+ stdio: ["ignore", "pipe", "ignore"],
266
+ });
267
+ // Running services show a numeric `pid = N`; idle ones show `state = not running`.
268
+ if (/^\s*pid\s*=\s*\d+/m.test(out))
269
+ return true;
270
+ }
271
+ catch { /* service may not be loaded */ }
272
+ try {
273
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
274
+ const status = readTaskStatus(taskDir);
275
+ if (status?.pid) {
276
+ process.kill(status.pid, 0);
277
+ return true;
278
+ }
279
+ }
280
+ catch { /* process not running or config unavailable */ }
281
+ return false;
282
+ }
283
+ getGuiEnv() {
284
+ return {};
285
+ }
286
+ }
287
+ //# sourceMappingURL=macos.js.map
@@ -1,8 +1,5 @@
1
1
  import type { HostConfig, ParsedTask } from "../types.js";
2
- /**
3
- * Abstracts OS-specific daemon, scheduling, and process management.
4
- * Linux uses systemd; Windows uses Task Scheduler; macOS will use launchd.
5
- */
2
+ /** Linux: systemd. Windows: Task Scheduler. macOS: launchd (planned). */
6
3
  export interface PlatformService {
7
4
  /** Install the main `palmier serve` daemon to start at boot. */
8
5
  installDaemon(config: HostConfig): void;
@@ -12,17 +12,14 @@ import type { HostConfig, ParsedTask } from "../types.js";
12
12
  * monthly: "MM HH D * *"
13
13
  */
14
14
  export declare function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string;
15
- /**
16
- * Build a complete Task Scheduler XML definition.
17
- */
18
15
  export declare function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string;
19
16
  export declare class WindowsPlatform implements PlatformService {
20
17
  installDaemon(config: HostConfig): void;
21
18
  uninstallDaemon(): void;
22
19
  restartDaemon(): Promise<void>;
23
- /** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
20
+ /** S4U LogonType requires elevation to create. */
24
21
  private ensureDaemonTask;
25
- /** Start the daemon via Task Scheduler (runs outside any session's job object). */
22
+ /** Starting via Task Scheduler runs the daemon outside any session's job object. */
26
23
  private startDaemonTask;
27
24
  installTaskTimer(config: HostConfig, task: ParsedTask): void;
28
25
  removeTaskTimer(taskId: string): void;
@@ -26,27 +26,20 @@ export function scheduleValueToXml(scheduleType, value) {
26
26
  throw new Error(`Invalid cron expression: ${value}`);
27
27
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
28
28
  const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
29
- // StartBoundary needs a full date; use a past date as the anchor
29
+ // StartBoundary needs a full date; anchor to a past one.
30
30
  const base = `2000-01-01T${st}`;
31
- // Hourly
32
31
  if (hour === "*") {
33
32
  return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
34
33
  }
35
- // Weekly
36
34
  if (dayOfMonth === "*" && dayOfWeek !== "*") {
37
35
  const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
38
36
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
39
37
  }
40
- // Monthly
41
38
  if (dayOfMonth !== "*" && dayOfWeek === "*") {
42
39
  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>`;
43
40
  }
44
- // Daily
45
41
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
46
42
  }
47
- /**
48
- * Build a complete Task Scheduler XML definition.
49
- */
50
43
  export function buildTaskXml(tr, triggers, foreground) {
51
44
  const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
52
45
  const commandStr = command?.replace(/"/g, "") ?? "";
@@ -82,20 +75,17 @@ function schtasksTaskName(taskId) {
82
75
  export class WindowsPlatform {
83
76
  installDaemon(config) {
84
77
  const script = process.argv[1] || "palmier";
85
- // Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
86
78
  this.ensureDaemonTask(script);
87
- // Start the daemon now
88
79
  this.startDaemonTask();
89
80
  console.log("\nHost initialization complete!");
90
81
  }
91
82
  uninstallDaemon() {
92
83
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
93
- // Stop the daemon via Task Scheduler
94
84
  try {
95
85
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
96
86
  }
97
87
  catch { /* task may not be running */ }
98
- // Remove daemon scheduled task (elevated — S4U task requires elevation to delete)
88
+ // Deleting an S4U task requires elevation.
99
89
  try {
100
90
  execFileSync("powershell", [
101
91
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
@@ -103,7 +93,6 @@ export class WindowsPlatform {
103
93
  console.log("Daemon task removed.");
104
94
  }
105
95
  catch { /* task may not exist */ }
106
- // Remove all Palmier task timers
107
96
  try {
108
97
  const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
109
98
  for (const line of out.split("\n")) {
@@ -126,15 +115,13 @@ export class WindowsPlatform {
126
115
  }
127
116
  async restartDaemon() {
128
117
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
129
- // Stop the daemon via Task Scheduler
130
118
  try {
131
119
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
132
120
  }
133
121
  catch { /* task may not be running */ }
134
- // Start it again
135
122
  this.startDaemonTask();
136
123
  }
137
- /** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
124
+ /** S4U LogonType requires elevation to create. */
138
125
  ensureDaemonTask(script) {
139
126
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
140
127
  const tr = `"${process.execPath}" "${script}" serve`;
@@ -143,7 +130,7 @@ export class WindowsPlatform {
143
130
  try {
144
131
  const bom = Buffer.from([0xFF, 0xFE]);
145
132
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
146
- // S4U LogonType requires elevation — spawn schtasks via RunAs
133
+ // S4U requires elevation — spawn schtasks via RunAs.
147
134
  const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
148
135
  execFileSync("powershell", [
149
136
  "-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
@@ -160,7 +147,7 @@ export class WindowsPlatform {
160
147
  catch { /* ignore */ }
161
148
  }
162
149
  }
163
- /** Start the daemon via Task Scheduler (runs outside any session's job object). */
150
+ /** Starting via Task Scheduler runs the daemon outside any session's job object. */
164
151
  startDaemonTask() {
165
152
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
166
153
  try {
@@ -177,9 +164,8 @@ export class WindowsPlatform {
177
164
  const tn = schtasksTaskName(taskId);
178
165
  const script = process.argv[1] || "palmier";
179
166
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
180
- // Build trigger XML elements. Event-based schedule types (on_new_notification,
181
- // on_new_sms) carry no values and are driven by the run process, not the OS
182
- // scheduler — they intentionally produce only the dummy trigger below.
167
+ // Event-based schedule types (on_new_notification/on_new_sms) are driven by
168
+ // the run process, not the OS scheduler they fall through to the dummy trigger.
183
169
  const triggerElements = [];
184
170
  const scheduleType = task.frontmatter.schedule_type;
185
171
  const scheduleValues = task.frontmatter.schedule_values;
@@ -194,18 +180,18 @@ export class WindowsPlatform {
194
180
  }
195
181
  }
196
182
  }
197
- // Always include a dummy trigger so startTask (/run) works
183
+ // Dummy trigger so schtasks /run still works.
198
184
  if (triggerElements.length === 0) {
199
185
  triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
200
186
  }
201
- // Write XML and register via schtasks gives us full control over
202
- // settings like MultipleInstancesPolicy that schtasks flags don't expose.
203
- // S4U LogonType ensures no console window (unless foreground_mode is set).
204
- // Works without elevation because the daemon (which calls this) runs elevated.
187
+ // XML registration (vs schtasks flags) gives us access to settings like
188
+ // MultipleInstancesPolicy. S4U keeps the console hidden unless
189
+ // foreground_mode is set. Works unelevated because the caller (daemon)
190
+ // runs elevated.
205
191
  const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
206
192
  const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
207
193
  try {
208
- // schtasks /xml requires UTF-16LE with BOM
194
+ // schtasks /xml requires UTF-16LE with BOM.
209
195
  const bom = Buffer.from([0xFF, 0xFE]);
210
196
  fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
211
197
  execFileSync("schtasks", [
@@ -228,9 +214,7 @@ export class WindowsPlatform {
228
214
  try {
229
215
  execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
230
216
  }
231
- catch {
232
- // Task might not exist — that's fine
233
- }
217
+ catch { /* task may not exist */ }
234
218
  }
235
219
  async startTask(taskId) {
236
220
  const tn = schtasksTaskName(taskId);
@@ -243,8 +227,8 @@ export class WindowsPlatform {
243
227
  }
244
228
  }
245
229
  async stopTask(taskId) {
246
- // Try to kill the entire process tree via the PID recorded in status.json.
247
- // schtasks /end only kills the top-level process, leaving agent children orphaned.
230
+ // schtasks /end leaves agent children orphaned, so kill the process tree
231
+ // via the PID recorded in status.json first.
248
232
  try {
249
233
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
250
234
  const status = readTaskStatus(taskDir);
@@ -254,9 +238,8 @@ export class WindowsPlatform {
254
238
  }
255
239
  }
256
240
  catch {
257
- // PID may be stale or config unavailable; fall through to schtasks /end
241
+ // PID may be stale or config unavailable; fall through to schtasks /end.
258
242
  }
259
- // Fallback: schtasks /end (kills top-level process only)
260
243
  const tn = schtasksTaskName(taskId);
261
244
  try {
262
245
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -267,7 +250,6 @@ export class WindowsPlatform {
267
250
  }
268
251
  }
269
252
  isTaskRunning(taskId) {
270
- // Check Task Scheduler first (for scheduled/on-demand runs)
271
253
  const tn = schtasksTaskName(taskId);
272
254
  try {
273
255
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
@@ -278,18 +260,17 @@ export class WindowsPlatform {
278
260
  return true;
279
261
  }
280
262
  catch { /* task may not exist in scheduler */ }
281
- // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
263
+ // Follow-up runs are spawned directly (not via schtasks), so check PID too.
282
264
  try {
283
265
  const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
284
266
  const status = readTaskStatus(taskDir);
285
267
  if (status?.pid) {
286
- // tasklist exits 0 if the PID is found
287
268
  execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
288
269
  encoding: "utf-8",
289
270
  windowsHide: true,
290
271
  stdio: "pipe",
291
272
  });
292
- // tasklist always exits 0; check if output contains the PID
273
+ // tasklist always exits 0, so match the output for the PID.
293
274
  const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
294
275
  encoding: "utf-8",
295
276
  windowsHide: true,
@@ -303,7 +284,6 @@ export class WindowsPlatform {
303
284
  return false;
304
285
  }
305
286
  getGuiEnv() {
306
- // Windows GUI is always available — no special env vars needed
307
287
  return {};
308
288
  }
309
289
  }