palmier 0.2.5 → 0.2.7

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 (78) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/LICENSE +190 -0
  3. package/README.md +286 -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/info.js +0 -22
  18. package/dist/commands/init.js +59 -95
  19. package/dist/commands/lan.d.ts +8 -0
  20. package/dist/commands/lan.js +51 -0
  21. package/dist/commands/mcpserver.js +12 -27
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +52 -56
  24. package/dist/commands/plan-generation.md +24 -32
  25. package/dist/commands/restart.d.ts +5 -0
  26. package/dist/commands/restart.js +9 -0
  27. package/dist/commands/run.js +311 -124
  28. package/dist/commands/serve.d.ts +1 -1
  29. package/dist/commands/serve.js +77 -17
  30. package/dist/commands/task-cleanup.d.ts +14 -0
  31. package/dist/commands/task-cleanup.js +84 -0
  32. package/dist/config.js +3 -17
  33. package/dist/events.d.ts +9 -0
  34. package/dist/events.js +46 -0
  35. package/dist/index.js +15 -0
  36. package/dist/platform/linux.d.ts +2 -0
  37. package/dist/platform/linux.js +22 -1
  38. package/dist/platform/platform.d.ts +4 -0
  39. package/dist/platform/windows.d.ts +3 -0
  40. package/dist/platform/windows.js +99 -82
  41. package/dist/rpc-handler.d.ts +2 -1
  42. package/dist/rpc-handler.js +43 -52
  43. package/dist/spawn-command.d.ts +29 -6
  44. package/dist/spawn-command.js +38 -15
  45. package/dist/transports/http-transport.d.ts +1 -1
  46. package/dist/transports/http-transport.js +103 -18
  47. package/dist/transports/nats-transport.d.ts +4 -2
  48. package/dist/transports/nats-transport.js +3 -4
  49. package/dist/types.d.ts +5 -5
  50. package/package.json +5 -3
  51. package/src/agents/agent.ts +8 -3
  52. package/src/agents/claude.ts +44 -43
  53. package/src/agents/codex.ts +11 -12
  54. package/src/agents/gemini.ts +12 -10
  55. package/src/agents/openclaw.ts +8 -7
  56. package/src/agents/shared-prompt.ts +10 -8
  57. package/src/commands/agents.ts +11 -0
  58. package/src/commands/info.ts +0 -24
  59. package/src/commands/init.ts +62 -119
  60. package/src/commands/lan.ts +58 -0
  61. package/src/commands/mcpserver.ts +12 -31
  62. package/src/commands/pair.ts +50 -63
  63. package/src/commands/plan-generation.md +24 -32
  64. package/src/commands/restart.ts +9 -0
  65. package/src/commands/run.ts +375 -143
  66. package/src/commands/serve.ts +96 -17
  67. package/src/config.ts +3 -18
  68. package/src/cross-spawn.d.ts +5 -0
  69. package/src/events.ts +51 -0
  70. package/src/index.ts +17 -0
  71. package/src/platform/linux.ts +25 -1
  72. package/src/platform/platform.ts +6 -0
  73. package/src/platform/windows.ts +100 -89
  74. package/src/rpc-handler.ts +46 -55
  75. package/src/spawn-command.ts +120 -83
  76. package/src/transports/http-transport.ts +123 -19
  77. package/src/transports/nats-transport.ts +4 -4
  78. package/src/types.ts +6 -8
@@ -1,22 +1,18 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { execSync, exec, spawn as nodeSpawn } from "child_process";
4
- import { promisify } from "util";
5
- import { getTaskDir } from "../task.js";
6
- import { loadConfig } from "../config.js";
7
- const execAsync = promisify(exec);
8
- const TASK_PREFIX = "PalmierTask-";
3
+ import { execFileSync } from "child_process";
4
+ import { spawn as nodeSpawn } from "child_process";
5
+ import { CONFIG_DIR } from "../config.js";
6
+ const TASK_PREFIX = "\\Palmier\\PalmierTask-";
9
7
  const DAEMON_TASK_NAME = "PalmierDaemon";
10
- const SHELL = "cmd.exe";
8
+ const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
11
9
  /**
12
- * Resolve the full command to invoke palmier, accounting for the fact that
13
- * on Windows, globally-installed npm packages are .cmd shims.
10
+ * Build the /tr value for schtasks: a single string with quoted paths
11
+ * so Task Scheduler can invoke node with the palmier script + subcommand.
14
12
  */
