palmier 0.4.1 → 0.4.3

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 (46) hide show
  1. package/README.md +13 -27
  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 +11 -0
  12. package/dist/commands/request-input.js +63 -0
  13. package/dist/commands/run.d.ts +0 -5
  14. package/dist/commands/run.js +67 -72
  15. package/dist/commands/serve.js +7 -6
  16. package/dist/index.js +15 -5
  17. package/dist/platform/windows.js +17 -2
  18. package/dist/rpc-handler.js +42 -27
  19. package/dist/spawn-command.d.ts +1 -1
  20. package/dist/spawn-command.js +13 -1
  21. package/dist/task.d.ts +12 -1
  22. package/dist/task.js +36 -1
  23. package/dist/types.d.ts +9 -0
  24. package/dist/update-checker.d.ts +0 -8
  25. package/dist/update-checker.js +0 -36
  26. package/package.json +2 -3
  27. package/src/agents/agent-instructions.md +40 -0
  28. package/src/agents/claude.ts +2 -7
  29. package/src/agents/codex.ts +0 -5
  30. package/src/agents/copilot.ts +0 -19
  31. package/src/agents/gemini.ts +0 -5
  32. package/src/agents/shared-prompt.ts +10 -18
  33. package/src/commands/notify.ts +44 -0
  34. package/src/commands/request-input.ts +65 -0
  35. package/src/commands/run.ts +78 -96
  36. package/src/commands/serve.ts +7 -7
  37. package/src/index.ts +16 -5
  38. package/src/platform/windows.ts +17 -2
  39. package/src/rpc-handler.ts +51 -26
  40. package/src/spawn-command.ts +13 -2
  41. package/src/task.ts +47 -2
  42. package/src/types.ts +10 -0
  43. package/src/update-checker.ts +0 -36
  44. package/dist/commands/mcpserver.d.ts +0 -2
  45. package/dist/commands/mcpserver.js +0 -93
  46. package/src/commands/mcpserver.ts +0 -113
@@ -0,0 +1,65 @@
1
+ import { loadConfig } from "../config.js";
2
+ import { connectNats } from "../nats-client.js";
3
+ import { getTaskDir, parseTaskFile, appendResultMessage } 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 environment variable to be set.
11
+ * Outputs each response on its own line: "description: value"
12
+ */
13
+ export async function requestInputCommand(opts: { description: string[] }): Promise<void> {
14
+ const taskId = process.env.PALMIER_TASK_ID;
15
+ if (!taskId) {
16
+ console.error("Error: PALMIER_TASK_ID environment variable is not set.");
17
+ process.exit(1);
18
+ }
19
+
20
+ const config = loadConfig();
21
+ const nc = await connectNats(config);
22
+ const taskDir = getTaskDir(config.projectRoot, taskId);
23
+ const task = parseTaskFile(taskDir);
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
+ // 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
+ });
39
+ }
40
+ console.error("User aborted the input request.");
41
+ process.exit(1);
42
+ }
43
+
44
+ // Write user input as a conversation message
45
+ const resultFile = process.env.PALMIER_RESULT_FILE;
46
+ if (resultFile) {
47
+ 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
+ });
54
+ }
55
+
56
+ for (let i = 0; i < opts.description.length; i++) {
57
+ console.log(response[i]);
58
+ }
59
+ } catch (err) {
60
+ console.error(`Error requesting user input: ${err}`);
61
+ process.exit(1);
62
+ } finally {
63
+ if (nc) await nc.drain();
64
+ }
65
+ }
@@ -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, createResultFile, appendResultMessage, finalizeResultFrontmatter } 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
+ resultFileName: 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
  /**
@@ -80,7 +52,7 @@ async function invokeAgentWithRetry(
80
52
  const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
81
53
  const result = await spawnCommand(command, args, {
82
54
  cwd: ctx.taskDir,
83
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
55
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RESULT_FILE: ctx.resultFileName },
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,26 +110,29 @@ 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
 
118
+ /**
119
+ * Strip [PALMIER_*] marker lines from agent output.
120
+ */
121
+ 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 appendResultMessage>[2],
131
+ ): Promise<void> {
132
+ appendResultMessage(ctx.taskDir, ctx.resultFileName, msg);
133
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated" });
134
+ }
135
+
139
136
  /**
140
137
  * Find an existing RESULT file with running_state=started (created by the RPC handler).
141
138
  */
