palmier 0.2.5 → 0.2.7

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 (78) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/LICENSE +190 -0
  3. package/README.md +286 -219
  4. package/dist/agents/agent.d.ts +6 -3
  5. package/dist/agents/agent.js +2 -0
  6. package/dist/agents/claude.d.ts +1 -1
  7. package/dist/agents/claude.js +12 -9
  8. package/dist/agents/codex.d.ts +1 -1
  9. package/dist/agents/codex.js +12 -10
  10. package/dist/agents/gemini.d.ts +1 -1
  11. package/dist/agents/gemini.js +13 -9
  12. package/dist/agents/openclaw.d.ts +2 -2
  13. package/dist/agents/openclaw.js +8 -7
  14. package/dist/agents/shared-prompt.d.ts +5 -4
  15. package/dist/agents/shared-prompt.js +10 -8
  16. package/dist/commands/agents.js +11 -0
  17. package/dist/commands/info.js +0 -22
  18. package/dist/commands/init.js +59 -95
  19. package/dist/commands/lan.d.ts +8 -0
  20. package/dist/commands/lan.js +51 -0
  21. package/dist/commands/mcpserver.js +12 -27
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +52 -56
  24. package/dist/commands/plan-generation.md +24 -32
  25. package/dist/commands/restart.d.ts +5 -0
  26. package/dist/commands/restart.js +9 -0
  27. package/dist/commands/run.js +311 -124
  28. package/dist/commands/serve.d.ts +1 -1
  29. package/dist/commands/serve.js +77 -17
  30. package/dist/commands/task-cleanup.d.ts +14 -0
  31. package/dist/commands/task-cleanup.js +84 -0
  32. package/dist/config.js +3 -17
  33. package/dist/events.d.ts +9 -0
  34. package/dist/events.js +46 -0
  35. package/dist/index.js +15 -0
  36. package/dist/platform/linux.d.ts +2 -0
  37. package/dist/platform/linux.js +22 -1
  38. package/dist/platform/platform.d.ts +4 -0
  39. package/dist/platform/windows.d.ts +3 -0
  40. package/dist/platform/windows.js +99 -82
  41. package/dist/rpc-handler.d.ts +2 -1
  42. package/dist/rpc-handler.js +43 -52
  43. package/dist/spawn-command.d.ts +29 -6
  44. package/dist/spawn-command.js +38 -15
  45. package/dist/transports/http-transport.d.ts +1 -1
  46. package/dist/transports/http-transport.js +103 -18
  47. package/dist/transports/nats-transport.d.ts +4 -2
  48. package/dist/transports/nats-transport.js +3 -4
  49. package/dist/types.d.ts +5 -5
  50. package/package.json +5 -3
  51. package/src/agents/agent.ts +8 -3
  52. package/src/agents/claude.ts +44 -43
  53. package/src/agents/codex.ts +11 -12
  54. package/src/agents/gemini.ts +12 -10
  55. package/src/agents/openclaw.ts +8 -7
  56. package/src/agents/shared-prompt.ts +10 -8
  57. package/src/commands/agents.ts +11 -0
  58. package/src/commands/info.ts +0 -24
  59. package/src/commands/init.ts +62 -119
  60. package/src/commands/lan.ts +58 -0
  61. package/src/commands/mcpserver.ts +12 -31
  62. package/src/commands/pair.ts +50 -63
  63. package/src/commands/plan-generation.md +24 -32
  64. package/src/commands/restart.ts +9 -0
  65. package/src/commands/run.ts +375 -143
  66. package/src/commands/serve.ts +96 -17
  67. package/src/config.ts +3 -18
  68. package/src/cross-spawn.d.ts +5 -0
  69. package/src/events.ts +51 -0
  70. package/src/index.ts +17 -0
  71. package/src/platform/linux.ts +25 -1
  72. package/src/platform/platform.ts +6 -0
  73. package/src/platform/windows.ts +100 -89
  74. package/src/rpc-handler.ts +46 -55
  75. package/src/spawn-command.ts +120 -83
  76. package/src/transports/http-transport.ts +123 -19
  77. package/src/transports/nats-transport.ts +4 -4
  78. package/src/types.ts +6 -8