15
- function getPalmierCommand() {
16
- // process.argv[1] is the script path; wrap with node so it works as
17
- // a Task Scheduler command without relying on file associations.
13
+ function schtasksTr(...subcommand) {
18
14
  const script = process.argv[1] || "palmier";
19
- return `"${process.execPath}" "${script}"`;
15
+ return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
20
16
  }
21
17
  /**
22
18
  * Convert one of the 4 supported cron patterns to schtasks flags.
@@ -65,56 +61,81 @@ function schtasksTaskName(taskId) {
65
61
  }
66
62
  export class WindowsPlatform {
67
63
  installDaemon(config) {
68
- const cmd = getPalmierCommand();
69
- // Try ONSTART first (requires elevation), fall back to ONLOGON
70
- const baseArgs = [
71
- "/create", "/tn", DAEMON_TASK_NAME,
72
- "/tr", `${cmd} serve`,
73
- "/rl", "HIGHEST",
74
- "/f", // force overwrite if exists
75
- ];
64
+ const script = process.argv[1] || "palmier";
65
+ const regValue = `"${process.execPath}" "${script}" serve`;
76
66
  try {
77
- execSync(`schtasks ${[...baseArgs, "/sc", "ONSTART"].join(" ")}`, { encoding: "utf-8", shell: SHELL });
78
- console.log(`Task Scheduler: "${DAEMON_TASK_NAME}" installed (runs at startup).`);
67
+ execFileSync("reg", [
68
+ "add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
69
+ "/v", DAEMON_TASK_NAME, "/t", "REG_SZ", "/d", regValue, "/f",
70
+ ], { encoding: "utf-8", stdio: "pipe" });
71
+ console.log(`Registry Run key "${DAEMON_TASK_NAME}" installed (runs at logon).`);
79
72
  }
80
- catch {
81
- // ONSTART requires admin fall back to ONLOGON which does not
73
+ catch (err) {
74
+ console.error(`Warning: failed to install registry run entry: ${err}`);
75
+ console.error("You may need to start palmier serve manually.");
76
+ }
77
+ // Start the daemon now
78
+ this.spawnDaemon(script);
79
+ console.log("\nHost initialization complete!");
80
+ }
81
+ async restartDaemon() {
82
+ // Kill the old daemon if we have its PID
83
+ if (fs.existsSync(DAEMON_PID_FILE)) {
84
+ const oldPid = fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim();
82
85
  try {
83
- execSync(`schtasks ${[...baseArgs, "/sc", "ONLOGON"].join(" ")}`, { encoding: "utf-8", shell: SHELL });
84
- console.log(`Task Scheduler: "${DAEMON_TASK_NAME}" installed (runs at logon).`);
85
- console.log(" Tip: run as Administrator to use ONSTART instead.");
86
+ execFileSync("taskkill", ["/pid", oldPid, "/t", "/f"], { windowsHide: true });
86
87
  }
87
- catch (err) {
88
- console.error(`Warning: failed to create scheduled task: ${err}`);
89
- console.error("You may need to start palmier serve manually.");
88
+ catch {
89
+ // Process may have already exited
90
90
  }
91
91
  }
92
- // Start the daemon now
93
- try {
94
- execSync(`schtasks /run /tn ${DAEMON_TASK_NAME}`, { encoding: "utf-8", shell: SHELL });
95
- console.log("Palmier daemon started.");
96
- }
97
- catch {
98
- console.log("Note: could not start daemon immediately. It will start at next login/boot.");
92
+ const script = process.argv[1] || "palmier";
93
+ this.spawnDaemon(script);
94
+ }
95
+ spawnDaemon(script) {
96
+ const child = nodeSpawn(process.execPath, [script, "serve"], {
97
+ detached: true,
98
+ stdio: "ignore",
99
+ windowsHide: true,
100
+ });
101
+ if (child.pid) {
102
+ fs.writeFileSync(DAEMON_PID_FILE, String(child.pid), "utf-8");
99
103
  }
100
- console.log("\nHost initialization complete!");
104
+ child.unref();
105
+ console.log("Palmier daemon started.");
101
106
  }
102
107
  installTaskTimer(config, task) {
103
108
  const taskId = task.frontmatter.id;
104
109
  const tn = schtasksTaskName(taskId);
105
- const cmd = getPalmierCommand();
110
+ const tr = schtasksTr("run", taskId);
111
+ // Always create the scheduled task with a dummy trigger first.
112
+ // This ensures startTask (/run) works even when no triggers are configured.
113
+ try {
114
+ execFileSync("schtasks", [
115
+ "/create", "/tn", tn,
116
+ "/tr", tr,
117
+ "/sc", "ONCE", "/sd", "01/01/2000", "/st", "00:00",
118
+ "/f",
119
+ ], { encoding: "utf-8", windowsHide: true });
120
+ }
121
+ catch (err) {
122
+ const e = err;
123
+ console.error(`Failed to create scheduled task ${tn}: ${e.stderr || err}`);
124
+ }
125
+ // Overlay with real schedule triggers if enabled
126
+ if (!task.frontmatter.triggers_enabled)
127
+ return;
106
128
  const triggers = task.frontmatter.triggers || [];
107
129
  for (const trigger of triggers) {
108
130
  if (trigger.type === "cron") {
109
131
  const schedArgs = cronToSchtasksArgs(trigger.value);
110
- const args = [
111
- "/create", "/tn", tn,
112
- "/tr", `${cmd} run ${taskId}`,
113
- ...schedArgs,
114
- "/f",
115
- ];
116
132
  try {
117
- execSync(`schtasks ${args.join(" ")}`, { encoding: "utf-8", shell: SHELL });
133
+ execFileSync("schtasks", [
134
+ "/create", "/tn", tn,
135
+ "/tr", tr,
136
+ ...schedArgs,
137
+ "/f",
138
+ ], { encoding: "utf-8", windowsHide: true });
118
139
  }
119
140
  catch (err) {
120
141
  const e = err;
@@ -132,14 +153,13 @@ export class WindowsPlatform {
132
153
  const [year, month, day] = datePart.split("-");
133
154
  const sd = `${month}/${day}/${year}`;
134
155
  const st = timePart.slice(0, 5);
135
- const args = [
136
- "/create", "/tn", tn,
137
- "/tr", `${cmd} run ${taskId}`,
138
- "/sc", "ONCE", "/sd", sd, "/st", st,
139
- "/f",
140
- ];
141
156
  try {
142
- execSync(`schtasks ${args.join(" ")}`, { encoding: "utf-8", shell: SHELL });
157
+ execFileSync("schtasks", [
158
+ "/create", "/tn", tn,
159
+ "/tr", tr,
160
+ "/sc", "ONCE", "/sd", sd, "/st", st,
161
+ "/f",
162
+ ], { encoding: "utf-8", windowsHide: true });
143
163
  }
144
164
  catch (err) {
145
165
  const e = err;
@@ -151,46 +171,43 @@ export class WindowsPlatform {
151
171
  removeTaskTimer(taskId) {
152
172
  const tn = schtasksTaskName(taskId);
153
173
  try {
154
- execSync(`schtasks /delete /tn ${tn} /f`, { encoding: "utf-8", shell: SHELL });
174
+ execFileSync("schtasks", ["/delete", "/tn", tn, "/f"], { encoding: "utf-8", windowsHide: true });
155
175
  }
156
176
  catch {
157
177
  // Task might not exist — that's fine
158
178
  }
159
179
  }
160
180
  async startTask(taskId) {
161
- const config = loadConfig();
162
- const taskDir = getTaskDir(config.projectRoot, taskId);
163
- const script = process.argv[1] || "palmier";
164
- // Spawn a detached child process and record its PID for later abort
165
- const child = nodeSpawn(process.execPath, [script, "run", taskId], {
166
- detached: true,
167
- stdio: "ignore",
168
- cwd: config.projectRoot,
169
- });
170
- if (child.pid) {
171
- fs.mkdirSync(taskDir, { recursive: true });
172
- fs.writeFileSync(path.join(taskDir, "pid"), String(child.pid), "utf-8");
181
+ const tn = schtasksTaskName(taskId);
182
+ try {
183
+ execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true });
184
+ }
185
+ catch (err) {
186
+ const e = err;
187
+ throw new Error(`Failed to start task via schtasks: ${e.stderr || e.message}`);
173
188
  }
174
- child.unref();
175
189
  }
176
190
  async stopTask(taskId) {
177
- const config = loadConfig();
178
- const taskDir = getTaskDir(config.projectRoot, taskId);
179
- const pidPath = path.join(taskDir, "pid");
180
- if (!fs.existsSync(pidPath)) {
181
- throw new Error(`No PID file found for task ${taskId}`);
191
+ const tn = schtasksTaskName(taskId);
192
+ try {
193
+ execFileSync("schtasks", ["/end", "/tn", tn], { encoding: "utf-8", windowsHide: true });
182
194
  }
183
- const pid = fs.readFileSync(pidPath, "utf-8").trim();
195
+ catch (err) {
196
+ const e = err;
197
+ throw new Error(`Failed to stop task via schtasks: ${e.stderr || e.message}`);
198
+ }
199
+ }
200
+ isTaskRunning(taskId) {
201
+ const tn = schtasksTaskName(taskId);
184
202
  try {
185
- // /t kills the entire process tree, /f forces termination
186
- await execAsync(`taskkill /pid ${pid} /t /f`);
203
+ const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
204
+ encoding: "utf-8",
205
+ windowsHide: true,
206
+ });
207
+ return out.includes('"Running"');
187
208
  }
188
- finally {
189
- // Clean up PID file regardless of whether taskkill succeeded
190
- try {
191
- fs.unlinkSync(pidPath);
192
- }
193
- catch { /* ignore */ }
209
+ catch {
210
+ return false;
194
211
  }
