palmier 0.5.3 → 0.5.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/README.md +18 -28
- package/dist/commands/init.js +12 -0
- package/dist/commands/serve.js +12 -1
- package/dist/commands/uninstall.d.ts +2 -0
- package/dist/commands/uninstall.js +8 -0
- package/dist/index.js +7 -0
- package/dist/platform/linux.d.ts +1 -0
- package/dist/platform/linux.js +38 -0
- package/dist/platform/platform.d.ts +2 -0
- package/dist/platform/windows.d.ts +3 -2
- package/dist/platform/windows.js +54 -60
- package/dist/rpc-handler.js +5 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/commands/init.ts +14 -0
- package/src/commands/serve.ts +12 -1
- package/src/commands/uninstall.ts +9 -0
- package/src/index.ts +8 -0
- package/src/platform/linux.ts +26 -0
- package/src/platform/platform.ts +3 -0
- package/src/platform/windows.ts +56 -62
- package/src/rpc-handler.ts +7 -0
- package/src/types.ts +1 -0
- package/test/windows-xml.test.ts +2 -0
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ All `palmier` commands should be run from a dedicated Palmier root directory (e.
|
|
|
53
53
|
| `palmier serve` | Run the persistent RPC handler (default command) |
|
|
54
54
|
| `palmier restart` | Restart the palmier serve daemon |
|
|
55
55
|
| `palmier run <task-id>` | Execute a specific task |
|
|
56
|
+
| `palmier uninstall` | Stop daemon and remove all scheduled tasks |
|
|
56
57
|
|
|
57
58
|
## Setup
|
|
58
59
|
|
|
@@ -84,13 +85,15 @@ palmier clients revoke-all
|
|
|
84
85
|
```
|
|
85
86
|
|
|
86
87
|
The `init` command:
|
|
87
|
-
- Detects installed agent CLIs (Claude Code, Gemini CLI, Codex CLI, GitHub Copilot) and caches the result
|
|
88
|
+
- Detects installed agent CLIs (Claude Code, Gemini CLI, Codex CLI, GitHub Copilot, Qwen Code, Kimi Code, OpenClaw) and caches the result
|
|
88
89
|
- Configures access modes (HTTP port, LAN access)
|
|
89
|
-
- Shows a summary and asks for confirmation
|
|
90
|
+
- Shows a summary (including any existing scheduled tasks to recover) and asks for confirmation
|
|
90
91
|
- Registers with the Palmier server, saves configuration to `~/.config/palmier/host.json`
|
|
91
|
-
- Installs a background daemon (systemd user service on Linux,
|
|
92
|
+
- Installs a background daemon (systemd user service on Linux, Task Scheduler on Windows)
|
|
92
93
|
- Auto-enters pair mode to connect your first device
|
|
93
94
|
|
|
95
|
+
The daemon automatically recovers existing tasks by reinstalling their system timers on startup.
|
|
96
|
+
|
|
94
97
|
Agents are re-detected on every daemon start. Run `palmier restart` after installing or removing a CLI.
|
|
95
98
|
|
|
96
99
|
### Verifying the Service
|
|
@@ -125,7 +128,7 @@ palmier restart
|
|
|
125
128
|
|
|
126
129
|
## How It Works
|
|
127
130
|
|
|
128
|
-
- The host runs as a **background daemon** (systemd user service on Linux,
|
|
131
|
+
- The host runs as a **background daemon** (systemd user service on Linux, Task Scheduler on Windows), staying alive via `palmier serve`.
|
|
129
132
|
- **Device access** — localhost is always trusted (no pairing needed). LAN and server mode devices communicate via direct HTTP or NATS respectively, and must pair via OTP to get a client token.
|
|
130
133
|
- **Tasks** are stored locally as Markdown files in a `tasks/` directory. Each task has a name, prompt, execution plan, and optional schedules (cron schedules or one-time dates).
|
|
131
134
|
- **Plan generation** is automatic — when you create or update a task, the host invokes your chosen agent CLI to generate an execution plan and name.
|
|
@@ -139,43 +142,30 @@ To fully remove Palmier from a machine:
|
|
|
139
142
|
|
|
140
143
|
1. **Unpair your device** in the PWA (via the host menu).
|
|
141
144
|
|
|
142
|
-
2. **Stop and remove
|
|
145
|
+
2. **Stop the daemon and remove all scheduled tasks:**
|
|
143
146
|
|
|
144
|
-
**Linux:**
|
|
145
147
|
```bash
|
|
146
|
-
|
|
147
|
-
systemctl --user disable palmier.service
|
|
148
|
-
rm ~/.config/systemd/user/palmier.service
|
|
148
|
+
palmier uninstall
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
# Remove the Registry Run key
|
|
156
|
-
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "PalmierDaemon" -ErrorAction SilentlyContinue
|
|
151
|
+
3. **Uninstall the package:**
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
npm uninstall -g palmier
|
|
157
155
|
```
|
|
158
156
|
|
|
159
|
-
|
|
157
|
+
4. **(Optional) Remove configuration and task data:**
|
|
160
158
|
|
|
161
159
|
**Linux:**
|
|
162
160
|
```bash
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
rm -f ~/.config/systemd/user/palmier-task-*.timer ~/.config/systemd/user/palmier-task-*.service
|
|
166
|
-
systemctl --user daemon-reload
|
|
161
|
+
rm -rf ~/.config/palmier
|
|
162
|
+
rm -rf ~/palmier # or wherever your Palmier root directory is
|
|
167
163
|
```
|
|
168
164
|
|
|
169
165
|
**Windows (PowerShell):**
|
|
170
166
|
```powershell
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
4. **Remove configuration and task data:**
|
|
175
|
-
|
|
176
|
-
```bash
|
|
177
|
-
rm -rf ~/.config/palmier
|
|
178
|
-
rm -rf tasks/ # from your Palmier root directory
|
|
167
|
+
Remove-Item -Recurse -Force "$env:USERPROFILE\.config\palmier"
|
|
168
|
+
Remove-Item -Recurse -Force "$env:USERPROFILE\palmier" # or wherever your Palmier root directory is
|
|
179
169
|
```
|
|
180
170
|
|
|
181
171
|
## Disclaimer
|
package/dist/commands/init.js
CHANGED
|
@@ -4,6 +4,7 @@ import { detectAgents } from "../agents/agent.js";
|
|
|
4
4
|
import { getPlatform } from "../platform/index.js";
|
|
5
5
|
import { pairCommand } from "./pair.js";
|
|
6
6
|
import { detectLanIp } from "../transports/http-transport.js";
|
|
7
|
+
import { listTasks } from "../task.js";
|
|
7
8
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
8
9
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
9
10
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
@@ -51,6 +52,15 @@ export async function initCommand() {
|
|
|
51
52
|
console.log(` Accessible from other devices on your local network. Pairing required.\n`);
|
|
52
53
|
}
|
|
53
54
|
console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
|
|
55
|
+
// Check for existing tasks to recover
|
|
56
|
+
const existingTasks = listTasks(process.cwd());
|
|
57
|
+
if (existingTasks.length > 0) {
|
|
58
|
+
console.log(` ${dim("Recover tasks:")} ${existingTasks.length} existing task(s) found:`);
|
|
59
|
+
for (const t of existingTasks) {
|
|
60
|
+
console.log(` - ${t.frontmatter.name || t.frontmatter.user_prompt.slice(0, 50)}`);
|
|
61
|
+
}
|
|
62
|
+
console.log();
|
|
63
|
+
}
|
|
54
64
|
const confirm = await ask("Proceed? (Y/n): ");
|
|
55
65
|
if (confirm.trim().toLowerCase() === "n") {
|
|
56
66
|
console.log("\nSetup cancelled.");
|
|
@@ -96,6 +106,8 @@ export async function initCommand() {
|
|
|
96
106
|
saveConfig(config);
|
|
97
107
|
console.log(`Config saved to ${dim("~/.config/palmier/host.json")}`);
|
|
98
108
|
getPlatform().installDaemon(config);
|
|
109
|
+
// Task recovery happens in the daemon (palmier serve) on startup,
|
|
110
|
+
// since the daemon runs elevated and can create S4U scheduled tasks.
|
|
99
111
|
console.log("\nStarting pairing...");
|
|
100
112
|
rl.close();
|
|
101
113
|
await pairCommand();
|
package/dist/commands/serve.js
CHANGED
|
@@ -5,7 +5,7 @@ import { connectNats } from "../nats-client.js";
|
|
|
5
5
|
import { createRpcHandler } from "../rpc-handler.js";
|
|
6
6
|
import { startNatsTransport } from "../transports/nats-transport.js";
|
|
7
7
|
import { startHttpTransport } from "../transports/http-transport.js";
|
|
8
|
-
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage } from "../task.js";
|
|
8
|
+
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage, listTasks } from "../task.js";
|
|
9
9
|
import { publishHostEvent } from "../events.js";
|
|
10
10
|
import { getPlatform } from "../platform/index.js";
|
|
11
11
|
import { detectAgents } from "../agents/agent.js";
|
|
@@ -92,6 +92,17 @@ export async function serveCommand() {
|
|
|
92
92
|
}
|
|
93
93
|
// Reconcile any tasks stuck from before daemon started
|
|
94
94
|
await checkStaleTasks(config, nc);
|
|
95
|
+
// Ensure all tasks have their scheduler entries (recovery after init/reinstall)
|
|
96
|
+
const platform = getPlatform();
|
|
97
|
+
const allTasks = listTasks(config.projectRoot);
|
|
98
|
+
for (const task of allTasks) {
|
|
99
|
+
try {
|
|
100
|
+
platform.installTaskTimer(config, task);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
console.error(`Warning: failed to install timer for task ${task.frontmatter.id}: ${err}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
95
106
|
// Poll for crashed tasks every 30 seconds
|
|
96
107
|
setInterval(() => {
|
|
97
108
|
checkStaleTasks(config, nc).catch((err) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getPlatform } from "../platform/index.js";
|
|
2
|
+
export async function uninstallCommand() {
|
|
3
|
+
const platform = getPlatform();
|
|
4
|
+
platform.uninstallDaemon();
|
|
5
|
+
console.log("\nTo uninstall the package: npm uninstall -g palmier");
|
|
6
|
+
console.log("To also remove configuration and task data, see https://github.com/caihongxu/palmier#uninstalling");
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=uninstall.js.map
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { serveCommand } from "./commands/serve.js";
|
|
|
11
11
|
import { pairCommand } from "./commands/pair.js";
|
|
12
12
|
import { restartCommand } from "./commands/restart.js";
|
|
13
13
|
import { clientsListCommand, clientsRevokeCommand, clientsRevokeAllCommand } from "./commands/clients.js";
|
|
14
|
+
import { uninstallCommand } from "./commands/uninstall.js";
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
16
17
|
const program = new Command();
|
|
@@ -75,6 +76,12 @@ clientsCmd
|
|
|
75
76
|
.action(async () => {
|
|
76
77
|
await clientsRevokeAllCommand();
|
|
77
78
|
});
|
|
79
|
+
program
|
|
80
|
+
.command("uninstall")
|
|
81
|
+
.description("Stop the daemon and remove all scheduled tasks")
|
|
82
|
+
.action(async () => {
|
|
83
|
+
await uninstallCommand();
|
|
84
|
+
});
|
|
78
85
|
// No subcommand → default to serve
|
|
79
86
|
if (process.argv.length <= 2) {
|
|
80
87
|
process.argv.push("serve");
|
package/dist/platform/linux.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { HostConfig, ParsedTask } from "../types.js";
|
|
|
14
14
|
export declare function cronToOnCalendar(cron: string): string;
|
|
15
15
|
export declare class LinuxPlatform implements PlatformService {
|
|
16
16
|
installDaemon(config: HostConfig): void;
|
|
17
|
+
uninstallDaemon(): void;
|
|
17
18
|
restartDaemon(): Promise<void>;
|
|
18
19
|
installTaskTimer(config: HostConfig, task: ParsedTask): void;
|
|
19
20
|
removeTaskTimer(taskId: string): void;
|
package/dist/platform/linux.js
CHANGED
|
@@ -103,6 +103,44 @@ WantedBy=default.target
|
|
|
103
103
|
}
|
|
104
104
|
console.log("\nHost initialization complete!");
|
|
105
105
|
}
|
|
106
|
+
uninstallDaemon() {
|
|
107
|
+
try {
|
|
108
|
+
execSync("systemctl --user stop palmier.service 2>/dev/null", { stdio: "pipe" });
|
|
109
|
+
execSync("systemctl --user disable palmier.service 2>/dev/null", { stdio: "pipe" });
|
|
110
|
+
}
|
|
111
|
+
catch { /* service may not exist */ }
|
|
112
|
+
// Remove daemon service file
|
|
113
|
+
const servicePath = path.join(UNIT_DIR, "palmier.service");
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(servicePath);
|
|
116
|
+
}
|
|
117
|
+
catch { /* ignore */ }
|
|
118
|
+
// Remove all task timers and services
|
|
119
|
+
try {
|
|
120
|
+
const files = fs.readdirSync(UNIT_DIR).filter((f) => f.startsWith("palmier-task-"));
|
|
121
|
+
for (const f of files) {
|
|
122
|
+
const unit = f.replace(/\.(timer|service)$/, "");
|
|
123
|
+
try {
|
|
124
|
+
execSync(`systemctl --user stop ${f} 2>/dev/null`, { stdio: "pipe" });
|
|
125
|
+
}
|
|
126
|
+
catch { /* ignore */ }
|
|
127
|
+
try {
|
|
128
|
+
execSync(`systemctl --user disable ${f} 2>/dev/null`, { stdio: "pipe" });
|
|
129
|
+
}
|
|
130
|
+
catch { /* ignore */ }
|
|
131
|
+
try {
|
|
132
|
+
fs.unlinkSync(path.join(UNIT_DIR, f));
|
|
133
|
+
}
|
|
134
|
+
catch { /* ignore */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch { /* ignore */ }
|
|
138
|
+
try {
|
|
139
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
140
|
+
}
|
|
141
|
+
catch { /* ignore */ }
|
|
142
|
+
console.log("Palmier daemon and tasks uninstalled.");
|
|
143
|
+
}
|
|
106
144
|
async restartDaemon() {
|
|
107
145
|
// If called from a user's terminal, save the current PATH for future use.
|
|
108
146
|
// If called from the daemon (auto-update), read the saved PATH instead.
|
|
@@ -8,6 +8,8 @@ export interface PlatformService {
|
|
|
8
8
|
installDaemon(config: HostConfig): void;
|
|
9
9
|
/** Restart the `palmier serve` daemon. */
|
|
10
10
|
restartDaemon(): Promise<void>;
|
|
11
|
+
/** Stop the daemon and remove all scheduled tasks/timers. */
|
|
12
|
+
uninstallDaemon(): void;
|
|
11
13
|
/** Install a scheduled trigger (timer) for a task. */
|
|
12
14
|
installTaskTimer(config: HostConfig, task: ParsedTask): void;
|
|
13
15
|
/** Remove a task's scheduled trigger and service files. */
|
|
@@ -16,11 +16,12 @@ export declare function triggerToXml(trigger: {
|
|
|
16
16
|
/**
|
|
17
17
|
* Build a complete Task Scheduler XML definition.
|
|
18
18
|
*/
|
|
19
|
-
export declare function buildTaskXml(tr: string, triggers: string[]): string;
|
|
19
|
+
export declare function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string;
|
|
20
20
|
export declare class WindowsPlatform implements PlatformService {
|
|
21
21
|
installDaemon(config: HostConfig): void;
|
|
22
|
+
uninstallDaemon(): void;
|
|
22
23
|
restartDaemon(): Promise<void>;
|
|
23
|
-
/** Create or update the Task Scheduler entry for the daemon. */
|
|
24
|
+
/** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
|
|
24
25
|
private ensureDaemonTask;
|
|
25
26
|
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
26
27
|
private startDaemonTask;
|
package/dist/platform/windows.js
CHANGED
|
@@ -5,8 +5,6 @@ import { CONFIG_DIR, loadConfig } from "../config.js";
|
|
|
5
5
|
import { getTaskDir, readTaskStatus } from "../task.js";
|
|
6
6
|
const TASK_PREFIX = "\\Palmier\\PalmierTask-";
|
|
7
7
|
const DAEMON_TASK_NAME = "PalmierDaemon";
|
|
8
|
-
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
9
|
-
const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
|
|
10
8
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
11
9
|
/**
|
|
12
10
|
* Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
|
|
@@ -48,13 +46,19 @@ export function triggerToXml(trigger) {
|
|
|
48
46
|
/**
|
|
49
47
|
* Build a complete Task Scheduler XML definition.
|
|
50
48
|
*/
|
|
51
|
-
export function buildTaskXml(tr, triggers) {
|
|
49
|
+
export function buildTaskXml(tr, triggers, foreground) {
|
|
52
50
|
const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
|
|
53
51
|
const commandStr = command?.replace(/"/g, "") ?? "";
|
|
54
52
|
const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
|
|
55
53
|
return [
|
|
56
54
|
`<?xml version="1.0" encoding="UTF-16"?>`,
|
|
57
55
|
`<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
|
|
56
|
+
` <Principals>`,
|
|
57
|
+
` <Principal>`,
|
|
58
|
+
` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
|
|
59
|
+
` <RunLevel>LeastPrivilege</RunLevel>`,
|
|
60
|
+
` </Principal>`,
|
|
61
|
+
` </Principals>`,
|
|
58
62
|
` <Settings>`,
|
|
59
63
|
` <MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>`,
|
|
60
64
|
` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
|
|
@@ -77,75 +81,72 @@ function schtasksTaskName(taskId) {
|
|
|
77
81
|
export class WindowsPlatform {
|
|
78
82
|
installDaemon(config) {
|
|
79
83
|
const script = process.argv[1] || "palmier";
|
|
80
|
-
// Create the Task Scheduler entry for the daemon
|
|
84
|
+
// Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
|
|
81
85
|
this.ensureDaemonTask(script);
|
|
82
|
-
// Registry Run key triggers the Task Scheduler entry on logon,
|
|
83
|
-
// so the daemon always runs outside any session's job object.
|
|
84
|
-
const regValue = `schtasks /run /tn "\\Palmier\\${DAEMON_TASK_NAME}"`;
|
|
85
|
-
try {
|
|
86
|
-
execFileSync("reg", [
|
|
87
|
-
"add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
88
|
-
"/v", DAEMON_TASK_NAME, "/t", "REG_SZ", "/d", regValue, "/f",
|
|
89
|
-
], { encoding: "utf-8", stdio: "pipe" });
|
|
90
|
-
console.log(`Registry Run key "${DAEMON_TASK_NAME}" installed (runs at logon).`);
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
console.error(`Warning: failed to install registry run entry: ${err}`);
|
|
94
|
-
console.error("You may need to start palmier serve manually.");
|
|
95
|
-
}
|
|
96
86
|
// Start the daemon now
|
|
97
87
|
this.startDaemonTask();
|
|
98
88
|
console.log("\nHost initialization complete!");
|
|
99
89
|
}
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// We ARE the old daemon (auto-update) — spawn replacement then exit.
|
|
106
|
-
this.startDaemonTask();
|
|
107
|
-
process.exit(0);
|
|
90
|
+
uninstallDaemon() {
|
|
91
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
92
|
+
// Stop the daemon via Task Scheduler
|
|
93
|
+
try {
|
|
94
|
+
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
108
95
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
96
|
+
catch { /* task may not be running */ }
|
|
97
|
+
// Remove daemon scheduled task (elevated — S4U task requires elevation to delete)
|
|
98
|
+
try {
|
|
99
|
+
execFileSync("powershell", [
|
|
100
|
+
"-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
|
|
101
|
+
], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
102
|
+
console.log("Daemon task removed.");
|
|
117
103
|
}
|
|
118
|
-
|
|
104
|
+
catch { /* task may not exist */ }
|
|
105
|
+
// Remove all Palmier task timers
|
|
119
106
|
try {
|
|
120
|
-
const out = execFileSync("
|
|
107
|
+
const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
121
108
|
for (const line of out.split("\n")) {
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
109
|
+
const match = line.match(/"(\\Palmier\\PalmierTask-[^"]+)"/);
|
|
110
|
+
if (match) {
|
|
124
111
|
try {
|
|
125
|
-
execFileSync("
|
|
112
|
+
execFileSync("schtasks", ["/end", "/tn", match[1]], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
126
113
|
}
|
|
127
|
-
catch { }
|
|
114
|
+
catch { /* ignore */ }
|
|
115
|
+
try {
|
|
116
|
+
execFileSync("schtasks", ["/delete", "/tn", match[1], "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore */ }
|
|
128
119
|
}
|
|
129
120
|
}
|
|
121
|
+
console.log("Task timers removed.");
|
|
130
122
|
}
|
|
131
|
-
catch {
|
|
132
|
-
|
|
123
|
+
catch { /* ignore */ }
|
|
124
|
+
console.log("Palmier daemon and tasks uninstalled.");
|
|
125
|
+
}
|
|
126
|
+
async restartDaemon() {
|
|
127
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
128
|
+
// Stop the daemon via Task Scheduler
|
|
129
|
+
try {
|
|
130
|
+
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
133
131
|
}
|
|
132
|
+
catch { /* task may not be running */ }
|
|
133
|
+
// Start it again
|
|
134
134
|
this.startDaemonTask();
|
|
135
135
|
}
|
|
136
|
-
/** Create or update the Task Scheduler entry for the daemon. */
|
|
136
|
+
/** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
|
|
137
137
|
ensureDaemonTask(script) {
|
|
138
|
-
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
139
|
-
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
140
|
-
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
141
138
|
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
142
|
-
const tr = `"${
|
|
143
|
-
const xml = buildTaskXml(tr, [`<
|
|
139
|
+
const tr = `"${process.execPath}" "${script}" serve`;
|
|
140
|
+
const xml = buildTaskXml(tr, [`<BootTrigger><Enabled>true</Enabled></BootTrigger>`]);
|
|
144
141
|
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
145
142
|
try {
|
|
146
143
|
const bom = Buffer.from([0xFF, 0xFE]);
|
|
147
144
|
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
148
|
-
|
|
145
|
+
// S4U LogonType requires elevation — spawn schtasks via RunAs
|
|
146
|
+
const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
|
|
147
|
+
execFileSync("powershell", [
|
|
148
|
+
"-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
|
|
149
|
+
], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
149
150
|
}
|
|
150
151
|
catch (err) {
|
|
151
152
|
const e = err;
|
|
@@ -174,12 +175,7 @@ export class WindowsPlatform {
|
|
|
174
175
|
const taskId = task.frontmatter.id;
|
|
175
176
|
const tn = schtasksTaskName(taskId);
|
|
176
177
|
const script = process.argv[1] || "palmier";
|
|
177
|
-
|
|
178
|
-
const vbsPath = path.join(CONFIG_DIR, `task-${taskId}.vbs`);
|
|
179
|
-
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" run ${taskId}", 0, True`;
|
|
180
|
-
fs.writeFileSync(vbsPath, vbs, "utf-8");
|
|
181
|
-
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
182
|
-
const tr = `"${wscript}" "${vbsPath}"`;
|
|
178
|
+
const tr = `"${process.execPath}" "${script}" run ${taskId}`;
|
|
183
179
|
// Build trigger XML elements
|
|
184
180
|
const triggerElements = [];
|
|
185
181
|
if (task.frontmatter.triggers_enabled) {
|
|
@@ -198,7 +194,9 @@ export class WindowsPlatform {
|
|
|
198
194
|
}
|
|
199
195
|
// Write XML and register via schtasks — gives us full control over
|
|
200
196
|
// settings like MultipleInstancesPolicy that schtasks flags don't expose.
|
|
201
|
-
|
|
197
|
+
// S4U LogonType ensures no console window (unless foreground_mode is set).
|
|
198
|
+
// Works without elevation because the daemon (which calls this) runs elevated.
|
|
199
|
+
const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
|
|
202
200
|
const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
|
|
203
201
|
try {
|
|
204
202
|
// schtasks /xml requires UTF-16LE with BOM
|
|
@@ -227,10 +225,6 @@ export class WindowsPlatform {
|
|
|
227
225
|
catch {
|
|
228
226
|
// Task might not exist — that's fine
|
|
229
227
|
}
|
|
230
|
-
try {
|
|
231
|
-
fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`));
|
|
232
|
-
}
|
|
233
|
-
catch { /* ignore */ }
|
|
234
228
|
}
|
|
235
229
|
async startTask(taskId) {
|
|
236
230
|
const tn = schtasksTaskName(taskId);
|
package/dist/rpc-handler.js
CHANGED
|
@@ -151,6 +151,7 @@ export function createRpcHandler(config, nc) {
|
|
|
151
151
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
152
152
|
agents: config.agents ?? [],
|
|
153
153
|
version: currentVersion,
|
|
154
|
+
host_platform: process.platform,
|
|
154
155
|
};
|
|
155
156
|
}
|
|
156
157
|
case "task.create": {
|
|
@@ -184,6 +185,7 @@ export function createRpcHandler(config, nc) {
|
|
|
184
185
|
triggers_enabled: params.triggers_enabled ?? true,
|
|
185
186
|
requires_confirmation: params.requires_confirmation ?? true,
|
|
186
187
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
188
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
187
189
|
...(params.command ? { command: params.command } : {}),
|
|
188
190
|
},
|
|
189
191
|
body,
|
|
@@ -217,6 +219,8 @@ export function createRpcHandler(config, nc) {
|
|
|
217
219
|
if (params.yolo_mode)
|
|
218
220
|
delete existing.frontmatter.permissions;
|
|
219
221
|
}
|
|
222
|
+
if (params.foreground_mode !== undefined)
|
|
223
|
+
existing.frontmatter.foreground_mode = params.foreground_mode || undefined;
|
|
220
224
|
if (params.command !== undefined) {
|
|
221
225
|
if (params.command) {
|
|
222
226
|
existing.frontmatter.command = params.command;
|
|
@@ -268,6 +272,7 @@ export function createRpcHandler(config, nc) {
|
|
|
268
272
|
triggers_enabled: false,
|
|
269
273
|
requires_confirmation: params.requires_confirmation ?? false,
|
|
270
274
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
275
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
271
276
|
...(params.command ? { command: params.command } : {}),
|
|
272
277
|
},
|
|
273
278
|
body: "",
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
package/src/commands/init.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { detectAgents } from "../agents/agent.js";
|
|
|
4
4
|
import { getPlatform } from "../platform/index.js";
|
|
5
5
|
import { pairCommand } from "./pair.js";
|
|
6
6
|
import { detectLanIp } from "../transports/http-transport.js";
|
|
7
|
+
import { listTasks } from "../task.js";
|
|
7
8
|
import type { HostConfig } from "../types.js";
|
|
8
9
|
|
|
9
10
|
type AskFn = (q: string) => Promise<string>;
|
|
@@ -63,6 +64,16 @@ export async function initCommand(): Promise<void> {
|
|
|
63
64
|
}
|
|
64
65
|
console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
|
|
65
66
|
|
|
67
|
+
// Check for existing tasks to recover
|
|
68
|
+
const existingTasks = listTasks(process.cwd());
|
|
69
|
+
if (existingTasks.length > 0) {
|
|
70
|
+
console.log(` ${dim("Recover tasks:")} ${existingTasks.length} existing task(s) found:`);
|
|
71
|
+
for (const t of existingTasks) {
|
|
72
|
+
console.log(` - ${t.frontmatter.name || t.frontmatter.user_prompt.slice(0, 50)}`);
|
|
73
|
+
}
|
|
74
|
+
console.log();
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
const confirm = await ask("Proceed? (Y/n): ");
|
|
67
78
|
if (confirm.trim().toLowerCase() === "n") {
|
|
68
79
|
console.log("\nSetup cancelled.");
|
|
@@ -111,6 +122,9 @@ export async function initCommand(): Promise<void> {
|
|
|
111
122
|
|
|
112
123
|
getPlatform().installDaemon(config);
|
|
113
124
|
|
|
125
|
+
// Task recovery happens in the daemon (palmier serve) on startup,
|
|
126
|
+
// since the daemon runs elevated and can create S4U scheduled tasks.
|
|
127
|
+
|
|
114
128
|
console.log("\nStarting pairing...");
|
|
115
129
|
rl.close();
|
|
116
130
|
await pairCommand();
|
package/src/commands/serve.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { connectNats } from "../nats-client.js";
|
|
|
5
5
|
import { createRpcHandler } from "../rpc-handler.js";
|
|
6
6
|
import { startNatsTransport } from "../transports/nats-transport.js";
|
|
7
7
|
import { startHttpTransport } from "../transports/http-transport.js";
|
|
8
|
-
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage } from "../task.js";
|
|
8
|
+
import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage, listTasks } from "../task.js";
|
|
9
9
|
import { publishHostEvent } from "../events.js";
|
|
10
10
|
import { getPlatform } from "../platform/index.js";
|
|
11
11
|
import { detectAgents } from "../agents/agent.js";
|
|
@@ -106,6 +106,17 @@ export async function serveCommand(): Promise<void> {
|
|
|
106
106
|
// Reconcile any tasks stuck from before daemon started
|
|
107
107
|
await checkStaleTasks(config, nc);
|
|
108
108
|
|
|
109
|
+
// Ensure all tasks have their scheduler entries (recovery after init/reinstall)
|
|
110
|
+
const platform = getPlatform();
|
|
111
|
+
const allTasks = listTasks(config.projectRoot);
|
|
112
|
+
for (const task of allTasks) {
|
|
113
|
+
try {
|
|
114
|
+
platform.installTaskTimer(config, task);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`Warning: failed to install timer for task ${task.frontmatter.id}: ${err}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
// Poll for crashed tasks every 30 seconds
|
|
110
121
|
setInterval(() => {
|
|
111
122
|
checkStaleTasks(config, nc).catch((err) => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getPlatform } from "../platform/index.js";
|
|
2
|
+
|
|
3
|
+
export async function uninstallCommand(): Promise<void> {
|
|
4
|
+
const platform = getPlatform();
|
|
5
|
+
platform.uninstallDaemon();
|
|
6
|
+
|
|
7
|
+
console.log("\nTo uninstall the package: npm uninstall -g palmier");
|
|
8
|
+
console.log("To also remove configuration and task data, see https://github.com/caihongxu/palmier#uninstalling");
|
|
9
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { serveCommand } from "./commands/serve.js";
|
|
|
13
13
|
import { pairCommand } from "./commands/pair.js";
|
|
14
14
|
import { restartCommand } from "./commands/restart.js";
|
|
15
15
|
import { clientsListCommand, clientsRevokeCommand, clientsRevokeAllCommand } from "./commands/clients.js";
|
|
16
|
+
import { uninstallCommand } from "./commands/uninstall.js";
|
|
16
17
|
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
@@ -93,6 +94,13 @@ clientsCmd
|
|
|
93
94
|
await clientsRevokeAllCommand();
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
program
|
|
98
|
+
.command("uninstall")
|
|
99
|
+
.description("Stop the daemon and remove all scheduled tasks")
|
|
100
|
+
.action(async () => {
|
|
101
|
+
await uninstallCommand();
|
|
102
|
+
});
|
|
103
|
+
|
|
96
104
|
// No subcommand → default to serve
|
|
97
105
|
if (process.argv.length <= 2) {
|
|
98
106
|
process.argv.push("serve");
|
package/src/platform/linux.ts
CHANGED
|
@@ -121,6 +121,32 @@ WantedBy=default.target
|
|
|
121
121
|
console.log("\nHost initialization complete!");
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
uninstallDaemon(): void {
|
|
125
|
+
try {
|
|
126
|
+
execSync("systemctl --user stop palmier.service 2>/dev/null", { stdio: "pipe" });
|
|
127
|
+
execSync("systemctl --user disable palmier.service 2>/dev/null", { stdio: "pipe" });
|
|
128
|
+
} catch { /* service may not exist */ }
|
|
129
|
+
|
|
130
|
+
// Remove daemon service file
|
|
131
|
+
const servicePath = path.join(UNIT_DIR, "palmier.service");
|
|
132
|
+
try { fs.unlinkSync(servicePath); } catch { /* ignore */ }
|
|
133
|
+
|
|
134
|
+
// Remove all task timers and services
|
|
135
|
+
try {
|
|
136
|
+
const files = fs.readdirSync(UNIT_DIR).filter((f) => f.startsWith("palmier-task-"));
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const unit = f.replace(/\.(timer|service)$/, "");
|
|
139
|
+
try { execSync(`systemctl --user stop ${f} 2>/dev/null`, { stdio: "pipe" }); } catch { /* ignore */ }
|
|
140
|
+
try { execSync(`systemctl --user disable ${f} 2>/dev/null`, { stdio: "pipe" }); } catch { /* ignore */ }
|
|
141
|
+
try { fs.unlinkSync(path.join(UNIT_DIR, f)); } catch { /* ignore */ }
|
|
142
|
+
}
|
|
143
|
+
} catch { /* ignore */ }
|
|
144
|
+
|
|
145
|
+
try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch { /* ignore */ }
|
|
146
|
+
|
|
147
|
+
console.log("Palmier daemon and tasks uninstalled.");
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
async restartDaemon(): Promise<void> {
|
|
125
151
|
// If called from a user's terminal, save the current PATH for future use.
|
|
126
152
|
// If called from the daemon (auto-update), read the saved PATH instead.
|
package/src/platform/platform.ts
CHANGED
|
@@ -11,6 +11,9 @@ export interface PlatformService {
|
|
|
11
11
|
/** Restart the `palmier serve` daemon. */
|
|
12
12
|
restartDaemon(): Promise<void>;
|
|
13
13
|
|
|
14
|
+
/** Stop the daemon and remove all scheduled tasks/timers. */
|
|
15
|
+
uninstallDaemon(): void;
|
|
16
|
+
|
|
14
17
|
/** Install a scheduled trigger (timer) for a task. */
|
|
15
18
|
installTaskTimer(config: HostConfig, task: ParsedTask): void;
|
|
16
19
|
|
package/src/platform/windows.ts
CHANGED
|
@@ -9,8 +9,6 @@ import { getTaskDir, readTaskStatus } from "../task.js";
|
|
|
9
9
|
|
|
10
10
|
const TASK_PREFIX = "\\Palmier\\PalmierTask-";
|
|
11
11
|
const DAEMON_TASK_NAME = "PalmierDaemon";
|
|
12
|
-
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
13
|
-
const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
@@ -60,7 +58,7 @@ export function triggerToXml(trigger: { type: string; value: string }): string {
|
|
|
60
58
|
/**
|
|
61
59
|
* Build a complete Task Scheduler XML definition.
|
|
62
60
|
*/
|
|
63
|
-
export function buildTaskXml(tr: string, triggers: string[]): string {
|
|
61
|
+
export function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string {
|
|
64
62
|
const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
|
|
65
63
|
const commandStr = command?.replace(/"/g, "") ?? "";
|
|
66
64
|
const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
|
|
@@ -68,6 +66,12 @@ export function buildTaskXml(tr: string, triggers: string[]): string {
|
|
|
68
66
|
return [
|
|
69
67
|
`<?xml version="1.0" encoding="UTF-16"?>`,
|
|
70
68
|
`<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
|
|
69
|
+
` <Principals>`,
|
|
70
|
+
` <Principal>`,
|
|
71
|
+
` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
|
|
72
|
+
` <RunLevel>LeastPrivilege</RunLevel>`,
|
|
73
|
+
` </Principal>`,
|
|
74
|
+
` </Principals>`,
|
|
71
75
|
` <Settings>`,
|
|
72
76
|
` <MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>`,
|
|
73
77
|
` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
|
|
@@ -93,85 +97,80 @@ export class WindowsPlatform implements PlatformService {
|
|
|
93
97
|
installDaemon(config: HostConfig): void {
|
|
94
98
|
const script = process.argv[1] || "palmier";
|
|
95
99
|
|
|
96
|
-
// Create the Task Scheduler entry for the daemon
|
|
100
|
+
// Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
|
|
97
101
|
this.ensureDaemonTask(script);
|
|
98
102
|
|
|
99
|
-
// Registry Run key triggers the Task Scheduler entry on logon,
|
|
100
|
-
// so the daemon always runs outside any session's job object.
|
|
101
|
-
const regValue = `schtasks /run /tn "\\Palmier\\${DAEMON_TASK_NAME}"`;
|
|
102
|
-
try {
|
|
103
|
-
execFileSync("reg", [
|
|
104
|
-
"add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
105
|
-
"/v", DAEMON_TASK_NAME, "/t", "REG_SZ", "/d", regValue, "/f",
|
|
106
|
-
], { encoding: "utf-8", stdio: "pipe" });
|
|
107
|
-
console.log(`Registry Run key "${DAEMON_TASK_NAME}" installed (runs at logon).`);
|
|
108
|
-
} catch (err) {
|
|
109
|
-
console.error(`Warning: failed to install registry run entry: ${err}`);
|
|
110
|
-
console.error("You may need to start palmier serve manually.");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
103
|
// Start the daemon now
|
|
114
104
|
this.startDaemonTask();
|
|
115
105
|
|
|
116
106
|
console.log("\nHost initialization complete!");
|
|
117
107
|
}
|
|
118
108
|
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
|
|
122
|
-
: null;
|
|
123
|
-
|
|
124
|
-
if (oldPid && oldPid === String(process.pid)) {
|
|
125
|
-
// We ARE the old daemon (auto-update) — spawn replacement then exit.
|
|
126
|
-
this.startDaemonTask();
|
|
127
|
-
process.exit(0);
|
|
128
|
-
}
|
|
109
|
+
uninstallDaemon(): void {
|
|
110
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
129
111
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
112
|
+
// Stop the daemon via Task Scheduler
|
|
113
|
+
try {
|
|
114
|
+
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
115
|
+
} catch { /* task may not be running */ }
|
|
116
|
+
|
|
117
|
+
// Remove daemon scheduled task (elevated — S4U task requires elevation to delete)
|
|
118
|
+
try {
|
|
119
|
+
execFileSync("powershell", [
|
|
120
|
+
"-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '/delete /tn "${tn}" /f'`,
|
|
121
|
+
], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
122
|
+
console.log("Daemon task removed.");
|
|
123
|
+
} catch { /* task may not exist */ }
|
|
138
124
|
|
|
139
|
-
//
|
|
125
|
+
// Remove all Palmier task timers
|
|
140
126
|
try {
|
|
141
|
-
const out = execFileSync("
|
|
127
|
+
const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
142
128
|
for (const line of out.split("\n")) {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
try { execFileSync("
|
|
129
|
+
const match = line.match(/"(\\Palmier\\PalmierTask-[^"]+)"/);
|
|
130
|
+
if (match) {
|
|
131
|
+
try { execFileSync("schtasks", ["/end", "/tn", match[1]], { encoding: "utf-8", windowsHide: true, stdio: "pipe" }); } catch { /* ignore */ }
|
|
132
|
+
try { execFileSync("schtasks", ["/delete", "/tn", match[1], "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" }); } catch { /* ignore */ }
|
|
146
133
|
}
|
|
147
134
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
135
|
+
console.log("Task timers removed.");
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
|
|
138
|
+
console.log("Palmier daemon and tasks uninstalled.");
|
|
139
|
+
}
|
|
151
140
|
|
|
141
|
+
async restartDaemon(): Promise<void> {
|
|
142
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
143
|
+
|
|
144
|
+
// Stop the daemon via Task Scheduler
|
|
145
|
+
try {
|
|
146
|
+
execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
147
|
+
} catch { /* task may not be running */ }
|
|
148
|
+
|
|
149
|
+
// Start it again
|
|
152
150
|
this.startDaemonTask();
|
|
153
151
|
}
|
|
154
152
|
|
|
155
|
-
/** Create or update the Task Scheduler entry for the daemon. */
|
|
153
|
+
/** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
|
|
156
154
|
private ensureDaemonTask(script: string): void {
|
|
157
|
-
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
158
|
-
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
159
|
-
|
|
160
|
-
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
161
155
|
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
162
|
-
const tr = `"${
|
|
163
|
-
const xml = buildTaskXml(tr, [`<
|
|
156
|
+
const tr = `"${process.execPath}" "${script}" serve`;
|
|
157
|
+
const xml = buildTaskXml(tr, [`<BootTrigger><Enabled>true</Enabled></BootTrigger>`]);
|
|
164
158
|
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
165
159
|
try {
|
|
166
160
|
const bom = Buffer.from([0xFF, 0xFE]);
|
|
167
161
|
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
168
|
-
|
|
162
|
+
// S4U LogonType requires elevation — spawn schtasks via RunAs
|
|
163
|
+
const args = `/create /tn "${tn}" /xml "${xmlPath}" /f`;
|
|
164
|
+
execFileSync("powershell", [
|
|
165
|
+
"-Command", `Start-Process -Verb RunAs -Wait -FilePath schtasks -ArgumentList '${args}'`,
|
|
166
|
+
], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
169
167
|
} catch (err: unknown) {
|
|
170
168
|
const e = err as { stderr?: string };
|
|
171
169
|
console.error(`Failed to create daemon task: ${e.stderr || err}`);
|
|
172
170
|
} finally {
|
|
173
171
|
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
174
172
|
}
|
|
173
|
+
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
@@ -190,14 +189,7 @@ export class WindowsPlatform implements PlatformService {
|
|
|
190
189
|
const taskId = task.frontmatter.id;
|
|
191
190
|
const tn = schtasksTaskName(taskId);
|
|
192
191
|
const script = process.argv[1] || "palmier";
|
|
193
|
-
|
|
194
|
-
// Write a VBS launcher so the task runs without a visible console window
|
|
195
|
-
const vbsPath = path.join(CONFIG_DIR, `task-${taskId}.vbs`);
|
|
196
|
-
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" run ${taskId}", 0, True`;
|
|
197
|
-
fs.writeFileSync(vbsPath, vbs, "utf-8");
|
|
198
|
-
|
|
199
|
-
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
200
|
-
const tr = `"${wscript}" "${vbsPath}"`;
|
|
192
|
+
const tr = `"${process.execPath}" "${script}" run ${taskId}`;
|
|
201
193
|
|
|
202
194
|
// Build trigger XML elements
|
|
203
195
|
const triggerElements: string[] = [];
|
|
@@ -217,7 +209,9 @@ export class WindowsPlatform implements PlatformService {
|
|
|
217
209
|
|
|
218
210
|
// Write XML and register via schtasks — gives us full control over
|
|
219
211
|
// settings like MultipleInstancesPolicy that schtasks flags don't expose.
|
|
220
|
-
|
|
212
|
+
// S4U LogonType ensures no console window (unless foreground_mode is set).
|
|
213
|
+
// Works without elevation because the daemon (which calls this) runs elevated.
|
|
214
|
+
const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
|
|
221
215
|
const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
|
|
222
216
|
try {
|
|
223
217
|
// schtasks /xml requires UTF-16LE with BOM
|
|
@@ -232,6 +226,7 @@ export class WindowsPlatform implements PlatformService {
|
|
|
232
226
|
} finally {
|
|
233
227
|
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
234
228
|
}
|
|
229
|
+
|
|
235
230
|
}
|
|
236
231
|
|
|
237
232
|
removeTaskTimer(taskId: string): void {
|
|
@@ -241,7 +236,6 @@ export class WindowsPlatform implements PlatformService {
|
|
|
241
236
|
} catch {
|
|
242
237
|
// Task might not exist — that's fine
|
|
243
238
|
}
|
|
244
|
-
try { fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`)); } catch { /* ignore */ }
|
|
245
239
|
}
|
|
246
240
|
|
|
247
241
|
async startTask(taskId: string): Promise<void> {
|
package/src/rpc-handler.ts
CHANGED
|
@@ -178,6 +178,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
178
178
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
179
179
|
agents: config.agents ?? [],
|
|
180
180
|
version: currentVersion,
|
|
181
|
+
host_platform: process.platform,
|
|
181
182
|
};
|
|
182
183
|
}
|
|
183
184
|
|
|
@@ -189,6 +190,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
189
190
|
triggers_enabled?: boolean;
|
|
190
191
|
requires_confirmation?: boolean;
|
|
191
192
|
yolo_mode?: boolean;
|
|
193
|
+
foreground_mode?: boolean;
|
|
192
194
|
command?: string;
|
|
193
195
|
};
|
|
194
196
|
|
|
@@ -220,6 +222,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
220
222
|
triggers_enabled: params.triggers_enabled ?? true,
|
|
221
223
|
requires_confirmation: params.requires_confirmation ?? true,
|
|
222
224
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
225
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
223
226
|
...(params.command ? { command: params.command } : {}),
|
|
224
227
|
},
|
|
225
228
|
body,
|
|
@@ -241,6 +244,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
241
244
|
triggers_enabled?: boolean;
|
|
242
245
|
requires_confirmation?: boolean;
|
|
243
246
|
yolo_mode?: boolean;
|
|
247
|
+
foreground_mode?: boolean;
|
|
244
248
|
command?: string;
|
|
245
249
|
};
|
|
246
250
|
|
|
@@ -263,6 +267,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
263
267
|
existing.frontmatter.yolo_mode = params.yolo_mode || undefined;
|
|
264
268
|
if (params.yolo_mode) delete existing.frontmatter.permissions;
|
|
265
269
|
}
|
|
270
|
+
if (params.foreground_mode !== undefined) existing.frontmatter.foreground_mode = params.foreground_mode || undefined;
|
|
266
271
|
if (params.command !== undefined) {
|
|
267
272
|
if (params.command) {
|
|
268
273
|
existing.frontmatter.command = params.command;
|
|
@@ -310,6 +315,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
310
315
|
agent: string;
|
|
311
316
|
requires_confirmation?: boolean;
|
|
312
317
|
yolo_mode?: boolean;
|
|
318
|
+
foreground_mode?: boolean;
|
|
313
319
|
command?: string;
|
|
314
320
|
};
|
|
315
321
|
|
|
@@ -326,6 +332,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
326
332
|
triggers_enabled: false,
|
|
327
333
|
requires_confirmation: params.requires_confirmation ?? false,
|
|
328
334
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
335
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
329
336
|
...(params.command ? { command: params.command } : {}),
|
|
330
337
|
},
|
|
331
338
|
body: "",
|
package/src/types.ts
CHANGED
package/test/windows-xml.test.ts
CHANGED
|
@@ -60,6 +60,8 @@ describe("buildTaskXml", () => {
|
|
|
60
60
|
const xml = buildTaskXml(tr, triggers);
|
|
61
61
|
|
|
62
62
|
assert.ok(xml.includes('<?xml version="1.0" encoding="UTF-16"?>'), "should have XML declaration");
|
|
63
|
+
assert.ok(xml.includes("<LogonType>S4U</LogonType>"), "should use S4U logon type");
|
|
64
|
+
assert.ok(xml.includes("<RunLevel>LeastPrivilege</RunLevel>"), "should use least privilege");
|
|
63
65
|
assert.ok(xml.includes("<MultipleInstancesPolicy>StopExisting</MultipleInstancesPolicy>"), "should set StopExisting");
|
|
64
66
|
assert.ok(xml.includes("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"), "should allow on battery");
|
|
65
67
|
assert.ok(xml.includes("<Command>C:\\Program Files\\nodejs\\node.exe</Command>"), "should extract command");
|