palmier 0.4.3 → 0.4.5

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/dist/types.d.ts CHANGED
@@ -61,7 +61,7 @@ export interface TaskStatus {
61
61
  }
62
62
  export interface HistoryEntry {
63
63
  task_id: string;
64
- result_file: string;
64
+ run_id: string;
65
65
  }
66
66
  export interface RequiredPermission {
67
67
  name: string;
@@ -71,7 +71,7 @@ export interface ConversationMessage {
71
71
  role: "assistant" | "user" | "status";
72
72
  time: number;
73
73
  content: string;
74
- type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted";
74
+ type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted" | "stopped";
75
75
  attachments?: string[];
76
76
  }
77
77
  export interface RpcMessage {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -20,10 +20,10 @@ export interface AgentTool {
20
20
  /** Return the command and args used to generate a plan from a prompt. */
21
21
  getPlanGenerationCommandLine(prompt: string): CommandLine;
22
22
 
23
- /** Return the command and args used to run a task. If retryPrompt is provided, use it instead of the task's prompt,
23
+ /** Return the command and args used to run a task. If followupPrompt is provided, use it instead of the task's prompt,
24
24
  * and treat it as a continuation of the original run (reuse the same session, etc). extraPermissions are transient
25
25
  * permissions granted for this run only (not persisted in frontmatter). */
26
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
26
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
27
27
 
28
28
  /** Detect whether the agent CLI is available and perform any agent-specific
29
29
  * initialization. Returns true if the agent was detected and initialized successfully. */
@@ -12,8 +12,8 @@ export class ClaudeAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
17
17
  const args = ["--permission-mode", "acceptEdits", "-p"];
18
18
 
19
19
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
@@ -21,7 +21,7 @@ export class ClaudeAgent implements AgentTool {
21
21
  args.push("--allowedTools", p.name);
22
22
  }
23
23
 
24
- if (retryPrompt) {args.push("-c");} // continue mode for retries
24
+ if (followupPrompt) {args.push("-c");} // continue mode for followups
25
25
  return { command: "claude", args, stdin: prompt };
26
26
  }
27
27
 
@@ -12,8 +12,8 @@ export class CodexAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
17
17
  // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
18
18
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
19
19
 
@@ -24,7 +24,7 @@ export class CodexAgent implements AgentTool {
24
24
  }
25
25
  args.push("-"); // read prompt from stdin
26
26
 
27
- if (retryPrompt) {args.push("resume", "--last");} // continue mode for retries
27
+ if (followupPrompt) {args.push("resume", "--last");} // continue mode for followups
28
28
  return { command: "codex", args, stdin: prompt };
29
29
  }
30
30
 
@@ -12,8 +12,8 @@ export class CopilotAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
17
17
  const args = ["-p", prompt];
18
18
 
19
19
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
@@ -21,7 +21,7 @@ export class CopilotAgent implements AgentTool {
21
21
  args.push(`--allow-tool='${allPerms.map((p) => p.name).join(",")}'`);;
22
22
  }
23
23
 
24
- if (retryPrompt) { args.push("--continue"); }
24
+ if (followupPrompt) { args.push("--continue"); }
25
25
  return { command: "copilot", args};
26
26
  }
27
27
 
@@ -12,8 +12,8 @@ export class GeminiAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = retryPrompt ?? (task.body || task.frontmatter.user_prompt);
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = followupPrompt ?? (task.body || task.frontmatter.user_prompt);
17
17
  const fullPrompt = AGENT_INSTRUCTIONS + "\n\n" + prompt;
18
18
  const args = ["--prompt", "-"];
19
19
 
@@ -25,7 +25,7 @@ export class GeminiAgent implements AgentTool {
25
25
  }
26
26
  }
27
27
 
28
- if (retryPrompt) {args.push("--resume");} // continue mode for retries
28
+ if (followupPrompt) {args.push("--resume");} // continue mode for followups
29
29
  return { command: "gemini", args, stdin: fullPrompt };
30
30
  }
31
31
 
