palmier 0.4.2 → 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.
Files changed (45) hide show
  1. package/README.md +18 -30
  2. package/dist/agents/agent-instructions.md +40 -0
  3. package/dist/agents/claude.js +2 -8
  4. package/dist/agents/codex.js +0 -6
  5. package/dist/agents/copilot.js +0 -20
  6. package/dist/agents/gemini.js +0 -6
  7. package/dist/agents/shared-prompt.d.ts +1 -2
  8. package/dist/agents/shared-prompt.js +5 -18
  9. package/dist/commands/notify.d.ts +9 -0
  10. package/dist/commands/notify.js +43 -0
  11. package/dist/commands/request-input.d.ts +10 -0
  12. package/dist/commands/request-input.js +49 -0
  13. package/dist/commands/run.d.ts +4 -5
  14. package/dist/commands/run.js +90 -105
  15. package/dist/commands/serve.js +31 -28
  16. package/dist/index.js +15 -5
  17. package/dist/platform/linux.js +16 -6
  18. package/dist/platform/windows.js +54 -14
  19. package/dist/rpc-handler.js +217 -54
  20. package/dist/spawn-command.d.ts +1 -1
  21. package/dist/spawn-command.js +13 -1
  22. package/dist/task.d.ts +18 -7
  23. package/dist/task.js +70 -27
  24. package/dist/types.d.ts +10 -1
  25. package/package.json +2 -3
  26. package/src/agents/agent-instructions.md +40 -0
  27. package/src/agents/claude.ts +2 -7
  28. package/src/agents/codex.ts +0 -5
  29. package/src/agents/copilot.ts +0 -19
  30. package/src/agents/gemini.ts +0 -5
  31. package/src/agents/shared-prompt.ts +10 -18
  32. package/src/commands/notify.ts +44 -0
  33. package/src/commands/request-input.ts +51 -0
  34. package/src/commands/run.ts +98 -129
  35. package/src/commands/serve.ts +34 -36
  36. package/src/index.ts +16 -5
  37. package/src/platform/linux.ts +17 -7
  38. package/src/platform/windows.ts +53 -15
  39. package/src/rpc-handler.ts +244 -57
  40. package/src/spawn-command.ts +13 -2
  41. package/src/task.ts +79 -29
  42. package/src/types.ts +11 -1
  43. package/dist/commands/mcpserver.d.ts +0 -2
  44. package/dist/commands/mcpserver.js +0 -93
  45. package/src/commands/mcpserver.ts +0 -113
@@ -4,25 +4,12 @@ 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 } 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
- import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX, TASK_INPUT_PREFIX } from "../agents/shared-prompt.js";
10
+ import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
11
11
  import { publishHostEvent } from "../events.js";
