palmier 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,11 +127,11 @@ palmier restart
127
127
  - **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).
128
128
  - **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.
129
129
  - **Schedules** are backed by systemd timers (Linux) or Task Scheduler (Windows). You can enable/disable them without deleting the task, and any task can still be run manually at any time.
130
- - **Task execution** uses the system scheduler on both platforms — `systemctl --user start` on Linux, `schtasks /run` on Windows. The daemon polls every 30 seconds to detect crashed tasks (processes that exited without updating status) and marks them as failed, broadcasting the failure to connected clients.
130
+ - **Task execution** uses the system scheduler on both platforms — `systemctl --user start` on Linux, `schtasks /run` on Windows. On Windows, tasks run via a VBS wrapper (`wscript.exe`) to avoid visible console windows. The daemon polls every 30 seconds to detect crashed tasks (processes that exited without updating status) and marks them as failed, broadcasting the failure to connected clients.
131
131
  - **Command-triggered tasks** — optionally specify a shell command (e.g., `tail -f /var/log/app.log`). Palmier runs the command continuously and invokes the agent for each line of stdout, passing it alongside your prompt. Useful for log monitoring, event-driven automation, and reactive workflows.
132
132
  - **Task confirmation** — tasks can optionally require your approval before running. You'll get a push notification (server mode) or a prompt in the PWA to confirm or abort.
133
- - **Run history** — each run produces a timestamped result file. You can view results and reports from the PWA.
134
- - **Real-time updates** — task status changes (started, finished, failed) are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode).
133
+ - **Conversational run history** — each run produces a timestamped `RESULT-{ts}.md` file with a conversational structure: a sequence of assistant messages (agent output), user messages (input responses, permission grants, confirmations), and status entries (started, finished, failed, aborted). Timing and outcome are derived from status messages — no redundant frontmatter. The PWA displays these as a chat-like thread. Reports are attached per-message.
134
+ - **Real-time updates** — task status changes and result updates are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode). The run detail view live-updates as the agent produces output.
135
135
  - **Agent CLI commands** — `palmier notify` and `palmier request-input` allow agents to send push notifications and request user input during task execution without requiring MCP support.
136
136
 
137
137
  ## NATS Subjects
@@ -159,6 +159,8 @@ src/
159
159
  events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
160
160
  agents/
161
161
  agent.ts # AgentTool interface, registry, and agent detection
162
+ shared-prompt.ts # Agent instructions loader
163
+ agent-instructions.md # System prompt injected into every agent invocation
162
164
  claude.ts # Claude Code agent implementation
163
165
  gemini.ts # Gemini CLI agent implementation
164
166
  codex.ts # Codex CLI agent implementation
@@ -2,8 +2,7 @@
2
2
  * Request input from the user and print responses to stdout.
3
3
  * Usage: palmier request-input --description "Question 1" --description "Question 2"
4
4
  *
5
- * Requires PALMIER_TASK_ID environment variable to be set.
6
- * Outputs each response on its own line: "description: value"
5
+ * Requires PALMIER_TASK_ID and PALMIER_RUN_DIR environment variables.
7
6
  */
