palmier 0.3.2 → 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
@@ -6,7 +6,7 @@
6
6
 
7
7
  **Website:** [palmier.me](https://www.palmier.me) | **App:** [app.palmier.me](https://app.palmier.me)
8
8
 
9
- A Node.js CLI that runs on your machine as a persistent daemon. It manages tasks, communicates with the Palmier app via NATS and/or direct HTTP, and executes tasks on schedule or demand using CLI tools.
9
+ A Node.js CLI that runs on your machine as a persistent daemon. It lets you create, schedule, and run AI agent tasks from your phone or browser, communicating via a cloud relay (NATS) and/or direct HTTP.
10
10
 
11
11
  > **Important:** By using Palmier, you agree to the [Terms of Service](https://www.palmier.me/terms) and [Privacy Policy](https://www.palmier.me/privacy). See the [Disclaimer](#disclaimer) section below.
12
12
 
@@ -16,17 +16,17 @@ The host supports two independent connection modes, enabled during `palmier init
16
16
 
17
17
  | Mode | Transport | PWA URL | Features |
18
18
  |------|-----------|---------|----------|
19
- | **Server** | NATS (cloud relay) | `https://app.palmier.me` | Push notifications, remote access |
19
+ | **Server** | Cloud relay (NATS) | `https://app.palmier.me` | Push notifications, remote access |
20
20
  | **LAN** | HTTP (direct, on-demand) | `http://<host-ip>:7400` | Low-latency, no external server needed |
21
21
 
22
- **Server mode** relays communication through the Palmier server via NATS. All features including push notifications are available. The PWA is served over HTTPS.
22
+ **Server mode** relays communication through the Palmier cloud server (via [NATS](https://nats.io), a lightweight messaging system). All features including push notifications are available. The PWA is served over HTTPS.
23
23
 
24
24
  **LAN mode** is started on-demand via `palmier lan`. It runs a local HTTP server that reverse-proxies PWA assets from `app.palmier.me` and serves API endpoints locally. The browser accesses everything at `http://<host-ip>:<port>` (same-origin). Push notifications are not available in LAN mode.
25
25
 
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
@@ -35,6 +35,8 @@ The host supports two independent connection modes, enabled during `palmier init
35
35
  npm install -g palmier
36
36
  ```
37
37
 
38
+ All `palmier` commands should be run from a dedicated Palmier root directory (e.g., `~/palmier`). This is where tasks, configuration, and execution data are stored.
39
+
38
40
  ## CLI Commands
39
41
 
40
42
  | Command | Description |
@@ -57,7 +59,7 @@ npm install -g palmier
57
59
  ### Quick Start
58
60
 
59
61
  1. Install the host: `npm install -g palmier`
60
- 2. Run `palmier init` in your project directory.
62
+ 2. Run `palmier init` in your Palmier root directory (e.g., `~/palmier`).
61
63
  3. The wizard detects installed agents, registers with the Palmier server, installs a background daemon, and generates a pairing code.
62
64
  4. Enter the pairing code in the Palmier PWA to connect your device.
63
65
 
@@ -81,7 +83,7 @@ palmier sessions revoke-all
81
83
  ```
82
84
 
83
85
  The `init` command:
84
- - 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
85
87
  - Saves host configuration to `~/.config/palmier/host.json`
86
88
  - Installs a background daemon (systemd user service on Linux, Registry Run key on Windows)
87
89
  - Auto-enters pair mode to connect your first device
@@ -153,7 +155,6 @@ src/
153
155
  spawn-command.ts # Shared helper for spawning CLI tools
154
156
  task.ts # Task file management
155
157
  types.ts # Shared type definitions
156
- pairing.ts # OTP code generation and expiry constant
157
158
  lan-lock.ts # LAN lockfile path and port reader
158
159
  events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
159
160
  agents/
@@ -161,6 +162,7 @@ src/
161
162
  claude.ts # Claude Code agent implementation
162
163
  gemini.ts # Gemini CLI agent implementation
163
164
  codex.ts # Codex CLI agent implementation
165
+ copilot.ts # GitHub Copilot agent implementation
164
166
  openclaw.ts # OpenClaw agent implementation
165
167
  commands/
166
168
  init.ts # Interactive setup wizard (auto-pair)
@@ -210,11 +212,11 @@ Requires a provisioned host (`palmier init`) with server mode enabled.
210
212
  |---|---|---|
211
213
  | `send-push-notification` | `title`, `body` (required) | Send a push notification to all paired devices |
212
214
 
213
- ## Removing a Host
215
+ ## Uninstalling
214
216
 
215
- To fully remove a host from a machine:
217
+ To fully remove Palmier from a machine:
216
218
 
217
- 1. **Unpair the host from the PWA** (via the host menu).
219
+ 1. **Unpair your device** in the PWA (via the host menu).
218
220
 
219
221
  2. **Stop and remove the daemon:**
220
222
 
@@ -248,16 +250,11 @@ To fully remove a host from a machine:
248
250
  schtasks /delete /tn "PalmierTask-*" /f 2>$null
249
251
  ```
250
252
 
251
- 4. **Remove the host configuration:**
253
+ 4. **Remove configuration and task data:**
252
254
 
253
255
  ```bash
254
256
  rm -rf ~/.config/palmier
255
- ```
256
-
257
- 5. **Remove the tasks directory** from your project root:
258
-
259
- ```bash
260
- rm -rf tasks/
257
+ rm -rf tasks/ # from your Palmier root directory
261
258
  ```
262
259
 
263
260
  ## Disclaimer
@@ -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
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import { loadConfig, CONFIG_DIR } from "../config.js";
3
3
  import { createRpcHandler } from "../rpc-handler.js";
4
4
  import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
5
- import { generatePairingCode } from "../pairing.js";
5
+ import { generatePairingCode } from "./pair.js";
6
6
  import { LAN_LOCKFILE } from "../lan-lock.js";
7
7
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
8
8
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
@@ -1,3 +1,5 @@
1
+ export declare const PAIRING_EXPIRY_MS: number;
2
+ export declare function generatePairingCode(): string;
1
3
  /**
2
4
  * Generate an OTP code and wait for a PWA client to pair.
3
5
  * Listens on NATS always, and also on the LAN server if `palmier lan` is running.
@@ -3,8 +3,15 @@ import { StringCodec } from "nats";
3
3
  import { loadConfig } from "../config.js";
4
4
  import { connectNats } from "../nats-client.js";
5
5
  import { addSession } from "../session-store.js";
6
- import { generatePairingCode, PAIRING_EXPIRY_MS } from "../pairing.js";
7
6
  import { getLanPort } from "../lan-lock.js";
7
+ const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
8
+ const CODE_LENGTH = 6;
9
+ export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
10
+ export function generatePairingCode() {
11
+ const bytes = new Uint8Array(CODE_LENGTH);
12
+ crypto.getRandomValues(bytes);
13
+ return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
14
+ }
8
15
  function buildPairResponse(config, label) {
9
16
  const session = addSession(label);
10
17
  return {
@@ -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}`;
@@ -63,8 +85,9 @@ function schtasksTaskName(taskId) {
63
85
  export class WindowsPlatform {
64
86
  installDaemon(config) {
65
87
  const script = process.argv[1] || "palmier";
66
- // Write a VBS launcher that starts the daemon with no visible console window
67
- const vbs = `CreateObject("WScript.Shell").Run """${process.execPath.replace(/\\/g, "\\\\")}"" ""${script.replace(/\\/g, "\\\\")}"" serve", 0, False`;
88
+ // Write a VBS launcher that starts the daemon with no visible console window.
89
+ // VBS doesn't use backslash escaping — only quotes need doubling ("").
90
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
68
91
  fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
69
92
  const regValue = `"${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe" "${DAEMON_VBS_FILE}"`;
70
93
  try {
@@ -112,64 +135,43 @@ export class WindowsPlatform {
112
135
  const taskId = task.frontmatter.id;
113
136
  const tn = schtasksTaskName(taskId);
114
137
  const tr = schtasksTr("run", taskId);
115
- // Always create the scheduled task with a dummy trigger first.
116
- // 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`);
117
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")]));
118
162
  execFileSync("schtasks", [
119
- "/create", "/tn", tn,
120
- "/tr", tr,
121
- "/sc", "ONCE", "/sd", "01/01/2000", "/st", "00:00",
122
- "/f",
163
+ "/create", "/tn", tn, "/xml", xmlPath, "/f",
123
164
  ], { encoding: "utf-8", windowsHide: true });
124
165
  }
125
166
  catch (err) {
126
167
  const e = err;
127
168
  console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
128
169
  }
129
- // Overlay with real schedule triggers if enabled
130
- if (!task.frontmatter.triggers_enabled)
131
- return;
132
- const triggers = task.frontmatter.triggers || [];
133
- for (const trigger of triggers) {
134
- if (trigger.type === "cron") {
135
- const schedArgs = cronToSchtasksArgs(trigger.value);
136
- try {
137
- execFileSync("schtasks", [
138
- "/create", "/tn", tn,
139
- "/tr", tr,
140
- ...schedArgs,
141
- "/f",
142
- ], { encoding: "utf-8", windowsHide: true });
143
- }
144
- catch (err) {
145
- const e = err;
146
- console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
147
- }
148
- }
149
- else if (trigger.type === "once") {
150
- // "once" triggers use ISO datetime: "2026-03-28T09:00"
151
- const [datePart, timePart] = trigger.value.split("T");
152
- if (!datePart || !timePart) {
153
- console.error(`Invalid once trigger value: ${trigger.value}`);
154
- continue;
155
- }
156
- // schtasks expects MM/DD/YYYY date format
157
- const [year, month, day] = datePart.split("-");
158
- const sd = `${month}/${day}/${year}`;
159
- const st = timePart.slice(0, 5);
160
- try {
161
- execFileSync("schtasks", [
162
- "/create", "/tn", tn,
163
- "/tr", tr,
164
- "/sc", "ONCE", "/sd", sd, "/st", st,
165
- "/f",
166
- ], { encoding: "utf-8", windowsHide: true });
167
- }
168
- catch (err) {
169
- const e = err;
170
- console.error(`Failed to create once task ${tn}: ${e.stderr || err}`);
171
- }
170
+ finally {
171
+ try {
172
+ fs.unlinkSync(xmlPath);
172
173
  }
174
+ catch { /* ignore */ }
173
175
  }
174
176
  }
175
177
  removeTaskTimer(taskId) {
@@ -1,3 +1,5 @@
1
+ /** True when running from a source checkout (has .git) rather than a global npm install. */
2
+ export declare const isDevBuild: boolean;
1
3
  export declare const currentVersion: string;
2
4
  /**
3
5
  * Check the npm registry for the latest version of palmier.
@@ -4,8 +4,11 @@ import { fileURLToPath } from "url";
4
4
  import { spawnCommand } from "./spawn-command.js";
5
5
  import { getPlatform } from "./platform/index.js";
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
8
- export const currentVersion = pkg.version;
7
+ const packageRoot = path.join(__dirname, "..");
8
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
9
+ /** True when running from a source checkout (has .git) rather than a global npm install. */
10
+ export const isDevBuild = fs.existsSync(path.join(packageRoot, ".git"));
11
+ export const currentVersion = isDevBuild ? `${pkg.version}-dev` : pkg.version;
9
12
  let latestVersion = null;
10
13
  let lastCheckTime = 0;
11
14
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -13,6 +16,8 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
16
  * Check the npm registry for the latest version of palmier.
14
17
  */
15
18
  export async function checkForUpdate() {
19
+ if (isDevBuild)
20
+ return;
16
21
  const now = Date.now();
17
22
  if (now - lastCheckTime < CHECK_INTERVAL_MS)
18
23
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.3.2",
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
+ }
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import { loadConfig, CONFIG_DIR } from "../config.js";
3
3
  import { createRpcHandler } from "../rpc-handler.js";
4
4
  import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
5
- import { generatePairingCode } from "../pairing.js";
5
+ import { generatePairingCode } from "./pair.js";
6
6
  import { LAN_LOCKFILE } from "../lan-lock.js";
7
7
 
8
8
  const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
@@ -3,10 +3,20 @@ import { StringCodec } from "nats";
3
3
  import { loadConfig } from "../config.js";
4
4
  import { connectNats } from "../nats-client.js";
5
5
  import { addSession } from "../session-store.js";
6
- import { generatePairingCode, PAIRING_EXPIRY_MS } from "../pairing.js";
7
6
  import { getLanPort } from "../lan-lock.js";
8
7
  import type { HostConfig } from "../types.js";
9
8
 
9
+ const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
10
+ const CODE_LENGTH = 6;
11
+
12
+ export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
13
+
14
+ export function generatePairingCode(): string {
15
+ const bytes = new Uint8Array(CODE_LENGTH);
16
+ crypto.getRandomValues(bytes);
17
+ return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
18
+ }
19
+
10
20
  function buildPairResponse(config: HostConfig, label?: string) {
11
21
  const session = addSession(label);
12
22
  return {
@@ -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 {
@@ -78,8 +101,9 @@ export class WindowsPlatform implements PlatformService {
78
101
  installDaemon(config: HostConfig): void {
79
102
  const script = process.argv[1] || "palmier";
80
103
 
81
- // Write a VBS launcher that starts the daemon with no visible console window
82
- const vbs = `CreateObject("WScript.Shell").Run """${process.execPath.replace(/\\/g, "\\\\")}"" ""${script.replace(/\\/g, "\\\\")}"" serve", 0, False`;
104
+ // Write a VBS launcher that starts the daemon with no visible console window.
105
+ // VBS doesn't use backslash escaping — only quotes need doubling ("").
106
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
83
107
  fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
84
108
 
85
109
  const regValue = `"${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe" "${DAEMON_VBS_FILE}"`;
@@ -134,60 +158,38 @@ export class WindowsPlatform implements PlatformService {
134
158
  const tn = schtasksTaskName(taskId);
135
159
  const tr = schtasksTr("run", taskId);
136
160
 
137
- // Always create the scheduled task with a dummy trigger first.
138
- // 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`);
139
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")]));
140
185
  execFileSync("schtasks", [
141
- "/create", "/tn", tn,
142
- "/tr", tr,
143
- "/sc", "ONCE", "/sd", "01/01/2000", "/st", "00:00",
144
- "/f",
186
+ "/create", "/tn", tn, "/xml", xmlPath, "/f",
145
187
  ], { encoding: "utf-8", windowsHide: true });
146
188
  } catch (err: unknown) {
147
189
  const e = err as { stderr?: string };
148
190
  console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
149
- }
150
-
151
- // Overlay with real schedule triggers if enabled
152
- if (!task.frontmatter.triggers_enabled) return;
153
- const triggers = task.frontmatter.triggers || [];
154
- for (const trigger of triggers) {
155
- if (trigger.type === "cron") {
156
- const schedArgs = cronToSchtasksArgs(trigger.value);
157
- try {
158
- execFileSync("schtasks", [
159
- "/create", "/tn", tn,
160
- "/tr", tr,
161
- ...schedArgs,
162
- "/f",
163
- ], { encoding: "utf-8", windowsHide: true });
164
- } catch (err: unknown) {
165
- const e = err as { stderr?: string };
166
- console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
167
- }
168
- } else if (trigger.type === "once") {
169
- // "once" triggers use ISO datetime: "2026-03-28T09:00"
170
- const [datePart, timePart] = trigger.value.split("T");
171
- if (!datePart || !timePart) {
172
- console.error(`Invalid once trigger value: ${trigger.value}`);
173
- continue;
174
- }
175
- // schtasks expects MM/DD/YYYY date format
176
- const [year, month, day] = datePart.split("-");
177
- const sd = `${month}/${day}/${year}`;
178
- const st = timePart.slice(0, 5);
179
- try {
180
- execFileSync("schtasks", [
181
- "/create", "/tn", tn,
182
- "/tr", tr,
183
- "/sc", "ONCE", "/sd", sd, "/st", st,
184
- "/f",
185
- ], { encoding: "utf-8", windowsHide: true });
186
- } catch (err: unknown) {
187
- const e = err as { stderr?: string };
188
- console.error(`Failed to create once task ${tn}: ${e.stderr || err}`);
189
- }
190
- }
191
+ } finally {
192
+ try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
191
193
  }
192
194
  }
193
195
 
@@ -5,8 +5,12 @@ import { spawnCommand } from "./spawn-command.js";
5
5
  import { getPlatform } from "./platform/index.js";
6
6
 
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")) as { version: string };
9
- export const currentVersion = pkg.version;
8
+ const packageRoot = path.join(__dirname, "..");
9
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8")) as { version: string };
10
+
11
+ /** True when running from a source checkout (has .git) rather than a global npm install. */
12
+ export const isDevBuild = fs.existsSync(path.join(packageRoot, ".git"));
13
+ export const currentVersion = isDevBuild ? `${pkg.version}-dev` : pkg.version;
10
14
 
11
15
  let latestVersion: string | null = null;
12
16
  let lastCheckTime = 0;
@@ -16,6 +20,7 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
16
20
  * Check the npm registry for the latest version of palmier.
17
21
  */
18
22
  export async function checkForUpdate(): Promise<void> {
23
+ if (isDevBuild) return;
19
24
  const now = Date.now();
20
25
  if (now - lastCheckTime < CHECK_INTERVAL_MS) return;
21
26
  lastCheckTime = now;
package/dist/pairing.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export declare const PAIRING_EXPIRY_MS: number;
2
- export declare function generatePairingCode(): string;
3
- //# sourceMappingURL=pairing.d.ts.map
package/dist/pairing.js DELETED
@@ -1,9 +0,0 @@
1
- const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
2
- const CODE_LENGTH = 6;
3
- export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
4
- export function generatePairingCode() {
5
- const bytes = new Uint8Array(CODE_LENGTH);
6
- crypto.getRandomValues(bytes);
7
- return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
8
- }
9
- //# sourceMappingURL=pairing.js.map
package/src/pairing.ts DELETED
@@ -1,10 +0,0 @@
1
- const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
2
- const CODE_LENGTH = 6;
3
-
4
- export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
5
-
6
- export function generatePairingCode(): string {
7
- const bytes = new Uint8Array(CODE_LENGTH);
8
- crypto.getRandomValues(bytes);
9
- return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
10
- }