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