palmier 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +18 -30
  2. package/dist/agents/agent-instructions.md +40 -0
  3. package/dist/agents/claude.js +2 -8
  4. package/dist/agents/codex.js +0 -6
  5. package/dist/agents/copilot.js +0 -20
  6. package/dist/agents/gemini.js +0 -6
  7. package/dist/agents/shared-prompt.d.ts +1 -2
  8. package/dist/agents/shared-prompt.js +5 -18
  9. package/dist/commands/notify.d.ts +9 -0
  10. package/dist/commands/notify.js +43 -0
  11. package/dist/commands/request-input.d.ts +10 -0
  12. package/dist/commands/request-input.js +49 -0
  13. package/dist/commands/run.d.ts +4 -5
  14. package/dist/commands/run.js +90 -105
  15. package/dist/commands/serve.js +31 -28
  16. package/dist/index.js +15 -5
  17. package/dist/platform/linux.js +16 -6
  18. package/dist/platform/windows.js +54 -14
  19. package/dist/rpc-handler.js +217 -54
  20. package/dist/spawn-command.d.ts +1 -1
  21. package/dist/spawn-command.js +13 -1
  22. package/dist/task.d.ts +18 -7
  23. package/dist/task.js +70 -27
  24. package/dist/types.d.ts +10 -1
  25. package/package.json +2 -3
  26. package/src/agents/agent-instructions.md +40 -0
  27. package/src/agents/claude.ts +2 -7
  28. package/src/agents/codex.ts +0 -5
  29. package/src/agents/copilot.ts +0 -19
  30. package/src/agents/gemini.ts +0 -5
  31. package/src/agents/shared-prompt.ts +10 -18
  32. package/src/commands/notify.ts +44 -0
  33. package/src/commands/request-input.ts +51 -0
  34. package/src/commands/run.ts +98 -129
  35. package/src/commands/serve.ts +34 -36
  36. package/src/index.ts +16 -5
  37. package/src/platform/linux.ts +17 -7
  38. package/src/platform/windows.ts +53 -15
  39. package/src/rpc-handler.ts +244 -57
  40. package/src/spawn-command.ts +13 -2
  41. package/src/task.ts +79 -29
  42. package/src/types.ts +11 -1
  43. package/dist/commands/mcpserver.d.ts +0 -2
  44. package/dist/commands/mcpserver.js +0 -93
  45. package/src/commands/mcpserver.ts +0 -113
@@ -0,0 +1,40 @@
1
+ You are an AI agent executing a task on behalf of the user via the Palmier platform. Follow these instructions carefully.
2
+
3
+ ## Reporting Output
4
+
5
+ If you generate report or output files, print each file path on its own line prefixed with [PALMIER_REPORT]:
6
+ [PALMIER_REPORT] report.md
7
+ [PALMIER_REPORT] summary.md
8
+
9
+ ## Completion
10
+
11
+ When you are done, output exactly one of these markers as the very last line:
12
+ - Success: [PALMIER_TASK_SUCCESS]
13
+ - Failure: [PALMIER_TASK_FAILURE]
14
+ Do not wrap them in code blocks or add text on the same line.
15
+
16
+ ## Permissions
17
+
18
+ If the task fails because a tool was denied or you lack the required permissions, print each required permission on its own line prefixed with [PALMIER_PERMISSION]:
19
+ [PALMIER_PERMISSION] Read | Read file contents from the repository
20
+ [PALMIER_PERMISSION] Bash(npm test) | Run the test suite via npm
21
+ [PALMIER_PERMISSION] Write | Write generated output files
22
+
23
+ ## CLI Commands
24
+
25
+ You have access to the following palmier CLI commands:
26
+
27
+ **Requesting user input** — If you need any information you do not have (credentials, configuration values, preferences, clarifications, etc.) or the task explicitly asks you to get input from the user, do NOT fail the task. Instead, request it:
28
+ ```
29
+ palmier request-input --description "What is the database connection string?" --description "What is the API key?"
30
+ ```
31
+ The command blocks until the user responds and prints each value on its own line. If the user aborts, the command exits with a non-zero status.
32
+
33
+ **Sending push notifications** — If you need to send a push notification to the user:
34
+ ```
35
+ palmier notify --title "Task Complete" --body "The deployment finished successfully."
36
+ ```
37
+
38
+ ---
39
+
40
+ The task to execute follows below.
@@ -13,8 +13,8 @@ export class ClaudeAgent implements AgentTool {
13
13
  }
