palmier 0.4.0 → 0.4.2

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.
@@ -4,6 +4,8 @@ import { z } from "zod";
4
4
  import { StringCodec } from "nats";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { connectNats } from "../nats-client.js";
7
+ import { getTaskDir, parseTaskFile } from "../task.js";
8
+ import { requestUserInput, publishInputResolved } from "../user-input.js";
7
9
  export async function mcpserverCommand() {
8
10
  const config = loadConfig();
9
11
  const nc = await connectNats(config);
@@ -12,7 +14,7 @@ export async function mcpserverCommand() {
12
14
  // send-push-notification requires NATS — only register when server mode is enabled
13
15
  if (nc) {
14
16
  server.registerTool("send-push-notification", {
15
- description: "Send a push notification to all paired devices via the Palmier platform",
17
+ description: "Send a push notification to the user",
16
18
  inputSchema: {
17
19
  title: z.string().describe("Notification title"),
18
20
  body: z.string().describe("Notification body text"),
@@ -49,6 +51,37 @@ export async function mcpserverCommand() {
49
51
  }
50
52
  });
51
53
  }
54
+ const taskId = process.env.PALMIER_TASK_ID;
55
+ if (taskId) {
56
+ const taskDir = getTaskDir(config.projectRoot, taskId);
57
+ const task = parseTaskFile(taskDir);
58
+ server.registerTool("request-user-input", {
59
+ description: "Request input from the user. The user will see the descriptions and can provide values or abort.",
60
+ inputSchema: {
61
+ descriptions: z.array(z.string()).describe("List of input descriptions to show the user"),
62
+ },
63
+ }, async (args) => {
64
+ try {
65
+ const response = await requestUserInput(nc, config, taskId, task.frontmatter.name, taskDir, args.descriptions);
66
+ await publishInputResolved(nc, config, taskId, response === "aborted" ? "aborted" : "provided");
67
+ if (response === "aborted") {
68
+ return {
69
+ content: [{ type: "text", text: "User aborted the input request." }],
70
+ };
71
+ }
72
+ const lines = args.descriptions.map((desc, i) => `${desc}: ${response[i]}`).join("\n");
73
+ return {
74
+ content: [{ type: "text", text: lines }],
75
+ };
76
+ }
77
+ catch (err) {
78
+ return {
79
+ content: [{ type: "text", text: `Error requesting user input: ${err}` }],
80
+ isError: true,
81
+ };
82
+ }
83
+ });
84
+ }
52
85
  const transport = new StdioServerTransport();
53
86
  await server.connect(transport);
54
87
  // Graceful shutdown
@@ -4,22 +4,24 @@ 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 } from "../task.js";
7
+ import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createResultFile } 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, TASK_INPUT_PREFIX } from "../agents/shared-prompt.js";
11
11
  import { publishHostEvent } from "../events.js";
12
+ import { waitForUserInput, requestUserInput, publishInputResolved } from "../user-input.js";
12
13
  /**
13
14
  * Write a time-stamped RESULT file with frontmatter.
14
15
  * Always generated, even for abort/fail.
15
16
  */
16
- function writeResult(taskDir, taskName, taskSnapshotName, runningState, startTime, endTime, output, reportFiles, requiredPermissions) {
17
- const resultFileName = `RESULT-${endTime}.md`;
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) {
18
21
  const reportLine = reportFiles.length > 0 ? `\nreport_files: ${reportFiles.join(", ")}` : "";
19
22
  const permLines = requiredPermissions.map((p) => `\nrequired_permission: ${p.name} | ${p.description}`).join("");
20
23
  const content = `---\ntask_name: ${taskName}\nrunning_state: ${runningState}\nstart_time: ${startTime}\nend_time: ${endTime}\ntask_file: ${taskSnapshotName}${reportLine}${permLines}\n---\n${output}`;
21
24
  fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
22
- return resultFileName;
23
25
  }
