palmier 0.3.3 → 0.3.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/README.md CHANGED
@@ -26,7 +26,7 @@ The host supports two independent connection modes, enabled during `palmier init
26
26
  ## Prerequisites
27
27
 
28
28
  - **Node.js 24+**
29
- - An agent CLI tool for task execution (e.g., Claude Code, Gemini CLI, OpenAI Codex)
29
+ - An agent CLI tool for task execution (e.g., Claude Code, Gemini CLI, OpenAI Codex, GitHub Copilot)
30
30
  - **Linux with systemd** or **Windows 10/11**
31
31
 
32
32
  ## Installation
@@ -83,7 +83,7 @@ palmier sessions revoke-all
83
83
  ```
84
84
 
85
85
  The `init` command:
86
- - Detects installed agent CLIs (Claude Code, Gemini CLI, Codex CLI) and caches the result
86
+ - Detects installed agent CLIs (Claude Code, Gemini CLI, Codex CLI, GitHub Copilot) and caches the result
87
87
  - Saves host configuration to `~/.config/palmier/host.json`
88
88
  - Installs a background daemon (systemd user service on Linux, Registry Run key on Windows)
89
89
  - Auto-enters pair mode to connect your first device
@@ -162,6 +162,7 @@ src/
162
162
  claude.ts # Claude Code agent implementation
163
163
  gemini.ts # Gemini CLI agent implementation
164
164
  codex.ts # Codex CLI agent implementation
165
+ copilot.ts # GitHub Copilot agent implementation
165
166
  openclaw.ts # OpenClaw agent implementation
166
167
  commands/
167
168
  init.ts # Interactive setup wizard (auto-pair)
@@ -2,17 +2,20 @@ import { ClaudeAgent } from "./claude.js";
2
2
  import { GeminiAgent } from "./gemini.js";
3
3
  import { CodexAgent } from "./codex.js";
4
4
  import { OpenClawAgent } from "./openclaw.js";
5
+ import { CopilotAgent } from "./copilot.js";
5
6
  const agentRegistry = {
6
7
  claude: new ClaudeAgent(),
7
8
  gemini: new GeminiAgent(),
8
9
  codex: new CodexAgent(),
9
10
  openclaw: new OpenClawAgent(),
11
+ copilot: new CopilotAgent(),
10
12
  };
11
13
  const agentLabels = {
12
14
  claude: "Claude Code",
13
15
  gemini: "Gemini CLI",
14
16
  codex: "Codex CLI",
15
- openclaw: "OpenClaw"
17
+ openclaw: "OpenClaw",
18
+ copilot: "GitHub Copilot",
16
19
  };
17
20
  export async function detectAgents() {
18
21
  const detected = [];
@@ -0,0 +1,8 @@
1
+ import type { ParsedTask, RequiredPermission } from "../types.js";
2
+ import type { AgentTool, CommandLine } from "./agent.js";
3
+ export declare class CopilotAgent implements AgentTool {
4
+ getPlanGenerationCommandLine(prompt: string): CommandLine;
5
+ getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
6
+ init(): Promise<boolean>;
7
+ }
8
+ //# sourceMappingURL=copilot.d.ts.map
@@ -0,0 +1,53 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { homedir } from "os";
4
+ import { execSync } from "child_process";
5
+ import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
6
+ import { SHELL } from "../platform/index.js";
7
+ export class CopilotAgent {
8
+ getPlanGenerationCommandLine(prompt) {
9
+ return {
10
+ command: "copilot",
11
+ args: ["-p", prompt],
12
+ };
13
+ }
14
+ getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
15
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
16
+ const args = ["-p", prompt];
17
+ const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
18
+ if (allPerms.length > 0) {
19
+ args.push("--allow-tool", allPerms.map((p) => p.name).join(","));
20
+ }
21
+ if (retryPrompt) {
22
+ args.push("--continue");
23
+ }
24
+ return { command: "copilot", args };
25
+ }
26
+ async init() {
27
+ try {
28
+ execSync("gh copilot -v", { stdio: "ignore", shell: SHELL });
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ // Register Palmier MCP server in ~/.copilot/mcp-config.json
34
+ try {
35
+ const configDir = path.join(homedir(), ".copilot");
36
+ const configFile = path.join(configDir, "mcp-config.json");
37
+ let config = {};
38
+ if (fs.existsSync(configFile)) {
39
+ config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
40
+ }
41
+ const servers = (config.mcpServers ?? {});
42
+ servers.palmier = { command: "palmier", args: ["mcpserver"] };
43
+ config.mcpServers = servers;
44
+ fs.mkdirSync(configDir, { recursive: true });
45
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2), "utf-8");
46
+ }
47
+ catch {
48
+ // MCP registration is best-effort
49
+ }
50
+ return true;
51
+ }
52
+ }
53
+ //# sourceMappingURL=copilot.js.map
@@ -15,47 +15,69 @@ function schtasksTr(...subcommand) {
15
15
  const script = process.argv[1] || "palmier";
16
16
  return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
17
17
  }