14
14
 
15
15
  getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = retryPrompt ?? (task.body || task.frontmatter.user_prompt);
17
- const args = ["--permission-mode", "acceptEdits", "--append-system-prompt", AGENT_INSTRUCTIONS, "-p"];
16
+ const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
17
+ const args = ["--permission-mode", "acceptEdits", "-p"];
18
18
 
19
19
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
20
20
  for (const p of allPerms) {
@@ -31,11 +31,6 @@ export class ClaudeAgent implements AgentTool {
31
31
  } catch {
32
32
  return false;
33
33
  }
34
- try {
35
- execSync("claude mcp add --transport stdio palmier --scope user -- palmier mcpserver", { stdio: "ignore", shell: SHELL });
36
- } catch {
37
- // MCP registration is best-effort; agent still works without it
38
- }
39
34
  return true;
40
35
  }
41
36
  }
@@ -34,11 +34,6 @@ export class CodexAgent implements AgentTool {
34
34
  } catch {
35
35
  return false;
36
36
  }
37
- try {
38
- execSync("codex mcp add palmier palmier mcpserver", { stdio: "ignore", shell: SHELL });
39
- } catch {
40
- // MCP registration is best-effort; agent still works without it
41
- }
42
37
  return true;
43
38
  }
44
39
  }
@@ -1,6 +1,3 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { homedir } from "os";
4
1
  import type { ParsedTask, RequiredPermission } from "../types.js";
5
2
  import { execSync } from "child_process";
6
3
  import type { AgentTool, CommandLine } from "./agent.js";
@@ -34,22 +31,6 @@ export class CopilotAgent implements AgentTool {
34
31
  } catch {
35
32
  return false;
36
33
  }
37
- // Register Palmier MCP server in ~/.copilot/mcp-config.json
38
- try {
39
- const configDir = path.join(homedir(), ".copilot");
40
- const configFile = path.join(configDir, "mcp-config.json");
41
- let config: Record<string, unknown> = {};
42
- if (fs.existsSync(configFile)) {
43
- config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Record<string, unknown>;
44
- }
45
- const servers = (config.mcpServers ?? {}) as Record<string, unknown>;
46
- servers.palmier = { command: "palmier", args: ["mcpserver"] };
47
- config.mcpServers = servers;
48
- fs.mkdirSync(configDir, { recursive: true });
49
- fs.writeFileSync(configFile, JSON.stringify(config, null, 2), "utf-8");
50
- } catch {
51
- // MCP registration is best-effort
52
- }
53
34
  return true;
54
35
  }
55
36
  }
@@ -35,11 +35,6 @@ export class GeminiAgent implements AgentTool {
35
35
  } catch {
36
36
  return false;
37
37
  }
38
- try {
39
- execSync("gemini mcp add --scope user palmier palmier mcpserver", { stdio: "ignore", shell: SHELL });
40
- } catch {
41
- // MCP registration is best-effort; agent still works without it
42
- }
43
38
  return true;
44
39
  }
45
40
  }
@@ -1,28 +1,20 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
1
7
  /**
2
8
  * Instructions prepended or injected as system prompt for every task invocation.
3
9
  * Instructs the agent to output structured markers so palmier can determine
4
10
  * the task outcome, report files, and permission/input requests.
5
11
  */