@@ -11,8 +11,8 @@ export class OpenClawAgent implements AgentTool {
11
11
  };
12
12
  }
13
13
 
14
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
15
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
14
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
15
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
16
16
  // OpenClaw does not support stdin as prompt.
17
17
  const args = ["agent", "--local", "--session-id", task.frontmatter.id, "--message", prompt];
18
18
 
@@ -1,14 +1,13 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { connectNats } from "../nats-client.js";
3
- import { getTaskDir, parseTaskFile, appendResultMessage } from "../task.js";
3
+ import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
4
4
  import { requestUserInput, publishInputResolved } from "../user-input.js";
5
5
 
6
6
  /**
7
7
  * Request input from the user and print responses to stdout.
8
8
  * Usage: palmier request-input --description "Question 1" --description "Question 2"
9
9
  *
10
- * Requires PALMIER_TASK_ID environment variable to be set.
11
- * Outputs each response on its own line: "description: value"
10
+ * Requires PALMIER_TASK_ID and PALMIER_RUN_DIR environment variables.
12
11
  */
13
12
  export async function requestInputCommand(opts: { description: string[] }): Promise<void> {
14
13
  const taskId = process.env.PALMIER_TASK_ID;
@@ -21,36 +20,23 @@ export async function requestInputCommand(opts: { description: string[] }): Prom
21
20
  const nc = await connectNats(config);
22
21
  const taskDir = getTaskDir(config.projectRoot, taskId);
23
22
  const task = parseTaskFile(taskDir);
23
+ const runId = process.env.PALMIER_RUN_DIR?.split(/[/\\]/).pop();
24
24
 
25
25
  try {
26
26
  const response = await requestUserInput(nc, config, taskId, task.frontmatter.name, taskDir, opts.description);
27
27
  await publishInputResolved(nc, config, taskId, response === "aborted" ? "aborted" : "provided");
28
28
 
29
29
  if (response === "aborted") {
30
- // Write abort as user message if RESULT file is available
31
- const resultFile = process.env.PALMIER_RESULT_FILE;
32
- if (resultFile) {
33
- appendResultMessage(taskDir, resultFile, {
34
- role: "user",
35
- time: Date.now(),
36
- content: "Input request aborted.",
37
- type: "input",
38
- });
30
+ if (runId) {
31
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
39
32
  }
40
33
  console.error("User aborted the input request.");
41
34
  process.exit(1);
42
35
  }
43
36
 
44
- // Write user input as a conversation message
45
- const resultFile = process.env.PALMIER_RESULT_FILE;
46
- if (resultFile) {
37
+ if (runId) {
47
38
  const lines = opts.description.map((desc, i) => `**${desc}** ${response[i]}`);
48
- appendResultMessage(taskDir, resultFile, {
49
- role: "user",
50
- time: Date.now(),
51
- content: lines.join("\n"),
52
- type: "input",
53
- });
39
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
54
40
  }
55
41
 
56
42
  for (let i = 0; i < opts.description.length; i++) {
@@ -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, createResultFile, appendResultMessage, finalizeResultFrontmatter } from "../task.js";
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";
@@ -22,7 +22,7 @@ interface InvocationContext {
22
22
  agent: AgentTool;
23
23
  task: ParsedTask;
24
24
  taskDir: string;
25
- resultFileName: string;
25
+ runId: string;
26
26
  guiEnv: Record<string, string>;
27
27
  nc: NatsConnection | undefined;
28
28
  config: HostConfig;
@@ -36,23 +36,23 @@ interface InvocationResult {
36
36
  }
37
37
 
38
38
  /**
39
- * Invoke the agent CLI with a retry loop for permissions and user input.
39
+ * Invoke the agent CLI with a continuation loop for permissions and user input.
40
40
  *
41
41
  * Both standard and command-triggered execution use this.
42
42
  * The `invokeTask` is the ParsedTask whose prompt is passed to the agent
43
43
  * (for command-triggered mode this is the per-line augmented task).
44
44
  */
45
- async function invokeAgentWithRetry(
45
+ async function invokeAgentWithContinuation(
46
46
  ctx: InvocationContext,
47
47
  invokeTask: ParsedTask,
48
48
  ): Promise<InvocationResult> {
49
- let retryPrompt: string | undefined;
49
+ let followupPrompt: string | undefined;
50
50
  // eslint-disable-next-line no-constant-condition
51
51
  while (true) {
52
- const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
52
+ const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, followupPrompt, ctx.transientPermissions);
53
53
  const result = await spawnCommand(command, args, {
54
- cwd: ctx.taskDir,
55
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RESULT_FILE: ctx.resultFileName },
54
+ cwd: getRunDir(ctx.taskDir, ctx.runId),
55
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
56
56
  echoStdout: true,
57
57
  resolveOnFailure: true,
58
58
  stdin,
@@ -70,8 +70,8 @@ async function invokeAgentWithRetry(
70
70
  attachments: reportFiles.length > 0 ? reportFiles : undefined,
71
71
  });
72
72
 
73
- // Permission retry
74
- if (outcome === "failed" && requiredPermissions.length > 0) {
73
+ // Permission handling — agent requested permissions
74
+ if (requiredPermissions.length > 0) {
75
75
  const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
76
76
  await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
77
77
 
@@ -106,11 +106,14 @@ async function invokeAgentWithRetry(
106
106
  ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
107
107
  }
108
108
 
109
- retryPrompt = "Permissions granted, please continue.";
110
- continue;
109
+ // If the agent actually failed, retry with the new permissions
110
+ if (outcome === "failed") {
111
+ followupPrompt = "Permissions granted, please continue.";
112
+ continue;
113
+ }
111
114
  }
112
115
 
113
- // Normal completion (success or non-retryable failure)
116
+ // Normal completion (success or terminal failure)
114
117
  return { outcome };
115
118
  }
116
119
  }
@@ -118,7 +121,7 @@ async function invokeAgentWithRetry(
118
121
  /**
119
122
  * Strip [PALMIER_*] marker lines from agent output.
120
123
  */
121
- function stripPalmierMarkers(output: string): string {
124
+ export function stripPalmierMarkers(output: string): string {
122
125
  return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
123
126
  }
124
127
 
@@ -127,22 +130,24 @@ function stripPalmierMarkers(output: string): string {
127
130
  */
128
131
  async function appendAndNotify(
129
132
  ctx: InvocationContext,
130
- msg: Parameters<typeof appendResultMessage>[2],
133
+ msg: Parameters<typeof appendRunMessage>[2],
131
134
  ): Promise<void> {
132
- appendResultMessage(ctx.taskDir, ctx.resultFileName, msg);
133
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated" });
135
+ appendRunMessage(ctx.taskDir, ctx.runId, msg);
136
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
134
137
  }
135
138
 
136
139
  /**
137
- * Find an existing RESULT file with running_state=started (created by the RPC handler).
140
+ * Find the latest run dir that has no status messages yet (just created by the RPC handler).
138
141
  */
139
- function findStartedResultFile(taskDir: string): string | null {
140
- const files = fs.readdirSync(taskDir).filter((f) => f.startsWith("RESULT-") && f.endsWith(".md"));
141
- for (const file of files) {
142
- const content = fs.readFileSync(path.join(taskDir, file), "utf-8");
143
- if (content.includes("running_state: started")) return file;
144
- }
145
- return null;
142
+ function findLatestPendingRunId(taskDir: string): string | null {
143
+ const dirs = fs.readdirSync(taskDir)
144
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
145
+ .sort();
146
+ if (dirs.length === 0) return null;
147
+ const latest = dirs[dirs.length - 1];
148
+ const messages = readRunMessages(taskDir, latest);
149
+ const hasStatus = messages.some((m) => m.role === "status");
150
+ return hasStatus ? null : latest;
146
151
  }
147
152
 
148
153
  /**
@@ -167,15 +172,11 @@ export async function runCommand(taskId: string): Promise<void> {
167
172
  let nc: NatsConnection | undefined;
168
173
  const taskName = task.frontmatter.name;
169
174
 
170
- // Check for an existing "started" result file (created by the RPC handler)
171
- const existingResult = findStartedResultFile(taskDir);
172
- const startTime = existingResult ? parseInt(existingResult.replace("RESULT-", "").replace(".md", ""), 10) : Date.now();
173
- const resultFileName = existingResult ?? createResultFile(taskDir, taskName, startTime);
174
-
175
- // Snapshot the task file at run time
176
- const taskSnapshotName = `TASK-${startTime}.md`;
177
- if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
178
- fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
175
+ // Use existing run dir if just created by RPC, otherwise create a new one
176
+ const existingRunId = findLatestPendingRunId(taskDir);
177
+ const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now());
178
+ if (!existingRunId) {
179
+ appendHistory(config.projectRoot, { task_id: taskId, run_id: runId });
179
180
  }
180
181
 
181
182
  const cleanup = async () => {
@@ -184,19 +185,12 @@ export async function runCommand(taskId: string): Promise<void> {
184
185
  }
185
186
  };
186
187
 
187
- if (!existingResult) {
188
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
189
- }
190
-
191
188
  try {
192
189
  nc = await connectNats(config);
193
190
 
194
- // Mark as started immediately
195
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
196
-
197
- // Status: started
198
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "started" });
199
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
191
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, runId);
192
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
193
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
200
194
 
201
195
  // If requires_confirmation, notify clients and wait
202
196
  if (task.frontmatter.requires_confirmation) {
@@ -205,22 +199,21 @@ export async function runCommand(taskId: string): Promise<void> {
205
199
  await publishConfirmResolved(nc, config, taskId, resolvedStatus);
206
200
  if (!confirmed) {
207
201
  console.log("Task aborted by user.");
208
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "aborted" });
209
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: "aborted" });
210
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
202
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
203
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
211
204
  await cleanup();
212
205
  return;
213
206
  }
214
207
  console.log("Task confirmed by user.");
215
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "confirmation" });
216
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
208
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
209
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
217
210
  }
218
211
 
219
212
  // Shared invocation context
220
213
  const guiEnv = getPlatform().getGuiEnv();
221
214
  const agent = getAgent(task.frontmatter.agent);
222
215
  const ctx: InvocationContext = {
223
- agent, task, taskDir, resultFileName, guiEnv, nc, config, taskId,
216
+ agent, task, taskDir, runId, guiEnv, nc, config, taskId,
224
217
  transientPermissions: [],
225
218
  };
226
219
 
@@ -228,9 +221,8 @@ export async function runCommand(taskId: string): Promise<void> {
228
221
  // Command-triggered mode
229
222
  const result = await runCommandTriggeredMode(ctx);
230
223
  const outcome = resolveOutcome(taskDir, result.outcome);
231
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
232
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: result.endTime, running_state: outcome });
233
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
224
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
225
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
234
226
  console.log(`Task ${taskId} completed (command-triggered).`);
235
227
  } else {
236
228
  // Standard execution — add user prompt as first message
@@ -240,25 +232,23 @@ export async function runCommand(taskId: string): Promise<void> {
240
232
  content: task.body || task.frontmatter.user_prompt,
241
233
  });
242
234
 
243
- const result = await invokeAgentWithRetry(ctx, task);
235
+ const result = await invokeAgentWithContinuation(ctx, task);
244
236
  const outcome = resolveOutcome(taskDir, result.outcome);
245
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
246
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
247
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
237
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
238
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
248
239
  console.log(`Task ${taskId} completed.`);
249
240
  }
250
241
  } catch (err) {
251
242
  console.error(`Task ${taskId} failed:`, err);
252
243
  const outcome = resolveOutcome(taskDir, "failed");
253
244
  const errorMsg = err instanceof Error ? err.message : String(err);
254
- appendResultMessage(taskDir, resultFileName, {
245
+ appendRunMessage(taskDir, runId, {
255
246
  role: "assistant",
256
247
  time: Date.now(),
257
248
  content: errorMsg,
258
249
  });
259
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
260
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
261
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
250
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
251
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
262
252
  process.exitCode = 1;
263
253
  } finally {
264
254
  await cleanup();
@@ -284,8 +274,8 @@ async function runCommandTriggeredMode(
284
274
  console.log(`[command-triggered] Spawning: ${commandStr}`);
285
275
 
286
276
  const child = spawnStreamingCommand(commandStr, {
287
- cwd: ctx.taskDir,
288
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
277
+ cwd: getRunDir(ctx.taskDir, ctx.runId),
278
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
289
279
  });
290
280
 
291
281
  let linesProcessed = 0;
@@ -297,7 +287,7 @@ async function runCommandTriggeredMode(
297
287
  let commandExited = false;
298
288
  let resolveWhenDone: (() => void) | undefined;
299
289
 
300
- const logPath = path.join(ctx.taskDir, "command-output.log");
290
+ const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
301
291
  function appendLog(line: string, agentOutput: string, outcome: string) {
302
292
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
303
293
  fs.appendFileSync(logPath, entry, "utf-8");
@@ -329,7 +319,7 @@ async function runCommandTriggeredMode(
329
319
  body: "",
330
320
  };
331
321
 
332
- const result = await invokeAgentWithRetry(ctx, perLineTask);
322
+ const result = await invokeAgentWithContinuation(ctx, perLineTask);
333
323
  if (result.outcome === "finished") {
334
324
  invocationsSucceeded++;
335
325
  } else {
@@ -404,7 +394,7 @@ async function publishTaskEvent(
404
394
  taskId: string,
405
395
  eventType: TaskRunningState,
406
396
  taskName?: string,
407
- resultFile?: string,
397
+ runId?: string,
408
398
  ): Promise<void> {
409
399
  writeTaskStatus(taskDir, {
410
400
  running_state: eventType,
@@ -414,7 +404,7 @@ async function publishTaskEvent(
414
404
 
415
405
  const payload: Record<string, unknown> = { event_type: "running-state", running_state: eventType };
416
406
  if (taskName) payload.name = taskName;
417
- if (resultFile) payload.result_file = resultFile;
407
+ if (runId) payload.run_id = runId;
418
408
  await publishHostEvent(nc, config.hostId, taskId, payload);
419
409
  }
420
410
 
@@ -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, appendHistory, parseTaskFile, appendResultMessage } from "../task.js";
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";
@@ -17,46 +17,11 @@ const POLL_INTERVAL_MS = 30_000;
17
17
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
18
18
 
19
19
  /**
20
- * Mark a stuck task as failed: update status.json, write RESULT, append history,
21
- * and broadcast the failure event.
22
- */
23
- async function markTaskFailed(
24
- config: HostConfig,
25
- nc: NatsConnection | undefined,
26
- taskId: string,
27
- reason: string,
28
- ): Promise<void> {
29
- const taskDir = getTaskDir(config.projectRoot, taskId);
30
- const status = readTaskStatus(taskDir);
31
- if (!status || status.running_state !== "started") return;
32
-
33
- console.log(`[monitor] Task ${taskId} ${reason}, marking as failed.`);
34
- const endTime = Date.now();
35
- writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
36
-
37
- let taskName = taskId;
38
- try {
39
- const task = parseTaskFile(taskDir);
40
- taskName = task.frontmatter.name || taskId;
41
- } catch { /* use taskId as fallback */ }
42
-
43
- const resultFileName = `RESULT-${endTime}.md`;
44
- const content = `---\ntask_name: ${taskName}\nrunning_state: failed\nstart_time: ${status.time_stamp}\nend_time: ${endTime}\ntask_file: \n---\n\n`;
45
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
46
- appendResultMessage(taskDir, resultFileName, {
47
- role: "assistant",
48
- time: endTime,
49
- content: reason,
50
- });
51
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
52
-
53
- const payload: Record<string, unknown> = { event_type: "running-state", running_state: "failed", name: taskName };
54
- await publishHostEvent(nc, config.hostId, taskId, payload);
55
- }
56
-
57
- /**
58
- * Scan all tasks for any stuck in "start" state whose process is no longer alive.
20
+ * Scan all tasks for any stuck in "started" state whose process is no longer alive.
59
21
  * Uses the system scheduler (Task Scheduler / systemd) as the authoritative source.
22
+ *
23
+ * Since run.ts creates the RESULT file and history entry at start, we just need to
24
+ * finalize the existing RESULT file, append a failed status entry, and broadcast.
60
25
  */
61
26
  async function checkStaleTasks(
62
27
  config: HostConfig,
@@ -80,7 +45,35 @@ async function checkStaleTasks(
80
45
  // Ask the system scheduler if the task is still running
81
46
  if (platform.isTaskRunning(taskId)) continue;
82
47
 
83
- await markTaskFailed(config, nc, taskId, "Task process exited unexpectedly");
48
+ console.log(`[monitor] Task ${taskId} process exited unexpectedly, marking as failed.`);
49
+ const endTime = Date.now();
50
+ writeTaskStatus(taskDir, { running_state: "failed", time_stamp: endTime });
51
+
52
+ // Find the latest run directory (created by run.ts at start)
53
+ const runId = fs.readdirSync(taskDir)
54
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
55
+ .sort()
56
+ .pop();
57
+
58
+ if (runId) {
59
+ appendRunMessage(taskDir, runId, {
60
+ role: "status",
61
+ time: endTime,
62
+ content: "",
63
+ type: "failed",
64
+ });
65
+ }
66
+
67
+ let taskName = taskId;
68
+ try {
69
+ taskName = parseTaskFile(taskDir).frontmatter.name || taskId;
70
+ } catch { /* use taskId as fallback */ }
71
+
72
+ await publishHostEvent(nc, config.hostId, taskId, {
73
+ event_type: "running-state",
74
+ running_state: "failed",
75
+ name: taskName,
76
+ });
84
77
  }
85
78
  }
86
79
 
@@ -5,6 +5,8 @@ import { execSync, exec } from "child_process";
5
5
  import { promisify } from "util";
6
6
  import type { PlatformService } from "./platform.js";
7
7
  import type { HostConfig, ParsedTask } from "../types.js";
8
+ import { loadConfig } from "../config.js";
9
+ import { getTaskDir, readTaskStatus } from "../task.js";
8
10
 
9
11
  const execAsync = promisify(exec);
10
12
 
@@ -230,20 +232,28 @@ WantedBy=timers.target
230
232
  }
231
233
 
232
234
  isTaskRunning(taskId: string): boolean {
235
+ // Check systemd first (for scheduled/on-demand runs)
233
236
  const serviceName = getServiceName(taskId);
234
237
  try {
235
- // is-active exits 0 only for "active". For oneshot services (Type=oneshot),
236
- // the state is "activating" while running, which exits non-zero.
237
- // Use show -p ActiveState to reliably get the state without exit code issues.
238
238
  const out = execSync(
239
239
  `systemctl --user show -p ActiveState --value ${serviceName}`,
240
240
  { encoding: "utf-8" },
241
241
  );
242
242
  const state = out.trim();
243
- return state === "active" || state === "activating";
244
- } catch {
245
- return false;
246
- }
243
+ if (state === "active" || state === "activating") return true;
244
+ } catch { /* service may not exist */ }
245
+
246
+ // Fall back to PID check (for follow-up runs spawned directly)
247
+ try {
248
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
249
+ const status = readTaskStatus(taskDir);
250
+ if (status?.pid) {
251
+ process.kill(status.pid, 0); // signal 0 = check if process exists
252
+ return true;
253
+ }
254
+ } catch { /* process not running or config unavailable */ }
255
+
256
+ return false;
247
257
  }
248
258
 
249
259
  getGuiEnv(): Record<string, string> {