@@ -197,6 +194,10 @@ export async function runCommand(taskId: string): Promise<void> {
197
194
  // Mark as started immediately
198
195
  await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, resultFileName);
199
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" });
200
+
200
201
  // If requires_confirmation, notify clients and wait
201
202
  if (task.frontmatter.requires_confirmation) {
202
203
  const confirmed = await requestConfirmation(nc, config, task, taskDir);
@@ -204,20 +205,22 @@ export async function runCommand(taskId: string): Promise<void> {
204
205
  await publishConfirmResolved(nc, config, taskId, resolvedStatus);
205
206
  if (!confirmed) {
206
207
  console.log("Task aborted by user.");
207
- const endTime = Date.now();
208
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
208
+ appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: "aborted" });
209
+ finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: "aborted" });
209
210
  await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, resultFileName);
210
211
  await cleanup();
211
212
  return;
212
213
  }
213
214
  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" });
214
217
  }
215
218
 
216
219
  // Shared invocation context
217
220
  const guiEnv = getPlatform().getGuiEnv();
218
221
  const agent = getAgent(task.frontmatter.agent);
219
222
  const ctx: InvocationContext = {
220
- agent, task, taskDir, guiEnv, nc, config, taskId,
223
+ agent, task, taskDir, resultFileName, guiEnv, nc, config, taskId,
221
224
  transientPermissions: [],
222
225
  };
223
226
 
@@ -225,35 +228,36 @@ export async function runCommand(taskId: string): Promise<void> {
225
228
  // Command-triggered mode
226
229
  const result = await runCommandTriggeredMode(ctx);
227
230
  const outcome = resolveOutcome(taskDir, result.outcome);
228
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, result.endTime, result.output, [], []);
231
+ appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
232
+ finalizeResultFrontmatter(taskDir, resultFileName, { end_time: result.endTime, running_state: outcome });
229
233
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
230
234
  console.log(`Task ${taskId} completed (command-triggered).`);
231
235
  } else {
232
- // Standard execution
236
+ // Standard execution — add user prompt as first message
237
+ await appendAndNotify(ctx, {
238
+ role: "user",
239
+ time: Date.now(),
240
+ content: task.body || task.frontmatter.user_prompt,
241
+ });
242
+
233
243
  const result = await invokeAgentWithRetry(ctx, task);
234
244
  const outcome = resolveOutcome(taskDir, result.outcome);
235
-
236
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
245
+ appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
246
+ finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
237
247
  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
-
249
248
  console.log(`Task ${taskId} completed.`);
250
249
  }
251
250
  } catch (err) {
252
251
  console.error(`Task ${taskId} failed:`, err);
253
- const endTime = Date.now();
254
252
  const outcome = resolveOutcome(taskDir, "failed");
255
253
  const errorMsg = err instanceof Error ? err.message : String(err);
256
- finalizeResultFile(taskDir, resultFileName, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
254
+ appendResultMessage(taskDir, resultFileName, {
255
+ role: "assistant",
256
+ time: Date.now(),
257
+ content: errorMsg,
258
+ });
259
+ appendResultMessage(taskDir, resultFileName, { role: "status", time: Date.now(), content: "", type: outcome });
260
+ finalizeResultFrontmatter(taskDir, resultFileName, { end_time: Date.now(), running_state: outcome });
257
261
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, resultFileName);
258
262
  process.exitCode = 1;
259
263
  } finally {
@@ -275,7 +279,7 @@ const MAX_LINE_LENGTH = 200_000;
275
279
  */
276
280
  async function runCommandTriggeredMode(
277
281
  ctx: InvocationContext,
278
- ): Promise<{ outcome: TaskRunningState; endTime: number; output: string }> {
282
+ ): Promise<{ outcome: TaskRunningState; endTime: number }> {
279
283
  const commandStr = ctx.task.frontmatter.command!;
280
284
  console.log(`[command-triggered] Spawning: ${commandStr}`);
281
285
 
@@ -331,7 +335,7 @@ async function runCommandTriggeredMode(
331
335
  } else {
332
336
  invocationsFailed++;
333
337
  }
334
- appendLog(line, result.output, result.outcome);
338
+ appendLog(line, "", result.outcome);
335
339
  }
336
340
 
337
341
  async function drainQueue(): Promise<void> {
@@ -390,15 +394,7 @@ async function runCommandTriggeredMode(
390
394
  }
391
395
 
392
396
  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 };
397
+ return { outcome: "finished", endTime };
402
398
  }
