palmier 0.3.3 → 0.3.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.
- package/.github/workflows/ci.yml +1 -1
- 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/commands/run.d.ts +21 -0
- package/dist/commands/run.js +4 -4
- package/dist/platform/linux.d.ts +12 -0
- package/dist/platform/linux.js +1 -1
- package/dist/platform/windows.d.ts +17 -0
- package/dist/platform/windows.js +81 -80
- package/dist/task.d.ts +4 -0
- package/dist/task.js +1 -1
- package/package.json +2 -1
- package/src/agents/agent.ts +4 -1
- package/src/agents/copilot.ts +55 -0
- package/src/commands/run.ts +4 -4
- package/src/platform/linux.ts +1 -1
- package/src/platform/windows.ts +81 -80
- package/src/task.ts +1 -1
- package/test/agent-output-parsing.test.ts +74 -0
- package/test/linux-cron.test.ts +41 -0
- package/test/pairing.test.ts +35 -0
- package/test/task-parsing.test.ts +83 -0
- package/test/tsconfig.json +9 -0
- package/test/windows-xml.test.ts +88 -0
package/.github/workflows/ci.yml
CHANGED
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("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/commands/run.d.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
|
+
import type { TaskRunningState, RequiredPermission } from "../types.js";
|
|
1
2
|
/**
|
|
2
3
|
* Execute a task by ID.
|
|
3
4
|
*/
|
|
4
5
|
export declare function runCommand(taskId: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Extract report file names from agent output.
|
|
8
|
+
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseReportFiles(output: string): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Extract required permissions from agent output.
|
|
13
|
+
* Looks for lines matching: [PALMIER_PERMISSION] <tool> | <description>
|
|
14
|
+
*/
|
|
15
|
+
export declare function parsePermissions(output: string): RequiredPermission[];
|
|
16
|
+
/**
|
|
17
|
+
* Extract user input requests from agent output.
|
|
18
|
+
* Looks for lines matching: [PALMIER_INPUT] <description>
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseInputRequests(output: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Parse the agent's output for success/failure markers.
|
|
23
|
+
* Falls back to "finished" if no marker is found.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseTaskOutcome(output: string): TaskRunningState;
|
|
5
26
|
//# sourceMappingURL=run.d.ts.map
|
package/dist/commands/run.js
CHANGED
|
@@ -405,7 +405,7 @@ async function requestConfirmation(nc, config, task, taskDir) {
|
|
|
405
405
|
* Extract report file names from agent output.
|
|
406
406
|
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
407
407
|
*/
|
|
408
|
-
function parseReportFiles(output) {
|
|
408
|
+
export function parseReportFiles(output) {
|
|
409
409
|
const regex = new RegExp(`^\\${TASK_REPORT_PREFIX}\\s+(.+)$`, "gm");
|
|
410
410
|
const files = [];
|
|
411
411
|
let match;
|
|
@@ -420,7 +420,7 @@ function parseReportFiles(output) {
|
|
|
420
420
|
* Extract required permissions from agent output.
|
|
421
421
|
* Looks for lines matching: [PALMIER_PERMISSION] <tool> | <description>
|
|
422
422
|
*/
|
|
423
|
-
function parsePermissions(output) {
|
|
423
|
+
export function parsePermissions(output) {
|
|
424
424
|
const regex = new RegExp(`^\\${TASK_PERMISSION_PREFIX}\\s+(.+)$`, "gm");
|
|
425
425
|
const perms = [];
|
|
426
426
|
let match;
|
|
@@ -440,7 +440,7 @@ function parsePermissions(output) {
|
|
|
440
440
|
* Extract user input requests from agent output.
|
|
441
441
|
* Looks for lines matching: [PALMIER_INPUT] <description>
|
|
442
442
|
*/
|
|
443
|
-
function parseInputRequests(output) {
|
|
443
|
+
export function parseInputRequests(output) {
|
|
444
444
|
const regex = new RegExp(`^\\${TASK_INPUT_PREFIX}\\s+(.+)$`, "gm");
|
|
445
445
|
const inputs = [];
|
|
446
446
|
let match;
|
|
@@ -455,7 +455,7 @@ function parseInputRequests(output) {
|
|
|
455
455
|
* Parse the agent's output for success/failure markers.
|
|
456
456
|
* Falls back to "finished" if no marker is found.
|
|
457
457
|
*/
|
|
458
|
-
function parseTaskOutcome(output) {
|
|
458
|
+
export function parseTaskOutcome(output) {
|
|
459
459
|
const lastChunk = output.slice(-500);
|
|
460
460
|
if (lastChunk.includes(TASK_FAILURE_MARKER))
|
|
461
461
|
return "failed";
|
package/dist/platform/linux.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { PlatformService } from "./platform.js";
|
|
2
2
|
import type { HostConfig, ParsedTask } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Convert a cron expression to a systemd OnCalendar string.
|
|
5
|
+
*
|
|
6
|
+
* Only the 4 cron patterns the PWA UI can produce are supported:
|
|
7
|
+
* hourly: "0 * * * *"
|
|
8
|
+
* daily: "MM HH * * *"
|
|
9
|
+
* weekly: "MM HH * * D"
|
|
10
|
+
* monthly: "MM HH D * *"
|
|
11
|
+
* Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
|
|
12
|
+
* handled because the UI never generates them.
|
|
13
|
+
*/
|
|
14
|
+
export declare function cronToOnCalendar(cron: string): string;
|
|
3
15
|
export declare class LinuxPlatform implements PlatformService {
|
|
4
16
|
installDaemon(config: HostConfig): void;
|
|
5
17
|
restartDaemon(): Promise<void>;
|
package/dist/platform/linux.js
CHANGED
|
@@ -22,7 +22,7 @@ function getServiceName(taskId) {
|
|
|
22
22
|
* Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
|
|
23
23
|
* handled because the UI never generates them.
|
|
24
24
|
*/
|
|
25
|
-
function cronToOnCalendar(cron) {
|
|
25
|
+
export function cronToOnCalendar(cron) {
|
|
26
26
|
const parts = cron.trim().split(/\s+/);
|
|
27
27
|
if (parts.length !== 5) {
|
|
28
28
|
throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import type { PlatformService } from "./platform.js";
|
|
2
2
|
import type { HostConfig, ParsedTask } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
|
|
5
|
+
*
|
|
6
|
+
* Only these cron patterns (produced by the PWA UI) are handled:
|
|
7
|
+
* hourly: "0 * * * *"
|
|
8
|
+
* daily: "MM HH * * *"
|
|
9
|
+
* weekly: "MM HH * * D"
|
|
10
|
+
* monthly: "MM HH D * *"
|
|
11
|
+
*/
|
|
12
|
+
export declare function triggerToXml(trigger: {
|
|
13
|
+
type: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}): string;
|
|
16
|
+
/**
|
|
17
|
+
* Build a complete Task Scheduler XML definition.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildTaskXml(tr: string, triggers: string[]): string;
|
|
3
20
|
export declare class WindowsPlatform implements PlatformService {
|
|
4
21
|
installDaemon(config: HostConfig): void;
|
|
5
22
|
restartDaemon(): Promise<void>;
|
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
|
+
export 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
|
+
export 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/dist/task.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ import type { ParsedTask, TaskStatus, HistoryEntry } from "./types.js";
|
|
|
3
3
|
* Parse a TASK.md file from the given task directory.
|
|
4
4
|
*/
|
|
5
5
|
export declare function parseTaskFile(taskDir: string): ParsedTask;
|
|
6
|
+
/**
|
|
7
|
+
* Parse TASK.md content string into frontmatter + body.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseTaskContent(content: string): ParsedTask;
|
|
6
10
|
/**
|
|
7
11
|
* Write a TASK.md file to the given task directory.
|
|
8
12
|
* Creates the directory if it doesn't exist.
|
package/dist/task.js
CHANGED
|
@@ -15,7 +15,7 @@ export function parseTaskFile(taskDir) {
|
|
|
15
15
|
/**
|
|
16
16
|
* Parse TASK.md content string into frontmatter + body.
|
|
17
17
|
*/
|
|
18
|
-
function parseTaskContent(content) {
|
|
18
|
+
export function parseTaskContent(content) {
|
|
19
19
|
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
20
20
|
const match = content.match(fmRegex);
|
|
21
21
|
if (!match) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "palmier",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Hongxu Cai",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"dev": "tsx src/index.ts",
|
|
23
23
|
"build": "tsc && node -e \"require('fs').cpSync('src/commands/plan-generation.md','dist/commands/plan-generation.md')\"",
|
|
24
|
+
"test": "tsx --test test/**/*.test.ts",
|
|
24
25
|
"prepare": "npm run build",
|
|
25
26
|
"start": "node dist/index.js"
|
|
26
27
|
},
|
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("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/commands/run.ts
CHANGED
|
@@ -536,7 +536,7 @@ async function requestConfirmation(
|
|
|
536
536
|
* Extract report file names from agent output.
|
|
537
537
|
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
538
538
|
*/
|
|
539
|
-
function parseReportFiles(output: string): string[] {
|
|
539
|
+
export function parseReportFiles(output: string): string[] {
|
|
540
540
|
const regex = new RegExp(`^\\${TASK_REPORT_PREFIX}\\s+(.+)$`, "gm");
|
|
541
541
|
const files: string[] = [];
|
|
542
542
|
let match;
|
|
@@ -551,7 +551,7 @@ function parseReportFiles(output: string): string[] {
|
|
|
551
551
|
* Extract required permissions from agent output.
|
|
552
552
|
* Looks for lines matching: [PALMIER_PERMISSION] <tool> | <description>
|
|
553
553
|
*/
|
|
554
|
-
function parsePermissions(output: string): RequiredPermission[] {
|
|
554
|
+
export function parsePermissions(output: string): RequiredPermission[] {
|
|
555
555
|
const regex = new RegExp(`^\\${TASK_PERMISSION_PREFIX}\\s+(.+)$`, "gm");
|
|
556
556
|
const perms: RequiredPermission[] = [];
|
|
557
557
|
let match;
|
|
@@ -571,7 +571,7 @@ function parsePermissions(output: string): RequiredPermission[] {
|
|
|
571
571
|
* Extract user input requests from agent output.
|
|
572
572
|
* Looks for lines matching: [PALMIER_INPUT] <description>
|
|
573
573
|
*/
|
|
574
|
-
function parseInputRequests(output: string): string[] {
|
|
574
|
+
export function parseInputRequests(output: string): string[] {
|
|
575
575
|
const regex = new RegExp(`^\\${TASK_INPUT_PREFIX}\\s+(.+)$`, "gm");
|
|
576
576
|
const inputs: string[] = [];
|
|
577
577
|
let match;
|
|
@@ -586,7 +586,7 @@ function parseInputRequests(output: string): string[] {
|
|
|
586
586
|
* Parse the agent's output for success/failure markers.
|
|
587
587
|
* Falls back to "finished" if no marker is found.
|
|
588
588
|
*/
|
|
589
|
-
function parseTaskOutcome(output: string): TaskRunningState {
|
|
589
|
+
export function parseTaskOutcome(output: string): TaskRunningState {
|
|
590
590
|
const lastChunk = output.slice(-500);
|
|
591
591
|
if (lastChunk.includes(TASK_FAILURE_MARKER)) return "failed";
|
|
592
592
|
if (lastChunk.includes(TASK_SUCCESS_MARKER)) return "finished";
|
package/src/platform/linux.ts
CHANGED
|
@@ -29,7 +29,7 @@ function getServiceName(taskId: string): string {
|
|
|
29
29
|
* Arbitrary cron expressions (ranges, lists, steps beyond hourly) are NOT
|
|
30
30
|
* handled because the UI never generates them.
|
|
31
31
|
*/
|
|
32
|
-
function cronToOnCalendar(cron: string): string {
|
|
32
|
+
export function cronToOnCalendar(cron: string): string {
|
|
33
33
|
const parts = cron.trim().split(/\s+/);
|
|
34
34
|
if (parts.length !== 5) {
|
|
35
35
|
throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
|
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
|
+
export 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
|
+
export 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
|
|
package/src/task.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function parseTaskFile(taskDir: string): ParsedTask {
|
|
|
20
20
|
/**
|
|
21
21
|
* Parse TASK.md content string into frontmatter + body.
|
|
22
22
|
*/
|
|
23
|
-
function parseTaskContent(content: string): ParsedTask {
|
|
23
|
+
export function parseTaskContent(content: string): ParsedTask {
|
|
24
24
|
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
25
25
|
const match = content.match(fmRegex);
|
|
26
26
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseTaskOutcome, parseReportFiles, parsePermissions, parseInputRequests } from "../src/commands/run.js";
|
|
4
|
+
|
|
5
|
+
describe("parseTaskOutcome", () => {
|
|
6
|
+
it("returns 'finished' for success marker", () => {
|
|
7
|
+
assert.equal(parseTaskOutcome("some output\n[PALMIER_TASK_SUCCESS]"), "finished");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns 'failed' for failure marker", () => {
|
|
11
|
+
assert.equal(parseTaskOutcome("some output\n[PALMIER_TASK_FAILURE]"), "failed");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns 'finished' when no marker is present", () => {
|
|
15
|
+
assert.equal(parseTaskOutcome("just some regular output"), "finished");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns 'failed' when both markers present (failure takes priority)", () => {
|
|
19
|
+
assert.equal(parseTaskOutcome("[PALMIER_TASK_SUCCESS]\n[PALMIER_TASK_FAILURE]"), "failed");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("only looks at last 500 chars", () => {
|
|
23
|
+
const padding = "x".repeat(600);
|
|
24
|
+
assert.equal(parseTaskOutcome("[PALMIER_TASK_FAILURE]" + padding), "finished");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("parseReportFiles", () => {
|
|
29
|
+
it("extracts report file names", () => {
|
|
30
|
+
const output = "doing work\n[PALMIER_REPORT] report.md\nmore work\n[PALMIER_REPORT] summary.md";
|
|
31
|
+
assert.deepEqual(parseReportFiles(output), ["report.md", "summary.md"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns empty array when no reports", () => {
|
|
35
|
+
assert.deepEqual(parseReportFiles("no reports here"), []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("trims whitespace from file names", () => {
|
|
39
|
+
assert.deepEqual(parseReportFiles("[PALMIER_REPORT] report.md "), ["report.md"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("parsePermissions", () => {
|
|
44
|
+
it("extracts permissions with name and description", () => {
|
|
45
|
+
const output = "[PALMIER_PERMISSION] Read | Read file contents\n[PALMIER_PERMISSION] Bash(npm test) | Run tests";
|
|
46
|
+
const perms = parsePermissions(output);
|
|
47
|
+
assert.equal(perms.length, 2);
|
|
48
|
+
assert.deepEqual(perms[0], { name: "Read", description: "Read file contents" });
|
|
49
|
+
assert.deepEqual(perms[1], { name: "Bash(npm test)", description: "Run tests" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("handles permission without description", () => {
|
|
53
|
+
const perms = parsePermissions("[PALMIER_PERMISSION] Write");
|
|
54
|
+
assert.deepEqual(perms, [{ name: "Write", description: "" }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns empty array when no permissions", () => {
|
|
58
|
+
assert.deepEqual(parsePermissions("no permissions"), []);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("parseInputRequests", () => {
|
|
63
|
+
it("extracts input descriptions", () => {
|
|
64
|
+
const output = "[PALMIER_INPUT] What is the API key?\n[PALMIER_INPUT] Database connection string?";
|
|
65
|
+
assert.deepEqual(parseInputRequests(output), [
|
|
66
|
+
"What is the API key?",
|
|
67
|
+
"Database connection string?",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns empty array when no inputs", () => {
|
|
72
|
+
assert.deepEqual(parseInputRequests("no inputs"), []);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { cronToOnCalendar } from "../src/platform/linux.js";
|
|
4
|
+
|
|
5
|
+
describe("cronToOnCalendar", () => {
|
|
6
|
+
it("converts hourly cron", () => {
|
|
7
|
+
assert.equal(cronToOnCalendar("0 * * * *"), "*-*-* *:00:00");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("converts daily cron", () => {
|
|
11
|
+
assert.equal(cronToOnCalendar("30 9 * * *"), "*-*-* 09:30:00");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("converts weekly Monday cron", () => {
|
|
15
|
+
assert.equal(cronToOnCalendar("0 10 * * 1"), "Mon *-*-* 10:00:00");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("converts weekly Sunday (day 0)", () => {
|
|
19
|
+
assert.equal(cronToOnCalendar("0 8 * * 0"), "Sun *-*-* 08:00:00");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("converts weekly Sunday (day 7)", () => {
|
|
23
|
+
assert.equal(cronToOnCalendar("0 8 * * 7"), "Sun *-*-* 08:00:00");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("converts monthly cron", () => {
|
|
27
|
+
assert.equal(cronToOnCalendar("0 14 15 * *"), "*-*-15 14:00:00");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("pads single-digit hours and minutes", () => {
|
|
31
|
+
assert.equal(cronToOnCalendar("5 3 * * *"), "*-*-* 03:05:00");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("throws on invalid cron expression", () => {
|
|
35
|
+
assert.throws(() => cronToOnCalendar("bad"), /Invalid cron/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws on too few fields", () => {
|
|
39
|
+
assert.throws(() => cronToOnCalendar("0 * *"), /Invalid cron/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { generatePairingCode, PAIRING_EXPIRY_MS } from "../src/commands/pair.js";
|
|
4
|
+
|
|
5
|
+
describe("generatePairingCode", () => {
|
|
6
|
+
it("generates a 6-character code", () => {
|
|
7
|
+
const code = generatePairingCode();
|
|
8
|
+
assert.equal(code.length, 6);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("only contains allowed characters (no O/0/I/1/L)", () => {
|
|
12
|
+
const allowed = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
13
|
+
for (let i = 0; i < 50; i++) {
|
|
14
|
+
const code = generatePairingCode();
|
|
15
|
+
for (const ch of code) {
|
|
16
|
+
assert.ok(allowed.includes(ch), `Character '${ch}' is not in allowed set`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("generates unique codes", () => {
|
|
22
|
+
const codes = new Set<string>();
|
|
23
|
+
for (let i = 0; i < 100; i++) {
|
|
24
|
+
codes.add(generatePairingCode());
|
|
25
|
+
}
|
|
26
|
+
// With 30^6 ≈ 729M possibilities, 100 codes should all be unique
|
|
27
|
+
assert.equal(codes.size, 100);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("PAIRING_EXPIRY_MS", () => {
|
|
32
|
+
it("is 5 minutes", () => {
|
|
33
|
+
assert.equal(PAIRING_EXPIRY_MS, 5 * 60 * 1000);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseTaskContent } from "../src/task.js";
|
|
4
|
+
|
|
5
|
+
describe("parseTaskContent", () => {
|
|
6
|
+
it("parses valid frontmatter and body", () => {
|
|
7
|
+
const content = `---
|
|
8
|
+
id: abc123
|
|
9
|
+
name: Test Task
|
|
10
|
+
user_prompt: Do something
|
|
11
|
+
agent: claude
|
|
12
|
+
triggers: []
|
|
13
|
+
triggers_enabled: true
|
|
14
|
+
requires_confirmation: false
|
|
15
|
+
---
|
|
16
|
+
This is the task body.`;
|
|
17
|
+
|
|
18
|
+
const result = parseTaskContent(content);
|
|
19
|
+
assert.equal(result.frontmatter.id, "abc123");
|
|
20
|
+
assert.equal(result.frontmatter.name, "Test Task");
|
|
21
|
+
assert.equal(result.frontmatter.agent, "claude");
|
|
22
|
+
assert.equal(result.body, "This is the task body.");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("defaults agent to claude when not specified", () => {
|
|
26
|
+
const content = `---
|
|
27
|
+
id: abc123
|
|
28
|
+
user_prompt: Do something
|
|
29
|
+
triggers: []
|
|
30
|
+
triggers_enabled: true
|
|
31
|
+
requires_confirmation: false
|
|
32
|
+
---`;
|
|
33
|
+
|
|
34
|
+
const result = parseTaskContent(content);
|
|
35
|
+
assert.equal(result.frontmatter.agent, "claude");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("defaults triggers_enabled to true", () => {
|
|
39
|
+
const content = `---
|
|
40
|
+
id: abc123
|
|
41
|
+
user_prompt: Do something
|
|
42
|
+
triggers: []
|
|
43
|
+
requires_confirmation: false
|
|
44
|
+
---`;
|
|
45
|
+
|
|
46
|
+
const result = parseTaskContent(content);
|
|
47
|
+
assert.equal(result.frontmatter.triggers_enabled, true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("derives name from user_prompt when not specified", () => {
|
|
51
|
+
const content = `---
|
|
52
|
+
id: abc123
|
|
53
|
+
user_prompt: A very long prompt that should be truncated to sixty characters maximum length here
|
|
54
|
+
triggers: []
|
|
55
|
+
triggers_enabled: true
|
|
56
|
+
requires_confirmation: false
|
|
57
|
+
---`;
|
|
58
|
+
|
|
59
|
+
const result = parseTaskContent(content);
|
|
60
|
+
assert.equal(result.frontmatter.name.length, 60);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("throws on missing frontmatter delimiters", () => {
|
|
64
|
+
assert.throws(() => parseTaskContent("no frontmatter here"), /missing valid YAML/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("throws on missing id", () => {
|
|
68
|
+
assert.throws(() => parseTaskContent("---\nname: test\n---\n"), /must include at least: id/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles empty body", () => {
|
|
72
|
+
const content = `---
|
|
73
|
+
id: abc123
|
|
74
|
+
user_prompt: test
|
|
75
|
+
triggers: []
|
|
76
|
+
triggers_enabled: true
|
|
77
|
+
requires_confirmation: false
|
|
78
|
+
---`;
|
|
79
|
+
|
|
80
|
+
const result = parseTaskContent(content);
|
|
81
|
+
assert.equal(result.body, "");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { triggerToXml, buildTaskXml } from "../src/platform/windows.js";
|
|
4
|
+
|
|
5
|
+
describe("triggerToXml", () => {
|
|
6
|
+
it("converts a once trigger to TimeTrigger", () => {
|
|
7
|
+
const xml = triggerToXml({ type: "once", value: "2026-03-28T09:00" });
|
|
8
|
+
assert.equal(xml, "<TimeTrigger><StartBoundary>2026-03-28T09:00:00</StartBoundary></TimeTrigger>");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("converts hourly cron to TimeTrigger with PT1H repetition", () => {
|
|
12
|
+
const xml = triggerToXml({ type: "cron", value: "0 * * * *" });
|
|
13
|
+
assert.ok(xml.includes("<Interval>PT1H</Interval>"), "should have hourly interval");
|
|
14
|
+
assert.ok(xml.includes("<TimeTrigger>"), "should be a TimeTrigger");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("converts daily cron to CalendarTrigger with DaysInterval", () => {
|
|
18
|
+
const xml = triggerToXml({ type: "cron", value: "30 9 * * *" });
|
|
19
|
+
assert.ok(xml.includes("<ScheduleByDay>"), "should use ScheduleByDay");
|
|
20
|
+
assert.ok(xml.includes("<DaysInterval>1</DaysInterval>"), "should have interval 1");
|
|
21
|
+
assert.ok(xml.includes("T09:30:00"), "should encode time as 09:30");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("converts weekly cron to CalendarTrigger with DaysOfWeek", () => {
|
|
25
|
+
const xml = triggerToXml({ type: "cron", value: "0 10 * * 1" });
|
|
26
|
+
assert.ok(xml.includes("<ScheduleByWeek>"), "should use ScheduleByWeek");
|
|
27
|
+
assert.ok(xml.includes("<Monday />"), "day 1 should be Monday");
|
|
28
|
+
assert.ok(xml.includes("T10:00:00"), "should encode time as 10:00");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("converts weekly cron for Sunday (day 0)", () => {
|
|
32
|
+
const xml = triggerToXml({ type: "cron", value: "0 8 * * 0" });
|
|
33
|
+
assert.ok(xml.includes("<Sunday />"), "day 0 should be Sunday");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("converts weekly cron for Sunday (day 7)", () => {
|
|
37
|
+
const xml = triggerToXml({ type: "cron", value: "0 8 * * 7" });
|
|
38
|
+
assert.ok(xml.includes("<Sunday />"), "day 7 should also be Sunday");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("converts monthly cron to CalendarTrigger with DaysOfMonth", () => {
|
|
42
|
+
const xml = triggerToXml({ type: "cron", value: "0 14 15 * *" });
|
|
43
|
+
assert.ok(xml.includes("<ScheduleByMonth>"), "should use ScheduleByMonth");
|
|
44
|
+
assert.ok(xml.includes("<Day>15</Day>"), "should have day 15");
|
|
45
|
+
assert.ok(xml.includes("T14:00:00"), "should encode time as 14:00");
|
|
46
|
+
// All months should be listed
|
|
47
|
+
assert.ok(xml.includes("<January />"), "should include January");
|
|
48
|
+
assert.ok(xml.includes("<December />"), "should include December");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws on invalid cron expression", () => {
|
|
52
|
+
assert.throws(() => triggerToXml({ type: "cron", value: "bad" }), /Invalid cron/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("buildTaskXml", () => {
|
|
57
|
+
it("produces valid XML structure with StopExisting policy", () => {
|
|
58
|
+
const tr = '"C:\\Program Files\\nodejs\\node.exe" "C:\\palmier\\dist\\index.js" run abc123';
|
|
59
|
+
const triggers = ['<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>'];
|
|
60
|
+
const xml = buildTaskXml(tr, triggers);
|
|
61
|
+
|
|
62
|
+
assert.ok(xml.includes('<?xml version="1.0" encoding="UTF-16"?>'), "should have XML declaration");
|
|
63
|
+
assert.ok(xml.includes("<MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>"), "should set StopExisting");
|
|
64
|
+
assert.ok(xml.includes("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"), "should allow on battery");
|
|
65
|
+
assert.ok(xml.includes("<Command>C:\\Program Files\\nodejs\\node.exe</Command>"), "should extract command");
|
|
66
|
+
assert.ok(xml.includes("<Arguments>C:\\palmier\\dist\\index.js run abc123</Arguments>"), "should extract arguments");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles multiple triggers", () => {
|
|
70
|
+
const tr = '"node" "palmier" run test';
|
|
71
|
+
const triggers = [
|
|
72
|
+
'<TimeTrigger><StartBoundary>2000-01-01T09:00:00</StartBoundary></TimeTrigger>',
|
|
73
|
+
'<CalendarTrigger><StartBoundary>2000-01-01T14:00:00</StartBoundary></CalendarTrigger>',
|
|
74
|
+
];
|
|
75
|
+
const xml = buildTaskXml(tr, triggers);
|
|
76
|
+
|
|
77
|
+
assert.ok(xml.includes("<Triggers><TimeTrigger>"), "should contain first trigger");
|
|
78
|
+
assert.ok(xml.includes("</TimeTrigger><CalendarTrigger>"), "triggers should be concatenated");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("parses command with spaces in path", () => {
|
|
82
|
+
const tr = '"C:\\Program Files\\nodejs\\node.exe" "C:\\My Folder\\script.js" serve';
|
|
83
|
+
const xml = buildTaskXml(tr, []);
|
|
84
|
+
|
|
85
|
+
assert.ok(xml.includes("<Command>C:\\Program Files\\nodejs\\node.exe</Command>"));
|
|
86
|
+
assert.ok(xml.includes("<Arguments>C:\\My Folder\\script.js serve</Arguments>"));
|
|
87
|
+
});
|
|
88
|
+
});
|