6
- export const AGENT_INSTRUCTIONS = `If you generate report or output files, print each file name on its own line prefixed with [PALMIER_REPORT]: e.g.
7
- [PALMIER_REPORT] report.md
8
- [PALMIER_REPORT] summary.md
9
-
10
- When you are done, output exactly one of these markers as the very last line:
11
- - Success: [PALMIER_TASK_SUCCESS]
12
- - Failure: [PALMIER_TASK_FAILURE]
13
- Do not wrap them in code blocks or add text on the same line.
14
-
15
- If the task fails because a tool was denied or you lack the required permissions, print each required permission on its own line prefixed with [PALMIER_PERMISSION]: e.g.
16
- [PALMIER_PERMISSION] Read | Read file contents from the repository
17
- [PALMIER_PERMISSION] Bash(npm test) | Run the test suite via npm
18
- [PALMIER_PERMISSION] Write | Write generated output files
19
-
20
- If the task requires information from the user that you do not have (such as credentials, connection strings, API keys, or configuration values), print each required input on its own line prefixed with [PALMIER_INPUT]: e.g.
21
- [PALMIER_INPUT] What is the database connection string?
22
- [PALMIER_INPUT] What is the API key for the external service?`;
12
+ export const AGENT_INSTRUCTIONS = fs.readFileSync(
13
+ path.join(__dirname, "agent-instructions.md"),
14
+ "utf-8",
15
+ );
23
16
 
24
17
  export const TASK_SUCCESS_MARKER = "[PALMIER_TASK_SUCCESS]";
25
18
  export const TASK_FAILURE_MARKER = "[PALMIER_TASK_FAILURE]";
26
19
  export const TASK_REPORT_PREFIX = "[PALMIER_REPORT]";
27
20
  export const TASK_PERMISSION_PREFIX = "[PALMIER_PERMISSION]";
28
- export const TASK_INPUT_PREFIX = "[PALMIER_INPUT]";
@@ -0,0 +1,44 @@
1
+ import { StringCodec } from "nats";
2
+ import { loadConfig } from "../config.js";
3
+ import { connectNats } from "../nats-client.js";
4
+
5
+ /**
6
+ * Send a push notification to the user via NATS.
7
+ * Usage: palmier notify --title "Title" --body "Body text"
8
+ */
9
+ export async function notifyCommand(opts: { title: string; body: string }): Promise<void> {
10
+ const config = loadConfig();
11
+ const nc = await connectNats(config);
12
+
13
+ if (!nc) {
14
+ console.error("Error: NATS connection required for push notifications.");
15
+ process.exit(1);
16
+ }
17
+
18
+ const sc = StringCodec();
19
+ const payload = {
20
+ hostId: config.hostId,
21
+ title: opts.title,
22
+ body: opts.body,
23
+ };
24
+
25
+ try {
26
+ const subject = `host.${config.hostId}.push.send`;
27
+ const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), {
28
+ timeout: 15_000,
29
+ });
30
+ const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
31
+
32
+ if (result.ok) {
33
+ console.log("Push notification sent successfully.");
34
+ } else {
35
+ console.error(`Failed to send push notification: ${result.error}`);
36
+ process.exit(1);
37
+ }
38
+ } catch (err) {
39
+ console.error(`Error sending push notification: ${err}`);
40
+ process.exit(1);
41
+ } finally {
42
+ await nc.drain();
43
+ }
44
+ }
@@ -0,0 +1,51 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { connectNats } from "../nats-client.js";
3
+ import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
4
+ import { requestUserInput, publishInputResolved } from "../user-input.js";
5
+
6
+ /**
7
+ * Request input from the user and print responses to stdout.
8
+ * Usage: palmier request-input --description "Question 1" --description "Question 2"
9
+ *
10
+ * Requires PALMIER_TASK_ID and PALMIER_RUN_DIR environment variables.
11
+ */
12
+ export async function requestInputCommand(opts: { description: string[] }): Promise<void> {
13
+ const taskId = process.env.PALMIER_TASK_ID;
14
+ if (!taskId) {
15
+ console.error("Error: PALMIER_TASK_ID environment variable is not set.");
16
+ process.exit(1);
17
+ }
18
+
19
+ const config = loadConfig();
20
+ const nc = await connectNats(config);
21
+ const taskDir = getTaskDir(config.projectRoot, taskId);
22
+ const task = parseTaskFile(taskDir);
23
+ const runId = process.env.PALMIER_RUN_DIR?.split(/[/\\]/).pop();
24
+
25
+ try {
26
+ const response = await requestUserInput(nc, config, taskId, task.frontmatter.name, taskDir, opts.description);
27
+ await publishInputResolved(nc, config, taskId, response === "aborted" ? "aborted" : "provided");
28
+
29
+ if (response === "aborted") {
30
+ if (runId) {
31
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
32
+ }
33
+ console.error("User aborted the input request.");
34
+ process.exit(1);
35
+ }
36
+
37
+ if (runId) {
38
+ const lines = opts.description.map((desc, i) => `**${desc}** ${response[i]}`);
39
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
40
+ }
41
+
42
+ for (let i = 0; i < opts.description.length; i++) {
43
+ console.log(response[i]);
44
+ }
45
+ } catch (err) {
46
+ console.error(`Error requesting user input: ${err}`);
47
+ process.exit(1);
48
+ } finally {
49
+ if (nc) await nc.drain();
50
+ }
51
+ }
@@ -4,42 +4,16 @@ 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 } 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
- import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX, TASK_INPUT_PREFIX } from "../agents/shared-prompt.js";
10
+ import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
11
11
  import type { AgentTool } from "../agents/agent.js";
