palmier 0.2.5 → 0.2.6

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 (64) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/LICENSE +190 -0
  3. package/README.md +288 -219
  4. package/dist/agents/agent.d.ts +6 -3
  5. package/dist/agents/agent.js +2 -0
  6. package/dist/agents/claude.d.ts +1 -1
  7. package/dist/agents/claude.js +12 -9
  8. package/dist/agents/codex.d.ts +1 -1
  9. package/dist/agents/codex.js +12 -10
  10. package/dist/agents/gemini.d.ts +1 -1
  11. package/dist/agents/gemini.js +13 -9
  12. package/dist/agents/openclaw.d.ts +2 -2
  13. package/dist/agents/openclaw.js +8 -7
  14. package/dist/agents/shared-prompt.d.ts +5 -4
  15. package/dist/agents/shared-prompt.js +10 -8
  16. package/dist/commands/agents.js +11 -0
  17. package/dist/commands/init.js +109 -49
  18. package/dist/commands/mcpserver.js +11 -21
  19. package/dist/commands/plan-generation.md +24 -32
  20. package/dist/commands/restart.d.ts +5 -0
  21. package/dist/commands/restart.js +9 -0
  22. package/dist/commands/run.js +293 -101
  23. package/dist/commands/serve.js +83 -4
  24. package/dist/commands/task-cleanup.d.ts +14 -0
  25. package/dist/commands/task-cleanup.js +84 -0
  26. package/dist/events.d.ts +10 -0
  27. package/dist/events.js +29 -0
  28. package/dist/index.js +7 -0
  29. package/dist/platform/linux.d.ts +2 -0
  30. package/dist/platform/linux.js +22 -1
  31. package/dist/platform/platform.d.ts +4 -0
  32. package/dist/platform/windows.d.ts +3 -0
  33. package/dist/platform/windows.js +99 -82
  34. package/dist/rpc-handler.d.ts +2 -1
  35. package/dist/rpc-handler.js +43 -28
  36. package/dist/spawn-command.d.ts +29 -6
  37. package/dist/spawn-command.js +38 -15
  38. package/dist/transports/nats-transport.d.ts +4 -2
  39. package/dist/transports/nats-transport.js +3 -4
  40. package/dist/types.d.ts +4 -2
  41. package/package.json +5 -3
  42. package/src/agents/agent.ts +8 -3
  43. package/src/agents/claude.ts +44 -43
  44. package/src/agents/codex.ts +11 -12
  45. package/src/agents/gemini.ts +12 -10
  46. package/src/agents/openclaw.ts +8 -7
  47. package/src/agents/shared-prompt.ts +10 -8
  48. package/src/commands/agents.ts +11 -0
  49. package/src/commands/init.ts +120 -56
  50. package/src/commands/mcpserver.ts +11 -22
  51. package/src/commands/plan-generation.md +24 -32
  52. package/src/commands/restart.ts +9 -0
  53. package/src/commands/run.ts +365 -119
  54. package/src/commands/serve.ts +101 -5
  55. package/src/cross-spawn.d.ts +5 -0
  56. package/src/events.ts +38 -0
  57. package/src/index.ts +8 -0
  58. package/src/platform/linux.ts +25 -1
  59. package/src/platform/platform.ts +6 -0
  60. package/src/platform/windows.ts +100 -89
  61. package/src/rpc-handler.ts +46 -29
  62. package/src/spawn-command.ts +120 -83
  63. package/src/transports/nats-transport.ts +4 -4
  64. package/src/types.ts +4 -2
@@ -4,11 +4,13 @@ import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { parse as parseYaml } from "yaml";
7
+ import { type NatsConnection } from "nats";
7
8
  import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, getTaskCreatedAt, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList } from "./task.js";
8
9
  import { getPlatform } from "./platform/index.js";
9
10
  import { spawnCommand } from "./spawn-command.js";
10
11
  import { getAgent } from "./agents/agent.js";
11
12
  import { validateSession } from "./session-store.js";
13
+ import { publishHostEvent } from "./events.js";
12
14
  import type { HostConfig, ParsedTask, RpcMessage } from "./types.js";
13
15
 