12
- import { waitForUserInput, requestUserInput, publishInputResolved } from "../user-input.js";
13
- /**
14
- * Write a time-stamped RESULT file with frontmatter.
15
- * Always generated, even for abort/fail.
16
- */
17
- /**
18
- * Update an existing result file with the final outcome.
19
- */
20
- function finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, runningState, startTime, endTime, output, reportFiles, requiredPermissions) {
21
- const reportLine = reportFiles.length > 0 ? `\nreport_files: ${reportFiles.join(", ")}` : "";
22
- const permLines = requiredPermissions.map((p) => `\nrequired_permission: ${p.name} | ${p.description}`).join("");
23
- const content = `---\ntask_name: ${taskName}\nrunning_state: ${runningState}\nstart_time: ${startTime}\nend_time: ${endTime}\ntask_file: ${taskSnapshotName}${reportLine}${permLines}\n---\n${output}`;
24
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
25
- }
12
+ import { waitForUserInput } from "../user-input.js";
26
13
  /**
27
14
  * Invoke the agent CLI with a retry loop for permissions and user input.
28
15
  *
@@ -36,8 +23,8 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
36
23
  while (true) {
37
24
  const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
38
25
  const result = await spawnCommand(command, args, {
39
- cwd: ctx.taskDir,
40
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
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) },
41
28
  echoStdout: true,
42
29
  resolveOnFailure: true,
43
30
  stdin,
@@ -45,15 +32,35 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
45
32
  const outcome = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
46
33
  const reportFiles = parseReportFiles(result.output);
47
34
  const requiredPermissions = parsePermissions(result.output);
35
+ // Append assistant message for this invocation
36
+ await appendAndNotify(ctx, {
37
+ role: "assistant",
38
+ time: Date.now(),
39
+ content: stripPalmierMarkers(result.output),
40
+ attachments: reportFiles.length > 0 ? reportFiles : undefined,
41
+ });
48
42
  // Permission retry
49
43
  if (outcome === "failed" && requiredPermissions.length > 0) {
50
44
  const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
51
45
  await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
52
46
  if (response === "aborted") {
53
- return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
47
+ await appendAndNotify(ctx, {
48
+ role: "user",
49
+ time: Date.now(),
50
+ content: "Permissions denied. Task aborted.",
51
+ type: "permission",
52
+ });
53
+ return { outcome: "failed" };
54
54
  }
55
55
  const newPerms = requiredPermissions.filter((rp) => !ctx.task.frontmatter.permissions?.some((ep) => ep.name === rp.name)
56
56
  && !ctx.transientPermissions.some((ep) => ep.name === rp.name));
57
+ // Append user message for permission grant
58
+ await appendAndNotify(ctx, {
59
+ role: "user",
60
+ time: Date.now(),
61
+ content: `Permissions granted: ${newPerms.map((p) => p.name).join(", ")}`,
62
+ type: "permission",
63
+ });
57
64
  if (response === "granted_all") {
58
65
  ctx.task.frontmatter.permissions = [...(ctx.task.frontmatter.permissions ?? []), ...newPerms];
59
66
  invokeTask.frontmatter.permissions = ctx.task.frontmatter.permissions;
@@ -65,33 +72,36 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
65
72
  retryPrompt = "Permissions granted, please continue.";
66
73
  continue;
67
74
  }
68
- // Input retry
69
- const inputRequests = parseInputRequests(result.output);
70
- if (outcome === "failed" && inputRequests.length > 0) {
71
- const response = await requestUserInput(ctx.nc, ctx.config, ctx.taskId, ctx.task.frontmatter.name, ctx.taskDir, inputRequests);
72
- await publishInputResolved(ctx.nc, ctx.config, ctx.taskId, response === "aborted" ? "aborted" : "provided");
73
- if (response === "aborted") {
74
- return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
75
- }
76
- const inputLines = inputRequests.map((desc, i) => `- ${desc} → ${response[i]}`).join("\n");
77
- retryPrompt = `The user provided the following inputs:\n${inputLines}\nPlease continue with these values.`;
78
- continue;
79
- }
80
75
  // Normal completion (success or non-retryable failure)
81
- return { output: result.output, outcome, reportFiles, requiredPermissions };
76
+ return { outcome };
82
77
  }
83
78
  }
84
79
  /**
85
- * Find an existing RESULT file with running_state=started (created by the RPC handler).
80
+ * Strip [PALMIER_*] marker lines from agent output.
86
81
  */
87
- function findStartedResultFile(taskDir) {
88
- const files = fs.readdirSync(taskDir).filter((f) => f.startsWith("RESULT-") && f.endsWith(".md"));
89
- for (const file of files) {
90
- const content = fs.readFileSync(path.join(taskDir, file), "utf-8");
91
- if (content.includes("running_state: started"))
92
- return file;
93
- }
94
- return null;
82
+ export function stripPalmierMarkers(output) {
83
+ return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
84
+ }
85
+ /**
86
+ * Append a conversation message to the RESULT file and notify connected clients.
87
+ */
88
+ async function appendAndNotify(ctx, msg) {
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
+ }
92
+ /**
93
+ * Find the latest run dir that has no status messages yet (just created by the RPC handler).
94
+ */
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;
95
105
  }
