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/README.md CHANGED
@@ -127,11 +127,12 @@ 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
- - **Run history** — each run produces a timestamped result file. You can view results and reports from the PWA.
134
- - **Real-time updates** — task status changes (started, finished, failed) are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode).
133
+ - **Conversational run history** — each run gets its own directory (`tasks/<id>/<timestamp>/`) with a `TASKRUN.md` file containing a conversational thread: assistant messages (agent output), user messages (input responses, permission grants, confirmations), and status entries (started, finished, failed, aborted, stopped). The agent runs inside the run directory, so each run's session files and artifacts are isolated. The PWA displays runs as a chat-like thread with follow-up support.
134
+ - **Follow-up messages** — after a task run completes, users can send follow-up messages from the run detail view. The agent is invoked inline by the serve daemon (no new process spawning), and the response is appended to the same conversation thread.
135
+ - **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. Events are scoped to specific runs.
135
136
  - **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
137
 
137
138
  ## NATS Subjects
@@ -139,7 +140,7 @@ palmier restart
139
140
  | Subject | Direction | Description |
140
141
  |---|---|---|
141
142
  | `host.<hostId>.rpc.<method>` | Client → Host | RPC request/reply (e.g., `task.list`, `task.create`) |
142
- | `host-event.<hostId>.<taskId>` | Host → Client | Real-time task events (`running-state`, `confirm-request`, `permission-request`, `input-request`) |
143
+ | `host-event.<hostId>.<taskId>` | Host → Client | Real-time task events (`running-state`, `result-updated`, `confirm-request`, `permission-request`, `input-request`) |
143
144
  | `host.<hostId>.push.send` | Host → Server | Request server to deliver a push notification |
144
145
  | `pair.<code>` | Client → Host | OTP pairing request/reply |
145
146
 
@@ -159,6 +160,8 @@ src/
159
160
  events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
160
161
  agents/
161
162
  agent.ts # AgentTool interface, registry, and agent detection
163
+ shared-prompt.ts # Agent instructions loader
164
+ agent-instructions.md # System prompt injected into every agent invocation
162
165
  claude.ts # Claude Code agent implementation
163
166
  gemini.ts # Gemini CLI agent implementation
164
167
  codex.ts # Codex CLI agent implementation
@@ -12,10 +12,10 @@ export interface CommandLine {
12
12
  export interface AgentTool {
13
13
  /** Return the command and args used to generate a plan from a prompt. */
14
14
  getPlanGenerationCommandLine(prompt: string): CommandLine;
15
- /** Return the command and args used to run a task. If retryPrompt is provided, use it instead of the task's prompt,
15
+ /** Return the command and args used to run a task. If followupPrompt is provided, use it instead of the task's prompt,
16
16
  * and treat it as a continuation of the original run (reuse the same session, etc). extraPermissions are transient
17
17
  * permissions granted for this run only (not persisted in frontmatter). */
18
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
18
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
19
19
  /** Detect whether the agent CLI is available and perform any agent-specific
20
20
  * initialization. Returns true if the agent was detected and initialized successfully. */
21
21
  init(): Promise<boolean>;
@@ -2,7 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import type { AgentTool, CommandLine } from "./agent.js";
3
3
  export declare class ClaudeAgent implements AgentTool {
4
4
  getPlanGenerationCommandLine(prompt: string): CommandLine;
5
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
5
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
6
6
  init(): Promise<boolean>;
7
7
  }
8
8
  //# sourceMappingURL=claude.d.ts.map
@@ -8,16 +8,16 @@ export class ClaudeAgent {
8
8
  args: ["-p", prompt],
9
9
  };
10
10
  }
11
- getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
12
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
11
+ getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
12
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
13
13
  const args = ["--permission-mode", "acceptEdits", "-p"];
14
14
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
15
15
  for (const p of allPerms) {
16
16
  args.push("--allowedTools", p.name);
17
17
  }
18
- if (retryPrompt) {
18
+ if (followupPrompt) {
19
19
  args.push("-c");
20
- } // continue mode for retries
20
+ } // continue mode for followups
21
21
  return { command: "claude", args, stdin: prompt };
22
22
  }