403
399
 
404
400
  async function publishTaskEvent(
@@ -413,6 +409,7 @@ async function publishTaskEvent(
413
409
  writeTaskStatus(taskDir, {
414
410
  running_state: eventType,
415
411
  time_stamp: Date.now(),
412
+ ...(eventType === "started" ? { pid: process.pid } : {}),
416
413
  });
417
414
 
418
415
  const payload: Record<string, unknown> = { event_type: "running-state", running_state: eventType };
@@ -534,21 +531,6 @@ export function parsePermissions(output: string): RequiredPermission[] {
534
531
  return perms;
535
532
  }
536
533
 
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
534
  /**
553
535
  * Parse the agent's output for success/failure markers.
554
536
  * Falls back to "finished" if no marker is found.
@@ -4,10 +4,9 @@ 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 } from "../task.js";
7
+ import { getTaskDir, readTaskStatus, writeTaskStatus, appendHistory, parseTaskFile, appendResultMessage } from "../task.js";
8
8
  import { publishHostEvent } from "../events.js";
9
9
  import { getPlatform } from "../platform/index.js";
10
- import { checkForUpdate } from "../update-checker.js";
11
10
  import { detectAgents } from "../agents/agent.js";
12
11
  import { saveConfig } from "../config.js";
13
12
  import type { HostConfig } from "../types.js";
@@ -42,8 +41,13 @@ async function markTaskFailed(
42
41
  } catch { /* use taskId as fallback */ }
43
42
 
44
43
  const resultFileName = `RESULT-${endTime}.md`;
45
- const content = `---\ntask_name: ${taskName}\nrunning_state: failed\nstart_time: ${status.time_stamp}\nend_time: ${endTime}\ntask_file: \n---\n${reason}`;
44
+ const content = `---\ntask_name: ${taskName}\nrunning_state: failed\nstart_time: ${status.time_stamp}\nend_time: ${endTime}\ntask_file: \n---\n\n`;
46
45
  fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
46
+ appendResultMessage(taskDir, resultFileName, {
47
+ role: "assistant",
48
+ time: endTime,
49
+ content: reason,
50
+ });
47
51
  appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
48
52
 
49
53
  const payload: Record<string, unknown> = { event_type: "running-state", running_state: "failed", name: taskName };
@@ -109,10 +113,6 @@ export async function serveCommand(): Promise<void> {
109
113
  });
110
114
  }, POLL_INTERVAL_MS);
111
115
 
112
- // Check for updates on startup and every 24 hours
113
- checkForUpdate().catch(() => {});
114
- setInterval(() => { checkForUpdate().catch(() => {}); }, 24 * 60 * 60 * 1000);
115
-
116
116
  const handleRpc = createRpcHandler(config, nc);
117
117
  await startNatsTransport(config, handleRpc, nc);
118
118
  }
package/src/index.ts CHANGED
@@ -9,7 +9,8 @@ import { initCommand } from "./commands/init.js";
9
9
  import { infoCommand } from "./commands/info.js";
10
10
  import { runCommand } from "./commands/run.js";
11
11
  import { serveCommand } from "./commands/serve.js";
12
- import { mcpserverCommand } from "./commands/mcpserver.js";
12
+ import { notifyCommand } from "./commands/notify.js";
13
+ import { requestInputCommand } from "./commands/request-input.js";
13
14
 
14
15
  import { pairCommand } from "./commands/pair.js";
15
16
  import { lanCommand } from "./commands/lan.js";
@@ -62,10 +63,20 @@ program
62
63
  });
63
64
 
64
65
  program
