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 +1 -1
- package/dist/agents/agent.js +1 -1
- package/dist/commands/mcpserver.js +34 -1
- package/dist/commands/run.js +42 -61
- package/dist/platform/windows.js +18 -9
- package/dist/rpc-handler.js +41 -2
- package/dist/task.d.ts +6 -0
- package/dist/task.js +12 -0
- package/dist/user-input.d.ts +15 -0
- package/dist/user-input.js +50 -0
- package/package.json +1 -1
- package/src/agents/agent.ts +1 -1
- package/src/commands/mcpserver.ts +41 -1
- package/src/commands/run.ts +46 -79
- package/src/platform/windows.ts +21 -9
- package/src/rpc-handler.ts +53 -2
- package/src/task.ts +17 -0
- package/src/user-input.ts +67 -0
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
|
|
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
|
|
package/dist/agents/agent.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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 });
|
package/dist/platform/windows.js
CHANGED
|
@@ -106,29 +106,38 @@ export class WindowsPlatform {
|
|
|
106
106
|
console.log("\nHost initialization complete!");
|
|
107
107
|
}
|
|
108
108
|
async restartDaemon() {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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, "/
|
|
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
|
-
|
|
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) {
|
package/dist/rpc-handler.js
CHANGED
|
@@ -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
package/src/agents/agent.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/commands/run.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
):
|
|
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.
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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,
|
package/src/platform/windows.ts
CHANGED
|
@@ -126,30 +126,42 @@ export class WindowsPlatform implements PlatformService {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
async restartDaemon(): Promise<void> {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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, "/
|
|
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
|
-
|
|
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
|
|
package/src/rpc-handler.ts
CHANGED
|
@@ -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
|
+
}
|