14
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -82,12 +84,13 @@ async function generatePlan(
82
84
  ): Promise<{ name: string; body: string }> {
83
85
  const fullPrompt = PLAN_GENERATION_PROMPT + userPrompt;
84
86
  const planAgent = getAgent(agentName);
85
- const { command, args } = planAgent.getPlanGenerationCommandLine(fullPrompt);
87
+ const { command, args, stdin } = planAgent.getPlanGenerationCommandLine(fullPrompt);
86
88
  console.log(`[generatePlan] Running: ${command} ${args.join(" ")}`);
87
89
 
88
90
  const { output } = await spawnCommand(command, args, {
89
91
  cwd: projectRoot,
90
92
  timeout: 120_000,
93
+ stdin,
91
94
  });
92
95
 
93
96
  let name = "";
@@ -109,7 +112,7 @@ async function generatePlan(
109
112
  /**
110
113
  * Create a transport-agnostic RPC handler bound to the given config.
111
114
  */
112
- export function createRpcHandler(config: HostConfig) {
115
+ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
113
116
  function flattenTask(task: ParsedTask) {
114
117
  const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
115
118
  return {
@@ -142,12 +145,13 @@ export function createRpcHandler(config: HostConfig) {
142
145
  triggers?: Array<{ type: "cron" | "once"; value: string }>;
143
146
  triggers_enabled?: boolean;
144
147
  requires_confirmation?: boolean;
148
+ command?: string;
145
149
  };
146
150
 
147
- // Short descriptions skip plan generation and use the description as-is
151
+ // Only generate a plan for longer prompts that benefit from it
148
152
  let name = "";
149
153
  let body = "";
150
- if (params.user_prompt.length < 50) {
154
+ if (params.user_prompt.length <= 50) {
151
155
  name = params.user_prompt;
152
156
  } else {
153
157
  try {
@@ -171,16 +175,14 @@ export function createRpcHandler(config: HostConfig) {
171
175
  triggers: params.triggers ?? [],
172
176
  triggers_enabled: params.triggers_enabled ?? true,
173
177
  requires_confirmation: params.requires_confirmation ?? true,
178
+ ...(params.command ? { command: params.command } : {}),
174
179
  },
175
180
  body,
176
181
  };
177
182
 
178
183
  writeTaskFile(taskDir, task);
179
184
  appendTaskList(config.projectRoot, id);
180
- const platform = getPlatform();
181
- if (task.frontmatter.triggers_enabled) {
182
- platform.installTaskTimer(config, task);
183
- }
185
+ getPlatform().installTaskTimer(config, task);
184
186
 
185
187
  return flattenTask(task);
186
188
  }
@@ -193,6 +195,7 @@ export function createRpcHandler(config: HostConfig) {
193
195
  triggers?: Array<{ type: "cron" | "once"; value: string }>;
194
196
  triggers_enabled?: boolean;
195
197
  requires_confirmation?: boolean;
198
+ command?: string;
196
199
  };
197
200
 
198
201
  const taskDir = getTaskDir(config.projectRoot, params.id);
@@ -210,32 +213,35 @@ export function createRpcHandler(config: HostConfig) {
210
213
  if (params.triggers_enabled !== undefined) existing.frontmatter.triggers_enabled = params.triggers_enabled;
211
214
  if (params.requires_confirmation !== undefined)
212
215
  existing.frontmatter.requires_confirmation = params.requires_confirmation;
213
-
214
- // Regenerate plan if needed
215
- if (needsRegeneration) {
216
- if (existing.frontmatter.user_prompt.length < 50) {
217
- existing.frontmatter.name = existing.frontmatter.user_prompt;
218
- existing.body = "";
216
+ if (params.command !== undefined) {
217
+ if (params.command) {
218
+ existing.frontmatter.command = params.command;
219
219
  } else {
220
- try {
221
- const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
222
- existing.frontmatter.name = plan.name;
223
- existing.body = plan.body;
224
- } catch (err: unknown) {
225
- const error = err as { stdout?: string; stderr?: string };
226
- return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
227
- }
220
+ delete existing.frontmatter.command;
221
+ }
222
+ }
223
+
224
+ // Regenerate plan if needed (only for longer prompts)
225
+ if (existing.frontmatter.user_prompt.length <= 50) {
226
+ existing.frontmatter.name = existing.frontmatter.user_prompt;
227
+ existing.body = "";
228
+ } else if (needsRegeneration) {
229
+ try {
230
+ const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
231
+ existing.frontmatter.name = plan.name;
232
+ existing.body = plan.body;
233
+ } catch (err: unknown) {
234
+ const error = err as { stdout?: string; stderr?: string };
235
+ return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
228
236
  }
229
237
  }
230
238
 
231
239
  writeTaskFile(taskDir, existing);
232
240
 
233
- // Reinstall or remove timers based on triggers_enabled
241
+ // Reinstall service and timers
234
242
  const platform = getPlatform();
235
243
  platform.removeTaskTimer(params.id);
236
- if (existing.frontmatter.triggers_enabled) {
237
- platform.installTaskTimer(config, existing);
238
- }
244
+ platform.installTaskTimer(config, existing);
239
245
 
240
246
  return flattenTask(existing);
241
247
  }
@@ -263,14 +269,25 @@ export function createRpcHandler(config: HostConfig) {
263
269
 
264
270
  case "task.abort": {
265
271
  const params = request.params as { id: string };
272
+ // Write abort status BEFORE killing so the dying process's signal
273
+ // handler can detect this was RPC-initiated and skip publishing.
274
+ const abortTaskDir = getTaskDir(config.projectRoot, params.id);
275
+ writeTaskStatus(abortTaskDir, {
276
+ running_state: "aborted",
277
+ time_stamp: Date.now(),
278
+ });
266
279
  try {
267
280
  await getPlatform().stopTask(params.id);
268
- return { ok: true, task_id: params.id };
269
281
  } catch (err: unknown) {
270
282
  const e = err as { stderr?: string; message?: string };
271
283
  console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
272
284
  return { error: `Failed to abort task: ${e.stderr || e.message}` };
273
285
  }
286
+ // Notify connected clients (NATS + HTTP SSE)
287
+ const abortPayload: Record<string, unknown> = { event_type: "running-state", running_state: "aborted" };
288
+ const useHttp = (config.mode ?? "nats") === "lan" || (config.mode ?? "nats") === "auto";
289
+ await publishHostEvent(nc, config, params.id, abortPayload, useHttp);
290
+ return { ok: true, task_id: params.id };
274
291
  }
275
292
 
276
293
  case "task.status": {
@@ -327,11 +344,11 @@ export function createRpcHandler(config: HostConfig) {
327
344
  }
328
345
 
329
346
  case "task.user_input": {
330
- const params = request.params as { id: string; value: string };
347
+ const params = request.params as { id: string; value: string[] };
331
348
  const taskDir = getTaskDir(config.projectRoot, params.id);
332
349
 
333
350
  const currentStatus = readTaskStatus(taskDir);
334
- if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length) {
351
+ if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length && !currentStatus?.pending_input?.length) {
335
352
  return { ok: false, error: "not pending" };
336
353
  }
337
354
 
@@ -1,83 +1,120 @@
1
- import { spawn } from "child_process";
2
-
3
- export interface SpawnCommandOptions {
4
- cwd: string;
5
- env?: Record<string, string>;
6
- timeout?: number;
7
- /** Echo stdout to process.stdout (useful for journald logging). */
8
- echoStdout?: boolean;
9
- /** Forward SIGINT/SIGTERM to the child and resolve on stop. */
10
- forwardSignals?: boolean;
11
- /** Resolve with output even on non-zero exit (instead of rejecting). */
12
- resolveOnFailure?: boolean;
13
- }
14
-
15
- /**
16
- * Spawn a command with additional arguments.
17
- *
18
- * On Windows, `shell: true` is used so that npm-installed .cmd shims
19
- * (e.g. claude.cmd, gemini.cmd) are resolved correctly.
20
- *
21
- * On other platforms the command is executed directly (no shell), so no
22
- * escaping is needed.
23
- *
24
- * stdin is set to "ignore" (equivalent to < /dev/null) because tools like
25
- * `claude -p` hang indefinitely on an open stdin pipe.
26
- */
27
- export interface SpawnCommandResult {
28
- output: string;
29
- exitCode: number | null;
30
- }
31
-
32
- export function spawnCommand(
33
- command: string,
34
- args: string[],
35
- opts: SpawnCommandOptions,
36
- ): Promise<SpawnCommandResult> {
37
- return new Promise<SpawnCommandResult>((resolve, reject) => {
38
- const child = spawn(command, args, {
39
- cwd: opts.cwd,
40
- stdio: ["ignore", "pipe", "pipe"],
41
- env: opts.env ? { ...process.env, ...opts.env } : undefined,
42
- // On Windows, spawn through shell so .cmd shims resolve correctly
43
- shell: process.platform === "win32",
44
- });
45
-
46
- const chunks: Buffer[] = [];
47
- child.stdout.on("data", (d: Buffer) => {
48
- chunks.push(d);
49
- if (opts.echoStdout) process.stdout.write(d);
50
- });
51
- child.stderr.on("data", (d: Buffer) => process.stderr.write(d));
52
-
53
- let stopping = false;
54
- if (opts.forwardSignals) {
55
- const killChild = () => {
56
- stopping = true;
57
- child.kill("SIGTERM");
58
- };
59
- process.on("SIGINT", killChild);
60
- process.on("SIGTERM", killChild);
61
- }
62
-
63
- let timer: ReturnType<typeof setTimeout> | undefined;
64
- if (opts.timeout) {
65
- timer = setTimeout(() => {
66
- child.kill();
67
- reject(new Error("command timed out"));
68
- }, opts.timeout);
69
- }
70
-
71
- child.on("close", (code) => {
72
- if (timer) clearTimeout(timer);
73
- const output = Buffer.concat(chunks).toString("utf-8");
74
- if (code === 0 || stopping || opts.resolveOnFailure) resolve({ output, exitCode: code });
75
- else reject(new Error(`process exited with code ${code}`));
76
- });
77
-
78
- child.on("error", (err) => {
79
- if (timer) clearTimeout(timer);
80
- reject(err);
81
- });
82
- });
83
- }
1
+ import crossSpawn from "cross-spawn";
2
+ import type { ChildProcess } from "child_process";
3
+
4
+ export interface SpawnStreamingOptions {
5
+ cwd: string;
6
+ env?: Record<string, string>;
7
+ }
8
+
9
+ /**
10
+ * Spawn a command with shell interpretation, returning the ChildProcess
11
+ * with stdout piped for line-by-line reading.
12
+ *
13
+ * Unlike spawnCommand(), this does NOT collect output into a buffer —
14
+ * the caller reads from child.stdout directly (e.g. via readline).
15
+ *
16
+ * shell: true is required so users can write piped commands like
17
+ * "tail -f log | grep ERROR".
18
+ *
19
+ * stdin is "pipe" (kept open, never written to) rather than "ignore"
20
+ * (/dev/null). Some long-running commands exit when stdin is closed/EOF.
21
+ * This differs from spawnCommand() which uses "ignore" because agent
22
+ * CLIs like `claude -p` hang on an open stdin pipe.
23
+ */
24
+ export function spawnStreamingCommand(
25
+ command: string,
26
+ opts: SpawnStreamingOptions,
27
+ ): ChildProcess {
28
+ return crossSpawn(command, [], {
29
+ cwd: opts.cwd,
30
+ stdio: ["pipe", "pipe", "pipe"],
31
+ shell: true,
32
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
33
+ windowsHide: true,
34
+ });
35
+ }
36
+
37
+ export interface SpawnCommandOptions {
38
+ cwd: string;
39
+ env?: Record<string, string>;
40
+ timeout?: number;
41
+ /** Echo stdout to process.stdout (useful for journald logging). */
42
+ echoStdout?: boolean;
43
+ /** Resolve with output even on non-zero exit (instead of rejecting). */
44
+ resolveOnFailure?: boolean;
45
+ /** If provided, write this string to the process's stdin and then close the pipe. */
46
+ stdin?: string;
47
+ }
48
+
49
+ /**
50
+ * Spawn a command with additional arguments.
51
+ *
52
+ * Uses cross-spawn to correctly resolve .cmd shims and escape arguments
53
+ * on Windows without shell: true (which mishandles special characters).
54
+ *
55
+ * On other platforms the command is executed directly (no shell), so no
56
+ * escaping is needed.
57
+ *
58
+ * stdin is set to "ignore" by default (equivalent to < /dev/null) because
59
+ * tools like `claude -p` hang indefinitely on an open stdin pipe.
60
+ * When opts.stdin is provided, stdin is set to "pipe" and the string is
61
+ * written to the process before closing the pipe.
62
+ */
63
+ export interface SpawnCommandResult {
64
+ output: string;
65
+ exitCode: number | null;
66
+ }
67
+
68
+ export function spawnCommand(
69
+ command: string,
70
+ args: string[],
71
+ opts: SpawnCommandOptions,
72
+ ): Promise<SpawnCommandResult> {
73
+ return new Promise<SpawnCommandResult>((resolve, reject) => {
74
+ // Collapse newlines to spaces cmd.exe can't handle literal newlines
75
+ // in arguments, and CLI prompts don't need them.
76
+ const finalArgs = process.platform === "win32"
77
+ ? args.map((a) => a.replace(/[\r\n]+/g, " "))
78
+ : args;
79
+
80
+ // console.log(`[spawn] ${command} ${finalArgs.join(" ")}`);
81
+
82
+ const child = crossSpawn(command, finalArgs, {
83
+ cwd: opts.cwd,
84
+ stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
85
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
86
+ windowsHide: true,
87
+ });
88
+
89
+ if (opts.stdin != null) {
90
+ child.stdin!.end(opts.stdin);
91
+ }
92
+
93
+ const chunks: Buffer[] = [];
94
+ child.stdout!.on("data", (d: Buffer) => {
95
+ chunks.push(d);
96
+ if (opts.echoStdout) process.stdout.write(d);
97
+ });
98
+ child.stderr!.on("data", (d: Buffer) => process.stderr.write(d));
99
+
100
+ let timer: ReturnType<typeof setTimeout> | undefined;
101
+ if (opts.timeout) {
102
+ timer = setTimeout(() => {
103
+ child.kill();
104
+ reject(new Error("command timed out"));
105
+ }, opts.timeout);
106
+ }
107
+
108
+ child.on("close", (code: number | null) => {
109
+ if (timer) clearTimeout(timer);
110
+ const output = Buffer.concat(chunks).toString("utf-8");
111
+ if (code === 0 || opts.resolveOnFailure) resolve({ output, exitCode: code });
112
+ else reject(new Error(`process exited with code ${code}`));
113
+ });
114
+
115
+ child.on("error", (err: Error) => {
116
+ if (timer) clearTimeout(timer);
117
+ reject(err);
118
+ });
119
+ });
120
+ }
@@ -1,15 +1,15 @@
1
- import { StringCodec, type Msg, type Subscription } from "nats";
2
- import { connectNats } from "../nats-client.js";
1
+ import { StringCodec, type NatsConnection, type Msg, type Subscription } from "nats";
3
2
  import type { HostConfig, RpcMessage } from "../types.js";
4
3
 
5
4
  /**
6
- * Start the NATS transport: connect, subscribe to RPC subjects, dispatch to handler.
5
+ * Start the NATS transport using an existing connection.
6
+ * Subscribe to RPC subjects and dispatch to handler.
7
7
  */
8
8
  export async function startNatsTransport(
9
9
  config: HostConfig,
10
10
  handleRpc: (req: RpcMessage) => Promise<unknown>,
11
+ nc: NatsConnection,
11
12
  ): Promise<void> {
12
- const nc = await connectNats(config);
13
13
  const sc = StringCodec();
14
14
 
15
15
  const subject = `host.${config.hostId}.rpc.>`;
package/src/types.ts CHANGED
@@ -25,6 +25,7 @@ export interface TaskFrontmatter {
25
25
  triggers_enabled: boolean;
26
26
  requires_confirmation: boolean;
27
27
  permissions?: RequiredPermission[];
28
+ command?: string;
28
29
  }
29
30
 
30
31
  export interface Trigger {
@@ -37,14 +38,15 @@ export interface ParsedTask {
37
38
  body: string;
38
39
  }
39
40
 
40
- export type TaskRunningState = "start" | "finish" | "abort" | "fail";
41
+ export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
41
42
 
42
43
  export interface TaskStatus {
43
44
  running_state: TaskRunningState;
44
45
  time_stamp: number;
45
46
  pending_confirmation?: boolean;
46
47
  pending_permission?: RequiredPermission[];
47
- user_input?: string;
48
+ pending_input?: string[];
49
+ user_input?: string[];
48
50
  }
49
51
 
50
52
  export interface HistoryEntry {