18
+ const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
18
19
  /**
19
- * Convert one of the 4 supported cron patterns to schtasks flags.
20
+ * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
20
21
  *
21
- * Only these patterns (produced by the PWA UI) are handled:
22
- * hourly: "0 * * * *" → /sc HOURLY
23
- * daily: "MM HH * * *" → /sc DAILY /st HH:MM
24
- * weekly: "MM HH * * D" → /sc WEEKLY /d <day> /st HH:MM
25
- * monthly: "MM HH D * *" → /sc MONTHLY /d D /st HH:MM
26
- *
27
- * Arbitrary cron expressions (ranges, lists, step values) are NOT handled
28
- * because the UI never generates them.
22
+ * Only these cron patterns (produced by the PWA UI) are handled:
23
+ * hourly: "0 * * * *"
24
+ * daily: "MM HH * * *"
25
+ * weekly: "MM HH * * D"
26
+ * monthly: "MM HH D * *"
29
27
  */
30
- function cronToSchtasksArgs(cron) {
31
- const parts = cron.trim().split(/\s+/);
32
- if (parts.length !== 5) {
33
- throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
28
+ function triggerToXml(trigger) {
29
+ if (trigger.type === "once") {
30
+ // ISO datetime "2026-03-28T09:00"
31
+ return `<TimeTrigger><StartBoundary>${trigger.value}:00</StartBoundary></TimeTrigger>`;
34
32
  }
33
+ const parts = trigger.value.trim().split(/\s+/);
34
+ if (parts.length !== 5)
35
+ throw new Error(`Invalid cron expression: ${trigger.value}`);
35
36
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
36
- // Map cron day-of-week numbers to schtasks day abbreviations
37
- const dowMap = {
38
- "0": "SUN", "1": "MON", "2": "TUE", "3": "WED",
39
- "4": "THU", "5": "FRI", "6": "SAT", "7": "SUN",
40
- };
41
- const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
42
- // Hourly: "0 * * * *"
43
- if (hour === "*" && dayOfMonth === "*" && dayOfWeek === "*") {
44
- return ["/sc", "HOURLY"];
37
+ const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
38
+ // StartBoundary needs a full date; use a past date as the anchor
39
+ const base = `2000-01-01T${st}`;
40
+ // Hourly
41
+ if (hour === "*") {
42
+ return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
45
43
  }
46
- // Weekly: "MM HH * * D"
44
+ // Weekly
47
45
  if (dayOfMonth === "*" && dayOfWeek !== "*") {
48
- const day = dowMap[dayOfWeek];
49
- if (!day)
50
- throw new Error(`Unsupported day-of-week: ${dayOfWeek}`);
51
- return ["/sc", "WEEKLY", "/d", day, "/st", st];
46
+ const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
47
+ return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
52
48
  }
53
- // Monthly: "MM HH D * *"
49
+ // Monthly
54
50
  if (dayOfMonth !== "*" && dayOfWeek === "*") {
55
- return ["/sc", "MONTHLY", "/d", dayOfMonth, "/st", st];
51
+ 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>`;
56
52
  }
57
- // Daily: "MM HH * * *" (most common fallback)
58
- return ["/sc", "DAILY", "/st", st];
53
+ // Daily
54
+ return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
55
+ }
56
+ /**
57
+ * Build a complete Task Scheduler XML definition.
58
+ */
59
+ function buildTaskXml(tr, triggers) {
60
+ const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
61
+ const commandStr = command?.replace(/"/g, "") ?? "";
62
+ const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
63
+ return [
64
+ `<?xml version="1.0" encoding="UTF-16"?>`,
65
+ `<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
66
+ ` <Settings>`,
67
+ ` <MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>`,
68
+ ` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
69
+ ` <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`,
70
+ ` <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>`,
71
+ ` </Settings>`,
72
+ ` <Triggers>${triggers.join("")}</Triggers>`,
73
+ ` <Actions>`,
74
+ ` <Exec>`,
75
+ ` <Command>${commandStr}</Command>`,
76
+ ` <Arguments>${argsStr}</Arguments>`,
77
+ ` </Exec>`,
78
+ ` </Actions>`,
79
+ `</Task>`,
80
+ ].join("\n");
59
81
  }
60
82
  function schtasksTaskName(taskId) {
61
83
  return `${TASK_PREFIX}${taskId}`;
@@ -113,64 +135,43 @@ export class WindowsPlatform {
113
135
  const taskId = task.frontmatter.id;
114
136
  const tn = schtasksTaskName(taskId);
115
137
  const tr = schtasksTr("run", taskId);
116
- // Always create the scheduled task with a dummy trigger first.
117
- // This ensures startTask (/run) works even when no triggers are configured.
138
+ // Build trigger XML elements
139
+ const triggerElements = [];
140
+ if (task.frontmatter.triggers_enabled) {
141
+ for (const trigger of task.frontmatter.triggers ?? []) {
142
+ try {
143
+ triggerElements.push(triggerToXml(trigger));
144
+ }
145
+ catch (err) {
146
+ console.error(`Invalid trigger: ${err}`);
147
+ }
148
+ }
149
+ }
150
+ // Always include a dummy trigger so startTask (/run) works
151
+ if (triggerElements.length === 0) {
152
+ triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
153
+ }
154
+ // Write XML and register via schtasks — gives us full control over
155
+ // settings like MultipleInstancesPolicy that schtasks flags don't expose.
156
+ const xml = buildTaskXml(tr, triggerElements);
157
+ const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
118
158
  try {
159
+ // schtasks /xml requires UTF-16LE with BOM
160
+ const bom = Buffer.from([0xFF, 0xFE]);
161
+ fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
119
162
  execFileSync("schtasks", [
120
- "/create", "/tn", tn,
121
- "/tr", tr,
122
- "/sc", "ONCE", "/sd", "01/01/2000", "/st", "00:00",
123
- "/f",
163
+ "/create", "/tn", tn, "/xml", xmlPath, "/f",
124
164
  ], { encoding: "utf-8", windowsHide: true });
125
165
  }
126
166
  catch (err) {
127
167
  const e = err;
128
168
  console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
129
169
  }
130
- // Overlay with real schedule triggers if enabled
131
- if (!task.frontmatter.triggers_enabled)
132
- return;
133
- const triggers = task.frontmatter.triggers || [];
134
- for (const trigger of triggers) {
135
- if (trigger.type === "cron") {
136
- const schedArgs = cronToSchtasksArgs(trigger.value);
137
- try {
138
- execFileSync("schtasks", [
139
- "/create", "/tn", tn,
140
- "/tr", tr,
141
- ...schedArgs,
142
- "/f",
143
- ], { encoding: "utf-8", windowsHide: true });
144
- }
145
- catch (err) {
146
- const e = err;
147
- console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
148
- }
149
- }
150
- else if (trigger.type === "once") {
151
- // "once" triggers use ISO datetime: "2026-03-28T09:00"
152
- const [datePart, timePart] = trigger.value.split("T");
153
- if (!datePart || !timePart) {
154
- console.error(`Invalid once trigger value: ${trigger.value}`);
155
- continue;
156
- }
157
- // schtasks expects MM/DD/YYYY date format
158
- const [year, month, day] = datePart.split("-");
159
- const sd = `${month}/${day}/${year}`;
160
- const st = timePart.slice(0, 5);
161
- try {
162
- execFileSync("schtasks", [
163
- "/create", "/tn", tn,
164
- "/tr", tr,
165
- "/sc", "ONCE", "/sd", sd, "/st", st,
166
- "/f",
167
- ], { encoding: "utf-8", windowsHide: true });
168
- }
169
- catch (err) {
170
- const e = err;
171
- console.error(`Failed to create once task ${tn}: ${e.stderr || err}`);
172
- }
170
+ finally {
171
+ try {
172
+ fs.unlinkSync(xmlPath);
173
173
  }
174
+ catch { /* ignore */ }
174
175
  }
175
176
  }
176
177
  removeTaskTimer(taskId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -3,6 +3,7 @@ import { ClaudeAgent } from "./claude.js";
3
3
  import { GeminiAgent } from "./gemini.js";
4
4
  import { CodexAgent } from "./codex.js";
5
5
  import { OpenClawAgent } from "./openclaw.js";
6
+ import { CopilotAgent } from "./copilot.js";
6
7
 
7
8
  export interface CommandLine {
8
9
  command: string;
@@ -34,13 +35,15 @@ const agentRegistry: Record<string, AgentTool> = {
34
35
  gemini: new GeminiAgent(),
35
36
  codex: new CodexAgent(),
36
37
  openclaw: new OpenClawAgent(),
38
+ copilot: new CopilotAgent(),
37
39
  };
38
40
 
39
41
  const agentLabels: Record<string, string> = {
40
42
  claude: "Claude Code",
41
43
  gemini: "Gemini CLI",
42
44
  codex: "Codex CLI",
43
- openclaw: "OpenClaw"
45
+ openclaw: "OpenClaw",
46
+ copilot: "GitHub Copilot",
44
47
  };
45
48
 
46
49
  export interface DetectedAgent {
@@ -0,0 +1,55 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { homedir } from "os";
4
+ import type { ParsedTask, RequiredPermission } from "../types.js";
5
+ import { execSync } from "child_process";
6
+ import type { AgentTool, CommandLine } from "./agent.js";
7
+ import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
8
+ import { SHELL } from "../platform/index.js";
9
+
10
+ export class CopilotAgent implements AgentTool {
11
+ getPlanGenerationCommandLine(prompt: string): CommandLine {
12
+ return {
13
+ command: "copilot",
14
+ args: ["-p", prompt],
15
+ };
16
+ }
17
+
18
+ getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
19
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
20
+ const args = ["-p", prompt];
21
+
22
+ const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
23
+ if (allPerms.length > 0) {
24
+ args.push("--allow-tool", allPerms.map((p) => p.name).join(","));
25
+ }
26
+
27
+ if (retryPrompt) { args.push("--continue"); }
28
+ return { command: "copilot", args};
29
+ }
30
+
31
+ async init(): Promise<boolean> {
32
+ try {
33
+ execSync("gh copilot -v", { stdio: "ignore", shell: SHELL });
34
+ } catch {
35
+ return false;
36
+ }
37
+ // Register Palmier MCP server in ~/.copilot/mcp-config.json
38
+ try {
39
+ const configDir = path.join(homedir(), ".copilot");
40
+ const configFile = path.join(configDir, "mcp-config.json");
41
+ let config: Record<string, unknown> = {};
42
+ if (fs.existsSync(configFile)) {
43
+ config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Record<string, unknown>;
44
+ }
45
+ const servers = (config.mcpServers ?? {}) as Record<string, unknown>;
46
+ servers.palmier = { command: "palmier", args: ["mcpserver"] };
47
+ config.mcpServers = servers;
48
+ fs.mkdirSync(configDir, { recursive: true });
49
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2), "utf-8");
50
+ } catch {
51
+ // MCP registration is best-effort
52
+ }
53
+ return true;
54
+ }
55
+ }
@@ -21,53 +21,76 @@ function schtasksTr(...subcommand: string[]): string {
21
21
  return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
22
22
  }
23
23
 
24
+ const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
25
+
24
26
  /**
25
- * Convert one of the 4 supported cron patterns to schtasks flags.
26
- *
27
- * Only these patterns (produced by the PWA UI) are handled:
28
- * hourly: "0 * * * *" → /sc HOURLY
29
- * daily: "MM HH * * *" → /sc DAILY /st HH:MM
30
- * weekly: "MM HH * * D" → /sc WEEKLY /d <day> /st HH:MM
31
- * monthly: "MM HH D * *" → /sc MONTHLY /d D /st HH:MM
27
+ * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
32
28
  *
33
- * Arbitrary cron expressions (ranges, lists, step values) are NOT handled
34
- * because the UI never generates them.
29
+ * Only these cron patterns (produced by the PWA UI) are handled:
30
+ * hourly: "0 * * * *"
31
+ * daily: "MM HH * * *"
32
+ * weekly: "MM HH * * D"
33
+ * monthly: "MM HH D * *"
35
34
  */
36
- function cronToSchtasksArgs(cron: string): string[] {
37
- const parts = cron.trim().split(/\s+/);
38
- if (parts.length !== 5) {
39
- throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
35
+ function triggerToXml(trigger: { type: string; value: string }): string {
36
+ if (trigger.type === "once") {
37
+ // ISO datetime "2026-03-28T09:00"
38
+ return `<TimeTrigger><StartBoundary>${trigger.value}:00</StartBoundary></TimeTrigger>`;
40
39
  }
41
40
 
41
+ const parts = trigger.value.trim().split(/\s+/);
42
+ if (parts.length !== 5) throw new Error(`Invalid cron expression: ${trigger.value}`);
42
43
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
44
+ const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
45
+ // StartBoundary needs a full date; use a past date as the anchor
46
+ const base = `2000-01-01T${st}`;
43
47
 
44
- // Map cron day-of-week numbers to schtasks day abbreviations
45
- const dowMap: Record<string, string> = {
46
- "0": "SUN", "1": "MON", "2": "TUE", "3": "WED",
47
- "4": "THU", "5": "FRI", "6": "SAT", "7": "SUN",
48
- };
49
-
50
- const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
51
-
52
- // Hourly: "0 * * * *"
53
- if (hour === "*" && dayOfMonth === "*" && dayOfWeek === "*") {
54
- return ["/sc", "HOURLY"];
48
+ // Hourly
49
+ if (hour === "*") {
50
+ return `<TimeTrigger><StartBoundary>${base}</StartBoundary><Repetition><Interval>PT1H</Interval></Repetition></TimeTrigger>`;
55
51
  }
56
52
 
57
- // Weekly: "MM HH * * D"
53
+ // Weekly
58
54
  if (dayOfMonth === "*" && dayOfWeek !== "*") {
59
- const day = dowMap[dayOfWeek];
60
- if (!day) throw new Error(`Unsupported day-of-week: ${dayOfWeek}`);
61
- return ["/sc", "WEEKLY", "/d", day, "/st", st];
55
+ const day = DOW_NAMES[Number(dayOfWeek)] ?? "Monday";
56
+ return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByWeek><DaysOfWeek><${day} /></DaysOfWeek><WeeksInterval>1</WeeksInterval></ScheduleByWeek></CalendarTrigger>`;
62
57
  }
63
58
 
64
- // Monthly: "MM HH D * *"
59
+ // Monthly
65
60
  if (dayOfMonth !== "*" && dayOfWeek === "*") {
66
- return ["/sc", "MONTHLY", "/d", dayOfMonth, "/st", st];
61
+ 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>`;
67
62
  }
68
63
 
69
- // Daily: "MM HH * * *" (most common fallback)
70
- return ["/sc", "DAILY", "/st", st];
64
+ // Daily
65
+ return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
66
+ }
67
+
68
+ /**
69
+ * Build a complete Task Scheduler XML definition.
70
+ */
71
+ function buildTaskXml(tr: string, triggers: string[]): string {
72
+ const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
73
+ const commandStr = command?.replace(/"/g, "") ?? "";
74
+ const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
75
+
76
+ return [
77
+ `<?xml version="1.0" encoding="UTF-16"?>`,
78
+ `<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
79
+ ` <Settings>`,
80
+ ` <MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>`,
81
+ ` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
82
+ ` <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`,
83
+ ` <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>`,
84
+ ` </Settings>`,
85
+ ` <Triggers>${triggers.join("")}</Triggers>`,
86
+ ` <Actions>`,
87
+ ` <Exec>`,
88
+ ` <Command>${commandStr}</Command>`,
89
+ ` <Arguments>${argsStr}</Arguments>`,
90
+ ` </Exec>`,
91
+ ` </Actions>`,
92
+ `</Task>`,
93
+ ].join("\n");
71
94
  }
72
95
 
73
96
  function schtasksTaskName(taskId: string): string {
@@ -135,60 +158,38 @@ export class WindowsPlatform implements PlatformService {
135
158
  const tn = schtasksTaskName(taskId);
136
159
  const tr = schtasksTr("run", taskId);
137
160
 
138
- // Always create the scheduled task with a dummy trigger first.
139
- // This ensures startTask (/run) works even when no triggers are configured.
161
+ // Build trigger XML elements
162
+ const triggerElements: string[] = [];
163
+ if (task.frontmatter.triggers_enabled) {
164
+ for (const trigger of task.frontmatter.triggers ?? []) {
165
+ try {
166
+ triggerElements.push(triggerToXml(trigger));
167
+ } catch (err) {
168
+ console.error(`Invalid trigger: ${err}`);
169
+ }
170
+ }
171
+ }
172
+ // Always include a dummy trigger so startTask (/run) works
173
+ if (triggerElements.length === 0) {
174
+ triggerElements.push(`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`);
175
+ }
176
+
177
+ // Write XML and register via schtasks — gives us full control over
178
+ // settings like MultipleInstancesPolicy that schtasks flags don't expose.
179
+ const xml = buildTaskXml(tr, triggerElements);
180
+ const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
140
181
  try {
182
+ // schtasks /xml requires UTF-16LE with BOM
183
+ const bom = Buffer.from([0xFF, 0xFE]);
184
+ fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
141
185
  execFileSync("schtasks", [
142
- "/create", "/tn", tn,
143
- "/tr", tr,
144
- "/sc", "ONCE", "/sd", "01/01/2000", "/st", "00:00",
145
- "/f",
186
+ "/create", "/tn", tn, "/xml", xmlPath, "/f",
146
187
  ], { encoding: "utf-8", windowsHide: true });
147
188
  } catch (err: unknown) {
148
189
  const e = err as { stderr?: string };
149
190
  console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
150
- }
151
-
152
- // Overlay with real schedule triggers if enabled
153
- if (!task.frontmatter.triggers_enabled) return;
154
- const triggers = task.frontmatter.triggers || [];
155
- for (const trigger of triggers) {
156
- if (trigger.type === "cron") {
157
- const schedArgs = cronToSchtasksArgs(trigger.value);
158
- try {
159
- execFileSync("schtasks", [
160
- "/create", "/tn", tn,
161
- "/tr", tr,
162
- ...schedArgs,
163
- "/f",
164
- ], { encoding: "utf-8", windowsHide: true });
165
- } catch (err: unknown) {
166
- const e = err as { stderr?: string };
167
- console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
168
- }
169
- } else if (trigger.type === "once") {
170
- // "once" triggers use ISO datetime: "2026-03-28T09:00"
171
- const [datePart, timePart] = trigger.value.split("T");
172
- if (!datePart || !timePart) {
173
- console.error(`Invalid once trigger value: ${trigger.value}`);
174
- continue;
175
- }
176
- // schtasks expects MM/DD/YYYY date format
177
- const [year, month, day] = datePart.split("-");
178
- const sd = `${month}/${day}/${year}`;
179
- const st = timePart.slice(0, 5);
180
- try {
181
- execFileSync("schtasks", [
182
- "/create", "/tn", tn,
183
- "/tr", tr,
184
- "/sc", "ONCE", "/sd", sd, "/st", st,
185
- "/f",
186
- ], { encoding: "utf-8", windowsHide: true });
187
- } catch (err: unknown) {
188
- const e = err as { stderr?: string };
189
- console.error(`Failed to create once task ${tn}: ${e.stderr || err}`);
190
- }
191
- }
191
+ } finally {
192
+ try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
192
193
  }
193
194
  }
194
195