palmier 0.4.3 → 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 +5 -3
- package/dist/commands/request-input.d.ts +1 -2
- package/dist/commands/request-input.js +7 -21
- package/dist/commands/run.d.ts +4 -0
- package/dist/commands/run.js +43 -53
- package/dist/commands/serve.js +31 -33
- package/dist/platform/linux.js +16 -6
- package/dist/platform/windows.js +37 -12
- package/dist/rpc-handler.js +177 -30
- package/dist/task.d.ts +13 -13
- package/dist/task.js +59 -51
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
- package/src/commands/request-input.ts +7 -21
- package/src/commands/run.ts +43 -56
- package/src/commands/serve.ts +34 -41
- package/src/platform/linux.ts +17 -7
- package/src/platform/windows.ts +36 -13
- package/src/rpc-handler.ts +195 -34
- package/src/task.ts +60 -55
- package/src/types.ts +2 -2
package/README.md
CHANGED
|
@@ -127,11 +127,11 @@ palmier restart
|
|
|
127
127
|
- **Tasks** are stored locally as Markdown files in a `tasks/` directory. Each task has a name, prompt, execution plan, and optional schedules (cron schedules or one-time dates).
|
|
128
128
|
- **Plan generation** is automatic — when you create or update a task, the host invokes your chosen agent CLI to generate an execution plan and name.
|
|
129
129
|
- **Schedules** are backed by systemd timers (Linux) or Task Scheduler (Windows). You can enable/disable them without deleting the task, and any task can still be run manually at any time.
|
|
130
|
-
- **Task execution** uses the system scheduler on both platforms — `systemctl --user start` on Linux, `schtasks /run` on Windows. The daemon polls every 30 seconds to detect crashed tasks (processes that exited without updating status) and marks them as failed, broadcasting the failure to connected clients.
|
|
130
|
+
- **Task execution** uses the system scheduler on both platforms — `systemctl --user start` on Linux, `schtasks /run` on Windows. On Windows, tasks run via a VBS wrapper (`wscript.exe`) to avoid visible console windows. The daemon polls every 30 seconds to detect crashed tasks (processes that exited without updating status) and marks them as failed, broadcasting the failure to connected clients.
|
|
131
131
|
- **Command-triggered tasks** — optionally specify a shell command (e.g., `tail -f /var/log/app.log`). Palmier runs the command continuously and invokes the agent for each line of stdout, passing it alongside your prompt. Useful for log monitoring, event-driven automation, and reactive workflows.
|
|
132
132
|
- **Task confirmation** — tasks can optionally require your approval before running. You'll get a push notification (server mode) or a prompt in the PWA to confirm or abort.
|
|
133
|
-
- **
|
|
134
|
-
- **Real-time updates** — task status changes
|
|
133
|
+
- **Conversational run history** — each run produces a timestamped `RESULT-{ts}.md` file with a conversational structure: a sequence of assistant messages (agent output), user messages (input responses, permission grants, confirmations), and status entries (started, finished, failed, aborted). Timing and outcome are derived from status messages — no redundant frontmatter. The PWA displays these as a chat-like thread. Reports are attached per-message.
|
|
134
|
+
- **Real-time updates** — task status changes and result updates are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode). The run detail view live-updates as the agent produces output.
|
|
135
135
|
- **Agent CLI commands** — `palmier notify` and `palmier request-input` allow agents to send push notifications and request user input during task execution without requiring MCP support.
|
|
136
136
|
|
|
137
137
|
## NATS Subjects
|
|
@@ -159,6 +159,8 @@ src/
|
|
|
159
159
|
events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
|
|
160
160
|
agents/
|
|
161
161
|
agent.ts # AgentTool interface, registry, and agent detection
|
|
162
|
+
shared-prompt.ts # Agent instructions loader
|
|
163
|
+
agent-instructions.md # System prompt injected into every agent invocation
|
|
162
164
|
claude.ts # Claude Code agent implementation
|
|
163
165
|
gemini.ts # Gemini CLI agent implementation
|
|
164
166
|
codex.ts # Codex CLI agent implementation
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Request input from the user and print responses to stdout.
|
|
3
3
|
* Usage: palmier request-input --description "Question 1" --description "Question 2"
|
|
4
4
|
*
|
|
5
|
-
* Requires PALMIER_TASK_ID
|
|
6
|
-
* Outputs each response on its own line: "description: value"
|
|
5
|
+
* Requires PALMIER_TASK_ID and PALMIER_RUN_DIR environment variables.
|
|
7
6
|
*/
|
|
8
7
|
export declare function requestInputCommand(opts: {
|
|
9
8
|
description: string[];
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { connectNats } from "../nats-client.js";
|
|
3
|
-
import { getTaskDir, parseTaskFile,
|
|
3
|
+
import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
|
|
4
4
|
import { requestUserInput, publishInputResolved } from "../user-input.js";
|
|
5
5
|
/**
|
|
6
6
|
* Request input from the user and print responses to stdout.
|
|
7
7
|
* Usage: palmier request-input --description "Question 1" --description "Question 2"
|
|
8
8
|
*
|
|
9
|
-
* Requires PALMIER_TASK_ID
|
|
10
|
-
* Outputs each response on its own line: "description: value"
|
|
9
|
+
* Requires PALMIER_TASK_ID and PALMIER_RUN_DIR environment variables.
|
|
11
10
|
*/
|
|
12
11
|
export async function requestInputCommand(opts) {
|
|
13
12
|
const taskId = process.env.PALMIER_TASK_ID;
|
|
@@ -19,33 +18,20 @@ export async function requestInputCommand(opts) {
|
|
|
19
18
|
const nc = await connectNats(config);
|
|
20
19
|
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
21
20
|
const task = parseTaskFile(taskDir);
|
|
21
|
+
const runId = process.env.PALMIER_RUN_DIR?.split(/[/\\]/).pop();
|
|
22
22
|
try {
|
|
23
23
|
const response = await requestUserInput(nc, config, taskId, task.frontmatter.name, taskDir, opts.description);
|
|
24
24
|
await publishInputResolved(nc, config, taskId, response === "aborted" ? "aborted" : "provided");
|
|
25
25
|
if (response === "aborted") {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (resultFile) {
|
|
29
|
-
appendResultMessage(taskDir, resultFile, {
|
|
30
|
-
role: "user",
|
|
31
|
-
time: Date.now(),
|
|
32
|
-
content: "Input request aborted.",
|
|
33
|
-
type: "input",
|
|
34
|
-
});
|
|
26
|
+
if (runId) {
|
|
27
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
|
|
35
28
|
}
|
|
36
29
|
console.error("User aborted the input request.");
|
|
37
30
|
process.exit(1);
|
|
38
31
|
}
|
|
39
|
-
|
|
40
|
-
const resultFile = process.env.PALMIER_RESULT_FILE;
|
|
41
|
-
if (resultFile) {
|
|
32
|
+
if (runId) {
|
|
42
33
|
const lines = opts.description.map((desc, i) => `**${desc}** ${response[i]}`);
|
|
43
|
-
|
|
44
|
-
role: "user",
|
|
45
|
-
time: Date.now(),
|
|
46
|
-
content: lines.join("\n"),
|
|
47
|
-
type: "input",
|
|
48
|
-
});
|
|
34
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
|
|
49
35
|
}
|
|
50
36
|
for (let i = 0; i < opts.description.length; i++) {
|
|
51
37
|
console.log(response[i]);
|
package/dist/commands/run.d.ts
CHANGED
package/dist/commands/run.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
10
|
import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
|
|
@@ -23,8 +23,8 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
|
|
|
23
23
|
while (true) {
|
|
24
24
|
const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
|
|
25
25
|
const result = await spawnCommand(command, args, {
|
|
26
|
-
cwd: ctx.taskDir,
|
|
27
|
-
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) },
|
|
28
28
|
echoStdout: true,
|
|
29
29
|
resolveOnFailure: true,
|
|
30
30
|
stdin,
|
|
@@ -79,27 +79,29 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
|
|
|
79
79
|
/**
|
|
80
80
|
* Strip [PALMIER_*] marker lines from agent output.
|
|
81
81
|
*/
|
|
82
|
-
function stripPalmierMarkers(output) {
|
|
82
|
+
export function stripPalmierMarkers(output) {
|
|
83
83
|
return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
86
|
* Append a conversation message to the RESULT file and notify connected clients.
|
|
87
87
|
*/
|
|
88
88
|
async function appendAndNotify(ctx, msg) {
|
|
89
|
-
|
|
90
|
-
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated" });
|
|
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
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
* Find
|
|
93
|
+
* Find the latest run dir that has no status messages yet (just created by the RPC handler).
|
|
94
94
|
*/
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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;
|
|
103
105
|
}
|
|
104
106
|
/**
|
|
105
107
|
* If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
|
|
@@ -121,30 +123,22 @@ export async function runCommand(taskId) {
|
|
|
121
123
|
console.log(`Running task: ${taskId}`);
|
|
122
124
|
let nc;
|
|
123
125
|
const taskName = task.frontmatter.name;
|
|
124
|
-
//
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const taskSnapshotName = `TASK-${startTime}.md`;
|
|
130
|
-
if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
|
|
131
|
-
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 });
|
|
132
131
|
}
|
|
133
132
|
const cleanup = async () => {
|
|
134
133
|
if (nc && !nc.isClosed()) {
|
|
135
134
|
await nc.drain();
|
|
136
135
|
}
|
|
137
136
|
};
|
|
138
|
-
if (!existingResult) {
|
|
139
|
-
appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
|
|
140
|
-
}
|
|
141
137
|
try {
|
|
142
138
|
nc = await connectNats(config);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "started" });
|
|
147
|
-
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
|
|
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 });
|
|
148
142
|
// If requires_confirmation, notify clients and wait
|
|
149
143
|
if (task.frontmatter.requires_confirmation) {
|
|
150
144
|
const confirmed = await requestConfirmation(nc, config, task, taskDir);
|
|
@@ -152,30 +146,28 @@ export async function runCommand(taskId) {
|
|
|
152
146
|
await publishConfirmResolved(nc, config, taskId, resolvedStatus);
|
|
153
147
|
if (!confirmed) {
|
|
154
148
|
console.log("Task aborted by user.");
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
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);
|
|
158
151
|
await cleanup();
|
|
159
152
|
return;
|
|
160
153
|
}
|
|
161
154
|
console.log("Task confirmed by user.");
|
|
162
|
-
|
|
163
|
-
await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
|
|
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 });
|
|
164
157
|
}
|
|
165
158
|
// Shared invocation context
|
|
166
159
|
const guiEnv = getPlatform().getGuiEnv();
|
|
167
160
|
const agent = getAgent(task.frontmatter.agent);
|
|
168
161
|
const ctx = {
|
|
169
|
-
agent, task, taskDir,
|
|
162
|
+
agent, task, taskDir, runId, guiEnv, nc, config, taskId,
|
|
170
163
|
transientPermissions: [],
|
|
171
164
|
};
|
|
172
165
|
if (task.frontmatter.command) {
|
|
173
166
|
// Command-triggered mode
|
|
174
167
|
const result = await runCommandTriggeredMode(ctx);
|
|
175
168
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
|
|
169
|
+
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
170
|
+
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
179
171
|
console.log(`Task ${taskId} completed (command-triggered).`);
|
|
180
172
|
}
|
|
181
173
|
else {
|
|
@@ -187,9 +179,8 @@ export async function runCommand(taskId) {
|
|
|
187
179
|
});
|
|
188
180
|
const result = await invokeAgentWithRetry(ctx, task);
|
|
189
181
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
|
|
182
|
+
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
183
|
+
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
193
184
|
console.log(`Task ${taskId} completed.`);
|
|
194
185
|
}
|
|
195
186
|
}
|
|
@@ -197,14 +188,13 @@ export async function runCommand(taskId) {
|
|
|
197
188
|
console.error(`Task ${taskId} failed:`, err);
|
|
198
189
|
const outcome = resolveOutcome(taskDir, "failed");
|
|
199
190
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
200
|
-
|
|
191
|
+
appendRunMessage(taskDir, runId, {
|
|
201
192
|
role: "assistant",
|
|
202
193
|
time: Date.now(),
|
|
203
194
|
content: errorMsg,
|
|
204
195
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
|
|
196
|
+
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
197
|
+
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
208
198
|
process.exitCode = 1;
|
|
209
199
|
}
|
|
210
200
|
finally {
|
|
@@ -226,8 +216,8 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
226
216
|
const commandStr = ctx.task.frontmatter.command;
|
|
227
217
|
console.log(`[command-triggered] Spawning: ${commandStr}`);
|
|
228
218
|
const child = spawnStreamingCommand(commandStr, {
|
|
229
|
-
cwd: ctx.taskDir,
|
|
230
|
-
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) },
|
|
231
221
|
});
|
|
232
222
|
let linesProcessed = 0;
|
|
233
223
|
let invocationsSucceeded = 0;
|
|
@@ -236,7 +226,7 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
236
226
|
let processing = false;
|
|
237
227
|
let commandExited = false;
|
|
238
228
|
let resolveWhenDone;
|
|
239
|
-
const logPath = path.join(ctx.taskDir, "command-output.log");
|
|
229
|
+
const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
|
|
240
230
|
function appendLog(line, agentOutput, outcome) {
|
|
241
231
|
const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
|
|
242
232
|
fs.appendFileSync(logPath, entry, "utf-8");
|
|
@@ -330,7 +320,7 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
330
320
|
const endTime = Date.now();
|
|
331
321
|
return { outcome: "finished", endTime };
|
|
332
322
|
}
|
|
333
|
-
async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName,
|
|
323
|
+
async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
|
|
334
324
|
writeTaskStatus(taskDir, {
|
|
335
325
|
running_state: eventType,
|
|
336
326
|
time_stamp: Date.now(),
|
|
@@ -339,8 +329,8 @@ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName
|
|
|
339
329
|
const payload = { event_type: "running-state", running_state: eventType };
|
|
340
330
|
if (taskName)
|
|
341
331
|
payload.name = taskName;
|
|
342
|
-
if (
|
|
343
|
-
payload.
|
|
332
|
+
if (runId)
|
|
333
|
+
payload.run_id = runId;
|
|
344
334
|
await publishHostEvent(nc, config.hostId, taskId, payload);
|
|
345
335
|
}
|
|
346
336
|
/**
|
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,38 +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\n`;
|
|
35
|
-
fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
|
|
36
|
-
appendResultMessage(taskDir, resultFileName, {
|
|
37
|
-
role: "assistant",
|
|
38
|
-
time: endTime,
|
|
39
|
-
content: reason,
|
|
40
|
-
});
|
|
41
|
-
appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
|
|
42
|
-
const payload = { event_type: "running-state", running_state: "failed", name: taskName };
|
|
43
|
-
await publishHostEvent(nc, config.hostId, taskId, payload);
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* 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.
|
|
47
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.
|
|
48
21
|
*/
|
|
49
22
|
async function checkStaleTasks(config, nc) {
|
|
50
23
|
const tasksJsonl = path.join(config.projectRoot, "tasks.jsonl");
|
|
@@ -67,7 +40,32 @@ async function checkStaleTasks(config, nc) {
|
|
|
67
40
|
// Ask the system scheduler if the task is still running
|
|
68
41
|
if (platform.isTaskRunning(taskId))
|
|
69
42
|
continue;
|
|
70
|
-
|
|
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
|
+
});
|
|
71
69
|
}
|
|
72
70
|
}
|
|
73
71
|
/**
|
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
|
@@ -8,14 +8,6 @@ const TASK_PREFIX = "\\Palmier\\PalmierTask-";
|
|
|
8
8
|
const DAEMON_TASK_NAME = "PalmierDaemon";
|
|
9
9
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
10
10
|
const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
|
|
11
|
-
/**
|
|
12
|
-
* Build the /tr value for schtasks: a single string with quoted paths
|
|
13
|
-
* so Task Scheduler can invoke node with the palmier script + subcommand.
|
|
14
|
-
*/
|
|
15
|
-
function schtasksTr(...subcommand) {
|
|
16
|
-
const script = process.argv[1] || "palmier";
|
|
17
|
-
return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
|
|
18
|
-
}
|
|
19
11
|
const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
20
12
|
/**
|
|
21
13
|
* Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
|
|
@@ -144,7 +136,13 @@ export class WindowsPlatform {
|
|
|
144
136
|
installTaskTimer(config, task) {
|
|
145
137
|
const taskId = task.frontmatter.id;
|
|
146
138
|
const tn = schtasksTaskName(taskId);
|
|
147
|
-
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}"`;
|
|
148
146
|
// Build trigger XML elements
|
|
149
147
|
const triggerElements = [];
|
|
150
148
|
if (task.frontmatter.triggers_enabled) {
|
|
@@ -192,6 +190,10 @@ export class WindowsPlatform {
|
|
|
192
190
|
catch {
|
|
193
191
|
// Task might not exist — that's fine
|
|
194
192
|
}
|
|
193
|
+
try {
|
|
194
|
+
fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`));
|
|
195
|
+
}
|
|
196
|
+
catch { /* ignore */ }
|
|
195
197
|
}
|
|
196
198
|
async startTask(taskId) {
|
|
197
199
|
const tn = schtasksTaskName(taskId);
|
|
@@ -228,17 +230,40 @@ export class WindowsPlatform {
|
|
|
228
230
|
}
|
|
229
231
|
}
|
|
230
232
|
isTaskRunning(taskId) {
|
|
233
|
+
// Check Task Scheduler first (for scheduled/on-demand runs)
|
|
231
234
|
const tn = schtasksTaskName(taskId);
|
|
232
235
|
try {
|
|
233
236
|
const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
|
|
234
237
|
encoding: "utf-8",
|
|
235
238
|
windowsHide: true,
|
|
236
239
|
});
|
|
237
|
-
|
|
240
|
+
if (out.includes('"Running"'))
|
|
241
|
+
return true;
|
|
238
242
|
}
|
|
239
|
-
catch {
|
|
240
|
-
|
|
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
|
+
}
|
|
241
264
|
}
|
|
265
|
+
catch { /* ignore */ }
|
|
266
|
+
return false;
|
|
242
267
|
}
|
|
243
268
|
getGuiEnv() {
|
|
244
269
|
// Windows GUI is always available — no special env vars needed
|