12
12
  import { publishHostEvent } from "../events.js";
13
- import { waitForUserInput, requestUserInput, publishInputResolved } from "../user-input.js";
13
+ import { waitForUserInput } from "../user-input.js";
14
14
  import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
15
15
  import type { NatsConnection } from "nats";
16
16
 
17
- /**
18
- * Write a time-stamped RESULT file with frontmatter.
19
- * Always generated, even for abort/fail.
20
- */
21
-
22
- /**
23
- * Update an existing result file with the final outcome.
24
- */
25
- function finalizeResultFile(
26
- taskDir: string,
27
- resultFileName: string,
28
- taskName: string,
29
- taskSnapshotName: string,
30
- runningState: string,
31
- startTime: number,
32
- endTime: number,
33
- output: string,
34
- reportFiles: string[],
35
- requiredPermissions: RequiredPermission[],
36
- ): void {
37
- const reportLine = reportFiles.length > 0 ? `\nreport_files: ${reportFiles.join(", ")}` : "";
38
- const permLines = requiredPermissions.map((p) => `\nrequired_permission: ${p.name} | ${p.description}`).join("");
39
- const content = `---\ntask_name: ${taskName}\nrunning_state: ${runningState}\nstart_time: ${startTime}\nend_time: ${endTime}\ntask_file: ${taskSnapshotName}${reportLine}${permLines}\n---\n${output}`;
40
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
41
- }
42
-
43
17
  /**
44
18
  * Shared context for agent invocation retry loops.
45
19
  * Passed around to avoid threading many individual parameters.
@@ -48,6 +22,7 @@ interface InvocationContext {
48
22
  agent: AgentTool;
49
23
  task: ParsedTask;
50
24
  taskDir: string;
25
+ runId: string;
51
26
  guiEnv: Record<string, string>;
52
27
  nc: NatsConnection | undefined;
53
28
  config: HostConfig;
@@ -57,10 +32,7 @@ interface InvocationContext {
57
32
  }
58
33
 
59
34
  interface InvocationResult {
60
- output: string;
61
35
  outcome: TaskRunningState;
62
- reportFiles: string[];
63
- requiredPermissions: RequiredPermission[];
64
36
  }
65
37
 
66
38
  /**
@@ -79,8 +51,8 @@ async function invokeAgentWithRetry(
79
51
  while (true) {
80
52
  const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
81
53
  const result = await spawnCommand(command, args, {
82
- cwd: ctx.taskDir,
83
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
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) },
84
56
  echoStdout: true,
85
57
  resolveOnFailure: true,
86
58
  stdin,
@@ -90,13 +62,27 @@ async function invokeAgentWithRetry(
90
62
  const reportFiles = parseReportFiles(result.output);
91
63
  const requiredPermissions = parsePermissions(result.output);
92
64
 
65
+ // Append assistant message for this invocation
66
+ await appendAndNotify(ctx, {
67
+ role: "assistant",
68
+ time: Date.now(),
69
+ content: stripPalmierMarkers(result.output),
70
+ attachments: reportFiles.length > 0 ? reportFiles : undefined,
71
+ });
72
+
93
73
  // Permission retry
94
74
  if (outcome === "failed" && requiredPermissions.length > 0) {
95
75
  const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
96
76
  await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
97
77
 
98
78
  if (response === "aborted") {
99
- return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
79
+ await appendAndNotify(ctx, {
80
+ role: "user",
81
+ time: Date.now(),
82
+ content: "Permissions denied. Task aborted.",
83
+ type: "permission",
84
+ });
85
+ return { outcome: "failed" };
100
86
  }
101
87
 
102
88
  const newPerms = requiredPermissions.filter(
@@ -104,6 +90,14 @@ async function invokeAgentWithRetry(
104
90
  && !ctx.transientPermissions.some((ep) => ep.name === rp.name),
105
91
  );
106
92
 
93
+ // Append user message for permission grant
94
+ await appendAndNotify(ctx, {
95
+ role: "user",
96
+ time: Date.now(),
97
+ content: `Permissions granted: ${newPerms.map((p) => p.name).join(", ")}`,
98
+ type: "permission",
99
+ });
100
+
107
101
  if (response === "granted_all") {
108
102
  ctx.task.frontmatter.permissions = [...(ctx.task.frontmatter.permissions ?? []), ...newPerms];
109
103
  invokeTask.frontmatter.permissions = ctx.task.frontmatter.permissions;
@@ -116,36 +110,41 @@ async function invokeAgentWithRetry(
116
110
  continue;
117
111
  }
118
112
 
119
- // Input retry
120
- const inputRequests = parseInputRequests(result.output);
121
- if (outcome === "failed" && inputRequests.length > 0) {
122
- const response = await requestUserInput(ctx.nc, ctx.config, ctx.taskId, ctx.task.frontmatter.name, ctx.taskDir, inputRequests);
123
- await publishInputResolved(ctx.nc, ctx.config, ctx.taskId, response === "aborted" ? "aborted" : "provided");
124
-
125
- if (response === "aborted") {
126
- return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
127
- }
128
-
129
- const inputLines = inputRequests.map((desc, i) => `- ${desc} → ${response[i]}`).join("\n");
130
- retryPrompt = `The user provided the following inputs:\n${inputLines}\nPlease continue with these values.`;
131
- continue;
132
- }
133
-
134
113
  // Normal completion (success or non-retryable failure)
135
- return { output: result.output, outcome, reportFiles, requiredPermissions };
114
+ return { outcome };
136
115
  }
137
116
  }
138
117
 
139
118
  /**
140
- * Find an existing RESULT file with running_state=started (created by the RPC handler).
119
+ * Strip [PALMIER_*] marker lines from agent output.
141
120
  */