24
26
  /**
25
27
  * Invoke the agent CLI with a retry loop for permissions and user input.
@@ -66,7 +68,7 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
66
68
  // Input retry
67
69
  const inputRequests = parseInputRequests(result.output);
68
70
  if (outcome === "failed" && inputRequests.length > 0) {
69
- const response = await requestUserInput(ctx.nc, ctx.config, ctx.task, ctx.taskDir, inputRequests);
71
+ const response = await requestUserInput(ctx.nc, ctx.config, ctx.taskId, ctx.task.frontmatter.name, ctx.taskDir, inputRequests);
70
72
  await publishInputResolved(ctx.nc, ctx.config, ctx.taskId, response === "aborted" ? "aborted" : "provided");
71
73
  if (response === "aborted") {
72
74
  return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
@@ -79,6 +81,18 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
79
81
  return { output: result.output, outcome, reportFiles, requiredPermissions };
80
82
  }
81
83
  }
84
+ /**
85
+ * Find an existing RESULT file with running_state=started (created by the RPC handler).
86
+ */
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;
95
+ }
82
96
  /**
83
97
  * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
84
98
  * respect that instead of overwriting with the process's own outcome.
@@ -98,20 +112,28 @@ export async function runCommand(taskId) {
98
112
  const task = parseTaskFile(taskDir);
99
113
  console.log(`Running task: ${taskId}`);
100
114
  let nc;
101
- const startTime = Date.now();
102
115
  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);
103
120
  // Snapshot the task file at run time
104
121
  const taskSnapshotName = `TASK-${startTime}.md`;
105
- fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
122
+ if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
123
+ fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
124
+ }
106
125
  const cleanup = async () => {
107
126
  if (nc && !nc.isClosed()) {
108
127
  await nc.drain();
109
128
  }
110
129
  };
130
+ if (!existingResult) {
131
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
132
+ }
111
133
  try {
112
134
  nc = await connectNats(config);
113
135
  // Mark as started immediately
114
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName);
136
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
115
137
  // If requires_confirmation, notify clients and wait
116
138
  if (task.frontmatter.requires_confirmation) {
117
139
  const confirmed = await requestConfirmation(nc, config, task, taskDir);
@@ -120,9 +142,8 @@ export async function runCommand(taskId) {
120
142
  if (!confirmed) {
121
143
  console.log("Task aborted by user.");
122
144
  const endTime = Date.now();
123
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
124
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
125
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName);
145
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
146
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
126
147
  await cleanup();
127
148
  return;
128
149
  }
@@ -139,24 +160,23 @@ export async function runCommand(taskId) {
139
160
  // Command-triggered mode
140
161
  const result = await runCommandTriggeredMode(ctx);
141
162
  const outcome = resolveOutcome(taskDir, result.outcome);
142
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, result.endTime, result.output, [], []);
143
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
144
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
163
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, result.endTime, result.output, [], []);
164
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
145
165
  console.log(`Task ${taskId} completed (command-triggered).`);
146
166
  }
147
167
  else {
148
168
  // Standard execution
149
169
  const result = await invokeAgentWithRetry(ctx, task);
150
170
  const outcome = resolveOutcome(taskDir, result.outcome);
151
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
152
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
153
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
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);
154
173
  if (result.reportFiles.length > 0) {
155
174
  await publishHostEvent(nc, config.hostId, taskId, {
156
175
  event_type: "report-generated",
157
176
  name: taskName,
158
177
  report_files: result.reportFiles,
159
178
  running_state: outcome,
179
+ result_file: resultFileName,
160
180
  });
161
181
  }
162
182
  console.log(`Task ${taskId} completed.`);
@@ -167,9 +187,8 @@ export async function runCommand(taskId) {
167
187
  const endTime = Date.now();
168
188
  const outcome = resolveOutcome(taskDir, "failed");
169
189
  const errorMsg = err instanceof Error ? err.message : String(err);
170
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
171
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
172
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
190
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
191
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
173
192
  process.exitCode = 1;
174
193
  }
175
194
  finally {
@@ -302,7 +321,7 @@ async function runCommandTriggeredMode(ctx) {
302
321
  ].join("\n");
303
322
  return { outcome: "finished", endTime, output: summary };
304
323
  }
305
- async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName) {
324
+ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, resultFile) {
306
325
  writeTaskStatus(taskDir, {
307
326
  running_state: eventType,
308
327
  time_stamp: Date.now(),
@@ -310,6 +329,8 @@ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName
310
329
  const payload = { event_type: "running-state", running_state: eventType };
311
330
  if (taskName)
312
331
  payload.name = taskName;
332
+ if (resultFile)
333
+ payload.result_file = resultFile;
313
334
  await publishHostEvent(nc, config.hostId, taskId, payload);
314
335
  }
315
336
  /**
@@ -322,22 +343,6 @@ async function publishConfirmResolved(nc, config, taskId, status) {
322
343
  status,
323
344
  });
324
345
  }
325
- /**
326
- * Watch status.json until user_input is populated by an RPC call, then resolve.
327
- * All interactive request flows (confirmation, permission, user input) share this.
328
- */
329
- function waitForUserInput(taskDir) {
330
- const statusPath = path.join(taskDir, "status.json");
331
- return new Promise((resolve) => {
332
- const watcher = fs.watch(statusPath, () => {
333
- const status = readTaskStatus(taskDir);
334
- if (!status || !status.user_input?.length)
335
- return;
336
- watcher.close();
337
- resolve(status.user_input);
338
- });
339
- });
340
- }
341
346
  async function requestPermission(nc, config, task, taskDir, requiredPermissions) {
342
347
  const currentStatus = readTaskStatus(taskDir);
343
348
  writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
@@ -362,30 +367,6 @@ async function publishPermissionResolved(nc, config, taskId, status) {
362
367
  status,
363
368
  });
364
369
  }
365
- async function requestUserInput(nc, config, task, taskDir, inputDescriptions) {
366
- const currentStatus = readTaskStatus(taskDir);
367
- writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
368
- await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
369
- event_type: "input-request",
370
- host_id: config.hostId,
371
- input_descriptions: inputDescriptions,
372
- name: task.frontmatter.name,
373
- });
374
- const userInput = await waitForUserInput(taskDir);
375
- if (userInput.length === 1 && userInput[0] === "aborted") {
376
- writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
377
- return "aborted";
378
- }
379
- writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
380
- return userInput;
381
- }
382
- async function publishInputResolved(nc, config, taskId, status) {
383
- await publishHostEvent(nc, config.hostId, taskId, {
384
- event_type: "input-resolved",
385
- host_id: config.hostId,
386
- status,
387
- });
388
- }
389
370
  async function requestConfirmation(nc, config, task, taskDir) {
390
371
  const currentStatus = readTaskStatus(taskDir);
391
372
  writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
@@ -7,7 +7,6 @@ import { startNatsTransport } from "../transports/nats-transport.js";
7
7
  import { getTaskDir, readTaskStatus, writeTaskStatus, appendHistory, parseTaskFile } from "../task.js";
8
8
  import { publishHostEvent } from "../events.js";
9
9
  import { getPlatform } from "../platform/index.js";
10
- import { checkForUpdate } from "../update-checker.js";
11
10
  import { detectAgents } from "../agents/agent.js";
12
11
  import { saveConfig } from "../config.js";
13
12
  import { CONFIG_DIR } from "../config.js";
@@ -88,9 +87,6 @@ export async function serveCommand() {
88
87
  console.error("[monitor] Error checking stale tasks:", err);
89
88
  });
90
89
  }, POLL_INTERVAL_MS);
91
- // Check for updates on startup and every 24 hours
92
- checkForUpdate().catch(() => { });
93
- setInterval(() => { checkForUpdate().catch(() => { }); }, 24 * 60 * 60 * 1000);
94
90
  const handleRpc = createRpcHandler(config, nc);
95
91
  await startNatsTransport(config, handleRpc, nc);
96
92
  }