96
106
  /**
97
107
  * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
@@ -113,27 +123,22 @@ export async function runCommand(taskId) {
113
123
  console.log(`Running task: ${taskId}`);
114
124
  let nc;
115
125
  const taskName = task.frontmatter.name;
116
- // Check for an existing "started" result file (created by the RPC handler)
117
- const existingResult = findStartedResultFile(taskDir);
118
- const startTime = existingResult ? parseInt(existingResult.replace("RESULT-", "").replace(".md", ""), 10) : Date.now();
119
- const resultFileName = existingResult ?? createResultFile(taskDir, taskName, startTime);
120
- // Snapshot the task file at run time
121
- const taskSnapshotName = `TASK-${startTime}.md`;
122
- if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
123
- 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 });
124
131
  }
125
132
  const cleanup = async () => {
126
133
  if (nc && !nc.isClosed()) {
127
134
  await nc.drain();
128
135
  }
129
136
  };
130
- if (!existingResult) {
131
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
132
- }
133
137
  try {
134
138
  nc = await connectNats(config);
135
- // Mark as started immediately
136
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
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 });
137
142
  // If requires_confirmation, notify clients and wait
138
143
  if (task.frontmatter.requires_confirmation) {
139
144
  const confirmed = await requestConfirmation(nc, config, task, taskDir);
@@ -141,54 +146,55 @@ export async function runCommand(taskId) {
141
146
  await publishConfirmResolved(nc, config, taskId, resolvedStatus);
142
147
  if (!confirmed) {
143
148
  console.log("Task aborted by user.");
144
- const endTime = Date.now();
145
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
146
- 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);
147
151
  await cleanup();
148
152
  return;
149
153
  }
150
154
  console.log("Task confirmed by user.");
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 });
151
157
  }
152
158
  // Shared invocation context
153
159
  const guiEnv = getPlatform().getGuiEnv();
154
160
  const agent = getAgent(task.frontmatter.agent);
155
161
  const ctx = {
156
- agent, task, taskDir, guiEnv, nc, config, taskId,
162
+ agent, task, taskDir, runId, guiEnv, nc, config, taskId,
157
163
  transientPermissions: [],
158
164
  };
159
165
  if (task.frontmatter.command) {
160
166
  // Command-triggered mode
161
167
  const result = await runCommandTriggeredMode(ctx);
162
168
  const outcome = resolveOutcome(taskDir, result.outcome);
163
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, result.endTime, result.output, [], []);
164
- 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);
165
171
  console.log(`Task ${taskId} completed (command-triggered).`);
166
172
  }
167
173
  else {
168
- // Standard execution
174
+ // Standard execution — add user prompt as first message
175
+ await appendAndNotify(ctx, {
176
+ role: "user",
177
+ time: Date.now(),
178
+ content: task.body || task.frontmatter.user_prompt,
179
+ });
169
180
  const result = await invokeAgentWithRetry(ctx, task);
170
181
  const outcome = resolveOutcome(taskDir, result.outcome);
171
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
172
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
173
- if (result.reportFiles.length > 0) {
174
- await publishHostEvent(nc, config.hostId, taskId, {
175
- event_type: "report-generated",
176
- name: taskName,
177
- report_files: result.reportFiles,
178
- running_state: outcome,
179
- result_file: resultFileName,
180
- });
181
- }
182
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
183
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
182
184
  console.log(`Task ${taskId} completed.`);
183
185
  }
184
186
  }
185
187
  catch (err) {
186
188
  console.error(`Task ${taskId} failed:`, err);
187
- const endTime = Date.now();
188
189
  const outcome = resolveOutcome(taskDir, "failed");
189
190
  const errorMsg = err instanceof Error ? err.message : String(err);
190
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
191
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
191
+ appendRunMessage(taskDir, runId, {
192
+ role: "assistant",
193
+ time: Date.now(),
194
+ content: errorMsg,
195
+ });
196
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
197
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
192
198
  process.exitCode = 1;
193
199
  }
194
200
  finally {
@@ -210,8 +216,8 @@ async function runCommandTriggeredMode(ctx) {
210
216
  const commandStr = ctx.task.frontmatter.command;
211
217
  console.log(`[command-triggered] Spawning: ${commandStr}`);
212
218
  const child = spawnStreamingCommand(commandStr, {
213
- cwd: ctx.taskDir,
214
- 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) },
215
221
  });
216
222
  let linesProcessed = 0;
217
223
  let invocationsSucceeded = 0;
@@ -220,7 +226,7 @@ async function runCommandTriggeredMode(ctx) {
220
226
  let processing = false;
221
227
  let commandExited = false;
222
228
  let resolveWhenDone;
223
- const logPath = path.join(ctx.taskDir, "command-output.log");
229
+ const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
224
230
  function appendLog(line, agentOutput, outcome) {
225
231
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
226
232
  fs.appendFileSync(logPath, entry, "utf-8");
@@ -256,7 +262,7 @@ async function runCommandTriggeredMode(ctx) {
256
262
  else {
257
263
  invocationsFailed++;
258
264
  }
259
- appendLog(line, result.output, result.outcome);
265
+ appendLog(line, "", result.outcome);
260
266
  }
261
267
  async function drainQueue() {
262
268
  if (processing)
@@ -312,25 +318,19 @@ async function runCommandTriggeredMode(ctx) {
312
318
  });
313
319
  }
314
320
  const endTime = Date.now();
315
- const summary = [
316
- `Command: ${commandStr}`,
317
- `Exit code: ${exitCode}`,
318
- `Lines processed: ${linesProcessed}`,
319
- `Agent invocations succeeded: ${invocationsSucceeded}`,
320
- `Agent invocations failed: ${invocationsFailed}`,
321
- ].join("\n");
322
- return { outcome: "finished", endTime, output: summary };
321
+ return { outcome: "finished", endTime };
323
322
  }
324
- async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, resultFile) {
323
+ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
325
324
  writeTaskStatus(taskDir, {
326
325
  running_state: eventType,
327
326
  time_stamp: Date.now(),
327
+ ...(eventType === "started" ? { pid: process.pid } : {}),
328
328
  });
329
329
  const payload = { event_type: "running-state", running_state: eventType };
330
330
  if (taskName)
331
331
  payload.name = taskName;
332
- if (resultFile)
333
- payload.result_file = resultFile;
332
+ if (runId)
333
+ payload.run_id = runId;
334
334
  await publishHostEvent(nc, config.hostId, taskId, payload);
335
335
  }
336
336
  /**
@@ -417,21 +417,6 @@ export function parsePermissions(output) {
417
417
  }
418
418
  return perms;
419
419
  }
420
- /**
421
- * Extract user input requests from agent output.
422
- * Looks for lines matching: [PALMIER_INPUT] <description>
423
- */
424
- export function parseInputRequests(output) {
425
- const regex = new RegExp(`^\\${TASK_INPUT_PREFIX}\\s+(.+)$`, "gm");
426
- const inputs = [];
427
- let match;
428
- while ((match = regex.exec(output)) !== null) {
429
- const desc = match[1].trim();
430
- if (desc)
431
- inputs.push(desc);
432
- }
433
- return inputs;
434
- }
435
420
  /**
436
421
  * Parse the agent's output for success/failure markers.
437
422
  * Falls back to "finished" if no marker is found.
@@ -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 } 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,33 +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${reason}`;
35
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
36
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
37
- const payload = { event_type: "running-state", running_state: "failed", name: taskName };
38
- await publishHostEvent(nc, config.hostId, taskId, payload);
39
- }
40
- /**
41
- * 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.
42
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.
43
21
  */