142
- function findStartedResultFile(taskDir: string): string | null {
143
- const files = fs.readdirSync(taskDir).filter((f) => f.startsWith("RESULT-") && f.endsWith(".md"));
144
- for (const file of files) {
145
- const content = fs.readFileSync(path.join(taskDir, file), "utf-8");
146
- if (content.includes("running_state: started")) return file;
147
- }
148
- return null;
121
+ export function stripPalmierMarkers(output: string): string {
122
+ return output.split("\n").filter((l) => !l.startsWith("[PALMIER")).join("\n").trim();
123
+ }
124
+
125
+ /**
126
+ * Append a conversation message to the RESULT file and notify connected clients.
127
+ */
128
+ async function appendAndNotify(
129
+ ctx: InvocationContext,
130
+ msg: Parameters<typeof appendRunMessage>[2],
131
+ ): Promise<void> {
132
+ appendRunMessage(ctx.taskDir, ctx.runId, msg);
133
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
134
+ }
135
+
136
+ /**
137
+ * Find the latest run dir that has no status messages yet (just created by the RPC handler).
138
+ */
139
+ function findLatestPendingRunId(taskDir: string): string | null {
140
+ const dirs = fs.readdirSync(taskDir)
141
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(taskDir, f, "TASKRUN.md")))
142
+ .sort();
143
+ if (dirs.length === 0) return null;
144
+ const latest = dirs[dirs.length - 1];
145
+ const messages = readRunMessages(taskDir, latest);
146
+ const hasStatus = messages.some((m) => m.role === "status");
147
+ return hasStatus ? null : latest;
149
148
  }
150
149
 