@@ -110,31 +110,34 @@ export class WindowsPlatform {
110
110
  const oldPid = fs.existsSync(DAEMON_PID_FILE)
111
111
  ? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
112
112
  : null;
113
- // Spawn the new daemon before killing the old one.
114
- this.spawnDaemon(script);
115
113
  if (oldPid && oldPid === String(process.pid)) {
116
- // We ARE the old daemon (auto-update) — exit so only the new one runs.
114
+ // We ARE the old daemon (auto-update) — spawn replacement then exit.
115
+ this.spawnDaemon(script);
117
116
  process.exit(0);
118
117
  }
119
- else if (oldPid) {
118
+ // Kill old daemon first, then spawn new one.
119
+ if (oldPid) {
120
120
  try {
121
- execFileSync("taskkill", ["/pid", oldPid, "/t", "/f"], { windowsHide: true });
121
+ execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
122
122
  }
123
123
  catch {
124
124
  // Process may have already exited
125
125
  }
126
126
  }
127
+ this.spawnDaemon(script);
127
128
  }
128
129
  spawnDaemon(script) {
129
- const child = nodeSpawn(process.execPath, [script, "serve"], {
130
+ // Write a VBS launcher that starts the daemon with no visible console window.
131
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
132
+ fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
133
+ const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
134
+ const child = nodeSpawn(wscript, [DAEMON_VBS_FILE], {
130
135
  detached: true,
131
136
  stdio: "ignore",
132
137
  windowsHide: true,
133
138
  });
134
- if (child.pid) {
135
- fs.writeFileSync(DAEMON_PID_FILE, String(child.pid), "utf-8");
136
- }
137
139
  child.unref();
140
+ // PID file will be written by the serve command itself when it starts.
138
141
  console.log("Palmier daemon started.");
139
142
  }
140
143
  installTaskTimer(config, task) {
@@ -2,14 +2,15 @@ import { randomUUID } from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { spawn } from "child_process";
5
6
  import { parse as parseYaml } from "yaml";
6
- import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList } from "./task.js";
7
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createResultFile } from "./task.js";
7
8
  import { getPlatform } from "./platform/index.js";
8
9
  import { spawnCommand } from "./spawn-command.js";
9
10
  import { getAgent } from "./agents/agent.js";
10
11
  import { validateSession } from "./session-store.js";
11
12
  import { publishHostEvent } from "./events.js";
12
- import { currentVersion, getLatestVersion, performUpdate } from "./update-checker.js";
13
+ import { currentVersion, performUpdate } from "./update-checker.js";
13
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
15
  const PLAN_GENERATION_PROMPT = fs.readFileSync(path.join(__dirname, "commands", "plan-generation.md"), "utf-8");
15
16
  /**
@@ -108,7 +109,6 @@ export function createRpcHandler(config, nc) {
108
109
  tasks: tasks.map((task) => flattenTask(task)),
109
110
  agents: config.agents ?? [],
110
111
  version: currentVersion,
111
- latest_version: getLatestVersion(),
112
112
  };
113
113
  }
114
114
  case "task.create": {
@@ -205,11 +205,49 @@ export function createRpcHandler(config, nc) {
205
205
  removeFromTaskList(config.projectRoot, params.id);
206
206
  return { ok: true, task_id: params.id };
207
207
  }
208
+ case "task.run_oneoff": {
209
+ const params = request.params;
210
+ const id = randomUUID();
211
+ const taskDir = getTaskDir(config.projectRoot, id);
212
+ const name = params.user_prompt.slice(0, 60);
213
+ const task = {
214
+ frontmatter: {
215
+ id,
216
+ name,
217
+ user_prompt: params.user_prompt,
218
+ agent: params.agent,
219
+ triggers: [],
220
+ triggers_enabled: false,
221
+ requires_confirmation: params.requires_confirmation ?? false,
222
+ ...(params.command ? { command: params.command } : {}),
223
+ },
224
+ body: "",
225
+ };
226
+ writeTaskFile(taskDir, task);
227
+ // Do NOT append to tasks.jsonl — this is a one-off run
228
+ // Create initial result file so it appears in runs list immediately
229
+ const resultFileName = createResultFile(taskDir, name, Date.now());
230
+ appendHistory(config.projectRoot, { task_id: id, result_file: resultFileName });
231
+ // Spawn `palmier run <id>` directly as a detached process
232
+ const script = process.argv[1] || "palmier";
233
+ const child = spawn(process.execPath, [script, "run", id], {
234
+ detached: true,
235
+ stdio: "ignore",
236
+ windowsHide: true,
237
+ });
238
+ child.unref();
239
+ return { ok: true, task_id: id, result_file: resultFileName };
240
+ }
208
241
  case "task.run": {
209
242
  const params = request.params;
210
243
  try {
244
+ // Create initial result file so it appears in runs list immediately
245
+ const runTaskDir = getTaskDir(config.projectRoot, params.id);
246
+ const runTask = parseTaskFile(runTaskDir);
247
+ const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
248
+ appendHistory(config.projectRoot, { task_id: params.id, result_file: runResultFileName });
211
249
  await getPlatform().startTask(params.id);
212
- return { ok: true, task_id: params.id };
250
+ return { ok: true, task_id: params.id, result_file: runResultFileName };
213
251
  }
214
252
  catch (err) {
215
253
  const e = err;
package/dist/task.d.ts CHANGED
@@ -38,6 +38,12 @@ export declare function writeTaskStatus(taskDir: string, status: TaskStatus): vo
38
38
  * Returns undefined if the file doesn't exist.
39
39
  */
40
40
  export declare function readTaskStatus(taskDir: string): TaskStatus | undefined;
41
+ /**
42
+ * Create the initial result file when a task starts running.
43
+ * Contains only start_time and running_state=started; no end_time or content yet.
44
+ * Returns the result file name.
45
+ */
46
+ export declare function createResultFile(taskDir: string, taskName: string, startTime: number): string;
41
47
  /**
42
48
  * Append a history entry to the project-level history.jsonl file.
43
49
  */
package/dist/task.js CHANGED
@@ -129,6 +129,18 @@ export function readTaskStatus(taskDir) {
129
129
  return undefined;
130
130
  }
131
131
  }
132
+ /**
133
+ * Create the initial result file when a task starts running.
134
+ * Contains only start_time and running_state=started; no end_time or content yet.
135
+ * Returns the result file name.
136
+ */
137
+ export function createResultFile(taskDir, taskName, startTime) {
138
+ const resultFileName = `RESULT-${startTime}.md`;
139
+ const taskSnapshotName = `TASK-${startTime}.md`;
140
+ const content = `---\ntask_name: ${taskName}\nrunning_state: started\nstart_time: ${startTime}\ntask_file: ${taskSnapshotName}\n---\n`;
141
+ fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
142
+ return resultFileName;
143
+ }
132
144
  /**
133
145
  * Append a history entry to the project-level history.jsonl file.
134
146
  */
@@ -1,14 +1,6 @@
1
1
  /** True when running from a source checkout (has .git) rather than a global npm install. */
2
2
  export declare const isDevBuild: boolean;
3
3
  export declare const currentVersion: string;
4
- /**
5
- * Check the npm registry for the latest version of palmier.
6
- */
7
- export declare function checkForUpdate(): Promise<void>;
8
- /**
9
- * Get the latest version from npm, or null if not yet checked.
10
- */
11
- export declare function getLatestVersion(): string | null;
12
4
  /**
13
5
  * Run the update and restart the daemon.
14
6
  * Returns an error message if the update fails.
@@ -9,41 +9,6 @@ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "
9
9
  /** True when running from a source checkout (has .git) rather than a global npm install. */
10
10
  export const isDevBuild = fs.existsSync(path.join(packageRoot, ".git"));
11
11
  export const currentVersion = isDevBuild ? `${pkg.version}-dev` : pkg.version;
12
- let latestVersion = null;
13
- let lastCheckTime = 0;
14
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
- /**
16
- * Check the npm registry for the latest version of palmier.
17
- */
18
- export async function checkForUpdate() {
19
- if (isDevBuild)
20
- return;
21
- const now = Date.now();
22
- if (now - lastCheckTime < CHECK_INTERVAL_MS)
23
- return;
24
- lastCheckTime = now;
25
- try {
26
- const res = await fetch("https://registry.npmjs.org/palmier/latest", {
27
- signal: AbortSignal.timeout(10_000),
28
- });
29
- if (!res.ok)
30
- return;
31
- const data = (await res.json());
32
- if (data.version) {
33
- latestVersion = data.version;
34
- console.log(`[update] Latest version: ${data.version} (current: ${currentVersion})`);
35
- }
36
- }
37
- catch {
38
- // Network errors are expected (offline, etc.)
39
- }
40
- }
41
- /**
42
- * Get the latest version from npm, or null if not yet checked.
43
- */
44
- export function getLatestVersion() {
45
- return latestVersion;
46
- }
47
12
  /**
48
13
  * Run the update and restart the daemon.
49
14
  * Returns an error message if the update fails.
@@ -60,7 +25,6 @@ export async function performUpdate() {
60
25
  return `Update failed. Please run manually:\nnpm update -g palmier`;
61
26
  }
62
27
  console.log("[update] Update installed, restarting daemon...");
63
- latestVersion = null;
64
28
  // Small delay to allow the RPC response to be sent
65
29
  setTimeout(() => {
66
30
  getPlatform().restartDaemon().catch((err) => {
@@ -0,0 +1,15 @@
1
+ import type { HostConfig } from "./types.js";
2
+ import type { NatsConnection } from "nats";
3
+ /**
4
+ * Watch status.json until user_input is populated by an RPC call, then resolve.
5
+ */
6
+ export declare function waitForUserInput(taskDir: string): Promise<string[]>;
7
+ /**
8
+ * Send an input-request event and wait for the user's response.
9
+ */
10
+ export declare function requestUserInput(nc: NatsConnection | undefined, config: HostConfig, taskId: string, taskName: string, taskDir: string, inputDescriptions: string[]): Promise<string[] | "aborted">;
11
+ /**
12
+ * Notify clients that an input request has been resolved.
13
+ */
14
+ export declare function publishInputResolved(nc: NatsConnection | undefined, config: HostConfig, taskId: string, status: "provided" | "aborted"): Promise<void>;
15
+ //# sourceMappingURL=user-input.d.ts.map
@@ -0,0 +1,50 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { readTaskStatus, writeTaskStatus } from "./task.js";
4
+ import { publishHostEvent } from "./events.js";
5
+ /**
6
+ * Watch status.json until user_input is populated by an RPC call, then resolve.
7
+ */
8
+ export function waitForUserInput(taskDir) {
9
+ const statusPath = path.join(taskDir, "status.json");
10
+ return new Promise((resolve) => {
11
+ const watcher = fs.watch(statusPath, () => {
12
+ const status = readTaskStatus(taskDir);
13
+ if (!status || !status.user_input?.length)
14
+ return;
15
+ watcher.close();
16
+ resolve(status.user_input);
17
+ });
18
+ });
19
+ }
20
+ /**
21
+ * Send an input-request event and wait for the user's response.
22
+ */
23
+ export async function requestUserInput(nc, config, taskId, taskName, taskDir, inputDescriptions) {
24
+ const currentStatus = readTaskStatus(taskDir);
25
+ writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
26
+ await publishHostEvent(nc, config.hostId, taskId, {
27
+ event_type: "input-request",
28
+ host_id: config.hostId,
29
+ input_descriptions: inputDescriptions,
30
+ name: taskName,
31
+ });
32
+ const userInput = await waitForUserInput(taskDir);
33
+ if (userInput.length === 1 && userInput[0] === "aborted") {
34
+ writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
35
+ return "aborted";
36
+ }
37
+ writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
38
+ return userInput;
39
+ }
40
+ /**
41
+ * Notify clients that an input request has been resolved.
42
+ */
43
+ export async function publishInputResolved(nc, config, taskId, status) {
44
+ await publishHostEvent(nc, config.hostId, taskId, {
45
+ event_type: "input-resolved",
46
+ host_id: config.hostId,
47
+ status,
48
+ });
49
+ }
50
+ //# sourceMappingURL=user-input.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -4,6 +4,8 @@ import { z } from "zod";
4
4
  import { StringCodec } from "nats";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { connectNats } from "../nats-client.js";
7
+ import { getTaskDir, parseTaskFile } from "../task.js";
8
+ import { requestUserInput, publishInputResolved } from "../user-input.js";
7
9
  export async function mcpserverCommand(): Promise<void> {
8
10
  const config = loadConfig();
9
11
  const nc = await connectNats(config);
@@ -20,7 +22,7 @@ export async function mcpserverCommand(): Promise<void> {
20
22
  server.registerTool(
21
23
  "send-push-notification",
22
24
  {
23
- description: "Send a push notification to all paired devices via the Palmier platform",
25
+ description: "Send a push notification to the user",
24
26
  inputSchema: {
25
27
  title: z.string().describe("Notification title"),
26
28
  body: z.string().describe("Notification body text"),
@@ -63,6 +65,44 @@ export async function mcpserverCommand(): Promise<void> {
63
65
  );
64
66
  }
65
67
 
68
+ const taskId = process.env.PALMIER_TASK_ID;
69
+ if (taskId) {
70
+ const taskDir = getTaskDir(config.projectRoot, taskId);
71
+ const task = parseTaskFile(taskDir);
72
+
73
+ server.registerTool(
74
+ "request-user-input",
75
+ {
76
+ description: "Request input from the user. The user will see the descriptions and can provide values or abort.",
77
+ inputSchema: {
78
+ descriptions: z.array(z.string()).describe("List of input descriptions to show the user"),
79
+ },
80
+ },
81
+ async (args) => {
82
+ try {
83
+ const response = await requestUserInput(nc, config, taskId, task.frontmatter.name, taskDir, args.descriptions);
84
+ await publishInputResolved(nc, config, taskId, response === "aborted" ? "aborted" : "provided");
85
+
86
+ if (response === "aborted") {
87
+ return {
88
+ content: [{ type: "text" as const, text: "User aborted the input request." }],
89
+ };
90
+ }
91
+
92
+ const lines = args.descriptions.map((desc: string, i: number) => `${desc}: ${response[i]}`).join("\n");
93
+ return {
94
+ content: [{ type: "text" as const, text: lines }],
95
+ };
96
+ } catch (err) {
97
+ return {
98
+ content: [{ type: "text" as const, text: `Error requesting user input: ${err}` }],
99
+ isError: true,
100
+ };
101
+ }
102
+ }
103
+ );
104
+ }
105
+
66
106
  const transport = new StdioServerTransport();
67
107
  await server.connect(transport);
68
108
 
@@ -4,12 +4,13 @@ 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 } from "../task.js";
7
+ import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createResultFile } 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, TASK_INPUT_PREFIX } from "../agents/shared-prompt.js";
11
11
  import type { AgentTool } from "../agents/agent.js";
12
12
  import { publishHostEvent } from "../events.js";
13
+ import { waitForUserInput, requestUserInput, publishInputResolved } from "../user-input.js";
13
14
  import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
14
15
  import type { NatsConnection } from "nats";
15
16
 
@@ -17,8 +18,13 @@ import type { NatsConnection } from "nats";
17
18
  * Write a time-stamped RESULT file with frontmatter.
18
19
  * Always generated, even for abort/fail.
19
20
  */
20
- function writeResult(
21
+
22
+ /**
23
+ * Update an existing result file with the final outcome.
24
+ */
25
+ function finalizeResultFile(
21
26
  taskDir: string,
27
+ resultFileName: string,
22
28
  taskName: string,
23
29
  taskSnapshotName: string,
24
30
  runningState: string,
@@ -27,13 +33,11 @@ function writeResult(
27
33
  output: string,
28
34
  reportFiles: string[],
29
35
  requiredPermissions: RequiredPermission[],
30
- ): string {
31
- const resultFileName = `RESULT-${endTime}.md`;
36
+ ): void {
32
37
  const reportLine = reportFiles.length > 0 ? `\nreport_files: ${reportFiles.join(", ")}` : "";
33
38
  const permLines = requiredPermissions.map((p) => `\nrequired_permission: ${p.name} | ${p.description}`).join("");
34
39
  const content = `---\ntask_name: ${taskName}\nrunning_state: ${runningState}\nstart_time: ${startTime}\nend_time: ${endTime}\ntask_file: ${taskSnapshotName}${reportLine}${permLines}\n---\n${output}`;
35
40
  fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
36
- return resultFileName;
37
41
  }
38
42
 
39
43
  /**
@@ -115,7 +119,7 @@ async function invokeAgentWithRetry(
115
119
  // Input retry
116
120
  const inputRequests = parseInputRequests(result.output);
117
121
  if (outcome === "failed" && inputRequests.length > 0) {
118
- const response = await requestUserInput(ctx.nc, ctx.config, ctx.task, ctx.taskDir, inputRequests);
122
+ const response = await requestUserInput(ctx.nc, ctx.config, ctx.taskId, ctx.task.frontmatter.name, ctx.taskDir, inputRequests);
119
123
  await publishInputResolved(ctx.nc, ctx.config, ctx.taskId, response === "aborted" ? "aborted" : "provided");
120
124
 
121
125
  if (response === "aborted") {
@@ -132,6 +136,18 @@ async function invokeAgentWithRetry(
132
136
  }
133
137
  }
134
138
 
139
+ /**
140
+ * Find an existing RESULT file with running_state=started (created by the RPC handler).
141
+ */
142
+ function findStartedResultFile(taskDir: string): string | null {
143
+ const files = fs.readdirSync(taskDir).filter((f) => f.startsWith("RESULT-") && f.endsWith(".md"));
144
+ for (const file of files) {
145
+ const content = fs.readFileSync(path.join(taskDir, file), "utf-8");
146
+ if (content.includes("running_state: started")) return file;
147
+ }
148
+ return null;
149
+ }
150
+
135
151
  /**
136
152
  * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
137
153
  * respect that instead of overwriting with the process's own outcome.
@@ -152,12 +168,18 @@ export async function runCommand(taskId: string): Promise<void> {
152
168
  console.log(`Running task: ${taskId}`);
153
169
 
154
170
  let nc: NatsConnection | undefined;
155
- const startTime = Date.now();
156
171
  const taskName = task.frontmatter.name;
157
172
 
173
+ // Check for an existing "started" result file (created by the RPC handler)
174
+ const existingResult = findStartedResultFile(taskDir);
175
+ const startTime = existingResult ? parseInt(existingResult.replace("RESULT-", "").replace(".md", ""), 10) : Date.now();
176
+ const resultFileName = existingResult ?? createResultFile(taskDir, taskName, startTime);
177
+
158
178
  // Snapshot the task file at run time
159
179
  const taskSnapshotName = `TASK-${startTime}.md`;
160
- fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
180
+ if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
181
+ fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
182
+ }
161
183
 
162
184
  const cleanup = async () => {
163
185
  if (nc && !nc.isClosed()) {
@@ -165,11 +187,15 @@ export async function runCommand(taskId: string): Promise<void> {
165
187
  }
166
188
  };
167
189
 
190
+ if (!existingResult) {
191
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
192
+ }
193
+
168
194
  try {
169
195
  nc = await connectNats(config);
170
196
 
171
197
  // Mark as started immediately
172
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName);
198
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
173
199
 
174
200
  // If requires_confirmation, notify clients and wait
175
201
  if (task.frontmatter.requires_confirmation) {
@@ -179,9 +205,8 @@ export async function runCommand(taskId: string): Promise<void> {
179
205
  if (!confirmed) {
180
206
  console.log("Task aborted by user.");
181
207
  const endTime = Date.now();
182
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
183
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
184
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName);
208
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
209
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
185
210
  await cleanup();
186
211
  return;
187
212
  }
@@ -200,21 +225,16 @@ export async function runCommand(taskId: string): Promise<void> {
200
225
  // Command-triggered mode
201
226
  const result = await runCommandTriggeredMode(ctx);
202
227
  const outcome = resolveOutcome(taskDir, result.outcome);
203
- const resultFileName = writeResult(
204
- taskDir, taskName, taskSnapshotName, outcome,
205
- startTime, result.endTime, result.output, [], [],
206
- );
207
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
208
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
228
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, result.endTime, result.output, [], []);
229
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
209
230
  console.log(`Task ${taskId} completed (command-triggered).`);
210
231
  } else {
211
232
  // Standard execution
212
233
  const result = await invokeAgentWithRetry(ctx, task);
213
234
  const outcome = resolveOutcome(taskDir, result.outcome);
214
235
 
215
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
216
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
217
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
236
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
237
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
218
238
 
219
239
  if (result.reportFiles.length > 0) {
220
240
  await publishHostEvent(nc, config.hostId, taskId, {
@@ -222,6 +242,7 @@ export async function runCommand(taskId: string): Promise<void> {
222
242
  name: taskName,
223
243
  report_files: result.reportFiles,
224
244
  running_state: outcome,
245
+ result_file: resultFileName,
225
246
  });
226
247
  }
227
248
 
@@ -232,9 +253,8 @@ export async function runCommand(taskId: string): Promise<void> {
232
253
  const endTime = Date.now();
233
254
  const outcome = resolveOutcome(taskDir, "failed");
234
255
  const errorMsg = err instanceof Error ? err.message : String(err);
235
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
236
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
237
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
256
+ finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
257
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
238
258
  process.exitCode = 1;
239
259
  } finally {
240
260
  await cleanup();
@@ -388,6 +408,7 @@ async function publishTaskEvent(
388
408
  taskId: string,
389
409
  eventType: TaskRunningState,
390
410
  taskName?: string,
411
+ resultFile?: string,
391
412
  ): Promise<void> {
392
413
  writeTaskStatus(taskDir, {
393
414
  running_state: eventType,
@@ -396,6 +417,7 @@ async function publishTaskEvent(
396
417
 
397
418
  const payload: Record<string, unknown> = { event_type: "running-state", running_state: eventType };
398
419
  if (taskName) payload.name = taskName;
420
+ if (resultFile) payload.result_file = resultFile;
399
421
  await publishHostEvent(nc, config.hostId, taskId, payload);
400
422
  }
401
423
 
@@ -415,22 +437,6 @@ async function publishConfirmResolved(
415
437
  });
416
438
  }
417
439
 
418
- /**
419
- * Watch status.json until user_input is populated by an RPC call, then resolve.
420
- * All interactive request flows (confirmation, permission, user input) share this.
421
- */
422
- function waitForUserInput(taskDir: string): Promise<string[]> {
423
- const statusPath = path.join(taskDir, "status.json");
424
- return new Promise<string[]>((resolve) => {
425
- const watcher = fs.watch(statusPath, () => {
426
- const status = readTaskStatus(taskDir);
427
- if (!status || !status.user_input?.length) return;
428
- watcher.close();
429
- resolve(status.user_input);
430
- });
431
- });
432
- }
433
-
434
440
  async function requestPermission(
435
441
  nc: NatsConnection | undefined,
436
442
  config: HostConfig,
@@ -470,45 +476,6 @@ async function publishPermissionResolved(
470
476
  });
471
477
  }
472
478
 
473
- async function requestUserInput(
474
- nc: NatsConnection | undefined,
475
- config: HostConfig,
476
- task: ParsedTask,
477
- taskDir: string,
478
- inputDescriptions: string[],
479
- ): Promise<string[] | "aborted"> {
480
- const currentStatus = readTaskStatus(taskDir)!;
481
- writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
482
-
483
- await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
484
- event_type: "input-request",
485
- host_id: config.hostId,
486
- input_descriptions: inputDescriptions,
487
- name: task.frontmatter.name,
488
- });
489
-
490
- const userInput = await waitForUserInput(taskDir);
491
- if (userInput.length === 1 && userInput[0] === "aborted") {
492
- writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
493
- return "aborted";
494
- }
495
- writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
496
- return userInput;
497
- }
498
-
499
- async function publishInputResolved(
500
- nc: NatsConnection | undefined,
501
- config: HostConfig,
502
- taskId: string,
503
- status: "provided" | "aborted",
504
- ): Promise<void> {
505
- await publishHostEvent(nc, config.hostId, taskId, {
506
- event_type: "input-resolved",
507
- host_id: config.hostId,
508
- status,
509
- });
510
- }
511
-
512
479
  async function requestConfirmation(
513
480
  nc: NatsConnection | undefined,
514
481
  config: HostConfig,
@@ -7,7 +7,6 @@ import { startNatsTransport } from "../transports/nats-transport.js";
7
7
  import { getTaskDir, readTaskStatus, writeTaskStatus, appendHistory, parseTaskFile } from "../task.js";
8
8
  import { publishHostEvent } from "../events.js";
9
9
  import { getPlatform } from "../platform/index.js";
10
- import { checkForUpdate } from "../update-checker.js";
11
10
  import { detectAgents } from "../agents/agent.js";
12
11
  import { saveConfig } from "../config.js";
13
12
  import type { HostConfig } from "../types.js";
@@ -109,10 +108,6 @@ export async function serveCommand(): Promise<void> {
109
108
  });
110
109
  }, POLL_INTERVAL_MS);