23
23
  async init() {
@@ -2,7 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import type { AgentTool, CommandLine } from "./agent.js";
3
3
  export declare class CodexAgent implements AgentTool {
4
4
  getPlanGenerationCommandLine(prompt: string): CommandLine;
5
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
5
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
6
6
  init(): Promise<boolean>;
7
7
  }
8
8
  //# sourceMappingURL=codex.d.ts.map
@@ -8,8 +8,8 @@ export class CodexAgent {
8
8
  args: ["exec", "--skip-git-repo-check", prompt],
9
9
  };
10
10
  }
11
- getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
12
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
11
+ getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
12
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
13
13
  // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
14
14
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
15
15
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
@@ -18,9 +18,9 @@ export class CodexAgent {
18
18
  args.push(`apps.${p.name}.default_tools_approval_mode="approve"`);
19
19
  }
20
20
  args.push("-"); // read prompt from stdin
21
- if (retryPrompt) {
21
+ if (followupPrompt) {
22
22
  args.push("resume", "--last");
23
- } // continue mode for retries
23
+ } // continue mode for followups
24
24
  return { command: "codex", args, stdin: prompt };
25
25
  }
26
26
  async init() {
@@ -2,7 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import type { AgentTool, CommandLine } from "./agent.js";
3
3
  export declare class CopilotAgent implements AgentTool {
4
4
  getPlanGenerationCommandLine(prompt: string): CommandLine;
5
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
5
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
6
6
  init(): Promise<boolean>;
7
7
  }
8
8
  //# sourceMappingURL=copilot.d.ts.map
@@ -8,15 +8,15 @@ export class CopilotAgent {
8
8
  args: ["-p", prompt],
9
9
  };
10
10
  }
11
- getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
12
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
11
+ getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
12
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
13
13
  const args = ["-p", prompt];
14
14
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
15
15
  if (allPerms.length > 0) {
16
16
  args.push(`--allow-tool='${allPerms.map((p) => p.name).join(",")}'`);
17
17
  ;
18
18
  }
19
- if (retryPrompt) {
19
+ if (followupPrompt) {
20
20
  args.push("--continue");
21
21
  }
22
22
  return { command: "copilot", args };
@@ -2,7 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import type { AgentTool, CommandLine } from "./agent.js";
3
3
  export declare class GeminiAgent implements AgentTool {
4
4
  getPlanGenerationCommandLine(prompt: string): CommandLine;
5
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
5
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
6
6
  init(): Promise<boolean>;
7
7
  }
8
8
  //# sourceMappingURL=gemini.d.ts.map
@@ -8,8 +8,8 @@ export class GeminiAgent {
8
8
  args: ["--approval-mode", "auto_edit", "--prompt", prompt],
9
9
  };
10
10
  }
11
- getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
12
- const prompt = retryPrompt ?? (task.body || task.frontmatter.user_prompt);
11
+ getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
12
+ const prompt = followupPrompt ?? (task.body || task.frontmatter.user_prompt);
13
13
  const fullPrompt = AGENT_INSTRUCTIONS + "\n\n" + prompt;
14
14
  const args = ["--prompt", "-"];
15
15
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
@@ -19,9 +19,9 @@ export class GeminiAgent {
19
19
  args.push(p.name);
20
20
  }
21
21
  }
22
- if (retryPrompt) {
22
+ if (followupPrompt) {
23
23
  args.push("--resume");
24
- } // continue mode for retries
24
+ } // continue mode for followups
25
25
  return { command: "gemini", args, stdin: fullPrompt };
26
26
  }
