palmier 0.3.9 → 0.4.1

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  **Website:** [palmier.me](https://www.palmier.me) | **App:** [app.palmier.me](https://app.palmier.me)
8
8
 
9
- A Node.js CLI that runs on your machine as a persistent daemon. It lets you create, schedule, and run AI agent tasks from your phone or browser, communicating via a cloud relay (NATS) and/or direct HTTP.
9
+ A Node.js CLI that lets you run your own AI agents from your phone. It runs on your machine as a persistent daemon, letting you create, schedule, and monitor agent tasks from any device via a cloud relay (NATS) and/or direct HTTP.
10
10
 
11
11
  > **Important:** By using Palmier, you agree to the [Terms of Service](https://www.palmier.me/terms) and [Privacy Policy](https://www.palmier.me/privacy). See the [Disclaimer](#disclaimer) section below.
12
12
 
@@ -15,7 +15,7 @@ const agentLabels = {
15
15
  gemini: "Gemini CLI",
16
16
  codex: "Codex CLI",
17
17
  openclaw: "OpenClaw",
18
- copilot: "GitHub Copilot",
18
+ copilot: "Copilot CLI",
19
19
  };
20
20
  export async function detectAgents() {
21
21
  const detected = [];
@@ -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 });
@@ -106,29 +106,38 @@ export class WindowsPlatform {
106
106
  console.log("\nHost initialization complete!");
107
107
  }
108
108
  async restartDaemon() {
109
- // Kill the old daemon if we have its PID
110
- if (fs.existsSync(DAEMON_PID_FILE)) {
111
- const oldPid = fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim();
109
+ const script = process.argv[1] || "palmier";
110
+ const oldPid = fs.existsSync(DAEMON_PID_FILE)
111
+ ? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
112
+ : null;
113
+ if (oldPid && oldPid === String(process.pid)) {
114
+ // We ARE the old daemon (auto-update) — spawn replacement then exit.
115
+ this.spawnDaemon(script);
116
+ process.exit(0);
117
+ }
118
+ // Kill old daemon first, then spawn new one.
119
+ if (oldPid) {
112
120
  try {
113
- execFileSync("taskkill", ["/pid", oldPid, "/t", "/f"], { windowsHide: true });
121
+ execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
114
122
  }
115
123
  catch {
116
124
  // Process may have already exited
117
125
  }
118
126
  }
119
- const script = process.argv[1] || "palmier";
120
127
  this.spawnDaemon(script);
121
128
  }
122
129
  spawnDaemon(script) {
123
- 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], {
124
135
  detached: true,
125
136
  stdio: "ignore",
126
137
  windowsHide: true,
127
138
  });
128
- if (child.pid) {
129
- fs.writeFileSync(DAEMON_PID_FILE, String(child.pid), "utf-8");
130
- }
131
139
  child.unref();
140
+ // PID file will be written by the serve command itself when it starts.
132
141
  console.log("Palmier daemon started.");
133
142
  }
