palmier 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +12 -16
  2. package/dist/agents/claude.js +5 -2
  3. package/dist/agents/codex.js +4 -2
  4. package/dist/agents/gemini.js +4 -2
  5. package/dist/commands/info.js +17 -10
  6. package/dist/commands/init.d.ts +2 -10
  7. package/dist/commands/init.js +102 -154
  8. package/dist/commands/run.js +2 -13
  9. package/dist/commands/sessions.js +1 -4
  10. package/dist/config.js +1 -1
  11. package/dist/index.js +7 -7
  12. package/dist/platform/index.d.ts +4 -0
  13. package/dist/platform/index.js +12 -0
  14. package/dist/platform/linux.d.ts +11 -0
  15. package/dist/platform/linux.js +186 -0
  16. package/dist/platform/platform.d.ts +20 -0
  17. package/dist/platform/platform.js +2 -0
  18. package/dist/platform/windows.d.ts +11 -0
  19. package/dist/platform/windows.js +201 -0
  20. package/dist/rpc-handler.js +13 -30
  21. package/dist/spawn-command.d.ts +5 -2
  22. package/dist/spawn-command.js +2 -0
  23. package/dist/transports/http-transport.js +2 -7
  24. package/package.json +1 -1
  25. package/src/agents/claude.ts +6 -2
  26. package/src/agents/codex.ts +5 -2
  27. package/src/agents/gemini.ts +5 -2
  28. package/src/commands/info.ts +18 -10
  29. package/src/commands/init.ts +131 -180
  30. package/src/commands/run.ts +3 -15
  31. package/src/commands/sessions.ts +1 -4
  32. package/src/config.ts +1 -1
  33. package/src/index.ts +8 -7
  34. package/src/platform/index.ts +16 -0
  35. package/src/platform/linux.ts +207 -0
  36. package/src/platform/platform.ts +25 -0
  37. package/src/platform/windows.ts +223 -0
  38. package/src/rpc-handler.ts +13 -37
  39. package/src/spawn-command.ts +7 -2
  40. package/src/transports/http-transport.ts +2 -5
  41. package/src/systemd.ts +0 -164