@@ -1,26 +1,105 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
1
3
  import { loadConfig } from "../config.js";
4
+ import { connectNats } from "../nats-client.js";
2
5
  import { createRpcHandler } from "../rpc-handler.js";
3
6
  import { startNatsTransport } from "../transports/nats-transport.js";
4
- import { startHttpTransport } from "../transports/http-transport.js";
7
+ import { getTaskDir, readTaskStatus, writeTaskStatus, appendHistory, parseTaskFile } from "../task.js";
8
+ import { publishHostEvent } from "../events.js";
9
+ import { getPlatform } from "../platform/index.js";
10
+ import type { HostConfig } from "../types.js";
11
+ import { CONFIG_DIR } from "../config.js";
12
+ import type { NatsConnection } from "nats";
13
+
14
+ const POLL_INTERVAL_MS = 30_000;
15
+ const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
5
16
 
6
17
  /**
7
- * Start the persistent RPC handler using the configured transport(s).
18
+ * Mark a stuck task as failed: update status.json, write RESULT, append history,
19
+ * and broadcast the failure event.
20
+ */
21
+ async function markTaskFailed(
22
+ config: HostConfig,
23
+ nc: NatsConnection | undefined,
24
+ taskId: string,
25
+ reason: string,
26
+ ): Promise<void> {
27
+ const taskDir = getTaskDir(config.projectRoot, taskId);
28
+ const status = readTaskStatus(taskDir);
29
+ if (!status || status.running_state !== "started") return;
30
+
31
+ console.log(`[monitor] Task ${taskId} ${reason}, marking as failed.`);
32
+ const endTime = Date.now();
33
+ writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
34
+
35
+ let taskName = taskId;
36
+ try {
37
+ const task = parseTaskFile(taskDir);
38
+ taskName = task.frontmatter.name || taskId;
39
+ } catch { /* use taskId as fallback */ }
40
+
41
+ const resultFileName = `RESULT-${endTime}.md`;
42
+ const content = `---\ntask_name: ${taskName}\nrunning_state: failed\nstart_time: ${status.time_stamp}\nend_time: ${endTime}\ntask_file: \n---\n${reason}`;
43
+ fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
44
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
45
+
46
+ const payload: Record<string, unknown> = { event_type: "running-state", running_state: "failed", name: taskName };
47
+ await publishHostEvent(nc, config.hostId, taskId, payload);
48
+ }
49
+
50
+ /**
51
+ * Scan all tasks for any stuck in "start" state whose process is no longer alive.
52
+ * Uses the system scheduler (Task Scheduler / systemd) as the authoritative source.
53
+ */
54
+ async function checkStaleTasks(
55
+ config: HostConfig,
56
+ nc: NatsConnection | undefined,
57
+ ): Promise<void> {
58
+ const tasksJsonl = path.join(config.projectRoot, "tasks.jsonl");
59
+ if (!fs.existsSync(tasksJsonl)) return;
60
+
61
+ const platform = getPlatform();
62
+ const lines = fs.readFileSync(tasksJsonl, "utf-8").split("\n").filter(Boolean);
63
+ for (const line of lines) {
64
+ let taskId: string;
65
+ try {
66
+ taskId = (JSON.parse(line) as { task_id: string }).task_id;
67
+ } catch { continue; }
68
+
69
+ const taskDir = getTaskDir(config.projectRoot, taskId);
70
+ const status = readTaskStatus(taskDir);
71
+ if (!status || status.running_state !== "started") continue;
72
+
73
+ // Ask the system scheduler if the task is still running
74
+ if (platform.isTaskRunning(taskId)) continue;
75
+
76
+ await markTaskFailed(config, nc, taskId, "Task process exited unexpectedly");
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Start the persistent RPC handler (NATS only).
8
82
  */
9
83
  export async function serveCommand(): Promise<void> {
10
84
  const config = loadConfig();
11
- const handleRpc = createRpcHandler(config);
12
-
13
- const mode = config.mode ?? "nats";
14
- console.log(`Starting in ${mode} mode...`);
15
-
16
- if (mode === "auto") {
17
- await Promise.all([
18
- startNatsTransport(config, handleRpc),
19
- startHttpTransport(config, handleRpc),
20
- ]);
21
- } else if (mode === "lan") {
22
- await startHttpTransport(config, handleRpc);
23
- } else {
24
- await startNatsTransport(config, handleRpc);
25
- }
85
+
86
+ // Write PID so `palmier restart` can find us regardless of how we were started
87
+ fs.writeFileSync(DAEMON_PID_FILE, String(process.pid), "utf-8");
88
+
89
+ console.log("Starting...");
90
+
91
+ const nc = await connectNats(config);
92
+
93
+ // Reconcile any tasks stuck from before daemon started
94
+ await checkStaleTasks(config, nc);
95
+
96
+ // Poll for crashed tasks every 30 seconds
97
+ setInterval(() => {
98
+ checkStaleTasks(config, nc).catch((err) => {
99
+ console.error("[monitor] Error checking stale tasks:", err);
100
+ });
101
+ }, POLL_INTERVAL_MS);
102
+
103
+ const handleRpc = createRpcHandler(config, nc);
104
+ await startNatsTransport(config, handleRpc, nc);
26
105
  }
package/src/config.ts CHANGED
@@ -21,30 +21,15 @@ export function loadConfig(): HostConfig {
21
21
  const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
22
22
  const config = JSON.parse(raw) as HostConfig;
23
23
 
24
- // Default mode to "nats" for backward compatibility
25
- if (!config.mode) {
26
- config.mode = "nats";
27
- }
28
-
29
24
  if (!config.hostId) {
30
25
  throw new Error("Invalid host config: missing hostId");
31
26
  }
32
27
 
33
- const mode = config.mode;
34
- if (mode === "nats" || mode === "auto") {
35
- if (!config.natsUrl || !config.natsToken) {
36
- throw new Error(`Invalid host config: mode "${mode}" requires natsUrl and natsToken`);
37
- }
38
- }
39
- if (mode === "lan" || mode === "auto") {
40
- if (!config.directToken) {
41
- throw new Error(`Invalid host config: mode "${mode}" requires directToken`);
42
- }
43
- if (!config.directPort) {
44
- config.directPort = 7400;
45
- }
28
+ if (!config.natsUrl || !config.natsToken) {
29
+ throw new Error("Invalid host config: missing natsUrl or natsToken");
46
30
  }
47
31
 
32
+ config.nats = true;
48
33
  return config;
49
34
  }
50
35
 
@@ -0,0 +1,5 @@
1
+ declare module "cross-spawn" {
2
+ import type { ChildProcess, SpawnOptions } from "child_process";
3
+ function spawn(command: string, args?: string[], options?: SpawnOptions): ChildProcess;
4
+ export default spawn;
5
+ }
package/src/events.ts ADDED
@@ -0,0 +1,51 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { StringCodec, type NatsConnection } from "nats";
4
+ import { CONFIG_DIR } from "./config.js";
5
+
6
+ const sc = StringCodec();
7
+ const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
8
+
9
+ /**
10
+ * Read the LAN lockfile to determine if `palmier lan` is running.
11
+ */
12
+ function getLanPort(): number | null {
13
+ try {
14
+ const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
15
+ return (JSON.parse(raw) as { port: number }).port;
16
+ } catch { return null; }
17
+ }
18
+
19
+ /**
20
+ * Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
21
+ *
22
+ * - NATS: publishes to `host-event.{hostId}.{taskId}`
23
+ * - HTTP: POSTs to the LAN server's `/internal/event` endpoint (auto-detected via lockfile)
24
+ */
25
+ export async function publishHostEvent(
26
+ nc: NatsConnection | undefined,
27
+ hostId: string,
28
+ taskId: string,
29
+ payload: Record<string, unknown>,
30
+ ): Promise<void> {
31
+ const subject = `host-event.${hostId}.${taskId}`;
32
+
33
+ if (nc) {
34
+ nc.publish(subject, sc.encode(JSON.stringify(payload)));
35
+ console.log(`[nats] ${subject} →`, payload);
36
+ }
37
+
38
+ const lanPort = getLanPort();
39
+ if (lanPort) {
40
+ try {
41
+ await fetch(`http://localhost:${lanPort}/internal/event`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ task_id: taskId, ...payload }),
45
+ });
46
+ console.log(`[http] host-event: ${taskId} →`, payload);
47
+ } catch {
48
+ // LAN server may have shut down — ignore
49
+ }
50
+ }
51
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ import { serveCommand } from "./commands/serve.js";
12
12
  import { mcpserverCommand } from "./commands/mcpserver.js";
13
13
  import { agentsCommand } from "./commands/agents.js";
14
14
  import { pairCommand } from "./commands/pair.js";
15
+ import { lanCommand } from "./commands/lan.js";
16
+ import { restartCommand } from "./commands/restart.js";
15
17
  import { sessionsListCommand, sessionsRevokeCommand, sessionsRevokeAllCommand } from "./commands/sessions.js";
16
18
 
17
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -52,6 +54,13 @@ program
52
54
  await serveCommand();
53
55
  });