151
150
  /**
@@ -170,15 +169,11 @@ export async function runCommand(taskId: string): Promise<void> {
170
169
  let nc: NatsConnection | undefined;
171
170
  const taskName = task.frontmatter.name;
172
171
 
173
- // Check for an existing "started" result file (created by the RPC handler)
174
- const existingResult = findStartedResultFile(taskDir);
175
- const startTime = existingResult ? parseInt(existingResult.replace("RESULT-", "").replace(".md", ""), 10) : Date.now();
176
- const resultFileName = existingResult ?? createResultFile(taskDir, taskName, startTime);
177
-
178
- // Snapshot the task file at run time
179
- const taskSnapshotName = `TASK-${startTime}.md`;
180
- if (!fs.existsSync(path.join(taskDir, taskSnapshotName))) {
181
- fs.copyFileSync(path.join(taskDir, "TASK.md"), path.join(taskDir, taskSnapshotName));
172
+ // Use existing run dir if just created by RPC, otherwise create a new one
173
+ const existingRunId = findLatestPendingRunId(taskDir);
174
+ const runId = existingRunId ?? createRunDir(taskDir, taskName, Date.now());
175
+ if (!existingRunId) {
176
+ appendHistory(config.projectRoot, { task_id: taskId, run_id: runId });
182
177
  }
183
178
 
184
179
  const cleanup = async () => {
@@ -187,15 +182,12 @@ export async function runCommand(taskId: string): Promise<void> {
187
182
  }
188
183
  };
189
184
 
190
- if (!existingResult) {
191
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
192
- }
193
-
194
185
  try {
195
186
  nc = await connectNats(config);
196
187
 
197
- // Mark as started immediately
198
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
188
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, runId);
189
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
190
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
199
191
 
200
192
  // If requires_confirmation, notify clients and wait
201
193
  if (task.frontmatter.requires_confirmation) {
@@ -204,20 +196,21 @@ export async function runCommand(taskId: string): Promise<void> {
204
196
  await publishConfirmResolved(nc, config, taskId, resolvedStatus);
205
197
  if (!confirmed) {
206
198
  console.log("Task aborted by user.");
207
- const endTime = Date.now();
208
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
209
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
199
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
200
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
210
201
  await cleanup();
211
202
  return;
212
203
  }
213
204
  console.log("Task confirmed by user.");
205
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
206
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
214
207
  }
215
208
 
216
209
  // Shared invocation context
217
210
  const guiEnv = getPlatform().getGuiEnv();
218
211
  const agent = getAgent(task.frontmatter.agent);
219
212
  const ctx: InvocationContext = {
220
- agent, task, taskDir, guiEnv, nc, config, taskId,
213
+ agent, task, taskDir, runId, guiEnv, nc, config, taskId,
221
214
  transientPermissions: [],
222
215
  };
223
216
 
@@ -225,36 +218,34 @@ export async function runCommand(taskId: string): Promise<void> {
225
218
  // Command-triggered mode
226
219
  const result = await runCommandTriggeredMode(ctx);
227
220
  const outcome = resolveOutcome(taskDir, result.outcome);
228
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, result.endTime, result.output, [], []);
229
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
221
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
222
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
230
223
  console.log(`Task ${taskId} completed (command-triggered).`);
231
224
  } else {
232
- // Standard execution
225
+ // Standard execution — add user prompt as first message
226
+ await appendAndNotify(ctx, {
227
+ role: "user",
228
+ time: Date.now(),
229
+ content: task.body || task.frontmatter.user_prompt,
230
+ });
231
+
233
232
  const result = await invokeAgentWithRetry(ctx, task);
234
233
  const outcome = resolveOutcome(taskDir, result.outcome);
235
-
236
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
237
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
238
-
239
- if (result.reportFiles.length > 0) {
240
- await publishHostEvent(nc, config.hostId, taskId, {
241
- event_type: "report-generated",
242
- name: taskName,
243
- report_files: result.reportFiles,
244
- running_state: outcome,
245
- result_file: resultFileName,
246
- });
247
- }
248
-
234
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
235
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
249
236
  console.log(`Task ${taskId} completed.`);
250
237
  }
251
238
  } catch (err) {
252
239
  console.error(`Task ${taskId} failed:`, err);
253
- const endTime = Date.now();
254
240
  const outcome = resolveOutcome(taskDir, "failed");
255
241
  const errorMsg = err instanceof Error ? err.message : String(err);
256
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
257
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
242
+ appendRunMessage(taskDir, runId, {
243
+ role: "assistant",
244
+ time: Date.now(),
245
+ content: errorMsg,
246
+ });
247
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
248
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
258
249
  process.exitCode = 1;
259
250
  } finally {
260
251
  await cleanup();
@@ -275,13 +266,13 @@ const MAX_LINE_LENGTH = 200_000;
275
266
  */