111
110
 
112
- // Check for updates on startup and every 24 hours
113
- checkForUpdate().catch(() => {});
114
- setInterval(() => { checkForUpdate().catch(() => {}); }, 24 * 60 * 60 * 1000);
115
-
116
111
  const handleRpc = createRpcHandler(config, nc);
117
112
  await startNatsTransport(config, handleRpc, nc);
118
113
  }
@@ -131,31 +131,37 @@ export class WindowsPlatform implements PlatformService {
131
131
  ? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
132
132
  : null;
133
133
 
134
- // Spawn the new daemon before killing the old one.
135
- this.spawnDaemon(script);
136
-
137
134
  if (oldPid && oldPid === String(process.pid)) {
138
- // We ARE the old daemon (auto-update) — exit so only the new one runs.
135
+ // We ARE the old daemon (auto-update) — spawn replacement then exit.
136
+ this.spawnDaemon(script);
139
137
  process.exit(0);
140
- } else if (oldPid) {
138
+ }
139
+
140
+ // Kill old daemon first, then spawn new one.
141
+ if (oldPid) {
141
142
  try {
142
- execFileSync("taskkill", ["/pid", oldPid, "/t", "/f"], { windowsHide: true });
143
+ execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
143
144
  } catch {
144
145
  // Process may have already exited
145
146
  }
146
147
  }
148
+
149
+ this.spawnDaemon(script);
147
150
  }
