palmier 0.5.4 → 0.5.6
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 +19 -29
- package/dist/agents/codex.js +1 -2
- package/dist/commands/init.js +12 -0
- package/dist/commands/run.d.ts +0 -4
- package/dist/commands/run.js +15 -2
- 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 +2 -1
- package/dist/platform/windows.js +39 -49
- package/dist/rpc-handler.js +30 -4
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/agents/codex.ts +1 -2
- package/src/commands/init.ts +14 -0
- package/src/commands/run.ts +15 -1
- 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 +39 -45
- package/src/rpc-handler.ts +32 -5
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ The serve daemon always runs a local HTTP server. Three access modes are availab
|
|
|
22
22
|
|
|
23
23
|
**Local mode** is always available. The PWA is served at `http://localhost:<port>` and works without pairing or internet. The daemon binds to `127.0.0.1` by default.
|
|
24
24
|
|
|
25
|
-
**LAN mode**
|
|
25
|
+
**LAN mode** can be enabled during `palmier init`. The daemon binds to `0.0.0.0` instead, making the PWA and API endpoints accessible from the local network at `http://<host-ip>:<port>`. Devices must pair via OTP to access. Push notifications are not available.
|
|
26
26
|
|
|
27
27
|
**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. Server mode and LAN mode can be active at the same time.
|
|
28
28
|
|
|
@@ -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/agents/codex.js
CHANGED
|
@@ -12,8 +12,7 @@ export class CodexAgent {
|
|
|
12
12
|
getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
|
|
13
13
|
const yolo = extraPermissions === "yolo";
|
|
14
14
|
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id, yolo || !this.supportsPermissions) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
15
|
-
|
|
16
|
-
const args = ["exec", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
|
|
15
|
+
const args = ["exec", "--skip-git-repo-check", "--sandbox", yolo ? "danger-full-access" : "workspace-write"];
|
|
17
16
|
if (!yolo) {
|
|
18
17
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
19
18
|
for (const p of allPerms) {
|
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/run.d.ts
CHANGED
|
@@ -7,10 +7,6 @@ export declare function stripPalmierMarkers(output: string): string;
|
|
|
7
7
|
* Execute a task by ID.
|
|
8
8
|
*/
|
|
9
9
|
export declare function runCommand(taskId: string): Promise<void>;
|
|
10
|
-
/**
|
|
11
|
-
* Extract report file names from agent output.
|
|
12
|
-
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
13
|
-
*/
|
|
14
10
|
export declare function parseReportFiles(output: string): string[];
|
|
15
11
|
/**
|
|
16
12
|
* Extract required permissions from agent output.
|
package/dist/commands/run.js
CHANGED
|
@@ -70,6 +70,14 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
|
|
|
70
70
|
}
|
|
71
71
|
writer.end(reportFiles.length > 0 ? reportFiles : undefined);
|
|
72
72
|
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
73
|
+
if (reportFiles.length > 0) {
|
|
74
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, {
|
|
75
|
+
event_type: "report-generated",
|
|
76
|
+
run_id: ctx.runId,
|
|
77
|
+
name: ctx.task.frontmatter.name,
|
|
78
|
+
report_files: reportFiles,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
73
81
|
// Permission handling — agent requested permissions
|
|
74
82
|
if (requiredPermissions.length > 0) {
|
|
75
83
|
const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
|
|
@@ -408,6 +416,7 @@ async function requestConfirmation(config, task, taskDir) {
|
|
|
408
416
|
* Extract report file names from agent output.
|
|
409
417
|
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
410
418
|
*/
|
|
419
|
+
const ALLOWED_REPORT_EXT = [".md", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
411
420
|
export function parseReportFiles(output) {
|
|
412
421
|
const regex = new RegExp(`^\\${TASK_REPORT_PREFIX}\\s+(.+)$`, "gm");
|
|
413
422
|
const files = [];
|
|
@@ -415,8 +424,12 @@ export function parseReportFiles(output) {
|
|
|
415
424
|
while ((match = regex.exec(output)) !== null) {
|
|
416
425
|
const name = match[1].trim();
|
|
417
426
|
// Skip placeholder examples echoed from the prompt (e.g. "<filename>")
|
|
418
|
-
if (name
|
|
419
|
-
|
|
427
|
+
if (!name || name.startsWith("<"))
|
|
428
|
+
continue;
|
|
429
|
+
const ext = name.lastIndexOf(".") >= 0 ? name.slice(name.lastIndexOf(".")).toLowerCase() : "";
|
|
430
|
+
if (!ALLOWED_REPORT_EXT.includes(ext))
|
|
431
|
+
continue;
|
|
432
|
+
files.push(name);
|
|
420
433
|
}
|
|
421
434
|
return files;
|
|
422
435
|
}
|
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,9 +16,10 @@ 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
24
|
/** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
|
|
24
25
|
private ensureDaemonTask;
|
package/dist/platform/windows.js
CHANGED
|
@@ -5,7 +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
8
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
10
9
|
/**
|
|
11
10
|
* Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
|
|
@@ -47,7 +46,7 @@ export function triggerToXml(trigger) {
|
|
|
47
46
|
/**
|
|
48
47
|
* Build a complete Task Scheduler XML definition.
|
|
49
48
|
*/
|
|
50
|
-
export function buildTaskXml(tr, triggers) {
|
|
49
|
+
export function buildTaskXml(tr, triggers, foreground) {
|
|
51
50
|
const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
|
|
52
51
|
const commandStr = command?.replace(/"/g, "") ?? "";
|
|
53
52
|
const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
|
|
@@ -56,7 +55,7 @@ export function buildTaskXml(tr, triggers) {
|
|
|
56
55
|
`<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
|
|
57
56
|
` <Principals>`,
|
|
58
57
|
` <Principal>`,
|
|
59
|
-
` <LogonType
|
|
58
|
+
` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
|
|
60
59
|
` <RunLevel>LeastPrivilege</RunLevel>`,
|
|
61
60
|
` </Principal>`,
|
|
62
61
|
` </Principals>`,
|
|
@@ -84,52 +83,54 @@ export class WindowsPlatform {
|
|
|
84
83
|
const script = process.argv[1] || "palmier";
|
|
85
84
|
// Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
|
|
86
85
|
this.ensureDaemonTask(script);
|
|
87
|
-
// Remove old Registry Run key if upgrading
|
|
88
|
-
try {
|
|
89
|
-
execFileSync("reg", [
|
|
90
|
-
"delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
91
|
-
"/v", DAEMON_TASK_NAME, "/f",
|
|
92
|
-
], { encoding: "utf-8", stdio: "pipe" });
|
|
93
|
-
}
|
|
94
|
-
catch { /* key may not exist */ }
|
|
95
86
|
// Start the daemon now
|
|
96
87
|
this.startDaemonTask();
|
|
97
88
|
console.log("\nHost initialization complete!");
|
|
98
89
|
}
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// We ARE the old daemon (auto-update) — spawn replacement then exit.
|
|
105
|
-
this.startDaemonTask();
|
|
106
|
-
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" });
|
|
107
95
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
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.");
|
|
116
103
|
}
|
|
117
|
-
|
|
104
|
+
catch { /* task may not exist */ }
|
|
105
|
+
// Remove all Palmier task timers
|
|
118
106
|
try {
|
|
119
|
-
const out = execFileSync("
|
|
107
|
+
const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
120
108
|
for (const line of out.split("\n")) {
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
109
|
+
const match = line.match(/"(\\Palmier\\PalmierTask-[^"]+)"/);
|
|
110
|
+
if (match) {
|
|
111
|
+
try {
|
|
112
|
+
execFileSync("schtasks", ["/end", "/tn", match[1]], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
113
|
+
}
|
|
114
|
+
catch { /* ignore */ }
|
|
123
115
|
try {
|
|
124
|
-
execFileSync("
|
|
116
|
+
execFileSync("schtasks", ["/delete", "/tn", match[1], "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
125
117
|
}
|
|
126
|
-
catch { }
|
|
118
|
+
catch { /* ignore */ }
|
|
127
119
|
}
|
|
128
120
|
}
|
|
121
|
+
console.log("Task timers removed.");
|
|
129
122
|
}
|
|
130
|
-
catch {
|
|
131
|
-
|
|
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" });
|
|
132
131
|
}
|
|
132
|
+
catch { /* task may not be running */ }
|
|
133
|
+
// Start it again
|
|
133
134
|
this.startDaemonTask();
|
|
134
135
|
}
|
|
135
136
|
/** Create or update the Task Scheduler entry for the daemon (requires elevation for S4U). */
|
|
@@ -157,12 +158,6 @@ export class WindowsPlatform {
|
|
|
157
158
|
}
|
|
158
159
|
catch { /* ignore */ }
|
|
159
160
|
}
|
|
160
|
-
// Cleanup old VBS launcher if upgrading
|
|
161
|
-
const oldVbs = path.join(CONFIG_DIR, "daemon.vbs");
|
|
162
|
-
try {
|
|
163
|
-
fs.unlinkSync(oldVbs);
|
|
164
|
-
}
|
|
165
|
-
catch { /* ignore */ }
|
|
166
161
|
}
|
|
167
162
|
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
168
163
|
startDaemonTask() {
|
|
@@ -199,9 +194,9 @@ export class WindowsPlatform {
|
|
|
199
194
|
}
|
|
200
195
|
// Write XML and register via schtasks — gives us full control over
|
|
201
196
|
// settings like MultipleInstancesPolicy that schtasks flags don't expose.
|
|
202
|
-
// S4U LogonType ensures no console window
|
|
203
|
-
// because the daemon (which calls this) runs elevated.
|
|
204
|
-
const xml = buildTaskXml(tr, triggerElements);
|
|
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);
|
|
205
200
|
const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
|
|
206
201
|
try {
|
|
207
202
|
// schtasks /xml requires UTF-16LE with BOM
|
|
@@ -221,11 +216,6 @@ export class WindowsPlatform {
|
|
|
221
216
|
}
|
|
222
217
|
catch { /* ignore */ }
|
|
223
218
|
}
|
|
224
|
-
// Cleanup old VBS launcher if upgrading
|
|
225
|
-
try {
|
|
226
|
-
fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`));
|
|
227
|
-
}
|
|
228
|
-
catch { /* ignore */ }
|
|
229
219
|
}
|
|
230
220
|
removeTaskTimer(taskId) {
|
|
231
221
|
const tn = schtasksTaskName(taskId);
|
package/dist/rpc-handler.js
CHANGED
|
@@ -151,8 +151,20 @@ 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
|
}
|
|
157
|
+
case "task.get": {
|
|
158
|
+
const params = request.params;
|
|
159
|
+
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
160
|
+
try {
|
|
161
|
+
const task = parseTaskFile(taskDir);
|
|
162
|
+
return flattenTask(task);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return { error: "Task not found" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
156
168
|
case "task.create": {
|
|
157
169
|
const params = request.params;
|
|
158
170
|
// Only generate a plan for longer prompts that benefit from it
|
|
@@ -184,6 +196,7 @@ export function createRpcHandler(config, nc) {
|
|
|
184
196
|
triggers_enabled: params.triggers_enabled ?? true,
|
|
185
197
|
requires_confirmation: params.requires_confirmation ?? true,
|
|
186
198
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
199
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
187
200
|
...(params.command ? { command: params.command } : {}),
|
|
188
201
|
},
|
|
189
202
|
body,
|
|
@@ -217,6 +230,8 @@ export function createRpcHandler(config, nc) {
|
|
|
217
230
|
if (params.yolo_mode)
|
|
218
231
|
delete existing.frontmatter.permissions;
|
|
219
232
|
}
|
|
233
|
+
if (params.foreground_mode !== undefined)
|
|
234
|
+
existing.frontmatter.foreground_mode = params.foreground_mode || undefined;
|
|
220
235
|
if (params.command !== undefined) {
|
|
221
236
|
if (params.command) {
|
|
222
237
|
existing.frontmatter.command = params.command;
|
|
@@ -268,6 +283,7 @@ export function createRpcHandler(config, nc) {
|
|
|
268
283
|
triggers_enabled: false,
|
|
269
284
|
requires_confirmation: params.requires_confirmation ?? false,
|
|
270
285
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
286
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
271
287
|
...(params.command ? { command: params.command } : {}),
|
|
272
288
|
},
|
|
273
289
|
body: "",
|
|
@@ -492,11 +508,14 @@ export function createRpcHandler(config, nc) {
|
|
|
492
508
|
if (!params.run_id || !Array.isArray(params.report_files) || params.report_files.length === 0) {
|
|
493
509
|
return { error: "run_id and report_files are required" };
|
|
494
510
|
}
|
|
511
|
+
const ALLOWED_EXT = [".md", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
512
|
+
const IMAGE_EXT = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
495
513
|
const reports = [];
|
|
496
514
|
const runDir = path.join(config.projectRoot, "tasks", params.id, params.run_id);
|
|
497
515
|
for (const file of params.report_files) {
|
|
498
|
-
|
|
499
|
-
|
|
516
|
+
const ext = path.extname(file).toLowerCase();
|
|
517
|
+
if (!ALLOWED_EXT.includes(ext)) {
|
|
518
|
+
reports.push({ file, error: `unsupported file type: ${ext}` });
|
|
500
519
|
continue;
|
|
501
520
|
}
|
|
502
521
|
const basename = path.basename(file);
|
|
@@ -506,8 +525,15 @@ export function createRpcHandler(config, nc) {
|
|
|
506
525
|
}
|
|
507
526
|
const reportPath = path.join(runDir, basename);
|
|
508
527
|
try {
|
|
509
|
-
|
|
510
|
-
|
|
528
|
+
if (IMAGE_EXT.includes(ext)) {
|
|
529
|
+
const buf = fs.readFileSync(reportPath);
|
|
530
|
+
const mime = ext === ".svg" ? "image/svg+xml" : `image/${ext.slice(1).replace("jpg", "jpeg")}`;
|
|
531
|
+
reports.push({ file, data_url: `data:${mime};base64,${buf.toString("base64")}` });
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
const content = fs.readFileSync(reportPath, "utf-8");
|
|
535
|
+
reports.push({ file, content });
|
|
536
|
+
}
|
|
511
537
|
}
|
|
512
538
|
catch {
|
|
513
539
|
reports.push({ file, error: "Report file not found" });
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
package/src/agents/codex.ts
CHANGED
|
@@ -16,8 +16,7 @@ export class CodexAgent implements AgentTool {
|
|
|
16
16
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[] | "yolo"): CommandLine {
|
|
17
17
|
const yolo = extraPermissions === "yolo";
|
|
18
18
|
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id, yolo || !this.supportsPermissions) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
19
|
-
|
|
20
|
-
const args = ["exec", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
|
|
19
|
+
const args = ["exec", "--skip-git-repo-check", "--sandbox", yolo ? "danger-full-access" : "workspace-write"];
|
|
21
20
|
|
|
22
21
|
if (!yolo) {
|
|
23
22
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
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/run.ts
CHANGED
|
@@ -106,6 +106,15 @@ async function invokeAgentWithRetries(
|
|
|
106
106
|
writer.end(reportFiles.length > 0 ? reportFiles : undefined);
|
|
107
107
|
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
108
108
|
|
|
109
|
+
if (reportFiles.length > 0) {
|
|
110
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, {
|
|
111
|
+
event_type: "report-generated",
|
|
112
|
+
run_id: ctx.runId,
|
|
113
|
+
name: ctx.task.frontmatter.name,
|
|
114
|
+
report_files: reportFiles,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
// Permission handling — agent requested permissions
|
|
110
119
|
if (requiredPermissions.length > 0) {
|
|
111
120
|
const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
|
|
@@ -499,6 +508,8 @@ async function requestConfirmation(
|
|
|
499
508
|
* Extract report file names from agent output.
|
|
500
509
|
* Looks for lines matching: [PALMIER_REPORT] <filename>
|
|
501
510
|
*/
|
|
511
|
+
const ALLOWED_REPORT_EXT = [".md", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
512
|
+
|
|
502
513
|
export function parseReportFiles(output: string): string[] {
|
|
503
514
|
const regex = new RegExp(`^\\${TASK_REPORT_PREFIX}\\s+(.+)$`, "gm");
|
|
504
515
|
const files: string[] = [];
|
|
@@ -506,7 +517,10 @@ export function parseReportFiles(output: string): string[] {
|
|
|
506
517
|
while ((match = regex.exec(output)) !== null) {
|
|
507
518
|
const name = match[1].trim();
|
|
508
519
|
// Skip placeholder examples echoed from the prompt (e.g. "<filename>")
|
|
509
|
-
if (name
|
|
520
|
+
if (!name || name.startsWith("<")) continue;
|
|
521
|
+
const ext = name.lastIndexOf(".") >= 0 ? name.slice(name.lastIndexOf(".")).toLowerCase() : "";
|
|
522
|
+
if (!ALLOWED_REPORT_EXT.includes(ext)) continue;
|
|
523
|
+
files.push(name);
|
|
510
524
|
}
|
|
511
525
|
return files;
|
|
512
526
|
}
|
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,7 +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
12
|
|
|
14
13
|
|
|
15
14
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
@@ -59,7 +58,7 @@ export function triggerToXml(trigger: { type: string; value: string }): string {
|
|
|
59
58
|
/**
|
|
60
59
|
* Build a complete Task Scheduler XML definition.
|
|
61
60
|
*/
|
|
62
|
-
export function buildTaskXml(tr: string, triggers: string[]): string {
|
|
61
|
+
export function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string {
|
|
63
62
|
const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
|
|
64
63
|
const commandStr = command?.replace(/"/g, "") ?? "";
|
|
65
64
|
const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
|
|
@@ -69,7 +68,7 @@ export function buildTaskXml(tr: string, triggers: string[]): string {
|
|
|
69
68
|
`<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
|
|
70
69
|
` <Principals>`,
|
|
71
70
|
` <Principal>`,
|
|
72
|
-
` <LogonType
|
|
71
|
+
` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
|
|
73
72
|
` <RunLevel>LeastPrivilege</RunLevel>`,
|
|
74
73
|
` </Principal>`,
|
|
75
74
|
` </Principals>`,
|
|
@@ -101,53 +100,53 @@ export class WindowsPlatform implements PlatformService {
|
|
|
101
100
|
// Create the Task Scheduler entry for the daemon (BootTrigger starts it at system boot)
|
|
102
101
|
this.ensureDaemonTask(script);
|
|
103
102
|
|
|
104
|
-
// Remove old Registry Run key if upgrading
|
|
105
|
-
try {
|
|
106
|
-
execFileSync("reg", [
|
|
107
|
-
"delete", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
108
|
-
"/v", DAEMON_TASK_NAME, "/f",
|
|
109
|
-
], { encoding: "utf-8", stdio: "pipe" });
|
|
110
|
-
} catch { /* key may not exist */ }
|
|
111
|
-
|
|
112
103
|
// Start the daemon now
|
|
113
104
|
this.startDaemonTask();
|
|
114
105
|
|
|
115
106
|
console.log("\nHost initialization complete!");
|
|
116
107
|
}
|
|
117
108
|
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
|
|
121
|
-
: null;
|
|
122
|
-
|
|
123
|
-
if (oldPid && oldPid === String(process.pid)) {
|
|
124
|
-
// We ARE the old daemon (auto-update) — spawn replacement then exit.
|
|
125
|
-
this.startDaemonTask();
|
|
126
|
-
process.exit(0);
|
|
127
|
-
}
|
|
109
|
+
uninstallDaemon(): void {
|
|
110
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
128
111
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 */ }
|
|
137
124
|
|
|
138
|
-
//
|
|
125
|
+
// Remove all Palmier task timers
|
|
139
126
|
try {
|
|
140
|
-
const out = execFileSync("
|
|
127
|
+
const out = execFileSync("schtasks", ["/query", "/fo", "CSV", "/nh"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
141
128
|
for (const line of out.split("\n")) {
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
144
|
-
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 */ }
|
|
145
133
|
}
|
|
146
134
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
console.log("Task timers removed.");
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
|
|
138
|
+
console.log("Palmier daemon and tasks uninstalled.");
|
|
139
|
+
}
|
|
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 */ }
|
|
150
148
|
|
|
149
|
+
// Start it again
|
|
151
150
|
this.startDaemonTask();
|
|
152
151
|
}
|
|
153
152
|
|
|
@@ -172,9 +171,6 @@ export class WindowsPlatform implements PlatformService {
|
|
|
172
171
|
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
173
172
|
}
|
|
174
173
|
|
|
175
|
-
// Cleanup old VBS launcher if upgrading
|
|
176
|
-
const oldVbs = path.join(CONFIG_DIR, "daemon.vbs");
|
|
177
|
-
try { fs.unlinkSync(oldVbs); } catch { /* ignore */ }
|
|
178
174
|
}
|
|
179
175
|
|
|
180
176
|
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
@@ -213,9 +209,9 @@ export class WindowsPlatform implements PlatformService {
|
|
|
213
209
|
|
|
214
210
|
// Write XML and register via schtasks — gives us full control over
|
|
215
211
|
// settings like MultipleInstancesPolicy that schtasks flags don't expose.
|
|
216
|
-
// S4U LogonType ensures no console window
|
|
217
|
-
// because the daemon (which calls this) runs elevated.
|
|
218
|
-
const xml = buildTaskXml(tr, triggerElements);
|
|
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);
|
|
219
215
|
const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
|
|
220
216
|
try {
|
|
221
217
|
// schtasks /xml requires UTF-16LE with BOM
|
|
@@ -231,8 +227,6 @@ export class WindowsPlatform implements PlatformService {
|
|
|
231
227
|
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
232
228
|
}
|
|
233
229
|
|
|
234
|
-
// Cleanup old VBS launcher if upgrading
|
|
235
|
-
try { fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`)); } catch { /* ignore */ }
|
|
236
230
|
}
|
|
237
231
|
|
|
238
232
|
removeTaskTimer(taskId: string): void {
|
package/src/rpc-handler.ts
CHANGED
|
@@ -178,9 +178,21 @@ 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
|
|
|
185
|
+
case "task.get": {
|
|
186
|
+
const params = request.params as { id: string };
|
|
187
|
+
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
188
|
+
try {
|
|
189
|
+
const task = parseTaskFile(taskDir);
|
|
190
|
+
return flattenTask(task);
|
|
191
|
+
} catch {
|
|
192
|
+
return { error: "Task not found" };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
184
196
|
case "task.create": {
|
|
185
197
|
const params = request.params as {
|
|
186
198
|
user_prompt: string;
|
|
@@ -189,6 +201,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
189
201
|
triggers_enabled?: boolean;
|
|
190
202
|
requires_confirmation?: boolean;
|
|
191
203
|
yolo_mode?: boolean;
|
|
204
|
+
foreground_mode?: boolean;
|
|
192
205
|
command?: string;
|
|
193
206
|
};
|
|
194
207
|
|
|
@@ -220,6 +233,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
220
233
|
triggers_enabled: params.triggers_enabled ?? true,
|
|
221
234
|
requires_confirmation: params.requires_confirmation ?? true,
|
|
222
235
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
236
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
223
237
|
...(params.command ? { command: params.command } : {}),
|
|
224
238
|
},
|
|
225
239
|
body,
|
|
@@ -241,6 +255,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
241
255
|
triggers_enabled?: boolean;
|
|
242
256
|
requires_confirmation?: boolean;
|
|
243
257
|
yolo_mode?: boolean;
|
|
258
|
+
foreground_mode?: boolean;
|
|
244
259
|
command?: string;
|
|
245
260
|
};
|
|
246
261
|
|
|
@@ -263,6 +278,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
263
278
|
existing.frontmatter.yolo_mode = params.yolo_mode || undefined;
|
|
264
279
|
if (params.yolo_mode) delete existing.frontmatter.permissions;
|
|
265
280
|
}
|
|
281
|
+
if (params.foreground_mode !== undefined) existing.frontmatter.foreground_mode = params.foreground_mode || undefined;
|
|
266
282
|
if (params.command !== undefined) {
|
|
267
283
|
if (params.command) {
|
|
268
284
|
existing.frontmatter.command = params.command;
|
|
@@ -310,6 +326,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
310
326
|
agent: string;
|
|
311
327
|
requires_confirmation?: boolean;
|
|
312
328
|
yolo_mode?: boolean;
|
|
329
|
+
foreground_mode?: boolean;
|
|
313
330
|
command?: string;
|
|
314
331
|
};
|
|
315
332
|
|
|
@@ -326,6 +343,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
326
343
|
triggers_enabled: false,
|
|
327
344
|
requires_confirmation: params.requires_confirmation ?? false,
|
|
328
345
|
...(params.yolo_mode ? { yolo_mode: true } : {}),
|
|
346
|
+
...(params.foreground_mode ? { foreground_mode: true } : {}),
|
|
329
347
|
...(params.command ? { command: params.command } : {}),
|
|
330
348
|
},
|
|
331
349
|
body: "",
|
|
@@ -570,11 +588,14 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
570
588
|
if (!params.run_id || !Array.isArray(params.report_files) || params.report_files.length === 0) {
|
|
571
589
|
return { error: "run_id and report_files are required" };
|
|
572
590
|
}
|
|
573
|
-
const
|
|
591
|
+
const ALLOWED_EXT = [".md", ".txt", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
592
|
+
const IMAGE_EXT = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
|
|
593
|
+
const reports: Array<{ file: string; content?: string; data_url?: string; error?: string }> = [];
|
|
574
594
|
const runDir = path.join(config.projectRoot, "tasks", params.id, params.run_id);
|
|
575
595
|
for (const file of params.report_files) {
|
|
576
|
-
|
|
577
|
-
|
|
596
|
+
const ext = path.extname(file).toLowerCase();
|
|
597
|
+
if (!ALLOWED_EXT.includes(ext)) {
|
|
598
|
+
reports.push({ file, error: `unsupported file type: ${ext}` });
|
|
578
599
|
continue;
|
|
579
600
|
}
|
|
580
601
|
const basename = path.basename(file);
|
|
@@ -584,8 +605,14 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
584
605
|
}
|
|
585
606
|
const reportPath = path.join(runDir, basename);
|
|
586
607
|
try {
|
|
587
|
-
|
|
588
|
-
|
|
608
|
+
if (IMAGE_EXT.includes(ext)) {
|
|
609
|
+
const buf = fs.readFileSync(reportPath);
|
|
610
|
+
const mime = ext === ".svg" ? "image/svg+xml" : `image/${ext.slice(1).replace("jpg", "jpeg")}`;
|
|
611
|
+
reports.push({ file, data_url: `data:${mime};base64,${buf.toString("base64")}` });
|
|
612
|
+
} else {
|
|
613
|
+
const content = fs.readFileSync(reportPath, "utf-8");
|
|
614
|
+
reports.push({ file, content });
|
|
615
|
+
}
|
|
589
616
|
} catch {
|
|
590
617
|
reports.push({ file, error: "Report file not found" });
|
|
591
618
|
}
|