54
56
 
57
+ program
58
+ .command("restart")
59
+ .description("Restart the palmier serve daemon")
60
+ .action(async () => {
61
+ await restartCommand();
62
+ });
63
+
55
64
  program
56
65
  .command("mcpserver")
57
66
  .description("Start an MCP server exposing Palmier tools (stdio transport)")
@@ -73,6 +82,14 @@ program
73
82
  await pairCommand();
74
83
  });
75
84
 
85
+ program
86
+ .command("lan")
87
+ .description("Start an on-demand LAN server for direct HTTP connections")
88
+ .option("-p, --port <port>", "Port to listen on", "7400")
89
+ .action(async (opts: { port: string }) => {
90
+ await lanCommand({ port: parseInt(opts.port, 10) });
91
+ });
92
+
76
93
  const sessionsCmd = program
77
94
  .command("sessions")
78
95
  .description("Manage paired client sessions");
@@ -112,6 +112,11 @@ WantedBy=default.target
112
112
  console.log("\nHost initialization complete!");
113
113
  }
114
114
 
115
+ async restartDaemon(): Promise<void> {
116
+ execSync("systemctl --user restart palmier.service", { stdio: "inherit" });
117
+ console.log("Palmier daemon restarted.");
118
+ }
119
+
115
120
  installTaskTimer(config: HostConfig, task: ParsedTask): void {
116
121
  fs.mkdirSync(UNIT_DIR, { recursive: true });
117
122
 
@@ -125,6 +130,7 @@ Description=Palmier Task: ${taskId}
125
130
 
126
131
  [Service]
127
132
  Type=oneshot
133
+ TimeoutStartSec=infinity
128
134
  ExecStart=${palmierBin} run ${taskId}
129
135
  WorkingDirectory=${config.projectRoot}
130
136
  Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
@@ -133,7 +139,8 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
133
139
  fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
134
140
  daemonReload();
135
141
 
136
- // Only create and enable a timer if there are actual triggers
142
+ // Only create and enable a timer if triggers exist and are enabled
143
+ if (!task.frontmatter.triggers_enabled) return;
137
144
  const triggers = task.frontmatter.triggers || [];
138
145
  const onCalendarLines: string[] = [];
139
146
  for (const trigger of triggers) {
@@ -193,6 +200,23 @@ WantedBy=timers.target
193
200
  await execAsync(`systemctl --user stop ${serviceName}`);
194
201
  }
195
202
 
203
+ isTaskRunning(taskId: string): boolean {
204
+ const serviceName = getServiceName(taskId);
205
+ try {
206
+ // is-active exits 0 only for "active". For oneshot services (Type=oneshot),
207
+ // the state is "activating" while running, which exits non-zero.
208
+ // Use show -p ActiveState to reliably get the state without exit code issues.
209
+ const out = execSync(
210
+ `systemctl --user show -p ActiveState --value ${serviceName}`,
211
+ { encoding: "utf-8" },
212
+ );
213
+ const state = out.trim();
214
+ return state === "active" || state === "activating";
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
196
220
  getGuiEnv(): Record<string, string> {
197
221
  const uid = process.getuid?.();
198
222
  const runtimeDir =
@@ -8,6 +8,9 @@ export interface PlatformService {
8
8
  /** Install the main `palmier serve` daemon to start at boot. */
9
9
  installDaemon(config: HostConfig): void;
10
10
 
11
+ /** Restart the `palmier serve` daemon. */
12
+ restartDaemon(): Promise<void>;
13
+
11
14
  /** Install a scheduled trigger (timer) for a task. */
12
15
  installTaskTimer(config: HostConfig, task: ParsedTask): void;
13
16
 
@@ -20,6 +23,9 @@ export interface PlatformService {
20
23
  /** Abort/stop a running task. */
21
24
  stopTask(taskId: string): Promise<void>;
22
25
 
26
+ /** Check if a task is currently running via the system scheduler. */
27
+ isTaskRunning(taskId: string): boolean;
28
+
23
29
  /** Return env vars needed for GUI access (Linux: DISPLAY, etc.). */
24
30
  getGuiEnv(): Record<string, string>;
25
31
  }
@@ -1,27 +1,23 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { execSync, exec, spawn as nodeSpawn } from "child_process";
4
- import { promisify } from "util";
3
+ import { execFileSync } from "child_process";
4
+ import { spawn as nodeSpawn } from "child_process";
5
5
  import type { PlatformService } from "./platform.js";
6
6
  import type { HostConfig, ParsedTask } from "../types.js";
7
- import { getTaskDir } from "../task.js";
8
- import { loadConfig } from "../config.js";
7
+ import { CONFIG_DIR } from "../config.js";
9
8
 
10
- const execAsync = promisify(exec);
11
9
 
12
- const TASK_PREFIX = "PalmierTask-";
10
+ const TASK_PREFIX = "\\Palmier\\PalmierTask-";
13
11
  const DAEMON_TASK_NAME = "PalmierDaemon";
14
- const SHELL = "cmd.exe";
12
+ const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
15
13
 
16
14
  /**
17
- * Resolve the full command to invoke palmier, accounting for the fact that
18
- * on Windows, globally-installed npm packages are .cmd shims.
15
+ * Build the /tr value for schtasks: a single string with quoted paths
16
+ * so Task Scheduler can invoke node with the palmier script + subcommand.
19
17
  */
20
- function getPalmierCommand(): string {
21
- // process.argv[1] is the script path; wrap with node so it works as
22
- // a Task Scheduler command without relying on file associations.
18
+ function schtasksTr(...subcommand: string[]): string {
23
19
  const script = process.argv[1] || "palmier";
24
- return `"${process.execPath}" "${script}"`;
20
+ return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
25
21
  }
26
22
 
27
23
  /**
@@ -79,65 +75,86 @@ function schtasksTaskName(taskId: string): string {
79
75
 
80
76
  export class WindowsPlatform implements PlatformService {
81
77
  installDaemon(config: HostConfig): void {
82
- const cmd = getPalmierCommand();
83
-
84
- // Try ONSTART first (requires elevation), fall back to ONLOGON
85
- const baseArgs = [
86
- "/create", "/tn", DAEMON_TASK_NAME,
87
- "/tr", `${cmd} serve`,
88
- "/rl", "HIGHEST",
89
- "/f", // force overwrite if exists
90
- ];
78
+ const script = process.argv[1] || "palmier";
79
+ const regValue = `"${process.execPath}" "${script}" serve`;
91
80
 
92
81
  try {
93
- execSync(
94
- `schtasks ${[...baseArgs, "/sc", "ONSTART"].join(" ")}`,
95
- { encoding: "utf-8", shell: SHELL },
96
- );
97
- console.log(`Task Scheduler: "${DAEMON_TASK_NAME}" installed (runs at startup).`);
98
- } catch {
99
- // ONSTART requires admin fall back to ONLOGON which does not
100
- try {
101
- execSync(
102
- `schtasks ${[...baseArgs, "/sc", "ONLOGON"].join(" ")}`,
103
- { encoding: "utf-8", shell: SHELL },
104
- );
105
- console.log(`Task Scheduler: "${DAEMON_TASK_NAME}" installed (runs at logon).`);
106
- console.log(" Tip: run as Administrator to use ONSTART instead.");
107
- } catch (err) {
108
- console.error(`Warning: failed to create scheduled task: ${err}`);
109
- console.error("You may need to start palmier serve manually.");
110
- }
82
+ execFileSync("reg", [
83
+ "add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
84
+ "/v", DAEMON_TASK_NAME, "/t", "REG_SZ", "/d", regValue, "/f",
85
+ ], { encoding: "utf-8", stdio: "pipe" });
86
+ console.log(`Registry Run key "${DAEMON_TASK_NAME}" installed (runs at logon).`);
87
+ } catch (err) {
88
+ console.error(`Warning: failed to install registry run entry: ${err}`);
89
+ console.error("You may need to start palmier serve manually.");
111
90
  }
112
91
 
113
92
  // Start the daemon now
114
- try {
115
- execSync(`schtasks /run /tn ${DAEMON_TASK_NAME}`, { encoding: "utf-8", shell: SHELL });
116
- console.log("Palmier daemon started.");
117
- } catch {
118
- console.log("Note: could not start daemon immediately. It will start at next login/boot.");
119
- }
93
+ this.spawnDaemon(script);
120
94
 
121
95
  console.log("\nHost initialization complete!");
122
96
  }
123
97
 
98
+ async restartDaemon(): Promise<void> {
99
+ // Kill the old daemon if we have its PID
100
+ if (fs.existsSync(DAEMON_PID_FILE)) {
101
+ const oldPid = fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim();
102
+ try {
103
+ execFileSync("taskkill", ["/pid", oldPid, "/t", "/f"], { windowsHide: true });
104
+ } catch {
105
+ // Process may have already exited
106
+ }
107
+ }
108
+
109
+ const script = process.argv[1] || "palmier";
110
+ this.spawnDaemon(script);
111
+ }
112
+
113
+ private spawnDaemon(script: string): void {
114
+ const child = nodeSpawn(process.execPath, [script, "serve"], {
115
+ detached: true,
116
+ stdio: "ignore",
117
+ windowsHide: true,
118
+ });
119
+ if (child.pid) {
120
+ fs.writeFileSync(DAEMON_PID_FILE, String(child.pid), "utf-8");
121
+ }
122
+ child.unref();
123
+ console.log("Palmier daemon started.");
124
+ }
125
+
124
126
  installTaskTimer(config: HostConfig, task: ParsedTask): void {
125
127
  const taskId = task.frontmatter.id;
126
128
  const tn = schtasksTaskName(taskId);
127
- const cmd = getPalmierCommand();
129
+ const tr = schtasksTr("run", taskId);
130
+
131
+ // Always create the scheduled task with a dummy trigger first.
132
+ // This ensures startTask (/run) works even when no triggers are configured.
133
+ try {
134
+ execFileSync("schtasks", [
135
+ "/create", "/tn", tn,
136
+ "/tr", tr,
137
+ "/sc", "ONCE", "/sd", "01/01/2000", "/st", "00:00",
138
+ "/f",
139
+ ], { encoding: "utf-8", windowsHide: true });
140
+ } catch (err: unknown) {
141
+ const e = err as { stderr?: string };
142
+ console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
143
+ }
128
144
 
145
+ // Overlay with real schedule triggers if enabled
146
+ if (!task.frontmatter.triggers_enabled) return;
129
147
  const triggers = task.frontmatter.triggers || [];
130
148
  for (const trigger of triggers) {
131
149
  if (trigger.type === "cron") {
132
150
  const schedArgs = cronToSchtasksArgs(trigger.value);
133
- const args = [
134
- "/create", "/tn", tn,
135
- "/tr", `${cmd} run ${taskId}`,
136
- ...schedArgs,
137
- "/f",
138
- ];
139
151
  try {
140
- execSync(`schtasks ${args.join(" ")}`, { encoding: "utf-8", shell: SHELL });
152
+ execFileSync("schtasks", [
153
+ "/create", "/tn", tn,
154
+ "/tr", tr,
155
+ ...schedArgs,
156
+ "/f",
157
+ ], { encoding: "utf-8", windowsHide: true });
141
158
  } catch (err: unknown) {
142
159
  const e = err as { stderr?: string };
143
160
  console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
@@ -153,14 +170,13 @@ export class WindowsPlatform implements PlatformService {
153
170
  const [year, month, day] = datePart.split("-");
154
171
  const sd = `${month}/${day}/${year}`;
155
172
  const st = timePart.slice(0, 5);
156
- const args = [
157
- "/create", "/tn", tn,
158
- "/tr", `${cmd} run ${taskId}`,
159
- "/sc", "ONCE", "/sd", sd, "/st", st,
160
- "/f",
161
- ];
162
173
  try {
163
- execSync(`schtasks ${args.join(" ")}`, { encoding: "utf-8", shell: SHELL });
174
+ execFileSync("schtasks", [
175
+ "/create", "/tn", tn,
176
+ "/tr", tr,
177
+ "/sc", "ONCE", "/sd", sd, "/st", st,
178
+ "/f",
179
+ ], { encoding: "utf-8", windowsHide: true });
164
180
  } catch (err: unknown) {
165
181
  const e = err as { stderr?: string };
166
182
  console.error(`Failed to create once task ${tn}: ${e.stderr || err}`);
@@ -172,47 +188,42 @@ export class WindowsPlatform implements PlatformService {
172
188
  removeTaskTimer(taskId: string): void {
173
189
  const tn = schtasksTaskName(taskId);
174
190
  try {
175
- execSync(`schtasks /delete /tn ${tn} /f`, { encoding: "utf-8", shell: SHELL });
191
+ execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
176
192
  } catch {
177
193
  // Task might not exist — that's fine
178
194
  }
179
195
  }
180
196
 
181
197
  async startTask(taskId: string): Promise<void> {
182
- const config = loadConfig();
183
- const taskDir = getTaskDir(config.projectRoot, taskId);
184
- const script = process.argv[1] || "palmier";
185
-
186
- // Spawn a detached child process and record its PID for later abort
187
- const child = nodeSpawn(process.execPath, [script, "run", taskId], {
188
- detached: true,
189
- stdio: "ignore",
190
- cwd: config.projectRoot,
191
- });
192
-
193
- if (child.pid) {
194
- fs.mkdirSync(taskDir, { recursive: true });
195
- fs.writeFileSync(path.join(taskDir, "pid"), String(child.pid), "utf-8");
198
+ const tn = schtasksTaskName(taskId);
199
+ try {
200
+ execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true });
201
+ } catch (err: unknown) {
202
+ const e = err as { stderr?: string; message?: string };
203
+ throw new Error(`Failed to start task via schtasks: ${e.stderr || e.message}`);
196
204
  }
197
- child.unref();
198
205
  }
199
206
 
200
207
  async stopTask(taskId: string): Promise<void> {
201
- const config = loadConfig();
202
- const taskDir = getTaskDir(config.projectRoot, taskId);
203
- const pidPath = path.join(taskDir, "pid");
204
-
205
- if (!fs.existsSync(pidPath)) {
206
- throw new Error(`No PID file found for task ${taskId}`);
208
+ const tn = schtasksTaskName(taskId);
209
+ try {
210
+ execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
211
+ } catch (err: unknown) {
212
+ const e = err as { stderr?: string; message?: string };
213
+ throw new Error(`Failed to stop task via schtasks: ${e.stderr || e.message}`);
207
214
  }
215
+ }
208
216
 
209
- const pid = fs.readFileSync(pidPath, "utf-8").trim();
217
+ isTaskRunning(taskId: string): boolean {
218
+ const tn = schtasksTaskName(taskId);
210
219
  try {
211
- // /t kills the entire process tree, /f forces termination
212
- await execAsync(`taskkill /pid ${pid} /t /f`);
213
- } finally {
214
- // Clean up PID file regardless of whether taskkill succeeded
215
- try { fs.unlinkSync(pidPath); } catch { /* ignore */ }
220
+ const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
221
+ encoding: "utf-8",
222
+ windowsHide: true,
223
+ });
224
+ return out.includes('"Running"');
225
+ } catch {
226
+ return false;
216
227
  }
217
228
  }
218
229