148
151
 
149
152
  private spawnDaemon(script: string): void {
150
- const child = nodeSpawn(process.execPath, [script, "serve"], {
153
+ // Write a VBS launcher that starts the daemon with no visible console window.
154
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
155
+ fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
156
+
157
+ const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
158
+ const child = nodeSpawn(wscript, [DAEMON_VBS_FILE], {
151
159
  detached: true,
152
160
  stdio: "ignore",
153
161
  windowsHide: true,
154
162
  });
155
- if (child.pid) {
156
- fs.writeFileSync(DAEMON_PID_FILE, String(child.pid), "utf-8");
157
- }
158
163
  child.unref();
164
+ // PID file will be written by the serve command itself when it starts.
159
165
  console.log("Palmier daemon started.");
160
166
  }
161
167
 
@@ -2,15 +2,16 @@ import { randomUUID } from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { spawn } from "child_process";
5
6
  import { parse as parseYaml } from "yaml";
6
7
  import { type NatsConnection } from "nats";
7
- import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList } from "./task.js";
8
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createResultFile } from "./task.js";
8
9
  import { getPlatform } from "./platform/index.js";
9
10
  import { spawnCommand } from "./spawn-command.js";
10
11
  import { getAgent } from "./agents/agent.js";
11
12
  import { validateSession } from "./session-store.js";