134
143
  installTaskTimer(config, task) {
@@ -2,8 +2,9 @@ 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";
@@ -205,11 +206,49 @@ export function createRpcHandler(config, nc) {
205
206
  removeFromTaskList(config.projectRoot, params.id);
206
207
  return { ok: true, task_id: params.id };
207
208
  }
209
+ case "task.run_oneoff": {
210
+ const params = request.params;
211
+ const id = randomUUID();
212
+ const taskDir = getTaskDir(config.projectRoot, id);
213
+ const name = params.user_prompt.slice(0, 60);
214
+ const task = {
215
+ frontmatter: {
216
+ id,
217
+ name,
218
+ user_prompt: params.user_prompt,
219
+ agent: params.agent,
220
+ triggers: [],
221
+ triggers_enabled: false,
222
+ requires_confirmation: params.requires_confirmation ?? false,
223
+ ...(params.command ? { command: params.command } : {}),
224
+ },
225
+ body: "",
226
+ };
227
+ writeTaskFile(taskDir, task);
228
+ // Do NOT append to tasks.jsonl — this is a one-off run
229
+ // Create initial result file so it appears in runs list immediately
230
+ const resultFileName = createResultFile(taskDir, name, Date.now());
231
+ appendHistory(config.projectRoot, { task_id: id, result_file: resultFileName });
232
+ // Spawn `palmier run <id>` directly as a detached process
233
+ const script = process.argv[1] || "palmier";
234
+ const child = spawn(process.execPath, [script, "run", id], {
235
+ detached: true,
236
+ stdio: "ignore",
237
+ windowsHide: true,
238
+ });
239
+ child.unref();
240
+ return { ok: true, task_id: id, result_file: resultFileName };
241
+ }
208
242
  case "task.run": {
209
243
  const params = request.params;
210
244
  try {
245
+ // Create initial result file so it appears in runs list immediately
246
+ const runTaskDir = getTaskDir(config.projectRoot, params.id);
247
+ const runTask = parseTaskFile(runTaskDir);
248
+ const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
249
+ appendHistory(config.projectRoot, { task_id: params.id, result_file: runResultFileName });
211
250
  await getPlatform().startTask(params.id);
212
- return { ok: true, task_id: params.id };
251
+ return { ok: true, task_id: params.id, result_file: runResultFileName };
213
252
  }
214
253
  catch (err) {
215
254
  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
  */
@@ -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.3.9",
3
+ "version": "0.4.1",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -43,7 +43,7 @@ const agentLabels: Record<string, string> = {
43
43
  gemini: "Gemini CLI",
44
44
  codex: "Codex CLI",
45
45
  openclaw: "OpenClaw",
46
- copilot: "GitHub Copilot",
46
+ copilot: "Copilot CLI",
47
47
  };
48
48
 
49
49
  export interface DetectedAgent {
@@ -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,
@@ -126,30 +126,42 @@ export class WindowsPlatform implements PlatformService {
126
126
  }
127
127
 
128
128
  async restartDaemon(): Promise<void> {
129
- // Kill the old daemon if we have its PID
130
- if (fs.existsSync(DAEMON_PID_FILE)) {
131
- const oldPid = fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim();
129
+ const script = process.argv[1] || "palmier";
130
+ const oldPid = fs.existsSync(DAEMON_PID_FILE)
131
+ ? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
132
+ : null;
133
+
134
+ if (oldPid && oldPid === String(process.pid)) {
135
+ // We ARE the old daemon (auto-update) — spawn replacement then exit.
136
+ this.spawnDaemon(script);
137
+ process.exit(0);
138
+ }
139
+
140
+ // Kill old daemon first, then spawn new one.
141
+ if (oldPid) {
132
142
  try {
133
- execFileSync("taskkill", ["/pid", oldPid, "/t", "/f"], { windowsHide: true });
143
+ execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
134
144
  } catch {
135
145
  // Process may have already exited
136
146
  }
137
147
  }
138
148
 
139
- const script = process.argv[1] || "palmier";
140
149
  this.spawnDaemon(script);
141
150
  }
142
151
 
143
152
  private spawnDaemon(script: string): void {
144
- 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], {
145
159
  detached: true,
146
160
  stdio: "ignore",
147
161
  windowsHide: true,
148
162
  });
149
- if (child.pid) {
150
- fs.writeFileSync(DAEMON_PID_FILE, String(child.pid), "utf-8");
151
- }
152
163
  child.unref();
164
+ // PID file will be written by the serve command itself when it starts.
153
165
  console.log("Palmier daemon started.");
154
166
  }
155
167
 
@@ -2,9 +2,10 @@ 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";
@@ -243,11 +244,61 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
243
244
  return { ok: true, task_id: params.id };
244
245
  }
245
246
 
247
+ case "task.run_oneoff": {
248
+ const params = request.params as {
249
+ user_prompt: string;
250
+ agent: string;
251
+ requires_confirmation?: boolean;
252
+ command?: string;
253
+ };
254
+
255
+ const id = randomUUID();
256
+ const taskDir = getTaskDir(config.projectRoot, id);
257
+ const name = params.user_prompt.slice(0, 60);
258
+ const task: ParsedTask = {
259
+ frontmatter: {
260
+ id,
261
+ name,
262
+ user_prompt: params.user_prompt,
263
+ agent: params.agent,
264
+ triggers: [],
265
+ triggers_enabled: false,
266
+ requires_confirmation: params.requires_confirmation ?? false,
267
+ ...(params.command ? { command: params.command } : {}),
268
+ },
269
+ body: "",
270
+ };
271
+
272
+ writeTaskFile(taskDir, task);
273
+ // Do NOT append to tasks.jsonl — this is a one-off run
274
+
275
+ // Create initial result file so it appears in runs list immediately
276
+ const resultFileName = createResultFile(taskDir, name, Date.now());
277
+ appendHistory(config.projectRoot, { task_id: id, result_file: resultFileName });
278
+
279
+ // Spawn `palmier run <id>` directly as a detached process
280
+ const script = process.argv[1] || "palmier";
281
+ const child = spawn(process.execPath, [script, "run", id], {
282
+ detached: true,
283
+ stdio: "ignore",
284
+ windowsHide: true,
285
+ });
286
+ child.unref();
287
+
288
+ return { ok: true, task_id: id, result_file: resultFileName };
289
+ }
290
+
246
291
  case "task.run": {
247
292
  const params = request.params as { id: string };
248
293
  try {
294
+ // Create initial result file so it appears in runs list immediately
295
+ const runTaskDir = getTaskDir(config.projectRoot, params.id);
296
+ const runTask = parseTaskFile(runTaskDir);
297
+ const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
298
+ appendHistory(config.projectRoot, { task_id: params.id, result_file: runResultFileName });
299
+
249
300
  await getPlatform().startTask(params.id);
250
- return { ok: true, task_id: params.id };
301
+ return { ok: true, task_id: params.id, result_file: runResultFileName };
251
302
  } catch (err: unknown) {
252
303
  const e = err as { stderr?: string; message?: string };
253
304
  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
  */
@@ -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
+ }