276
267
  async function runCommandTriggeredMode(
277
268
  ctx: InvocationContext,
278
- ): Promise<{ outcome: TaskRunningState; endTime: number; output: string }> {
269
+ ): Promise<{ outcome: TaskRunningState; endTime: number }> {
279
270
  const commandStr = ctx.task.frontmatter.command!;
280
271
  console.log(`[command-triggered] Spawning: ${commandStr}`);
281
272
 
282
273
  const child = spawnStreamingCommand(commandStr, {
283
- cwd: ctx.taskDir,
284
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
274
+ cwd: getRunDir(ctx.taskDir, ctx.runId),
275
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
285
276
  });
286
277
 
287
278
  let linesProcessed = 0;
@@ -293,7 +284,7 @@ async function runCommandTriggeredMode(
293
284
  let commandExited = false;
294
285
  let resolveWhenDone: (() => void) | undefined;
295
286
 
296
- const logPath = path.join(ctx.taskDir, "command-output.log");
287
+ const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
297
288
  function appendLog(line: string, agentOutput: string, outcome: string) {
298
289
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
299
290
  fs.appendFileSync(logPath, entry, "utf-8");
@@ -331,7 +322,7 @@ async function runCommandTriggeredMode(
331
322
  } else {
332
323
  invocationsFailed++;
333
324
  }
334
- appendLog(line, result.output, result.outcome);
325
+ appendLog(line, "", result.outcome);
335
326
  }
336
327
 
337
328
  async function drainQueue(): Promise<void> {
@@ -390,15 +381,7 @@ async function runCommandTriggeredMode(
390
381
  }
391
382
 
392
383
  const endTime = Date.now();
393
- const summary = [
394
- `Command: ${commandStr}`,
395
- `Exit code: ${exitCode}`,
396
- `Lines processed: ${linesProcessed}`,
397
- `Agent invocations succeeded: ${invocationsSucceeded}`,
398
- `Agent invocations failed: ${invocationsFailed}`,
399
- ].join("\n");
400
-
401
- return { outcome: "finished", endTime, output: summary };
384
+ return { outcome: "finished", endTime };
402
385
  }
403
386
 
404
387
  async function publishTaskEvent(
@@ -408,16 +391,17 @@ async function publishTaskEvent(
408
391
  taskId: string,
409
392
  eventType: TaskRunningState,
410
393
  taskName?: string,
411
- resultFile?: string,
394
+ runId?: string,
412
395
  ): Promise<void> {
413
396
  writeTaskStatus(taskDir, {
414
397
  running_state: eventType,
415
398
  time_stamp: Date.now(),
399
+ ...(eventType === "started" ? { pid: process.pid } : {}),
416
400
  });
417
401
 
418
402
  const payload: Record<string, unknown> = { event_type: "running-state", running_state: eventType };
419
403
  if (taskName) payload.name = taskName;
420
- if (resultFile) payload.result_file = resultFile;
404
+ if (runId) payload.run_id = runId;
421
405
  await publishHostEvent(nc, config.hostId, taskId, payload);
422
406
  }
423
407
 
@@ -534,21 +518,6 @@ export function parsePermissions(output: string): RequiredPermission[] {
534
518
  return perms;
535
519
  }
536
520
 
537
- /**
538
- * Extract user input requests from agent output.
539
- * Looks for lines matching: [PALMIER_INPUT] <description>
540
- */
541
- export function parseInputRequests(output: string): string[] {
542
- const regex = new RegExp(`^\\${TASK_INPUT_PREFIX}\\s+(.+)$`, "gm");
543
- const inputs: string[] = [];
544
- let match;
545
- while ((match = regex.exec(output)) !== null) {
546
- const desc = match[1].trim();
547
- if (desc) inputs.push(desc);
548
- }
549
- return inputs;
550
- }
551
-
552
521
  /**
553
522
  * Parse the agent's output for success/failure markers.
554
523
  * Falls back to "finished" if no marker is found.