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 +3 -2
- package/dist/agents/agent.js +4 -1
- package/dist/agents/copilot.d.ts +8 -0
- package/dist/agents/copilot.js +53 -0
- package/dist/platform/windows.js +81 -80
- package/package.json +1 -1
- package/src/agents/agent.ts +4 -1
- package/src/agents/copilot.ts +55 -0
- package/src/platform/windows.ts +81 -80
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)
|
package/dist/agents/agent.js
CHANGED
|
@@ -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
|
package/dist/platform/windows.js
CHANGED
|
@@ -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
|
|
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 * * * *"
|
|
23
|
-
* daily: "MM HH * * *"
|
|
24
|
-
* weekly: "MM HH * * D"
|
|
25
|
-
* monthly: "MM HH D * *"
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
44
|
+
// Weekly
|
|
47
45
|
if (dayOfMonth === "*" && dayOfWeek !== "*") {
|
|
48
|
-
const day =
|
|
49
|
-
|
|
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
|
|
49
|
+
// Monthly
|
|
54
50
|
if (dayOfMonth !== "*" && dayOfWeek === "*") {
|
|
55
|
-
return
|
|
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
|
|
58
|
-
return
|
|
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
|
-
//
|
|
117
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
package/src/agents/agent.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/platform/windows.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
53
|
+
// Weekly
|
|
58
54
|
if (dayOfMonth === "*" && dayOfWeek !== "*") {
|
|
59
|
-
const day =
|
|
60
|
-
|
|
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
|
|
59
|
+
// Monthly
|
|
65
60
|
if (dayOfMonth !== "*" && dayOfWeek === "*") {
|
|
66
|
-
return
|
|
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
|
|
70
|
-
return
|
|
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
|
-
//
|
|
139
|
-
|
|
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
|
|