palmier 0.4.2 → 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 (43) 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 -2
  16. package/dist/index.js +15 -5
  17. package/dist/platform/windows.js +17 -2
  18. package/dist/rpc-handler.js +41 -25
  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/package.json +2 -3
  25. package/src/agents/agent-instructions.md +40 -0
  26. package/src/agents/claude.ts +2 -7
  27. package/src/agents/codex.ts +0 -5
  28. package/src/agents/copilot.ts +0 -19
  29. package/src/agents/gemini.ts +0 -5
  30. package/src/agents/shared-prompt.ts +10 -18
  31. package/src/commands/notify.ts +44 -0
  32. package/src/commands/request-input.ts +65 -0
  33. package/src/commands/run.ts +78 -96
  34. package/src/commands/serve.ts +7 -2
  35. package/src/index.ts +16 -5
  36. package/src/platform/windows.ts +17 -2
  37. package/src/rpc-handler.ts +50 -24
  38. package/src/spawn-command.ts +13 -2
  39. package/src/task.ts +47 -2
  40. package/src/types.ts +10 -0
  41. package/dist/commands/mcpserver.d.ts +0 -2
  42. package/dist/commands/mcpserver.js +0 -93
  43. package/src/commands/mcpserver.ts +0 -113
@@ -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 } 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
10
  import { detectAgents } from "../agents/agent.js";
@@ -31,8 +31,13 @@ async function markTaskFailed(config, nc, taskId, reason) {
31
31
  }
32
32
  catch { /* use taskId as fallback */ }