65
- .command("mcpserver")
66
- .description("Start an MCP server exposing Palmier tools (stdio transport)")
67
- .action(async () => {
68
- await mcpserverCommand();
66
+ .command("notify")
67
+ .description("Send a push notification to the user")
68
+ .requiredOption("--title <title>", "Notification title")
69
+ .requiredOption("--body <body>", "Notification body text")
70
+ .action(async (opts: { title: string; body: string }) => {
71
+ await notifyCommand(opts);
72
+ });
73
+
74
+ program
75
+ .command("request-input")
76
+ .description("Request input from the user (requires PALMIER_TASK_ID env var)")
77
+ .requiredOption("--description <desc...>", "Input descriptions to show the user")
78
+ .action(async (opts: { description: string[] }) => {
79
+ await requestInputCommand(opts);
69
80
  });
70
81
 
71
82
  program
@@ -4,7 +4,8 @@ import { execFileSync } from "child_process";
4
4
  import { spawn as nodeSpawn } from "child_process";
5
5
  import type { PlatformService } from "./platform.js";
6
6
  import type { HostConfig, ParsedTask } from "../types.js";
7
- import { CONFIG_DIR } from "../config.js";
7
+ import { CONFIG_DIR, loadConfig } from "../config.js";
8
+ import { getTaskDir, readTaskStatus } from "../task.js";
8
9
 
9
10
 
10
11
  const TASK_PREFIX = "\\Palmier\\PalmierTask-";
@@ -140,7 +141,7 @@ export class WindowsPlatform implements PlatformService {
140
141
  // Kill old daemon first, then spawn new one.
141
142
  if (oldPid) {
142
143
  try {
143
- execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
144
+ execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
144
145
  } catch {
145
146
  // Process may have already exited
146
147
  }
@@ -225,6 +226,20 @@ export class WindowsPlatform implements PlatformService {
225
226
  }
226
227
 
227
228
  async stopTask(taskId: string): Promise<void> {
229
+ // Try to kill the entire process tree via the PID recorded in status.json.
230
+ // schtasks /end only kills the top-level process, leaving agent children orphaned.
231
+ try {
232
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
233
+ const status = readTaskStatus(taskDir);
234
+ if (status?.pid) {
235
+ execFileSync("taskkill", ["/pid", String(status.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
236
+ return;
237
+ }
238
+ } catch {
239
+ // PID may be stale or config unavailable; fall through to schtasks /end
240
+ }
241
+
242
+ // Fallback: schtasks /end (kills top-level process only)
228
243
  const tn = schtasksTaskName(taskId);
229
244
  try {
230
245
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -11,8 +11,8 @@ import { spawnCommand } from "./spawn-command.js";
11
11
  import { getAgent } from "./agents/agent.js";
12
12
  import { validateSession } from "./session-store.js";
13
13
  import { publishHostEvent } from "./events.js";
14
- import { currentVersion, getLatestVersion, performUpdate } from "./update-checker.js";
15
- import type { HostConfig, ParsedTask, RpcMessage } from "./types.js";
14
+ import { currentVersion, performUpdate } from "./update-checker.js";
15
+ import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
16
16
 
17
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
18
 
@@ -22,46 +22,72 @@ const PLAN_GENERATION_PROMPT = fs.readFileSync(
22
22
  );
23
23
 
24
24
  /**
25
- * Parse RESULT frontmatter into a metadata object.
25
+ * Parse RESULT frontmatter and conversation messages.
26
26
  */
27
27
  function parseResultFrontmatter(raw: string): Record<string, unknown> {
28
28
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
29
- if (!fmMatch) return { content: raw };
29
+ if (!fmMatch) return { messages: [] };
30
30
 
31
31
  const meta: Record<string, string> = {};
32
- const requiredPermissions: Array<{ name: string; description: string }> = [];
33
32
  for (const line of fmMatch[1].split("\n")) {
34
33
  const sep = line.indexOf(": ");
35
34
  if (sep === -1) continue;
36
- const key = line.slice(0, sep).trim();
37
- const value = line.slice(sep + 2).trim();
38
- if (key === "required_permission") {
39
- const pipeSep = value.indexOf("|");
40
- if (pipeSep !== -1) {
41
- requiredPermissions.push({ name: value.slice(0, pipeSep).trim(), description: value.slice(pipeSep + 1).trim() });
42
- } else {
43
- requiredPermissions.push({ name: value, description: "" });
44
- }
45
- } else {
46
- meta[key] = value;
47
- }
35
+ meta[line.slice(0, sep).trim()] = line.slice(sep + 2).trim();
48
36
  }
49
- const reportFiles = meta.report_files
50
- ? meta.report_files.split(",").map((f: string) => f.trim()).filter(Boolean)
51
- : [];
37
+
38
+ const messages = parseConversationMessages(fmMatch[2]);
52
39
 
53
40
  return {
54
- content: fmMatch[2],
41
+ messages,
55
42
  task_name: meta.task_name,
56
43
  running_state: meta.running_state,
57
44
  start_time: meta.start_time ? Number(meta.start_time) : undefined,
58
45
  end_time: meta.end_time ? Number(meta.end_time) : undefined,
59
46
  task_file: meta.task_file,
60
- report_files: reportFiles.length > 0 ? reportFiles : undefined,
61
- required_permissions: requiredPermissions.length > 0 ? requiredPermissions : undefined,
62
47
  };
63
48
  }
64
49
 
50
+ /**
51
+ * Parse conversation messages from the body of a RESULT file.
52
+ */
53
+ function parseConversationMessages(body: string): ConversationMessage[] {
54
+ const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
55
+ const messages: ConversationMessage[] = [];
56
+ const matches = [...body.matchAll(delimiterRegex)];
57
+
58
+ if (matches.length === 0) {
59
+ // No delimiters — treat entire body as single assistant message if non-empty
60
+ const content = body.trim();
61
+ if (content) {
62
+ messages.push({ role: "assistant", time: 0, content });
63
+ }
64
+ return messages;
65
+ }
66
+
67
+ for (let i = 0; i < matches.length; i++) {
68
+ const match = matches[i];
69
+ const attrs = match[1];
70
+ const start = match.index! + match[0].length;
71
+ const end = i + 1 < matches.length ? matches[i + 1].index! : body.length;
72
+ const content = body.slice(start, end).trim();
73
+
74
+ const role = (parseAttr(attrs, "role") ?? "assistant") as "assistant" | "user";
75
+ const time = Number(parseAttr(attrs, "time") ?? "0");
76
+ const type = parseAttr(attrs, "type") as ConversationMessage["type"];
77
+ const attachmentsRaw = parseAttr(attrs, "attachments");
78
+ const attachments = attachmentsRaw ? attachmentsRaw.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
79
+
80
+ messages.push({ role, time, content, ...(type ? { type } : {}), ...(attachments ? { attachments } : {}) });
81
+ }
82
+
83
+ return messages;
84
+ }
85
+
86
+ function parseAttr(attrs: string, name: string): string | undefined {
87
+ const match = attrs.match(new RegExp(`${name}="([^"]*)"`));
88
+ return match ? match[1] : undefined;
89
+ }
90
+
65
91
  /**
66
92
  * Run plan generation for a task prompt using the given agent.
67
93
  * Returns the generated plan body and task name.
@@ -124,7 +150,6 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
124
150
  tasks: tasks.map((task) => flattenTask(task)),
125
151
  agents: config.agents ?? [],
126
152
  version: currentVersion,
127
- latest_version: getLatestVersion(),
128
153
  };
129
154
  }
130
155
 
@@ -409,8 +434,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
409
434
  try {
410
435
  const raw = fs.readFileSync(resultPath, "utf-8");
411
436
  const meta = parseResultFrontmatter(raw);
412
- // Exclude full content from list response
413
- const { content: _, ...rest } = meta;
437
+ // Exclude messages from list response
438
+ const { messages: _, ...rest } = meta;
414
439
  return { ...entry, ...rest };
415
440
  } catch {
416
441
  return { ...entry, error: "Result file not found" };
@@ -1,5 +1,16 @@
1
1
  import crossSpawn from "cross-spawn";
2
- import type { ChildProcess } from "child_process";
2
+ import { execFileSync, type ChildProcess } from "child_process";
3
+
4
+ /** Kill a child process and its entire tree on Windows; plain kill elsewhere. */
5
+ function treeKill(child: ChildProcess): void {
6
+ if (process.platform === "win32" && child.pid) {
7
+ try {
8
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
9
+ return;
10
+ } catch { /* fall through */ }
11
+ }
12
+ child.kill();
13
+ }
3
14
 
4
15
  export interface SpawnStreamingOptions {
5
16
  cwd: string;
@@ -100,7 +111,7 @@ export function spawnCommand(
100
111
  let timer: ReturnType<typeof setTimeout> | undefined;
101
112
  if (opts.timeout) {
102
113
  timer = setTimeout(() => {
103
- child.kill();
114
+ treeKill(child);
104
115
  reject(new Error("command timed out"));
105
116
  }, opts.timeout);
106
117
  }