8
7
  export declare function requestInputCommand(opts: {
9
8
  description: string[];
@@ -1,13 +1,12 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { connectNats } from "../nats-client.js";
3
- import { getTaskDir, parseTaskFile, appendResultMessage } from "../task.js";
3
+ import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
4
4
  import { requestUserInput, publishInputResolved } from "../user-input.js";
5
5
  /**
6
6
  * Request input from the user and print responses to stdout.
7
7
  * Usage: palmier request-input --description "Question 1" --description "Question 2"
8
8
  *
9
- * Requires PALMIER_TASK_ID environment variable to be set.
10
- * Outputs each response on its own line: "description: value"
9
+ * Requires PALMIER_TASK_ID and PALMIER_RUN_DIR environment variables.
11
10
  */
12
11
  export async function requestInputCommand(opts) {
13
12
  const taskId = process.env.PALMIER_TASK_ID;
@@ -19,33 +18,20 @@ export async function requestInputCommand(opts) {
19
18
  const nc = await connectNats(config);
20
19
  const taskDir = getTaskDir(config.projectRoot, taskId);
21
20
  const task = parseTaskFile(taskDir);
21
+ const runId = process.env.PALMIER_RUN_DIR?.split(/[/\\]/).pop();
22
22
  try {
23
23
  const response = await requestUserInput(nc, config, taskId, task.frontmatter.name, taskDir, opts.description);
24
24
  await publishInputResolved(nc, config, taskId, response === "aborted" ? "aborted" : "provided");
25
25
  if (response === "aborted") {
26
- // Write abort as user message if RESULT file is available
27
- const resultFile = process.env.PALMIER_RESULT_FILE;
28
- if (resultFile) {
29
- appendResultMessage(taskDir, resultFile, {
30
- role: "user",
31
- time: Date.now(),
32
- content: "Input request aborted.",
33
- type: "input",
34
- });
26
+ if (runId) {
27
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
35
28
  }
36
29
  console.error("User aborted the input request.");
37
30
  process.exit(1);
38
31
  }
39
- // Write user input as a conversation message
40
- const resultFile = process.env.PALMIER_RESULT_FILE;
41
- if (resultFile) {
32
+ if (runId) {
42
33
  const lines = opts.description.map((desc, i) => `**${desc}** ${response[i]}`);
43
- appendResultMessage(taskDir, resultFile, {
44
- role: "user",
45
- time: Date.now(),
46
- content: lines.join("\n"),
47
- type: "input",
48
- });
34
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
49
35
  }
50
36
  for (let i = 0; i < opts.description.length; i++) {
51
37
  console.log(response[i]);
@@ -1,4 +1,8 @@
1
1
  import type { TaskRunningState, RequiredPermission } from "../types.js";
2
+ /**
3
+ * Strip [PALMIER_*] marker lines from agent output.
4
+ */
5
+ export declare function stripPalmierMarkers(output: string): string;
2
6
  /**
3
7
  * Execute a task by ID.
4
8
  */
@@ -4,7 +4,7 @@ import * as readline from "readline";
4
4
  import { spawnCommand, spawnStreamingCommand } from "../spawn-command.js";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { connectNats } from "../nats-client.js";
7
- import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createResultFile, appendResultMessage, finalizeResultFrontmatter } from "../task.js";
7
+ import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir } from "../task.js";
8
8
  import { getAgent } from "../agents/agent.js";
9
9
  import { getPlatform } from "../platform/index.js";
10
10
  import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
@@ -23,8 +23,8 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
23
23
  while (true) {
24
24
  const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
25
25
  const result = await spawnCommand(command, args, {
26
- cwd: ctx.taskDir,
27
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RESULT_FILE: ctx.resultFileName },
26
+ cwd: getRunDir(ctx.taskDir, ctx.runId),
27
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
28
28
  echoStdout: true,
29
29
  resolveOnFailure: true,
30
30
  stdin,
@@ -79,27 +79,29 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
79
79
  /**
80
80
  * Strip [PALMIER_*] marker lines from agent output.
81
81
  */
82
- function stripPalmierMarkers(output) {
82
+ export function stripPalmierMarkers(output) {
83
83
  return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
84
84
  }
85
85
  /**
86
86
  * Append a conversation message to the RESULT file and notify connected clients.
87
87
  */
88
88
  async function appendAndNotify(ctx, msg) {
89
- appendResultMessage(ctx.taskDir, ctx.resultFileName, msg);
90
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated" });
89
+ appendRunMessage(ctx.taskDir, ctx.runId, msg);
90
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
91
91
  }
92
92
  /**
93
- * Find an existing RESULT file with running_state=started (created by the RPC handler).
93
+ * Find the latest run dir that has no status messages yet (just created by the RPC handler).
94
94
  */
95
- function findStartedResultFile(taskDir) {
96
- const files = fs.readdirSync(taskDir).filter((f) => f.startsWith("RESULT-") && f.endsWith(".md"));
97
- for (const file of files) {
98
- const content = fs.readFileSync(path.join(taskDir, file), "utf-8");
99
- if (content.includes("running_state: started"))
100
- return file;
101
- }
102
- return null;
95
+ function findLatestPendingRunId(taskDir) {
96
+ const dirs = fs.readdirSync(taskDir)
97
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
98
+ .sort();
99
+ if (dirs.length === 0)
100
+ return null;
101
+ const latest = dirs[dirs.length - 1];
102
+ const messages = readRunMessages(taskDir, latest);
103
+ const hasStatus = messages.some((m) => m.role === "status");
104
+ return hasStatus ? null : latest;
103
105
  }
104
106
  /**
105
107
  * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
@@ -121,30 +123,22 @@ export async function runCommand(taskId) {
121
123
  console.log(`Running task: ${taskId}`);
122
124
  let nc;
123
125
  const taskName = task.frontmatter.name;
124
- // Check for an existing "started" result file (created by the RPC handler)
125
- const existingResult = findStartedResultFile(taskDir);
126
- const startTime = existingResult ? parseInt(existingResult.replace("RESULT-", "").replace(".md", ""), 10) : Date.now();
127
- const resultFileName = existingResult ?? createResultFile(taskDir, taskName, startTime);
128
- // Snapshot the task file at run time
129
- const taskSnapshotName = `TASK-${startTime}.md`;
130
- if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
131
- fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
126
+ // Use existing run dir if just created by RPC, otherwise create a new one
127
+ const existingRunId = findLatestPendingRunId(taskDir);
128
+ const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now());
129
+ if (!existingRunId) {
130
+ appendHistory(config.projectRoot, { task_id: taskId, run_id: runId });
132
131
  }
133
132
  const cleanup = async () => {
134
133
  if (nc && !nc.isClosed()) {
135
134
  await nc.drain();
136
135
  }
137
136
  };
138
- if (!existingResult) {
139
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
140
- }
141
137
  try {
142
138
  nc = await connectNats(config);
143
- // Mark as started immediately
144
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
145
- // Status: started
146
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "started" });
147
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
139
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, runId);
140
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
141
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
148
142
  // If requires_confirmation, notify clients and wait
149
143
  if (task.frontmatter.requires_confirmation) {
150
144
  const confirmed = await requestConfirmation(nc, config, task, taskDir);
@@ -152,30 +146,28 @@ export async function runCommand(taskId) {
152
146
  await publishConfirmResolved(nc, config, taskId, resolvedStatus);
153
147
  if (!confirmed) {
154
148
  console.log("Task aborted by user.");
155
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "aborted" });
156
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: "aborted" });
157
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
149
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
150
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
158
151
  await cleanup();
159
152
  return;
160
153
  }
161
154
  console.log("Task confirmed by user.");
162
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "confirmation" });
163
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
155
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
156
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
164
157
  }
165
158
  // Shared invocation context
166
159
  const guiEnv = getPlatform().getGuiEnv();
167
160
  const agent = getAgent(task.frontmatter.agent);
168
161
  const ctx = {
169
- agent, task, taskDir, resultFileName, guiEnv, nc, config, taskId,
162
+ agent, task, taskDir, runId, guiEnv, nc, config, taskId,
170
163
  transientPermissions: [],
171
164
  };
172
165
  if (task.frontmatter.command) {
173
166
  // Command-triggered mode
174
167
  const result = await runCommandTriggeredMode(ctx);
175
168
  const outcome = resolveOutcome(taskDir, result.outcome);
176
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
177
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: result.endTime, running_state: outcome });
178
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
169
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
170
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
179
171
  console.log(`Task ${taskId} completed (command-triggered).`);
180
172
  }
181
173
  else {
@@ -187,9 +179,8 @@ export async function runCommand(taskId) {
187
179
  });
188
180
  const result = await invokeAgentWithRetry(ctx, task);
189
181
  const outcome = resolveOutcome(taskDir, result.outcome);
190
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
191
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
192
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
182
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
183
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
193
184
  console.log(`Task ${taskId} completed.`);
194
185
  }
195
186
  }
@@ -197,14 +188,13 @@ export async function runCommand(taskId) {
197
188
  console.error(`Task ${taskId} failed:`, err);
198
189
  const outcome = resolveOutcome(taskDir, "failed");
199
190
  const errorMsg = err instanceof Error ? err.message : String(err);
200
- appendResultMessage(taskDir, resultFileName, {
191
+ appendRunMessage(taskDir, runId, {
201
192
  role: "assistant",
202
193
  time: Date.now(),
203
194
  content: errorMsg,
204
195
  });
205
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
206
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
207
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
196
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
197
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
208
198
  process.exitCode = 1;
209
199
  }
210
200
  finally {
@@ -226,8 +216,8 @@ async function runCommandTriggeredMode(ctx) {
226
216
  const commandStr = ctx.task.frontmatter.command;
227
217
  console.log(`[command-triggered] Spawning: ${commandStr}`);
228
218
  const child = spawnStreamingCommand(commandStr, {
229
- cwd: ctx.taskDir,
230
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
219
+ cwd: getRunDir(ctx.taskDir, ctx.runId),
220
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
231
221
  });
232
222
  let linesProcessed = 0;
233
223
  let invocationsSucceeded = 0;
@@ -236,7 +226,7 @@ async function runCommandTriggeredMode(ctx) {
236
226
  let processing = false;
237
227
  let commandExited = false;
238
228
  let resolveWhenDone;
239
- const logPath = path.join(ctx.taskDir, "command-output.log");
229
+ const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
240
230
  function appendLog(line, agentOutput, outcome) {
241
231
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
242
232
  fs.appendFileSync(logPath, entry, "utf-8");
@@ -330,7 +320,7 @@ async function runCommandTriggeredMode(ctx) {
330
320
  const endTime = Date.now();
331
321
  return { outcome: "finished", endTime };
332
322
  }
333
- async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, resultFile) {
323
+ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
334
324
  writeTaskStatus(taskDir, {
335
325
  running_state: eventType,
336
326
  time_stamp: Date.now(),
@@ -339,8 +329,8 @@ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName
339
329
  const payload = { event_type: "running-state", running_state: eventType };
340
330
  if (taskName)
341
331
  payload.name = taskName;
342
- if (resultFile)
343
- payload.result_file = resultFile;
332
+ if (runId)
333
+ payload.run_id = runId;
344
334
  await publishHostEvent(nc, config.hostId, taskId, payload);
345
335
  }
346
336
  /**
@@ -4,7 +4,7 @@ import { loadConfig } from "../config.js";
4
4
  import { connectNats } from "../nats-client.js";
5
5
  import { createRpcHandler } from "../rpc-handler.js";
6
6
  import { startNatsTransport } from "../transports/nats-transport.js";
7
- import { getTaskDir, readTaskStatus, writeTaskStatus, appendHistory, parseTaskFile, appendResultMessage } from "../task.js";
7
+ import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage } from "../task.js";
8
8
  import { publishHostEvent } from "../events.js";
9
9
  import { getPlatform } from "../platform/index.js";
10
10
  import { detectAgents } from "../agents/agent.js";
@@ -13,38 +13,11 @@ import { CONFIG_DIR } from "../config.js";
13
13
  const POLL_INTERVAL_MS = 30_000;
14
14
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
15
15
  /**
16
- * Mark a stuck task as failed: update status.json, write RESULT, append history,
17
- * and broadcast the failure event.
18
- */
19
- async function markTaskFailed(config, nc, taskId, reason) {
20
- const taskDir = getTaskDir(config.projectRoot, taskId);
21
- const status = readTaskStatus(taskDir);
22
- if (!status || status.running_state !== "started")
23
- return;
24
- console.log(`[monitor] Task ${taskId} ${reason}, marking as failed.`);
25
- const endTime = Date.now();
26
- writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
27
- let taskName = taskId;
28
- try {
29
- const task = parseTaskFile(taskDir);
30
- taskName = task.frontmatter.name || taskId;
31
- }
32
- catch { /* use taskId as fallback */ }
33
- const resultFileName = `RESULT-${endTime}.md`;
34
- const content = `---\ntask_name: ${taskName}\nrunning_state: failed\nstart_time: ${status.time_stamp}\nend_time: ${endTime}\ntask_file: \n---\n\n`;
35
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
36
- appendResultMessage(taskDir, resultFileName, {
37
- role: "assistant",
38
- time: endTime,
39
- content: reason,
40
- });
41
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
42
- const payload = { event_type: "running-state", running_state: "failed", name: taskName };
43
- await publishHostEvent(nc, config.hostId, taskId, payload);
44
- }
45
- /**
46
- * Scan all tasks for any stuck in "start" state whose process is no longer alive.
16
+ * Scan all tasks for any stuck in "started" state whose process is no longer alive.
47
17
  * Uses the system scheduler (Task Scheduler / systemd) as the authoritative source.
18
+ *
19
+ * Since run.ts creates the RESULT file and history entry at start, we just need to
20
+ * finalize the existing RESULT file, append a failed status entry, and broadcast.
48
21
  */
49
22
  async function checkStaleTasks(config, nc) {
50
23
  const tasksJsonl = path.join(config.projectRoot, "tasks.jsonl");
@@ -67,7 +40,32 @@ async function checkStaleTasks(config, nc) {
67
40
  // Ask the system scheduler if the task is still running
68
41
  if (platform.isTaskRunning(taskId))
69
42
  continue;
70
- await markTaskFailed(config, nc, taskId, "Task process exited unexpectedly");
43
+ console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
44
+ const endTime = Date.now();
45
+ writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
46
+ // Find the latest run directory (created by run.ts at start)
47
+ const runId = fs.readdirSync(taskDir)
48
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
49
+ .sort()
50
+ .pop();
51
+ if (runId) {
52
+ appendRunMessage(taskDir, runId, {
53
+ role: "status",
54
+ time: endTime,
55
+ content: "",
56
+ type: "failed",
57
+ });
58
+ }
59
+ let taskName = taskId;
60
+ try {
61
+ taskName = parseTaskFile(taskDir).frontmatter.name || taskId;
62
+ }
63
+ catch { /* use taskId as fallback */ }
64
+ await publishHostEvent(nc, config.hostId, taskId, {
65
+ event_type: "running-state",
66
+ running_state: "failed",
67
+ name: taskName,
68
+ });
71
69
  }
72
70
  }
73
71
  /**
@@ -3,6 +3,8 @@ import * as path from "path";
3
3
  import { homedir } from "os";
4
4
  import { execSync, exec } from "child_process";
5
5
  import { promisify } from "util";
6
+ import { loadConfig } from "../config.js";
7
+ import { getTaskDir, readTaskStatus } from "../task.js";
6
8
  const execAsync = promisify(exec);
7
9
  const UNIT_DIR = path.join(homedir(), ".config", "systemd", "user");
8
10
  const PATH_FILE = path.join(homedir(), ".config", "palmier", "user-path");
@@ -205,18 +207,26 @@ WantedBy=timers.target
205
207
  await execAsync(`systemctl --user stop ${serviceName}`);
206
208
  }
207
209
  isTaskRunning(taskId) {
210
+ // Check systemd first (for scheduled/on-demand runs)
208
211
  const serviceName = getServiceName(taskId);
209
212
  try {
210
- // is-active exits 0 only for "active". For oneshot services (Type=oneshot),
211
- // the state is "activating" while running, which exits non-zero.
212
- // Use show -p ActiveState to reliably get the state without exit code issues.
213
213
  const out = execSync(`systemctl --user show -p ActiveState --value ${serviceName}`, { encoding: "utf-8" });
214
214
  const state = out.trim();
215
- return state === "active" || state === "activating";
215
+ if (state === "active" || state === "activating")
216
+ return true;
216
217
  }
217
- catch {
218
- return false;
218
+ catch { /* service may not exist */ }
219
+ // Fall back to PID check (for follow-up runs spawned directly)
220
+ try {
221
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
222
+ const status = readTaskStatus(taskDir);
223
+ if (status?.pid) {
224
+ process.kill(status.pid, 0); // signal 0 = check if process exists
225
+ return true;
226
+ }
219
227
  }
228
+ catch { /* process not running or config unavailable */ }
229
+ return false;
220
230
  }
221
231
  getGuiEnv() {
222
232
  const uid = process.getuid?.();
@@ -8,14 +8,6 @@ const TASK_PREFIX = "\\Palmier\\PalmierTask-";
8
8
  const DAEMON_TASK_NAME = "PalmierDaemon";
9
9
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
10
10
  const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
11
- /**
12
- * Build the /tr value for schtasks: a single string with quoted paths
13
- * so Task Scheduler can invoke node with the palmier script + subcommand.
14
- */
15
- function schtasksTr(...subcommand) {
16
- const script = process.argv[1] || "palmier";
17
- return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
18
- }
19
11
  const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
20
12
  /**
21
13
  * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
@@ -144,7 +136,13 @@ export class WindowsPlatform {
144
136
  installTaskTimer(config, task) {
145
137
  const taskId = task.frontmatter.id;
146
138
  const tn = schtasksTaskName(taskId);
147
- const tr = schtasksTr("run", taskId);
139
+ const script = process.argv[1] || "palmier";
140
+ // Write a VBS launcher so the task runs without a visible console window
141
+ const vbsPath = path.join(CONFIG_DIR, `task-${taskId}.vbs`);
142
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" run ${taskId}", 0, True`;
143
+ fs.writeFileSync(vbsPath, vbs, "utf-8");
144
+ const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
145
+ const tr = `"${wscript}" "${vbsPath}"`;
148
146
  // Build trigger XML elements
149
147
  const triggerElements = [];
150
148
  if (task.frontmatter.triggers_enabled) {
@@ -192,6 +190,10 @@ export class WindowsPlatform {
192
190
  catch {
193
191
  // Task might not exist — that's fine
194
192
  }
193
+ try {
194
+ fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`));
195
+ }
196
+ catch { /* ignore */ }
195
197
  }
196
198
  async startTask(taskId) {
197
199
  const tn = schtasksTaskName(taskId);
@@ -228,17 +230,40 @@ export class WindowsPlatform {
228
230
  }
229
231
  }
230
232
  isTaskRunning(taskId) {
233
+ // Check Task Scheduler first (for scheduled/on-demand runs)
231
234
  const tn = schtasksTaskName(taskId);
232
235
  try {
233
236
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
234
237
  encoding: "utf-8",
235
238
  windowsHide: true,
236
239
  });
237
- return out.includes('"Running"');
240
+ if (out.includes('"Running"'))
241
+ return true;
238
242
  }
239
- catch {
240
- return false;
243
+ catch { /* task may not exist in scheduler */ }
244
+ // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
245
+ try {
246
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
247
+ const status = readTaskStatus(taskDir);
248
+ if (status?.pid) {
249
+ // tasklist exits 0 if the PID is found
250
+ execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
251
+ encoding: "utf-8",
252
+ windowsHide: true,
253
+ stdio: "pipe",
254
+ });
255
+ // tasklist always exits 0; check if output contains the PID
256
+ const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
257
+ encoding: "utf-8",
258
+ windowsHide: true,
259
+ stdio: "pipe",
260
+ });
261
+ if (out.includes(`"${status.pid}"`))
262
+ return true;
263
+ }
241
264
  }
265
+ catch { /* ignore */ }
266
+ return false;
242
267
  }
243
268
  getGuiEnv() {
244
269
  // Windows GUI is always available — no special env vars needed