12
13
  import { publishHostEvent } from "./events.js";
13
- import { currentVersion, getLatestVersion, performUpdate } from "./update-checker.js";
14
+ import { currentVersion, performUpdate } from "./update-checker.js";
14
15
  import type { HostConfig, ParsedTask, RpcMessage } from "./types.js";
15
16
 
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -123,7 +124,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
123
124
  tasks: tasks.map((task) => flattenTask(task)),
124
125
  agents: config.agents ?? [],
125
126
  version: currentVersion,
126
- latest_version: getLatestVersion(),
127
127
  };
128
128
  }
129
129
 
@@ -243,11 +243,61 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
243
243
  return { ok: true, task_id: params.id };
244
244
  }
245
245
 
246
+ case "task.run_oneoff": {
247
+ const params = request.params as {
248
+ user_prompt: string;
249
+ agent: string;
250
+ requires_confirmation?: boolean;
251
+ command?: string;
252
+ };
253
+
254
+ const id = randomUUID();
255
+ const taskDir = getTaskDir(config.projectRoot, id);
256
+ const name = params.user_prompt.slice(0, 60);
257
+ const task: ParsedTask = {
258
+ frontmatter: {
259
+ id,
260
+ name,
261
+ user_prompt: params.user_prompt,
262
+ agent: params.agent,
263
+ triggers: [],
264
+ triggers_enabled: false,
265
+ requires_confirmation: params.requires_confirmation ?? false,
266
+ ...(params.command ? { command: params.command } : {}),
267
+ },
268
+ body: "",
269
+ };
270
+
271
+ writeTaskFile(taskDir, task);
272
+ // Do NOT append to tasks.jsonl — this is a one-off run
273
+
274
+ // Create initial result file so it appears in runs list immediately
275
+ const resultFileName = createResultFile(taskDir, name, Date.now());
276
+ appendHistory(config.projectRoot, { task_id: id, result_file: resultFileName });
277
+
278
+ // Spawn `palmier run <id>` directly as a detached process
279
+ const script = process.argv[1] || "palmier";
280
+ const child = spawn(process.execPath, [script, "run", id], {
281
+ detached: true,
282
+ stdio: "ignore",
283
+ windowsHide: true,
284
+ });
285
+ child.unref();
286
+
287
+ return { ok: true, task_id: id, result_file: resultFileName };
288
+ }
289
+
246
290
  case "task.run": {
247
291
  const params = request.params as { id: string };
248
292
  try {
293
+ // Create initial result file so it appears in runs list immediately
294
+ const runTaskDir = getTaskDir(config.projectRoot, params.id);
295
+ const runTask = parseTaskFile(runTaskDir);
296
+ const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
297
+ appendHistory(config.projectRoot, { task_id: params.id, result_file: runResultFileName });
298
+
249
299
  await getPlatform().startTask(params.id);
250
- return { ok: true, task_id: params.id };
300
+ return { ok: true, task_id: params.id, result_file: runResultFileName };
251
301
  } catch (err: unknown) {
252
302
  const e = err as { stderr?: string; message?: string };
253
303
  console.error(`task.run failed for ${params.id}: ${e.stderr || e.message}`);
package/src/task.ts CHANGED
@@ -147,6 +147,23 @@ export function readTaskStatus(taskDir: string): TaskStatus | undefined {
147
147
  }
148
148
  }
149
149
 
150
+ /**
151
+ * Create the initial result file when a task starts running.
152
+ * Contains only start_time and running_state=started; no end_time or content yet.
153
+ * Returns the result file name.
154
+ */
155
+ export function createResultFile(
156
+ taskDir: string,
157
+ taskName: string,
158
+ startTime: number,
159
+ ): string {
160
+ const resultFileName = `RESULT-${startTime}.md`;
161
+ const taskSnapshotName = `TASK-${startTime}.md`;
162
+ const content = `---\ntask_name: ${taskName}\nrunning_state: started\nstart_time: ${startTime}\ntask_file: ${taskSnapshotName}\n---\n`;
163
+ fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
164
+ return resultFileName;
165
+ }
166
+
150
167
  /**
151
168
  * Append a history entry to the project-level history.jsonl file.
152
169
  */
@@ -12,41 +12,6 @@ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "
12
12
  export const isDevBuild = fs.existsSync(path.join(packageRoot, ".git"));
13
13
  export const currentVersion = isDevBuild ? `${pkg.version}-dev` : pkg.version;
14
14
 
15
- let latestVersion: string | null = null;
16
- let lastCheckTime = 0;
17
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
-
19
- /**
20
- * Check the npm registry for the latest version of palmier.
21
- */
22
- export async function checkForUpdate(): Promise<void> {
23
- if (isDevBuild) return;
24
- const now = Date.now();
25
- if (now - lastCheckTime < CHECK_INTERVAL_MS) return;
26
- lastCheckTime = now;
27
-
28
- try {
29
- const res = await fetch("https://registry.npmjs.org/palmier/latest", {
30
- signal: AbortSignal.timeout(10_000),
31
- });
32
- if (!res.ok) return;
33
- const data = (await res.json()) as { version?: string };
34
- if (data.version) {
35
- latestVersion = data.version;
36
- console.log(`[update] Latest version: ${data.version} (current: ${currentVersion})`);
37
- }
38
- } catch {
39
- // Network errors are expected (offline, etc.)
40
- }
41
- }
42
-
43
- /**
44
- * Get the latest version from npm, or null if not yet checked.
45
- */
46
- export function getLatestVersion(): string | null {
47
- return latestVersion;
48
- }
49
-
50
15
  /**
51
16
  * Run the update and restart the daemon.
52
17
  * Returns an error message if the update fails.
@@ -63,7 +28,6 @@ export async function performUpdate(): Promise<string | null> {
63
28
  return `Update failed. Please run manually:\nnpm update -g palmier`;
64
29
  }
65
30
  console.log("[update] Update installed, restarting daemon...");
66
- latestVersion = null;
67
31
  // Small delay to allow the RPC response to be sent
68
32
  setTimeout(() => {
69
33
  getPlatform().restartDaemon().catch((err) => {
@@ -0,0 +1,67 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { readTaskStatus, writeTaskStatus } from "./task.js";
4
+ import { publishHostEvent } from "./events.js";
5
+ import type { HostConfig } from "./types.js";
6
+ import type { NatsConnection } from "nats";
7
+
8
+ /**
9
+ * Watch status.json until user_input is populated by an RPC call, then resolve.
10
+ */
11
+ export function waitForUserInput(taskDir: string): Promise<string[]> {
12
+ const statusPath = path.join(taskDir, "status.json");
13
+ return new Promise<string[]>((resolve) => {
14
+ const watcher = fs.watch(statusPath, () => {
15
+ const status = readTaskStatus(taskDir);
16
+ if (!status || !status.user_input?.length) return;
17
+ watcher.close();
18
+ resolve(status.user_input);
19
+ });
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Send an input-request event and wait for the user's response.
25
+ */
26
+ export async function requestUserInput(
27
+ nc: NatsConnection | undefined,
28
+ config: HostConfig,
29
+ taskId: string,
30
+ taskName: string,
31
+ taskDir: string,
32
+ inputDescriptions: string[],
33
+ ): Promise<string[] | "aborted"> {
34
+ const currentStatus = readTaskStatus(taskDir)!;
35
+ writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
36
+
37
+ await publishHostEvent(nc, config.hostId, taskId, {
38
+ event_type: "input-request",
39
+ host_id: config.hostId,
40
+ input_descriptions: inputDescriptions,
41
+ name: taskName,
42
+ });
43
+
44
+ const userInput = await waitForUserInput(taskDir);
45
+ if (userInput.length === 1 && userInput[0] === "aborted") {
46
+ writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
47
+ return "aborted";
48
+ }
49
+ writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
50
+ return userInput;
51
+ }
52
+
53
+ /**
54
+ * Notify clients that an input request has been resolved.
55
+ */
56
+ export async function publishInputResolved(
57
+ nc: NatsConnection | undefined,
58
+ config: HostConfig,
59
+ taskId: string,
60
+ status: "provided" | "aborted",
61
+ ): Promise<void> {
62
+ await publishHostEvent(nc, config.hostId, taskId, {
63
+ event_type: "input-resolved",
64
+ host_id: config.hostId,
65
+ status,
66
+ });
67
+ }