33
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${reason}`;
34
+ const content = `---\ntask_name: ${taskName}\nrunning_state: failed\nstart_time: ${status.time_stamp}\nend_time: ${endTime}\ntask_file: \n---\n\n`;
35
35
  fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
36
+ appendResultMessage(taskDir, resultFileName, {
37
+ role: "assistant",
38
+ time: endTime,
39
+ content: reason,
40
+ });
36
41
  appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
37
42
  const payload = { event_type: "running-state", running_state: "failed", name: taskName };
38
43
  await publishHostEvent(nc, config.hostId, taskId, payload);
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@ import { initCommand } from "./commands/init.js";
8
8
  import { infoCommand } from "./commands/info.js";
9
9
  import { runCommand } from "./commands/run.js";
10
10
  import { serveCommand } from "./commands/serve.js";
11
- import { mcpserverCommand } from "./commands/mcpserver.js";
11
+ import { notifyCommand } from "./commands/notify.js";
12
+ import { requestInputCommand } from "./commands/request-input.js";
12
13
  import { pairCommand } from "./commands/pair.js";
13
14
  import { lanCommand } from "./commands/lan.js";
14
15
  import { restartCommand } from "./commands/restart.js";
@@ -51,10 +52,19 @@ program
51
52
  await restartCommand();
52
53
  });
53
54
  program
54
- .command("mcpserver")
55
- .description("Start an MCP server exposing Palmier tools (stdio transport)")
56
- .action(async () => {
57
- await mcpserverCommand();
55
+ .command("notify")
56
+ .description("Send a push notification to the user")
57
+ .requiredOption("--title <title>", "Notification title")
58
+ .requiredOption("--body <body>", "Notification body text")
59
+ .action(async (opts) => {
60
+ await notifyCommand(opts);
61
+ });
62
+ program
63
+ .command("request-input")
64
+ .description("Request input from the user (requires PALMIER_TASK_ID env var)")
65
+ .requiredOption("--description <desc...>", "Input descriptions to show the user")
66
+ .action(async (opts) => {
67
+ await requestInputCommand(opts);
58
68
  });
59
69
  program
60
70
  .command("pair")
@@ -2,7 +2,8 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { execFileSync } from "child_process";
4
4
  import { spawn as nodeSpawn } from "child_process";
5
- import { CONFIG_DIR } from "../config.js";
5
+ import { CONFIG_DIR, loadConfig } from "../config.js";
6
+ import { getTaskDir, readTaskStatus } from "../task.js";
6
7
  const TASK_PREFIX = "\\Palmier\\PalmierTask-";
7
8
  const DAEMON_TASK_NAME = "PalmierDaemon";
8
9
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
@@ -118,7 +119,7 @@ export class WindowsPlatform {
118
119
  // Kill old daemon first, then spawn new one.
119
120
  if (oldPid) {
120
121
  try {
121
- execFileSync("taskkill", ["/pid", oldPid, "/f"], { windowsHide: true, stdio: "pipe" });
122
+ execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
122
123
  }
123
124
  catch {
124
125
  // Process may have already exited
@@ -203,6 +204,20 @@ export class WindowsPlatform {
203
204
  }
204
205
  }
205
206
  async stopTask(taskId) {
207
+ // Try to kill the entire process tree via the PID recorded in status.json.
208
+ // schtasks /end only kills the top-level process, leaving agent children orphaned.
209
+ try {
210
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
211
+ const status = readTaskStatus(taskDir);
212
+ if (status?.pid) {
213
+ execFileSync("taskkill", ["/pid", String(status.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
214
+ return;
215
+ }
216
+ }
217
+ catch {
218
+ // PID may be stale or config unavailable; fall through to schtasks /end
219
+ }
220
+ // Fallback: schtasks /end (kills top-level process only)
206
221
  const tn = schtasksTaskName(taskId);
207
222
  try {
208
223
  execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
@@ -14,47 +14,63 @@ import { currentVersion, performUpdate } from "./update-checker.js";
14
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  const PLAN_GENERATION_PROMPT = fs.readFileSync(path.join(__dirname, "commands", "plan-generation.md"), "utf-8");
16
16
  /**
17
- * Parse RESULT frontmatter into a metadata object.
17
+ * Parse RESULT frontmatter and conversation messages.
18
18
  */
19
19
  function parseResultFrontmatter(raw) {
20
20
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
21
21
  if (!fmMatch)
22
- return { content: raw };
22
+ return { messages: [] };
23
23
  const meta = {};
24
- const requiredPermissions = [];
25
24
  for (const line of fmMatch[1].split("\n")) {
26
25
  const sep = line.indexOf(": ");
27
26
  if (sep === -1)
28
27
  continue;
29
- const key = line.slice(0, sep).trim();
30
- const value = line.slice(sep + 2).trim();
31
- if (key === "required_permission") {
32
- const pipeSep = value.indexOf("|");
33
- if (pipeSep !== -1) {
34
- requiredPermissions.push({ name: value.slice(0, pipeSep).trim(), description: value.slice(pipeSep + 1).trim() });
35
- }
36
- else {
37
- requiredPermissions.push({ name: value, description: "" });
38
- }
39
- }
40
- else {
41
- meta[key] = value;
42
- }
28
+ meta[line.slice(0, sep).trim()] = line.slice(sep + 2).trim();
43
29
  }
44
- const reportFiles = meta.report_files
45
- ? meta.report_files.split(",").map((f) => f.trim()).filter(Boolean)
46
- : [];
30
+ const messages = parseConversationMessages(fmMatch[2]);
47
31
  return {
48
- content: fmMatch[2],
32
+ messages,
49
33
  task_name: meta.task_name,
50
34
  running_state: meta.running_state,
51
35
  start_time: meta.start_time ? Number(meta.start_time) : undefined,
52
36
  end_time: meta.end_time ? Number(meta.end_time) : undefined,
53
37
  task_file: meta.task_file,
54
- report_files: reportFiles.length > 0 ? reportFiles : undefined,
55
- required_permissions: requiredPermissions.length > 0 ? requiredPermissions : undefined,
56
38
  };
57
39
  }
40
+ /**
41
+ * Parse conversation messages from the body of a RESULT file.
42
+ */
43
+ function parseConversationMessages(body) {
44
+ const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
45
+ const messages = [];
46
+ const matches = [...body.matchAll(delimiterRegex)];
47
+ if (matches.length === 0) {
48
+ // No delimiters — treat entire body as single assistant message if non-empty
49
+ const content = body.trim();
50
+ if (content) {
51
+ messages.push({ role: "assistant", time: 0, content });
52
+ }
53
+ return messages;
54
+ }
55
+ for (let i = 0; i < matches.length; i++) {
56
+ const match = matches[i];
57
+ const attrs = match[1];
58
+ const start = match.index + match[0].length;
59
+ const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
60
+ const content = body.slice(start, end).trim();
61
+ const role = (parseAttr(attrs, "role") ?? "assistant");
62
+ const time = Number(parseAttr(attrs, "time") ?? "0");
63
+ const type = parseAttr(attrs, "type");
64
+ const attachmentsRaw = parseAttr(attrs, "attachments");
65
+ const attachments = attachmentsRaw ? attachmentsRaw.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
66
+ messages.push({ role, time, content, ...(type ? { type } : {}), ...(attachments ? { attachments } : {}) });
67
+ }
68
+ return messages;
69
+ }
70
+ function parseAttr(attrs, name) {
71
+ const match = attrs.match(new RegExp(`${name}="([^"]*)"`));
72
+ return match ? match[1] : undefined;
73
+ }
58
74
  /**
59
75
  * Run plan generation for a task prompt using the given agent.
60
76
  * Returns the generated plan body and task name.
@@ -351,8 +367,8 @@ export function createRpcHandler(config, nc) {
351
367
  try {
352
368
  const raw = fs.readFileSync(resultPath, "utf-8");
353
369
  const meta = parseResultFrontmatter(raw);
354
- // Exclude full content from list response
355
- const { content: _, ...rest } = meta;
370
+ // Exclude messages from list response
371
+ const { messages: _, ...rest } = meta;
356
372
  return { ...entry, ...rest };
357
373
  }
358
374
  catch {
@@ -1,4 +1,4 @@
1
- import type { ChildProcess } from "child_process";
1
+ import { type ChildProcess } from "child_process";
2
2
  export interface SpawnStreamingOptions {
3
3
  cwd: string;
4
4
  env?: Record<string, string>;
@@ -1,4 +1,16 @@
1
1
  import crossSpawn from "cross-spawn";
2
+ import { execFileSync } from "child_process";
3
+ /** Kill a child process and its entire tree on Windows; plain kill elsewhere. */
4
+ function treeKill(child) {
5
+ if (process.platform === "win32" && child.pid) {
6
+ try {
7
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
8
+ return;
9
+ }
10
+ catch { /* fall through */ }
11
+ }
12
+ child.kill();
13
+ }
2
14
  /**
3
15
  * Spawn a command with shell interpretation, returning the ChildProcess
4
16
  * with stdout piped for line-by-line reading.
@@ -50,7 +62,7 @@ export function spawnCommand(command, args, opts) {
50
62
  let timer;
51
63
  if (opts.timeout) {
52
64
  timer = setTimeout(() => {
53
- child.kill();
65
+ treeKill(child);
54
66
  reject(new Error("command timed out"));
55
67
  }, opts.timeout);
56
68
  }
package/dist/task.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ParsedTask, TaskStatus, HistoryEntry } from "./types.js";
1
+ import type { ParsedTask, TaskStatus, HistoryEntry, ConversationMessage } from "./types.js";
2
2
  /**
3
3
  * Parse a TASK.md file from the given task directory.
4
4
  */