27
27
  async init() {
@@ -2,7 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import type { AgentTool, CommandLine } from "./agent.js";
3
3
  export declare class OpenClawAgent implements AgentTool {
4
4
  getPlanGenerationCommandLine(prompt: string): CommandLine;
5
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
5
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
6
6
  init(): Promise<boolean>;
7
7
  }
8
8
  //# sourceMappingURL=openclaw.d.ts.map
@@ -7,8 +7,8 @@ export class OpenClawAgent {
7
7
  args: ["agent", "--local", "--agent", "main", "--message", prompt],
8
8
  };
9
9
  }
10
- getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
11
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
10
+ getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
11
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
12
12
  // OpenClaw does not support stdin as prompt.
13
13
  const args = ["agent", "--local", "--session-id", task.frontmatter.id, "--message", prompt];
14
14
  return { command: "openclaw", args };
@@ -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 environment variable to be set.
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, 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
  * 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 environment variable to be set.
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
- // Write abort as user message if RESULT file is available
27
- const resultFile = process.env.PALMIER_RESULT_FILE;
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
- // Write user input as a conversation message
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
- appendResultMessage(taskDir, resultFile, {
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]);
@@ -1,4 +1,8 @@
1
1
  import type { TaskRunningState, RequiredPermission } from "../types.js";
2
+ /**
3
+ * Strip [PALMIER_*] marker lines from agent output.
4
+ */
5
+ export declare function stripPalmierMarkers(output: string): string;
2
6
  /**
3
7
  * Execute a task by ID.
4
8
  */
@@ -4,27 +4,27 @@ 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";
11
11
  import { publishHostEvent } from "../events.js";
12
12
  import { waitForUserInput } from "../user-input.js";
13
13
  /**
14
- * Invoke the agent CLI with a retry loop for permissions and user input.
14
+ * Invoke the agent CLI with a continuation loop for permissions and user input.
15
15
  *
16
16
  * Both standard and command-triggered execution use this.
17
17
  * The `invokeTask` is the ParsedTask whose prompt is passed to the agent
18
18
  * (for command-triggered mode this is the per-line augmented task).
19
19
  */
20
- async function invokeAgentWithRetry(ctx, invokeTask) {
21
- let retryPrompt;
20
+ async function invokeAgentWithContinuation(ctx, invokeTask) {
21
+ let followupPrompt;
22
22
  // eslint-disable-next-line no-constant-condition
23
23
  while (true) {
24
- const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
24
+ const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, followupPrompt, 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, PALMIER_RESULT_FILE: ctx.resultFileName },
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,
@@ -39,8 +39,8 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
39
39
  content: stripPalmierMarkers(result.output),
40
40
  attachments: reportFiles.length > 0 ? reportFiles : undefined,
41
41
  });
