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.
- package/CLAUDE.md +13 -0
- package/README.md +16 -14
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +3 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +29 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +12 -16
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +8 -7
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/index-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.d.ts +0 -6
- package/dist/rpc-handler.js +14 -47
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +7 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +325 -22
- package/palmier-server/pwa/src/App.tsx +2 -0
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +18 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +38 -7
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +3 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +28 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +3 -2
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +12 -18
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +5 -7
- package/src/platform/linux.ts +9 -20
- package/src/platform/macos.ts +310 -0
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +14 -47
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +7 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/macos-plist.test.ts +112 -0
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- package/dist/pwa/assets/index-DQfOEB03.js +0 -120
|
@@ -0,0 +1,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, "&")
|
|
81
|
+
.replace(/</g, "<")
|
|
82
|
+
.replace(/>/g, ">");
|
|
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
|
-
/**
|
|
20
|
+
/** S4U LogonType requires elevation to create. */
|
|
24
21
|
private ensureDaemonTask;
|
|
25
|
-
/**
|
|
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;
|
package/dist/platform/windows.js
CHANGED
|
@@ -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;
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
181
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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
|
-
//
|
|
247
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
}
|