@@ -44,6 +44,17 @@ export declare function readTaskStatus(taskDir: string): TaskStatus | undefined;
44
44
  * Returns the result file name.
45
45
  */
46
46
  export declare function createResultFile(taskDir: string, taskName: string, startTime: number): string;
47
+ /**
48
+ * Append a conversation message to a RESULT file.
49
+ */
50
+ export declare function appendResultMessage(taskDir: string, resultFile: string, msg: ConversationMessage): void;
51
+ /**
52
+ * Update frontmatter fields in a RESULT file without touching the body.
53
+ */
54
+ export declare function finalizeResultFrontmatter(taskDir: string, resultFile: string, updates: {
55
+ end_time?: number;
56
+ running_state?: string;
57
+ }): void;
47
58
  /**
48
59
  * Append a history entry to the project-level history.jsonl file.
49
60
  */
package/dist/task.js CHANGED
@@ -137,10 +137,45 @@ export function readTaskStatus(taskDir) {
137
137
  export function createResultFile(taskDir, taskName, startTime) {
138
138
  const resultFileName = `RESULT-${startTime}.md`;
139
139
  const taskSnapshotName = `TASK-${startTime}.md`;
140
- const content = `---\ntask_name: ${taskName}\nrunning_state: started\nstart_time: ${startTime}\ntask_file: ${taskSnapshotName}\n---\n`;
140
+ const content = `---\ntask_name: ${taskName}\nrunning_state: started\nstart_time: ${startTime}\ntask_file: ${taskSnapshotName}\n---\n\n`;
141
141
  fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
142
142
  return resultFileName;
143
143
  }
144
+ /**
145
+ * Append a conversation message to a RESULT file.
146
+ */
147
+ export function appendResultMessage(taskDir, resultFile, msg) {
148
+ const attrs = [`role="${msg.role}"`, `time="${msg.time}"`];
149
+ if (msg.type)
150
+ attrs.push(`type="${msg.type}"`);
151
+ if (msg.attachments?.length)
152
+ attrs.push(`attachments="${msg.attachments.join(",")}"`);
153
+ const delimiter = `<!-- palmier:message ${attrs.join(" ")} -->`;
154
+ const entry = `${delimiter}\n\n${msg.content}\n\n`;
155
+ fs.appendFileSync(path.join(taskDir, resultFile), entry, "utf-8");
156
+ }
157
+ /**
158
+ * Update frontmatter fields in a RESULT file without touching the body.
159
+ */
160
+ export function finalizeResultFrontmatter(taskDir, resultFile, updates) {
161
+ const filePath = path.join(taskDir, resultFile);
162
+ const raw = fs.readFileSync(filePath, "utf-8");
163
+ const fmEnd = raw.indexOf("\n---\n", 4); // skip opening ---
164
+ if (fmEnd === -1)
165
+ return;
166
+ let frontmatter = raw.slice(0, fmEnd);
167
+ const body = raw.slice(fmEnd);
168
+ for (const [key, value] of Object.entries(updates)) {
169
+ const regex = new RegExp(`^${key}:.*$`, "m");
170
+ if (regex.test(frontmatter)) {
171
+ frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
172
+ }
173
+ else {
174
+ frontmatter += `\n${key}: ${value}`;
175
+ }
176
+ }
177
+ fs.writeFileSync(filePath, frontmatter + body, "utf-8");
178
+ }
144
179
  /**
145
180
  * Append a history entry to the project-level history.jsonl file.
146
181
  */
package/dist/types.d.ts CHANGED
@@ -48,6 +48,8 @@ export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
48
48
  export interface TaskStatus {
49
49
  running_state: TaskRunningState;
50
50
  time_stamp: number;
51
+ /** PID of the palmier run process (used on Windows to kill the process tree). */
52
+ pid?: number;
51
53
  /** Set when the task has `requires_confirmation` and is awaiting user approval. */
52
54
  pending_confirmation?: boolean;
53
55
  /** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
@@ -65,6 +67,13 @@ export interface RequiredPermission {
65
67
  name: string;
66
68
  description: string;
67
69
  }
70
+ export interface ConversationMessage {
71
+ role: "assistant" | "user" | "status";
72
+ time: number;
73
+ content: string;
74
+ type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted";
75
+ attachments?: string[];
76
+ }
68
77
  export interface RpcMessage {
69
78
  method: string;
70
79
  params: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -20,13 +20,12 @@
20
20
  },
21
21
  "scripts": {
22
22
  "dev": "tsx src/index.ts",
23
- "build": "tsc && node -e \"require('fs').cpSync('src/commands/plan-generation.md','dist/commands/plan-generation.md')\"",
23
+ "build": "tsc && node -e \"require('fs').cpSync('src/commands/plan-generation.md','dist/commands/plan-generation.md');require('fs').cpSync('src/agents/agent-instructions.md','dist/agents/agent-instructions.md')\"",
24
24
  "test": "tsx --test test/**/*.test.ts",
25
25
  "prepare": "npm run build",
26
26
  "start": "node dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@modelcontextprotocol/sdk": "^1.27.1",
30
29
  "commander": "^13.1.0",
31
30
  "cross-spawn": "^7.0.6",
32
31
  "dotenv": "^16.4.7",
@@ -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,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
+ }