195
212
  }
196
213
  getGuiEnv() {
@@ -1,6 +1,7 @@
1
+ import { type NatsConnection } from "nats";
1
2
  import type { HostConfig, RpcMessage } from "./types.js";
2
3
  /**
3
4
  * Create a transport-agnostic RPC handler bound to the given config.
4
5
  */
5
- export declare function createRpcHandler(config: HostConfig): (request: RpcMessage) => Promise<unknown>;
6
+ export declare function createRpcHandler(config: HostConfig, nc?: NatsConnection): (request: RpcMessage) => Promise<unknown>;
6
7
  //# sourceMappingURL=rpc-handler.d.ts.map
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "crypto";
2
- import * as os from "os";
3
2
  import * as fs from "fs";
4
3
  import * as path from "path";
5
4
  import { fileURLToPath } from "url";
@@ -9,19 +8,9 @@ import { getPlatform } from "./platform/index.js";
9
8
  import { spawnCommand } from "./spawn-command.js";
10
9
  import { getAgent } from "./agents/agent.js";
11
10
  import { validateSession } from "./session-store.js";
11
+ import { publishHostEvent } from "./events.js";
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  const PLAN_GENERATION_PROMPT = fs.readFileSync(path.join(__dirname, "commands", "plan-generation.md"), "utf-8");
14
- function detectLanIp() {
15
- const interfaces = os.networkInterfaces();
16
- for (const name of Object.keys(interfaces)) {
17
- for (const iface of interfaces[name] ?? []) {
18
- if (iface.family === "IPv4" && !iface.internal) {
19
- return iface.address;
20
- }
21
- }
22
- }
23
- return "127.0.0.1";
24
- }
25
14
  /**
26
15
  * Parse RESULT frontmatter into a metadata object.
27
16
  */
@@ -71,11 +60,12 @@ function parseResultFrontmatter(raw) {
71
60
  async function generatePlan(projectRoot, userPrompt, agentName) {
72
61
  const fullPrompt = PLAN_GENERATION_PROMPT + userPrompt;
73
62
  const planAgent = getAgent(agentName);
74
- const { command, args } = planAgent.getPlanGenerationCommandLine(fullPrompt);
63
+ const { command, args, stdin } = planAgent.getPlanGenerationCommandLine(fullPrompt);
75
64
  console.log(`[generatePlan] Running: ${command} ${args.join(" ")}`);
76
65
  const { output } = await spawnCommand(command, args, {
77
66
  cwd: projectRoot,
78
67
  timeout: 120_000,
68
+ stdin,
79
69
  });
80
70
  let name = "";
81
71
  const trimmed = output.trim();
@@ -96,7 +86,7 @@ async function generatePlan(projectRoot, userPrompt, agentName) {
96
86
  /**
97
87
  * Create a transport-agnostic RPC handler bound to the given config.
98
88
  */
99
- export function createRpcHandler(config) {
89
+ export function createRpcHandler(config, nc) {
100
90
  function flattenTask(task) {
101
91
  const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
102
92
  return {
@@ -121,10 +111,10 @@ export function createRpcHandler(config) {
121
111
  }
122
112
  case "task.create": {
123
113
  const params = request.params;
124
- // Short descriptions skip plan generation and use the description as-is
114
+ // Only generate a plan for longer prompts that benefit from it
125
115
  let name = "";
126
116
  let body = "";
127
- if (params.user_prompt.length < 50) {
117
+ if (params.user_prompt.length <= 50) {
128
118
  name = params.user_prompt;
129
119
  }
130
120
  else {
@@ -149,15 +139,13 @@ export function createRpcHandler(config) {
149
139
  triggers: params.triggers ?? [],
150
140
  triggers_enabled: params.triggers_enabled ?? true,
151
141
  requires_confirmation: params.requires_confirmation ?? true,
142
+ ...(params.command ? { command: params.command } : {}),
152
143
  },
153
144
  body,
154
145
  };
155
146
  writeTaskFile(taskDir, task);
156
147
  appendTaskList(config.projectRoot, id);
157
- const platform = getPlatform();
158
- if (task.frontmatter.triggers_enabled) {
159
- platform.installTaskTimer(config, task);
160
- }
148
+ getPlatform().installTaskTimer(config, task);
161
149
  return flattenTask(task);
162
150
  }
163
151
  case "task.update": {
@@ -179,31 +167,34 @@ export function createRpcHandler(config) {
179
167
  existing.frontmatter.triggers_enabled = params.triggers_enabled;
180
168
  if (params.requires_confirmation !== undefined)
181
169
  existing.frontmatter.requires_confirmation = params.requires_confirmation;
182
- // Regenerate plan if needed
183
- if (needsRegeneration) {
184
- if (existing.frontmatter.user_prompt.length < 50) {
185
- existing.frontmatter.name = existing.frontmatter.user_prompt;
186
- existing.body = "";
170
+ if (params.command !== undefined) {
171
+ if (params.command) {
172
+ existing.frontmatter.command = params.command;
187
173
  }
188
174
  else {
189
- try {
190
- const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
191
- existing.frontmatter.name = plan.name;
192
- existing.body = plan.body;
193
- }
194
- catch (err) {
195
- const error = err;
196
- return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
197
- }
175
+ delete existing.frontmatter.command;
198
176
  }
199
177
  }
200
- writeTaskFile(taskDir, existing);
201
- // Reinstall or remove timers based on triggers_enabled
202
- const platform = getPlatform();
203
- platform.removeTaskTimer(params.id);
204
- if (existing.frontmatter.triggers_enabled) {
205
- platform.installTaskTimer(config, existing);
178
+ // Regenerate plan if needed (only for longer prompts)
179
+ if (existing.frontmatter.user_prompt.length <= 50) {
180
+ existing.frontmatter.name = existing.frontmatter.user_prompt;
181
+ existing.body = "";
206
182
  }
183
+ else if (needsRegeneration) {
184
+ try {
185
+ const plan = await generatePlan(config.projectRoot, existing.frontmatter.user_prompt, existing.frontmatter.agent);
186
+ existing.frontmatter.name = plan.name;
187
+ existing.body = plan.body;
188
+ }
189
+ catch (err) {
190
+ const error = err;
191
+ return { error: "plan generation failed", stdout: error.stdout, stderr: error.stderr };
192
+ }
193
+ }
194
+ writeTaskFile(taskDir, existing);
195
+ // Update timers — installTaskTimer overwrites in-place (schtasks /f,
196
+ // systemd unit rewrite) without killing a running task process.
197
+ getPlatform().installTaskTimer(config, existing);
207
198
  return flattenTask(existing);
208
199
  }
209
200
  case "task.delete": {
@@ -226,15 +217,25 @@ export function createRpcHandler(config) {
226
217
  }
227
218
  case "task.abort": {
228
219
  const params = request.params;
220
+ // Write abort status BEFORE killing so the dying process's signal
221
+ // handler can detect this was RPC-initiated and skip publishing.
222
+ const abortTaskDir = getTaskDir(config.projectRoot, params.id);
223
+ writeTaskStatus(abortTaskDir, {
224
+ running_state: "aborted",
225
+ time_stamp: Date.now(),
226
+ });
229
227
  try {
230
228
  await getPlatform().stopTask(params.id);
231
- return { ok: true, task_id: params.id };
232
229
  }
233
230
  catch (err) {
234
231
  const e = err;
235
232
  console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
236
233
  return { error: `Failed to abort task: ${e.stderr || e.message}` };
237
234
  }
235
+ // Notify connected clients (NATS + HTTP SSE if LAN server is running)
236
+ const abortPayload = { event_type: "running-state", running_state: "aborted" };
237
+ await publishHostEvent(nc, config.hostId, params.id, abortPayload);
238
+ return { ok: true, task_id: params.id };
238
239
  }
239
240
  case "task.status": {
240
241
  const params = request.params;
@@ -291,7 +292,7 @@ export function createRpcHandler(config) {
291
292
  const params = request.params;
292
293
  const taskDir = getTaskDir(config.projectRoot, params.id);
293
294
  const currentStatus = readTaskStatus(taskDir);
294
- if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length) {
295
+ if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length && !currentStatus?.pending_input?.length) {
295
296
  return { ok: false, error: "not pending" };
296
297
  }
297
298
  writeTaskStatus(taskDir, { ...currentStatus, user_input: params.value });
@@ -331,16 +332,6 @@ export function createRpcHandler(config) {
331
332
  }
332
333
  return { ok: true, task_id: params.task_id, result_file: params.result_file };
333
334
  }
334
- case "host.directInfo": {
335
- if (config.mode === "lan" || config.mode === "auto") {
336
- const ip = detectLanIp();
337
- return {
338
- directUrl: `http://${ip}:${config.directPort ?? 7400}`,
339
- directToken: config.directToken,
340
- };
341
- }
342
- return { directUrl: null, directToken: null };
343
- }
344
335
  default:
345
336
  return { error: `Unknown method: ${request.method}` };
346
337
  }
@@ -1,25 +1,48 @@
1
+ import type { ChildProcess } from "child_process";
2
+ export interface SpawnStreamingOptions {
3
+ cwd: string;
4
+ env?: Record<string, string>;
5
+ }
6
+ /**
7
+ * Spawn a command with shell interpretation, returning the ChildProcess
8
+ * with stdout piped for line-by-line reading.
9
+ *
10
+ * Unlike spawnCommand(), this does NOT collect output into a buffer —
11
+ * the caller reads from child.stdout directly (e.g. via readline).
12
+ *
13
+ * shell: true is required so users can write piped commands like
14
+ * "tail -f log | grep ERROR".
15
+ *
16
+ * stdin is "pipe" (kept open, never written to) rather than "ignore"
17
+ * (/dev/null). Some long-running commands exit when stdin is closed/EOF.
18
+ * This differs from spawnCommand() which uses "ignore" because agent
19
+ * CLIs like `claude -p` hang on an open stdin pipe.
20
+ */
21
+ export declare function spawnStreamingCommand(command: string, opts: SpawnStreamingOptions): ChildProcess;
1
22
  export interface SpawnCommandOptions {
2
23
  cwd: string;
3
24
  env?: Record<string, string>;
4
25
  timeout?: number;
5
26
  /** Echo stdout to process.stdout (useful for journald logging). */
6
27
  echoStdout?: boolean;
7
- /** Forward SIGINT/SIGTERM to the child and resolve on stop. */
8
- forwardSignals?: boolean;
9
28
  /** Resolve with output even on non-zero exit (instead of rejecting). */
10
29
  resolveOnFailure?: boolean;
30
+ /** If provided, write this string to the process's stdin and then close the pipe. */
31
+ stdin?: string;
11
32
  }
12
33
  /**
13
34
  * Spawn a command with additional arguments.
14
35
  *
15
- * On Windows, `shell: true` is used so that npm-installed .cmd shims
16
- * (e.g. claude.cmd, gemini.cmd) are resolved correctly.
36
+ * Uses cross-spawn to correctly resolve .cmd shims and escape arguments
37
+ * on Windows without shell: true (which mishandles special characters).
17
38
  *
18
39
  * On other platforms the command is executed directly (no shell), so no
19
40
  * escaping is needed.
20
41
  *
21
- * stdin is set to "ignore" (equivalent to < /dev/null) because tools like
22
- * `claude -p` hang indefinitely on an open stdin pipe.
42
+ * stdin is set to "ignore" by default (equivalent to < /dev/null) because
43
+ * tools like `claude -p` hang indefinitely on an open stdin pipe.
44
+ * When opts.stdin is provided, stdin is set to "pipe" and the string is
45
+ * written to the process before closing the pipe.
23
46
  */
24
47
  export interface SpawnCommandResult {
25
48
  output: string;
@@ -1,13 +1,45 @@
1
- import { spawn } from "child_process";
1
+ import crossSpawn from "cross-spawn";
2
+ /**
3
+ * Spawn a command with shell interpretation, returning the ChildProcess
4
+ * with stdout piped for line-by-line reading.
5
+ *
6
+ * Unlike spawnCommand(), this does NOT collect output into a buffer —
7
+ * the caller reads from child.stdout directly (e.g. via readline).
8
+ *
9
+ * shell: true is required so users can write piped commands like
10
+ * "tail -f log | grep ERROR".
11
+ *
12
+ * stdin is "pipe" (kept open, never written to) rather than "ignore"
13
+ * (/dev/null). Some long-running commands exit when stdin is closed/EOF.
14
+ * This differs from spawnCommand() which uses "ignore" because agent
15
+ * CLIs like `claude -p` hang on an open stdin pipe.
16
+ */
17
+ export function spawnStreamingCommand(command, opts) {
18
+ return crossSpawn(command, [], {
19
+ cwd: opts.cwd,
20
+ stdio: ["pipe", "pipe", "pipe"],
21
+ shell: true,
22
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
23
+ windowsHide: true,
24
+ });
25
+ }
2
26
  export function spawnCommand(command, args, opts) {
3
27
  return new Promise((resolve, reject) => {
4
- const child = spawn(command, args, {
28
+ // Collapse newlines to spaces — cmd.exe can't handle literal newlines
29
+ // in arguments, and CLI prompts don't need them.
30
+ const finalArgs = process.platform === "win32"
31
+ ? args.map((a) => a.replace(/[\r\n]+/g, " "))
32
+ : args;
33
+ // console.log(`[spawn] ${command} ${finalArgs.join(" ")}`);
34
+ const child = crossSpawn(command, finalArgs, {
5
35
  cwd: opts.cwd,
6
- stdio: ["ignore", "pipe", "pipe"],
36
+ stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
7
37
  env: opts.env ? { ...process.env, ...opts.env } : undefined,
8
- // On Windows, spawn through shell so .cmd shims resolve correctly
9
- shell: process.platform === "win32",
38
+ windowsHide: true,
10
39
  });
40
+ if (opts.stdin != null) {
41
+ child.stdin.end(opts.stdin);
42
+ }
11
43
  const chunks = [];
12
44
  child.stdout.on("data", (d) => {
13
45
  chunks.push(d);
@@ -15,15 +47,6 @@ export function spawnCommand(command, args, opts) {
15
47
  process.stdout.write(d);
16
48
  });
17
49
  child.stderr.on("data", (d) => process.stderr.write(d));
18
- let stopping = false;
19
- if (opts.forwardSignals) {
20
- const killChild = () => {
21
- stopping = true;
22
- child.kill("SIGTERM");
23
- };
24
- process.on("SIGINT", killChild);
25
- process.on("SIGTERM", killChild);
26
- }
27
50
  let timer;
28
51
  if (opts.timeout) {
29
52
  timer = setTimeout(() => {
@@ -35,7 +58,7 @@ export function spawnCommand(command, args, opts) {
35
58
  if (timer)
36
59
  clearTimeout(timer);
37
60
  const output = Buffer.concat(chunks).toString("utf-8");
38
- if (code === 0 || stopping || opts.resolveOnFailure)
61
+ if (code === 0 || opts.resolveOnFailure)
39
62
  resolve({ output, exitCode: code });
40
63
  else
41
64
  reject(new Error(`process exited with code ${code}`));
@@ -3,5 +3,5 @@ export declare function detectLanIp(): string;
3
3
  /**
4
4
  * Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
5
5
  */
6
- export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>): Promise<void>;
6
+ export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, port: number, pairingCode?: string, onReady?: () => void): Promise<void>;
7
7
  //# sourceMappingURL=http-transport.d.ts.map