42
- // Permission retry
43
- if (outcome === "failed" && requiredPermissions.length > 0) {
42
+ // Permission handling — agent requested permissions
43
+ if (requiredPermissions.length > 0) {
44
44
  const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
45
45
  await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
46
46
  if (response === "aborted") {
@@ -69,37 +69,42 @@ async function invokeAgentWithRetry(ctx, invokeTask) {
69
69
  else {
70
70
  ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
71
71
  }
72
- retryPrompt = "Permissions granted, please continue.";
73
- continue;
72
+ // If the agent actually failed, retry with the new permissions
73
+ if (outcome === "failed") {
74
+ followupPrompt = "Permissions granted, please continue.";
75
+ continue;
76
+ }
74
77
  }
75
- // Normal completion (success or non-retryable failure)
78
+ // Normal completion (success or terminal failure)
76
79
  return { outcome };
77
80
  }
78
81
  }
79
82
  /**
80
83
  * Strip [PALMIER_*] marker lines from agent output.
81
84
  */
82
- function stripPalmierMarkers(output) {
85
+ export function stripPalmierMarkers(output) {
83
86
  return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
84
87
  }
85
88
  /**
86
89
  * Append a conversation message to the RESULT file and notify connected clients.
87
90
  */
88
91
  async function appendAndNotify(ctx, msg) {
89
- appendResultMessage(ctx.taskDir, ctx.resultFileName, msg);
90
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated" });
92
+ appendRunMessage(ctx.taskDir, ctx.runId, msg);
93
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
91
94
  }
92
95
  /**
93
- * Find an existing RESULT file with running_state=started (created by the RPC handler).
96
+ * Find the latest run dir that has no status messages yet (just created by the RPC handler).
94
97
  */
95
- function findStartedResultFile(taskDir) {
96
- const files = fs.readdirSync(taskDir).filter((f) => f.startsWith("RESULT-") && f.endsWith(".md"));
97
- for (const file of files) {
98
- const content = fs.readFileSync(path.join(taskDir, file), "utf-8");
99
- if (content.includes("running_state: started"))
100
- return file;
101
- }
102
- return null;
98
+ function findLatestPendingRunId(taskDir) {
99
+ const dirs = fs.readdirSync(taskDir)
100
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
101
+ .sort();
102
+ if (dirs.length === 0)
103
+ return null;
104
+ const latest = dirs[dirs.length - 1];
105
+ const messages = readRunMessages(taskDir, latest);
106
+ const hasStatus = messages.some((m) => m.role === "status");
107
+ return hasStatus ? null : latest;
103
108
  }
104
109
  /**
105
110
  * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
@@ -121,30 +126,22 @@ export async function runCommand(taskId) {
121
126
  console.log(`Running task: ${taskId}`);
122
127
  let nc;
123
128
  const taskName = task.frontmatter.name;
124
- // Check for an existing "started" result file (created by the RPC handler)
125
- const existingResult = findStartedResultFile(taskDir);
126
- const startTime = existingResult ? parseInt(existingResult.replace("RESULT-", "").replace(".md", ""), 10) : Date.now();
127
- const resultFileName = existingResult ?? createResultFile(taskDir, taskName, startTime);
128
- // Snapshot the task file at run time
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));
129
+ // Use existing run dir if just created by RPC, otherwise create a new one
130
+ const existingRunId = findLatestPendingRunId(taskDir);
131
+ const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now());
132
+ if (!existingRunId) {
133
+ appendHistory(config.projectRoot, { task_id: taskId, run_id: runId });
132
134
  }
133
135
  const cleanup = async () => {
134
136
  if (nc && !nc.isClosed()) {
135
137
  await nc.drain();
136
138
  }
137
139
  };
138
- if (!existingResult) {
139
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
140
- }
141
140
  try {
142
141
  nc = await connectNats(config);
143
- // Mark as started immediately
144
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
145
- // Status: started
146
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "started" });
147
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
142
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, runId);
143
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
144
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
148
145
  // If requires_confirmation, notify clients and wait
149
146
  if (task.frontmatter.requires_confirmation) {
150
147
  const confirmed = await requestConfirmation(nc, config, task, taskDir);
@@ -152,30 +149,28 @@ export async function runCommand(taskId) {
152
149
  await publishConfirmResolved(nc, config, taskId, resolvedStatus);
153
150
  if (!confirmed) {
154
151
  console.log("Task aborted by user.");
155
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "aborted" });
156
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: "aborted" });
157
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
152
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
153
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
158
154
  await cleanup();
159
155
  return;
160
156
  }
161
157
  console.log("Task confirmed by user.");
162
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "confirmation" });
163
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated" });
158
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
159
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
164
160
  }
165
161
  // Shared invocation context
166
162
  const guiEnv = getPlatform().getGuiEnv();
167
163
  const agent = getAgent(task.frontmatter.agent);
168
164
  const ctx = {
169
- agent, task, taskDir, resultFileName, guiEnv, nc, config, taskId,
165
+ agent, task, taskDir, runId, guiEnv, nc, config, taskId,
170
166
  transientPermissions: [],
171
167
  };
172
168
  if (task.frontmatter.command) {
173
169
  // Command-triggered mode
174
170
  const result = await runCommandTriggeredMode(ctx);
175
171
  const outcome = resolveOutcome(taskDir, result.outcome);
176
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
177
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: result.endTime, running_state: outcome });
178
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
172
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
173
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
179
174
  console.log(`Task ${taskId} completed (command-triggered).`);
180
175
  }
181
176
  else {
@@ -185,11 +180,10 @@ export async function runCommand(taskId) {
185
180
  time: Date.now(),
186
181
  content: task.body || task.frontmatter.user_prompt,
187
182
  });
188
- const result = await invokeAgentWithRetry(ctx, task);
183
+ const result = await invokeAgentWithContinuation(ctx, task);
189
184
  const outcome = resolveOutcome(taskDir, result.outcome);
190
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
191
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
192
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
185
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
186
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
193
187
  console.log(`Task ${taskId} completed.`);
194
188
  }
195
189
  }
@@ -197,14 +191,13 @@ export async function runCommand(taskId) {
197
191
  console.error(`Task ${taskId} failed:`, err);
198
192
  const outcome = resolveOutcome(taskDir, "failed");
199
193
  const errorMsg = err instanceof Error ? err.message : String(err);
200
- appendResultMessage(taskDir, resultFileName, {
194
+ appendRunMessage(taskDir, runId, {
201
195
  role: "assistant",
202
196
  time: Date.now(),
203
197
  content: errorMsg,
204
198
  });
205
- appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
206
- finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
207
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
199
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
200
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
208
201
  process.exitCode = 1;
209
202
  }
210
203
  finally {
@@ -226,8 +219,8 @@ async function runCommandTriggeredMode(ctx) {
226
219
  const commandStr = ctx.task.frontmatter.command;
227
220
  console.log(`[command-triggered] Spawning: ${commandStr}`);
228
221
  const child = spawnStreamingCommand(commandStr, {
229
- cwd: ctx.taskDir,
230
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
222
+ cwd: getRunDir(ctx.taskDir, ctx.runId),
223
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
231
224
  });
232
225
  let linesProcessed = 0;
233
226
  let invocationsSucceeded = 0;
@@ -236,7 +229,7 @@ async function runCommandTriggeredMode(ctx) {
236
229
  let processing = false;
237
230
  let commandExited = false;
238
231
  let resolveWhenDone;
239
- const logPath = path.join(ctx.taskDir, "command-output.log");
232
+ const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
240
233
  function appendLog(line, agentOutput, outcome) {
241
234
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
242
235
  fs.appendFileSync(logPath, entry, "utf-8");
@@ -265,7 +258,7 @@ async function runCommandTriggeredMode(ctx) {
265
258
  frontmatter: { ...ctx.task.frontmatter, user_prompt: perLinePrompt },
266
259
  body: "",
267
260
  };
268
- const result = await invokeAgentWithRetry(ctx, perLineTask);
261
+ const result = await invokeAgentWithContinuation(ctx, perLineTask);
269
262
  if (result.outcome === "finished") {
270
263
  invocationsSucceeded++;
271
264
  }
@@ -330,7 +323,7 @@ async function runCommandTriggeredMode(ctx) {
330
323
  const endTime = Date.now();
331
324
  return { outcome: "finished", endTime };
332
325
  }
333
- async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, resultFile) {
326
+ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
334
327
  writeTaskStatus(taskDir, {
335
328
  running_state: eventType,
336
329
  time_stamp: Date.now(),
@@ -339,8 +332,8 @@ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName
339
332
  const payload = { event_type: "running-state", running_state: eventType };
340
333
  if (taskName)
341
334
  payload.name = taskName;
342
- if (resultFile)
343
- payload.result_file = resultFile;
335
+ if (runId)
336
+ payload.run_id = runId;
344
337
  await publishHostEvent(nc, config.hostId, taskId, payload);
345
338
  }
346
339
  /**
@@ -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";
@@ -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
- * Mark a stuck task as failed: update status.json, write RESULT, append history,
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
- await markTaskFailed(config, nc, taskId, "Task process exited unexpectedly");
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
  /**