44
22
  async function checkStaleTasks(config, nc) {
45
23
  const tasksJsonl = path.join(config.projectRoot, "tasks.jsonl");
@@ -62,7 +40,32 @@ async function checkStaleTasks(config, nc) {
62
40
  // Ask the system scheduler if the task is still running
63
41
  if (platform.isTaskRunning(taskId))
64
42
  continue;
65
- 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
+ });
66
69
  }
67
70
  }
68
71
  /**
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@ import { initCommand } from "./commands/init.js";
8
8
  import { infoCommand } from "./commands/info.js";
9
9
  import { runCommand } from "./commands/run.js";
10
10
  import { serveCommand } from "./commands/serve.js";
11
- import { mcpserverCommand } from "./commands/mcpserver.js";
11
+ import { notifyCommand } from "./commands/notify.js";
12
+ import { requestInputCommand } from "./commands/request-input.js";
12
13
  import { pairCommand } from "./commands/pair.js";
13
14
  import { lanCommand } from "./commands/lan.js";
14
15
  import { restartCommand } from "./commands/restart.js";
@@ -51,10 +52,19 @@ program
51
52
  await restartCommand();
52
53
  });
53
54
  program
54
- .command("mcpserver")
55
- .description("Start an MCP server exposing Palmier tools (stdio transport)")
56
- .action(async () => {
57
- await mcpserverCommand();
55
+ .command("notify")
56
+ .description("Send a push notification to the user")
57
+ .requiredOption("--title <title>", "Notification title")
58
+ .requiredOption("--body <body>", "Notification body text")
59
+ .action(async (opts) => {
60
+ await notifyCommand(opts);
61
+ });
62
+ program
63
+ .command("request-input")
64
+ .description("Request input from the user (requires PALMIER_TASK_ID env var)")
65
+ .requiredOption("--description <desc...>", "Input descriptions to show the user")
66
+ .action(async (opts) => {
67
+ await requestInputCommand(opts);
58
68
  });
59
69
  program
60
70
  .command("pair")
@@ -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?.();
@@ -2,19 +2,12 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { execFileSync } from "child_process";
4
4
  import { spawn as nodeSpawn } from "child_process";
5
- import { CONFIG_DIR } from "../config.js";
5
+ import { CONFIG_DIR, loadConfig } from "../config.js";
6
+ import { getTaskDir, readTaskStatus } from "../task.js";
6
7
  const TASK_PREFIX = "\\Palmier\\PalmierTask-";
7
8
  const DAEMON_TASK_NAME = "PalmierDaemon";
8
9
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
9
10
  const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
10
- /**
11
- * Build the /tr value for schtasks: a single string with quoted paths
12
- * so Task Scheduler can invoke node with the palmier script + subcommand.
13
- */
14
- function schtasksTr(...subcommand) {
15
- const script = process.argv[1] || "palmier";
16
- return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
17
- }
18
11
  const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
