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 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** is 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.
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 before making changes
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, Registry Run key on Windows)
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, Registry Run key on Windows), staying alive via `palmier serve`.
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 the daemon:**
145
+ 2. **Stop the daemon and remove all scheduled tasks:**
143
146
 
144
- **Linux:**
145
147
  ```bash
146
- systemctl --user stop palmier.service
147
- systemctl --user disable palmier.service
148
- rm ~/.config/systemd/user/palmier.service
148
+ palmier uninstall
149
149
  ```
150
150
 
151
- **Windows (PowerShell):**
152
- ```powershell
153
- # Kill the daemon process
154
- Get-Content "$env:USERPROFILE\.config\palmier\daemon.pid" | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }
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
- 3. **Remove any task timers:**
157
+ 4. **(Optional) Remove configuration and task data:**
160
158
 
161
159
  **Linux:**
162
160
  ```bash
163
- systemctl --user stop palmier-task-*.timer palmier-task-*.service 2>/dev/null
164
- systemctl --user disable palmier-task-*.timer 2>/dev/null
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
- schtasks /delete /tn "PalmierTask-*" /f 2>$null
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
@@ -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
- // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
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) {
@@ -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();
@@ -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.
@@ -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 && !name.startsWith("<"))
419
- files.push(name);
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
  }
@@ -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,2 @@
1
+ export declare function uninstallCommand(): Promise<void>;
2
+ //# sourceMappingURL=uninstall.d.ts.map
@@ -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");
@@ -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;
@@ -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;
@@ -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>S4U</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
- async restartDaemon() {
100
- const oldPid = fs.existsSync(DAEMON_PID_FILE)
101
- ? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
102
- : null;
103
- if (oldPid && oldPid === String(process.pid)) {
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
- // Kill old daemon by PID
109
- if (oldPid) {
110
- try {
111
- execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
112
- }
113
- catch {
114
- // Process may have already exited
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
- // Also kill any stale palmier serve processes (e.g. leftover from a previous daemon)
104
+ catch { /* task may not exist */ }
105
+ // Remove all Palmier task timers
118
106
  try {
119
- const out = execFileSync("wmic", ["process", "where", `CommandLine like '%palmier%serve%' and ProcessId != '${process.pid}'`, "get", "ProcessId"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
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 pid = line.trim();
122
- if (pid && /^\d+$/.test(pid)) {
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("taskkill", ["/pid", pid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
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
- // wmic may not be available on all Windows versions
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. Works without elevation
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);
@@ -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
- if (!file.endsWith(".md") && !file.endsWith(".txt")) {
499
- reports.push({ file, error: "must end with .md or .txt" });
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
- const content = fs.readFileSync(reportPath, "utf-8");
510
- reports.push({ file, content });
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
@@ -20,6 +20,7 @@ export interface TaskFrontmatter {
20
20
  triggers_enabled: boolean;
21
21
  requires_confirmation: boolean;
22
22
  yolo_mode?: boolean;
23
+ foreground_mode?: boolean;
23
24
  permissions?: RequiredPermission[];
24
25
  command?: string;
25
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -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
- // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
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 ?? [])];
@@ -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();
@@ -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 && !name.startsWith("<")) files.push(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
  }
@@ -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");
@@ -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.
@@ -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
 
@@ -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>S4U</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
- async restartDaemon(): Promise<void> {
119
- const oldPid = fs.existsSync(DAEMON_PID_FILE)
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
- // Kill old daemon by PID
130
- if (oldPid) {
131
- try {
132
- execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
133
- } catch {
134
- // Process may have already exited
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
- // Also kill any stale palmier serve processes (e.g. leftover from a previous daemon)
125
+ // Remove all Palmier task timers
139
126
  try {
140
- const out = execFileSync("wmic", ["process", "where", `CommandLine like '%palmier%serve%' and ProcessId != '${process.pid}'`, "get", "ProcessId"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
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 pid = line.trim();
143
- if (pid && /^\d+$/.test(pid)) {
144
- try { execFileSync("taskkill", ["/pid", pid, "/f", "/t"], { windowsHide: true, stdio: "pipe" }); } catch {}
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
- } catch {
148
- // wmic may not be available on all Windows versions
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. Works without elevation
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 {
@@ -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 reports: Array<{ file: string; content?: string; error?: string }> = [];
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
- if (!file.endsWith(".md") && !file.endsWith(".txt")) {
577
- reports.push({ file, error: "must end with .md or .txt" });
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
- const content = fs.readFileSync(reportPath, "utf-8");
588
- reports.push({ file, content });
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
  }
package/src/types.ts CHANGED
@@ -24,6 +24,7 @@ export interface TaskFrontmatter {
24
24
  triggers_enabled: boolean;
25
25
  requires_confirmation: boolean;
26
26
  yolo_mode?: boolean;
27
+ foreground_mode?: boolean;
27
28
  permissions?: RequiredPermission[];
28
29
  command?: string;
29
30
  }