@@ -0,0 +1,186 @@
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
+ const execAsync = promisify(exec);
7
+ const UNIT_DIR = path.join(homedir(), ".config", "systemd", "user");
8
+ function getTimerName(taskId) {
9
+ return `palmier-task-${taskId}.timer`;
10
+ }
11
+ function getServiceName(taskId) {
12
+ return `palmier-task-${taskId}.service`;
13
+ }
14
+ /**
15
+ * Convert a cron expression to a systemd OnCalendar string.
16
+ *
17
+ * Only the 4 cron patterns the PWA UI can produce are supported:
18
+ * hourly: "0 * * * *"
19
+ * daily: "MM HH * * *"
20
+ * weekly: "MM HH * * D"
21
+ * monthly: "MM HH D * *"
22
+ * Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
23
+ * handled because the UI never generates them.
24
+ */
25
+ function cronToOnCalendar(cron) {
26
+ const parts = cron.trim().split(/\s+/);
27
+ if (parts.length !== 5) {
28
+ throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
29
+ }
30
+ const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
31
+ // Map cron day-of-week numbers to systemd abbreviated names
32
+ const dowMap = {
33
+ "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
34
+ "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
35
+ };
36
+ const monthPart = "*";
37
+ const dayPart = dayOfMonth === "*" ? "*" : dayOfMonth.padStart(2, "0");
38
+ const hourPart = hour === "*" ? "*" : hour.padStart(2, "0");
39
+ const minutePart = minute === "*" ? "*" : minute.padStart(2, "0");
40
+ if (dayOfWeek !== "*") {
41
+ const dow = dowMap[dayOfWeek] ?? dayOfWeek;
42
+ return `${dow} *-${monthPart}-${dayPart} ${hourPart}:${minutePart}:00`;
43
+ }
44
+ return `*-${monthPart}-${dayPart} ${hourPart}:${minutePart}:00`;
45
+ }
46
+ function daemonReload() {
47
+ try {
48
+ execSync("systemctl --user daemon-reload", { encoding: "utf-8" });
49
+ }
50
+ catch (err) {
51
+ const e = err;
52
+ console.error(`daemon-reload failed: ${e.stderr || err}`);
53
+ }
54
+ }
55
+ export class LinuxPlatform {
56
+ installDaemon(config) {
57
+ fs.mkdirSync(UNIT_DIR, { recursive: true });
58
+ const palmierBin = process.argv[1] || "palmier";
59
+ const serviceContent = `[Unit]
60
+ Description=Palmier Host
61
+ After=network-online.target
62
+ Wants=network-online.target
63
+
64
+ [Service]
65
+ Type=simple
66
+ ExecStart=${palmierBin} serve
67
+ WorkingDirectory=${config.projectRoot}
68
+ Restart=on-failure
69
+ RestartSec=5
70
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
71
+
72
+ [Install]
73
+ WantedBy=default.target
74
+ `;
75
+ const servicePath = path.join(UNIT_DIR, "palmier.service");
76
+ fs.writeFileSync(servicePath, serviceContent, "utf-8");
77
+ console.log("Systemd service installed at:", servicePath);
78
+ try {
79
+ execSync("systemctl --user daemon-reload", { stdio: "inherit" });
80
+ execSync("systemctl --user enable palmier.service", { stdio: "inherit" });
81
+ execSync("systemctl --user restart palmier.service", { stdio: "inherit" });
82
+ console.log("Palmier host service enabled and started.");
83
+ }
84
+ catch (err) {
85
+ console.error(`Warning: failed to enable systemd service: ${err}`);
86
+ console.error("You may need to start it manually: systemctl --user enable --now palmier.service");
87
+ }
88
+ // Enable lingering so service runs without active login session
89
+ try {
90
+ execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
91
+ console.log("Login lingering enabled.");
92
+ }
93
+ catch (err) {
94
+ console.error(`Warning: failed to enable linger: ${err}`);
95
+ }
96
+ console.log("\nHost initialization complete!");
97
+ }
98
+ installTaskTimer(config, task) {
99
+ fs.mkdirSync(UNIT_DIR, { recursive: true });
100
+ const taskId = task.frontmatter.id;
101
+ const serviceName = getServiceName(taskId);
102
+ const timerName = getTimerName(taskId);
103
+ const palmierBin = process.argv[1] || "palmier";
104
+ const serviceContent = `[Unit]
105
+ Description=Palmier Task: ${taskId}
106
+
107
+ [Service]
108
+ Type=oneshot
109
+ ExecStart=${palmierBin} run ${taskId}
110
+ WorkingDirectory=${config.projectRoot}
111
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
112
+ `;
113
+ fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
114
+ daemonReload();
115
+ // Only create and enable a timer if there are actual triggers
116
+ const triggers = task.frontmatter.triggers || [];
117
+ const onCalendarLines = [];
118
+ for (const trigger of triggers) {
119
+ if (trigger.type === "cron") {
120
+ onCalendarLines.push(`OnCalendar=${cronToOnCalendar(trigger.value)}`);
121
+ }
122
+ else if (trigger.type === "once") {
123
+ onCalendarLines.push(`OnActiveSec=${trigger.value}`);
124
+ }
125
+ }
126
+ if (onCalendarLines.length > 0) {
127
+ const timerContent = `[Unit]
128
+ Description=Timer for Palmier Task: ${taskId}
129
+
130
+ [Timer]
131
+ ${onCalendarLines.join("\n")}
132
+ Persistent=true
133
+
134
+ [Install]
135
+ WantedBy=timers.target
136
+ `;
137
+ fs.writeFileSync(path.join(UNIT_DIR, timerName), timerContent, "utf-8");
138
+ daemonReload();
139
+ try {
140
+ execSync(`systemctl --user enable --now ${timerName}`, { encoding: "utf-8" });
141
+ }
142
+ catch (err) {
143
+ const e = err;
144
+ console.error(`Failed to enable timer ${timerName}: ${e.stderr || err}`);
145
+ }
146
+ }
147
+ }
148
+ removeTaskTimer(taskId) {
149
+ const timerName = getTimerName(taskId);
150
+ const serviceName = getServiceName(taskId);
151
+ const timerPath = path.join(UNIT_DIR, timerName);
152
+ const servicePath = path.join(UNIT_DIR, serviceName);
153
+ if (fs.existsSync(timerPath)) {
154
+ try {
155
+ execSync(`systemctl --user stop ${timerName}`, { encoding: "utf-8" });
156
+ }
157
+ catch { /* timer might not be running */ }
158
+ try {
159
+ execSync(`systemctl --user disable ${timerName}`, { encoding: "utf-8" });
160
+ }
161
+ catch { /* timer might not be enabled */ }
162
+ fs.unlinkSync(timerPath);
163
+ }
164
+ if (fs.existsSync(servicePath))
165
+ fs.unlinkSync(servicePath);
166
+ daemonReload();
167
+ }
168
+ async startTask(taskId) {
169
+ const serviceName = getServiceName(taskId);
170
+ await execAsync(`systemctl --user start --no-block ${serviceName}`);
171
+ }
172
+ async stopTask(taskId) {
173
+ const serviceName = getServiceName(taskId);
174
+ await execAsync(`systemctl --user stop ${serviceName}`);
175
+ }
176
+ getGuiEnv() {
177
+ const uid = process.getuid?.();
178
+ const runtimeDir = process.env.XDG_RUNTIME_DIR ||
179
+ (uid !== undefined ? `/run/user/${uid}` : "");
180
+ return {
181
+ DISPLAY: ":0",
182
+ ...(runtimeDir ? { XDG_RUNTIME_DIR: runtimeDir } : {}),
183
+ };
184
+ }
185
+ }
186
+ //# sourceMappingURL=linux.js.map
@@ -0,0 +1,20 @@
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
+ */
6
+ export interface PlatformService {
7
+ /** Install the main `palmier serve` daemon to start at boot. */
8
+ installDaemon(config: HostConfig): void;
9
+ /** Install a scheduled trigger (timer) for a task. */
10
+ installTaskTimer(config: HostConfig, task: ParsedTask): void;
11
+ /** Remove a task's scheduled trigger and service files. */
12
+ removeTaskTimer(taskId: string): void;
13
+ /** Start a task execution (non-blocking). */
14
+ startTask(taskId: string): Promise<void>;
15
+ /** Abort/stop a running task. */
16
+ stopTask(taskId: string): Promise<void>;
17
+ /** Return env vars needed for GUI access (Linux: DISPLAY, etc.). */
18
+ getGuiEnv(): Record<string, string>;
19
+ }
20
+ //# sourceMappingURL=platform.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=platform.js.map
@@ -0,0 +1,11 @@
1
+ import type { PlatformService } from "./platform.js";
2
+ import type { HostConfig, ParsedTask } from "../types.js";
3
+ export declare class WindowsPlatform implements PlatformService {
4
+ installDaemon(config: HostConfig): void;
5
+ installTaskTimer(config: HostConfig, task: ParsedTask): void;
6
+ removeTaskTimer(taskId: string): void;
7
+ startTask(taskId: string): Promise<void>;
8
+ stopTask(taskId: string): Promise<void>;
9
+ getGuiEnv(): Record<string, string>;
10
+ }
11
+ //# sourceMappingURL=windows.d.ts.map
@@ -0,0 +1,201 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execSync, exec, spawn as nodeSpawn } from "child_process";
4
+ import { promisify } from "util";
5
+ import { getTaskDir } from "../task.js";
6
+ import { loadConfig } from "../config.js";
7
+ const execAsync = promisify(exec);
8
+ const TASK_PREFIX = "PalmierTask-";
9
+ const DAEMON_TASK_NAME = "PalmierDaemon";
10
+ const SHELL = "cmd.exe";
11
+ /**
12
+ * Resolve the full command to invoke palmier, accounting for the fact that
13
+ * on Windows, globally-installed npm packages are .cmd shims.
14
+ */
15
+ function getPalmierCommand() {
16
+ // process.argv[1] is the script path; wrap with node so it works as
17
+ // a Task Scheduler command without relying on file associations.
18
+ const script = process.argv[1] || "palmier";
19
+ return `"${process.execPath}" "${script}"`;
20
+ }
21
+ /**
22
+ * Convert one of the 4 supported cron patterns to schtasks flags.
23
+ *
24
+ * Only these patterns (produced by the PWA UI) are handled:
25
+ * hourly: "0 * * * *" → /sc HOURLY
26
+ * daily: "MM HH * * *" → /sc DAILY /st HH:MM
27
+ * weekly: "MM HH * * D" → /sc WEEKLY /d <day> /st HH:MM
28
+ * monthly: "MM HH D * *" → /sc MONTHLY /d D /st HH:MM
29
+ *
30
+ * Arbitrary cron expressions (ranges, lists, step values) are NOT handled
31
+ * because the UI never generates them.
32
+ */
33
+ function cronToSchtasksArgs(cron) {
34
+ const parts = cron.trim().split(/\s+/);
35
+ if (parts.length !== 5) {
36
+ throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
37
+ }
38
+ const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
39
+ // Map cron day-of-week numbers to schtasks day abbreviations
40
+ const dowMap = {
41
+ "0": "SUN", "1": "MON", "2": "TUE", "3": "WED",
42
+ "4": "THU", "5": "FRI", "6": "SAT", "7": "SUN",
43
+ };
44
+ const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
45
+ // Hourly: "0 * * * *"
46
+ if (hour === "*" && dayOfMonth === "*" && dayOfWeek === "*") {
47
+ return ["/sc", "HOURLY"];
48
+ }
49
+ // Weekly: "MM HH * * D"
50
+ if (dayOfMonth === "*" && dayOfWeek !== "*") {
51
+ const day = dowMap[dayOfWeek];
52
+ if (!day)
53
+ throw new Error(`Unsupported day-of-week: ${dayOfWeek}`);
54
+ return ["/sc", "WEEKLY", "/d", day, "/st", st];
55
+ }
56
+ // Monthly: "MM HH D * *"
57
+ if (dayOfMonth !== "*" && dayOfWeek === "*") {
58
+ return ["/sc", "MONTHLY", "/d", dayOfMonth, "/st", st];
59
+ }
60
+ // Daily: "MM HH * * *" (most common fallback)
61
+ return ["/sc", "DAILY", "/st", st];
62
+ }
63
+ function schtasksTaskName(taskId) {
64
+ return `${TASK_PREFIX}${taskId}`;
65
+ }
66
+ export class WindowsPlatform {
67
+ installDaemon(config) {
68
+ const cmd = getPalmierCommand();
69
+ // Try ONSTART first (requires elevation), fall back to ONLOGON
70
+ const baseArgs = [
71
+ "/create", "/tn", DAEMON_TASK_NAME,
72
+ "/tr", `${cmd} serve`,
73
+ "/rl", "HIGHEST",
74
+ "/f", // force overwrite if exists
75
+ ];
76
+ try {
77
+ execSync(`schtasks ${[...baseArgs, "/sc", "ONSTART"].join(" ")}`, { encoding: "utf-8", shell: SHELL });
78
+ console.log(`Task Scheduler: "${DAEMON_TASK_NAME}" installed (runs at startup).`);
79
+ }
80
+ catch {
81
+ // ONSTART requires admin — fall back to ONLOGON which does not
82
+ try {
83
+ execSync(`schtasks ${[...baseArgs, "/sc", "ONLOGON"].join(" ")}`, { encoding: "utf-8", shell: SHELL });
84
+ console.log(`Task Scheduler: "${DAEMON_TASK_NAME}" installed (runs at logon).`);
85
+ console.log(" Tip: run as Administrator to use ONSTART instead.");
86
+ }
87
+ catch (err) {
88
+ console.error(`Warning: failed to create scheduled task: ${err}`);
89
+ console.error("You may need to start palmier serve manually.");
90
+ }
91
+ }
92
+ // Start the daemon now
93
+ try {
94
+ execSync(`schtasks /run /tn ${DAEMON_TASK_NAME}`, { encoding: "utf-8", shell: SHELL });
95
+ console.log("Palmier daemon started.");
96
+ }
97
+ catch {
98
+ console.log("Note: could not start daemon immediately. It will start at next login/boot.");
99
+ }
100
+ console.log("\nHost initialization complete!");
101
+ }
102
+ installTaskTimer(config, task) {
103
+ const taskId = task.frontmatter.id;
104
+ const tn = schtasksTaskName(taskId);
105
+ const cmd = getPalmierCommand();
106
+ const triggers = task.frontmatter.triggers || [];
107
+ for (const trigger of triggers) {
108
+ if (trigger.type === "cron") {
109
+ const schedArgs = cronToSchtasksArgs(trigger.value);
110
+ const args = [
111
+ "/create", "/tn", tn,
112
+ "/tr", `${cmd} run ${taskId}`,
113
+ ...schedArgs,
114
+ "/f",
115
+ ];
116
+ try {
117
+ execSync(`schtasks ${args.join(" ")}`, { encoding: "utf-8", shell: SHELL });
118
+ }
119
+ catch (err) {
120
+ const e = err;
121
+ console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
122
+ }
123
+ }
124
+ else if (trigger.type === "once") {
125
+ // "once" triggers use ISO datetime: "2026-03-28T09:00"
126
+ const [datePart, timePart] = trigger.value.split("T");
127
+ if (!datePart || !timePart) {
128
+ console.error(`Invalid once trigger value: ${trigger.value}`);
129
+ continue;
130
+ }
131
+ // schtasks expects MM/DD/YYYY date format
132
+ const [year, month, day] = datePart.split("-");
133
+ const sd = `${month}/${day}/${year}`;
134
+ const st = timePart.slice(0, 5);
135
+ const args = [
136
+ "/create", "/tn", tn,
137
+ "/tr", `${cmd} run ${taskId}`,
138
+ "/sc", "ONCE", "/sd", sd, "/st", st,
139
+ "/f",
140
+ ];
141
+ try {
142
+ execSync(`schtasks ${args.join(" ")}`, { encoding: "utf-8", shell: SHELL });
143
+ }
144
+ catch (err) {
145
+ const e = err;
146
+ console.error(`Failed to create once task ${tn}: ${e.stderr || err}`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ removeTaskTimer(taskId) {
152
+ const tn = schtasksTaskName(taskId);
153
+ try {
154
+ execSync(`schtasks /delete /tn ${tn} /f`, { encoding: "utf-8", shell: SHELL });
155
+ }
156
+ catch {
157
+ // Task might not exist — that's fine
158
+ }
159
+ }
160
+ async startTask(taskId) {
161
+ const config = loadConfig();
162
+ const taskDir = getTaskDir(config.projectRoot, taskId);
163
+ const script = process.argv[1] || "palmier";
164
+ // Spawn a detached child process and record its PID for later abort
165
+ const child = nodeSpawn(process.execPath, [script, "run", taskId], {
166
+ detached: true,
167
+ stdio: "ignore",
168
+ cwd: config.projectRoot,
169
+ });
170
+ if (child.pid) {
171
+ fs.mkdirSync(taskDir, { recursive: true });
172
+ fs.writeFileSync(path.join(taskDir, "pid"), String(child.pid), "utf-8");
173
+ }
174
+ child.unref();
175
+ }
176
+ async stopTask(taskId) {
177
+ const config = loadConfig();
178
+ const taskDir = getTaskDir(config.projectRoot, taskId);
179
+ const pidPath = path.join(taskDir, "pid");
180
+ if (!fs.existsSync(pidPath)) {
181
+ throw new Error(`No PID file found for task ${taskId}`);
182
+ }
183
+ const pid = fs.readFileSync(pidPath, "utf-8").trim();
184
+ try {
185
+ // /t kills the entire process tree, /f forces termination
186
+ await execAsync(`taskkill /pid ${pid} /t /f`);
187
+ }
188
+ finally {
189
+ // Clean up PID file regardless of whether taskkill succeeded
190
+ try {
191
+ fs.unlinkSync(pidPath);
192
+ }
193
+ catch { /* ignore */ }
194
+ }
195
+ }
196
+ getGuiEnv() {
197
+ // Windows GUI is always available — no special env vars needed
198
+ return {};
199
+ }
200
+ }
201
+ //# sourceMappingURL=windows.js.map
@@ -1,17 +1,14 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import * as os from "os";
3
- import { execSync, exec } from "child_process";
4
- import { promisify } from "util";
5
- const execAsync = promisify(exec);
6
3
  import * as fs from "fs";
7
4
  import * as path from "path";
8
5
  import { fileURLToPath } from "url";
9
6
  import { parse as parseYaml } from "yaml";
10
7
  import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, getTaskCreatedAt, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList } from "./task.js";
11
- import { installTaskTimer, removeTaskTimer } from "./systemd.js";
8
+ import { getPlatform } from "./platform/index.js";
12
9
  import { spawnCommand } from "./spawn-command.js";
13
10
  import { getAgent } from "./agents/agent.js";
14
- import { hasSessions, validateSession } from "./session-store.js";
11
+ import { validateSession } from "./session-store.js";
15
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
13
  const PLAN_GENERATION_PROMPT = fs.readFileSync(path.join(__dirname, "commands", "plan-generation.md"), "utf-8");
17
14
  function detectLanIp() {
@@ -110,11 +107,9 @@ export function createRpcHandler(config) {
110
107
  };
111
108
  }
112
109
  async function handleRpc(request) {
113
- // Session token validation: if any sessions exist, require a valid token
114
- if (hasSessions()) {
115
- if (!request.sessionToken || !validateSession(request.sessionToken)) {
116
- return { error: "Unauthorized" };
117
- }
110
+ // Session token validation: always require a valid session token
111
+ if (!request.sessionToken || !validateSession(request.sessionToken)) {
112
+ return { error: "Unauthorized" };
118
113
  }
119
114
  switch (request.method) {
120
115
  case "task.list": {
@@ -159,8 +154,9 @@ export function createRpcHandler(config) {
159
154
  };
160
155
  writeTaskFile(taskDir, task);
161
156
  appendTaskList(config.projectRoot, id);
157
+ const platform = getPlatform();
162
158
  if (task.frontmatter.triggers_enabled) {
163
- installTaskTimer(config, task);
159
+ platform.installTaskTimer(config, task);
164
160
  }
165
161
  return flattenTask(task);
166
162
  }
@@ -203,23 +199,23 @@ export function createRpcHandler(config) {
203
199
  }
204
200
  writeTaskFile(taskDir, existing);
205
201
  // Reinstall or remove timers based on triggers_enabled
206
- removeTaskTimer(params.id);
202
+ const platform = getPlatform();
203
+ platform.removeTaskTimer(params.id);
207
204
  if (existing.frontmatter.triggers_enabled) {
208
- installTaskTimer(config, existing);
205
+ platform.installTaskTimer(config, existing);
209
206
  }
210
207
  return flattenTask(existing);
211
208
  }
212
209
  case "task.delete": {
213
210
  const params = request.params;
214
- removeTaskTimer(params.id);
211
+ getPlatform().removeTaskTimer(params.id);
215
212
  removeFromTaskList(config.projectRoot, params.id);
216
213
  return { ok: true, task_id: params.id };
217
214
  }
218
215
  case "task.run": {
219
216
  const params = request.params;
220
- const serviceName = `palmier-task-${params.id}.service`;
221
217
  try {
222
- await execAsync(`systemctl --user start --no-block ${serviceName}`);
218
+ await getPlatform().startTask(params.id);
223
219
  return { ok: true, task_id: params.id };
224
220
  }
225
221
  catch (err) {
@@ -230,9 +226,8 @@ export function createRpcHandler(config) {
230
226
  }
231
227
  case "task.abort": {
232
228
  const params = request.params;
233
- const serviceName = `palmier-task-${params.id}.service`;
234
229
  try {
235
- await execAsync(`systemctl --user stop ${serviceName}`);
230
+ await getPlatform().stopTask(params.id);
236
231
  return { ok: true, task_id: params.id };
237
232
  }
238
233
  catch (err) {
@@ -241,18 +236,6 @@ export function createRpcHandler(config) {
241
236
  return { error: `Failed to abort task: ${e.stderr || e.message}` };
242
237
  }
243
238
  }
244
- case "task.logs": {
245
- const params = request.params;
246
- const serviceName = `palmier-task-${params.id}.service`;
247
- try {
248
- const logs = execSync(`journalctl --user -u ${serviceName} -n 100 --no-pager`, { encoding: "utf-8" });
249
- return { task_id: params.id, logs };
250
- }
251
- catch (err) {
252
- const error = err;
253
- return { task_id: params.id, logs: error.stdout || "", error: error.stderr };
254
- }
255
- }
256
239
  case "task.status": {
257
240
  const params = request.params;
258
241
  const taskDir = getTaskDir(config.projectRoot, params.id);
@@ -12,8 +12,11 @@ export interface SpawnCommandOptions {
12
12
  /**
13
13
  * Spawn a command with additional arguments.
14
14
  *
15
- * Runs without a shell the command and args are passed directly to the
16
- * child process (no escaping needed).
15
+ * On Windows, `shell: true` is used so that npm-installed .cmd shims
16
+ * (e.g. claude.cmd, gemini.cmd) are resolved correctly.
17
+ *
18
+ * On other platforms the command is executed directly (no shell), so no
19
+ * escaping is needed.
17
20
  *
18
21
  * stdin is set to "ignore" (equivalent to < /dev/null) because tools like
19
22
  * `claude -p` hang indefinitely on an open stdin pipe.
@@ -5,6 +5,8 @@ export function spawnCommand(command, args, opts) {
5
5
  cwd: opts.cwd,
6
6
  stdio: ["ignore", "pipe", "pipe"],
7
7
  env: opts.env ? { ...process.env, ...opts.env } : undefined,
8
+ // On Windows, spawn through shell so .cmd shims resolve correctly
9
+ shell: process.platform === "win32",
8
10
  });
9
11
  const chunks = [];
10
12
  child.stdout.on("data", (d) => {
@@ -1,6 +1,6 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "os";
3
- import { validateSession, hasSessions, addSession } from "../session-store.js";
3
+ import { validateSession, addSession } from "../session-store.js";
4
4
  const pendingPairs = new Map();
5
5
  export function detectLanIp() {
6
6
  const interfaces = os.networkInterfaces();
@@ -35,12 +35,7 @@ export async function startHttpTransport(config, handleRpc) {
35
35
  if (!auth || !auth.startsWith("Bearer "))
36
36
  return false;
37
37
  const token = auth.slice(7);
38
- // Accept the original directToken or any valid session token
39
- if (token === config.directToken)
40
- return true;
41
- if (hasSessions() && validateSession(token))
42
- return true;
43
- return false;
38
+ return validateSession(token);
44
39
  }
45
40
  function extractSessionToken(req) {
46
41
  const auth = req.headers.authorization;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "ISC",
6
6
  "author": "Hongxu Cai",
@@ -3,6 +3,10 @@ import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
4
  import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
5
 
6
+ // execSync's shell option takes a string (shell path), not boolean.
7
+ // On Windows we need a shell so .cmd shims resolve correctly.
8
+ const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
9
+
6
10
  export class ClaudeAgent implements AgentTool {
7
11
  getPlanGenerationCommandLine(prompt: string): CommandLine {
8
12
  return {
@@ -25,12 +29,12 @@ export class ClaudeAgent implements AgentTool {
25
29
 
26
30
  async init(): Promise<boolean> {
27
31
  try {
28
- execSync("claude --version");
32
+ execSync("claude --version", { shell: SHELL });
29
33
  } catch {
30
34
  return false;
31
35
  }
32
36
  try {
33
- execSync("claude mcp add --transport stdio palmier --scope user -- palmier mcpserver");
37
+ execSync("claude mcp add --transport stdio palmier --scope user -- palmier mcpserver", { shell: SHELL });
34
38
  } catch (err) {
35
39
  console.warn("Warning: failed to install MCP for Claude:", err instanceof Error ? err.message : err);
36
40
  }
@@ -3,6 +3,9 @@ import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
4
  import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
5
 
6
+ // On Windows we need a shell so .cmd shims resolve correctly.
7
+ const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
8
+
6
9
  export class CodexAgent implements AgentTool {
7
10
  getPlanGenerationCommandLine(prompt: string): CommandLine {
8
11
  // TODO: fill in
@@ -32,12 +35,12 @@ export class CodexAgent implements AgentTool {
32
35
 
33
36
  async init(): Promise<boolean> {
34
37
  try {
35
- execSync("codex --version");
38
+ execSync("codex --version", { shell: SHELL });
36
39
  } catch {
37
40
  return false;
38
41
  }
39
42
  try {
40
- execSync("codex mcp add palmier palmier mcpserver");
43
+ execSync("codex mcp add palmier palmier mcpserver", { shell: SHELL });
41
44
  } catch (err) {
42
45
  console.warn("Warning: failed to install MCP for Codex:", err instanceof Error ? err.message : err);
43
46
  }
@@ -3,6 +3,9 @@ import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
4
  import { TASK_OUTCOME_SUFFIX } from "./shared-prompt.js";
5
5
 
6
+ // On Windows we need a shell so .cmd shims resolve correctly.
7
+ const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
8
+
6
9
  export class GeminiAgent implements AgentTool {
7
10
  getPlanGenerationCommandLine(prompt: string): CommandLine {
8
11
  // TODO: fill in
@@ -29,12 +32,12 @@ export class GeminiAgent implements AgentTool {
29
32
 
30
33
  async init(): Promise<boolean> {
31
34
  try {
32
- execSync("gemini --version");
35
+ execSync("gemini --version", { shell: SHELL });
33
36
  } catch {
34
37
  return false;
35
38
  }
36
39
  try {
37
- execSync("gemini mcp add --scope user palmier palmier mcpserver");
40
+ execSync("gemini mcp add --scope user palmier palmier mcpserver", { shell: SHELL });
38
41
  } catch (err) {
39
42
  console.warn("Warning: failed to install MCP for Gemini:", err instanceof Error ? err.message : err);
40
43
  }