19
12
  /**
20
13
  * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
@@ -118,7 +111,7 @@ export class WindowsPlatform {
118
111
  // Kill old daemon first, then spawn new one.
119
112
  if (oldPid) {
120
113
  try {
121
- execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
114
+ execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
122
115
  }
123
116
  catch {
124
117
  // Process may have already exited
@@ -143,7 +136,13 @@ export class WindowsPlatform {
143
136
  installTaskTimer(config, task) {
144
137
  const taskId = task.frontmatter.id;
145
138
  const tn = schtasksTaskName(taskId);
146
- 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}"`;
147
146
  // Build trigger XML elements
148
147
  const triggerElements = [];
149
148
  if (task.frontmatter.triggers_enabled) {
@@ -191,6 +190,10 @@ export class WindowsPlatform {
191
190
  catch {
192
191
  // Task might not exist — that's fine
193
192
  }
193
+ try {
194
+ fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`));
195
+ }
196
+ catch { /* ignore */ }
194
197
  }
195
198
  async startTask(taskId) {
196
199
  const tn = schtasksTaskName(taskId);
@@ -203,6 +206,20 @@ export class WindowsPlatform {
203
206
  }
204
207
  }
205
208
  async stopTask(taskId) {
209
+ // Try to kill the entire process tree via the PID recorded in status.json.
210
+ // schtasks /end only kills the top-level process, leaving agent children orphaned.
211
+ try {
212
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
213
+ const status = readTaskStatus(taskDir);
214
+ if (status?.pid) {
215
+ execFileSync("taskkill", ["/pid", String(status.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
216
+ return;
217
+ }
218
+ }
219
+ catch {
220
+ // PID may be stale or config unavailable; fall through to schtasks /end
221
+ }
222
+ // Fallback: schtasks /end (kills top-level process only)
206
223
  const tn = schtasksTaskName(taskId);
207
224
  try {
208
225
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -213,17 +230,40 @@ export class WindowsPlatform {
213
230
  }
214
231
  }
215
232
  isTaskRunning(taskId) {
233
+ // Check Task Scheduler first (for scheduled/on-demand runs)
216
234
  const tn = schtasksTaskName(taskId);
217
235
  try {
218
236
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
219
237
  encoding: "utf-8",
220
238
  windowsHide: true,
221
239
  });
222
- return out.includes('"Running"');
240
+ if (out.includes('"Running"'))
241
+ return true;
223
242
  }
224
- catch {
225
- 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
+ }
226
264
  }
265
+ catch { /* ignore */ }
266
+ return false;
227
267
  }
228
268
  getGuiEnv() {
229
269
  // Windows GUI is always available — no special env vars needed