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,15 +1,17 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { spawnCommand } from "../spawn-command.js";
3
+ import * as readline from "readline";
4
+ import { spawnCommand, spawnStreamingCommand } from "../spawn-command.js";
4
5
  import { loadConfig } from "../config.js";
5
6
  import { connectNats } from "../nats-client.js";
6
7
  import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory } from "../task.js";
7
8
  import { getAgent } from "../agents/agent.js";
8
9
  import { getPlatform } from "../platform/index.js";
9
- import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
10
+ import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX, TASK_INPUT_PREFIX } from "../agents/shared-prompt.js";
11
+ import type { AgentTool } from "../agents/agent.js";
12
+ import { publishHostEvent } from "../events.js";
10
13
  import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
11
14
  import type { NatsConnection } from "nats";
12
- import { StringCodec } from "nats";
13
15
 
14
16
  /**
15
17
  * Write a time-stamped RESULT file with frontmatter.
@@ -34,6 +36,112 @@ function writeResult(
34
36
  return resultFileName;
35
37
  }
36
38
 
39
+ /**
40
+ * Shared context for agent invocation retry loops.
41
+ * Passed around to avoid threading many individual parameters.
42
+ */
43
+ interface InvocationContext {
44
+ agent: AgentTool;
45
+ task: ParsedTask;
46
+ taskDir: string;
47
+ guiEnv: Record<string, string>;
48
+ nc: NatsConnection | undefined;
49
+ config: HostConfig;
50
+ taskId: string;
51
+ /** Mutable — accumulates across invocations within a run. */
52
+ transientPermissions: RequiredPermission[];
53
+ }
54
+
55
+ interface InvocationResult {
56
+ output: string;
57
+ outcome: TaskRunningState;
58
+ reportFiles: string[];
59
+ requiredPermissions: RequiredPermission[];
60
+ }
61
+
62
+ /**
63
+ * Invoke the agent CLI with a retry loop for permissions and user input.
64
+ *
65
+ * Both standard and command-triggered execution use this.
66
+ * The `invokeTask` is the ParsedTask whose prompt is passed to the agent
67
+ * (for command-triggered mode this is the per-line augmented task).
68
+ */
69
+ async function invokeAgentWithRetry(
70
+ ctx: InvocationContext,
71
+ invokeTask: ParsedTask,
72
+ ): Promise<InvocationResult> {
73
+ let retryPrompt: string | undefined;
74
+ // eslint-disable-next-line no-constant-condition
75
+ while (true) {
76
+ const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
77
+ const result = await spawnCommand(command, args, {
78
+ cwd: ctx.taskDir,
79
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
80
+ echoStdout: true,
81
+ resolveOnFailure: true,
82
+ stdin,
83
+ });
84
+
85
+ const outcome: TaskRunningState = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
86
+ const reportFiles = parseReportFiles(result.output);
87
+ const requiredPermissions = parsePermissions(result.output);
88
+
89
+ // Permission retry
90
+ if (outcome === "failed" && requiredPermissions.length > 0) {
91
+ const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
92
+ await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
93
+
94
+ if (response === "aborted") {
95
+ return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
96
+ }
97
+
98
+ const newPerms = requiredPermissions.filter(
99
+ (rp) => !ctx.task.frontmatter.permissions?.some((ep) => ep.name === rp.name)
100
+ && !ctx.transientPermissions.some((ep) => ep.name === rp.name),
101
+ );
102
+
103
+ if (response === "granted_all") {
104
+ ctx.task.frontmatter.permissions = [...(ctx.task.frontmatter.permissions ?? []), ...newPerms];
105
+ invokeTask.frontmatter.permissions = ctx.task.frontmatter.permissions;
106
+ writeTaskFile(ctx.taskDir, ctx.task);
107
+ } else {
108
+ ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
109
+ }
110
+
111
+ retryPrompt = "Permissions granted, please continue.";
112
+ continue;
113
+ }
114
+
115
+ // Input retry
116
+ const inputRequests = parseInputRequests(result.output);
117
+ if (outcome === "failed" && inputRequests.length > 0) {
118
+ const response = await requestUserInput(ctx.nc, ctx.config, ctx.task, ctx.taskDir, inputRequests);
119
+ await publishInputResolved(ctx.nc, ctx.config, ctx.taskId, response === "aborted" ? "aborted" : "provided");
120
+
121
+ if (response === "aborted") {
122
+ return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
123
+ }
124
+
125
+ const inputLines = inputRequests.map((desc, i) => `- ${desc} → ${response[i]}`).join("\n");
126
+ retryPrompt = `The user provided the following inputs:\n${inputLines}\nPlease continue with these values.`;
127
+ continue;
128
+ }
129
+
130
+ // Normal completion (success or non-retryable failure)
131
+ return { output: result.output, outcome, reportFiles, requiredPermissions };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
137
+ * respect that instead of overwriting with the process's own outcome.
138
+ */
139
+ function resolveOutcome(taskDir: string, outcome: TaskRunningState): TaskRunningState {
140
+ const current = readTaskStatus(taskDir);
141
+ if (current?.running_state === "aborted") return "aborted";
142
+ return outcome;
143
+ }
144
+
37
145
  /**
38
146
  * Execute a task by ID.
39
147
  */
@@ -41,11 +149,7 @@ export async function runCommand(taskId: string): Promise<void> {
41
149
  const config = loadConfig();
42
150
  const taskDir = getTaskDir(config.projectRoot, taskId);
43
151
  const task = parseTaskFile(taskDir);
44
- const mode = config.mode ?? "nats";
45
- const useNats = mode === "nats" || mode === "auto";
46
- const useHttp = mode === "lan" || mode === "auto";
47
-
48
- console.log(`Running task: ${taskId} (mode: ${mode})`);
152
+ console.log(`Running task: ${taskId}`);
49
153
 
50
154
  let nc: NatsConnection | undefined;
51
155
  const startTime = Date.now();
@@ -61,159 +165,227 @@ export async function runCommand(taskId: string): Promise<void> {
61
165
  }
62
166
  };
63
167
 
64
- // Handle signals
65
- const onSignal = async () => {
66
- console.log("Received signal, cleaning up...");
67
- const endTime = Date.now();
68
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "abort", startTime, endTime, "", [], []);
69
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
70
- await publishTaskEvent(nc, config, taskDir, taskId, "abort", useHttp, taskName);
71
- await cleanup();
72
- process.exit(1);
73
- };
74
- process.on("SIGINT", onSignal);
75
- process.on("SIGTERM", onSignal);
76
-
77
168
  try {
78
- if (useNats) {
79
- nc = await connectNats(config);
80
- }
169
+ nc = await connectNats(config);
81
170
 
82
171
  // Mark as started immediately
83
- await publishTaskEvent(nc, config, taskDir, taskId, "start", useHttp, taskName);
172
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName);
84
173
 
85
174
  // If requires_confirmation, notify clients and wait
86
175
  if (task.frontmatter.requires_confirmation) {
87
- const confirmed = await requestConfirmation(nc, config, task, taskDir, useHttp);
176
+ const confirmed = await requestConfirmation(nc, config, task, taskDir);
88
177
  const resolvedStatus = confirmed ? "confirmed" : "aborted";
89
- await publishConfirmResolved(nc, config, taskId, resolvedStatus, useHttp);
178
+ await publishConfirmResolved(nc, config, taskId, resolvedStatus);
90
179
  if (!confirmed) {
91
180
  console.log("Task aborted by user.");
92
181
  const endTime = Date.now();
93
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "abort", startTime, endTime, "", [], []);
182
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
94
183
  appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
95
- await publishTaskEvent(nc, config, taskDir, taskId, "abort", useHttp, taskName);
184
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName);
96
185
  await cleanup();
97
186
  return;
98
187
  }
99
188
  console.log("Task confirmed by user.");
100
189
  }
101
190
 
102
- // Execution loop: retry on permission failure if user grants
191
+ // Shared invocation context
103
192
  const guiEnv = getPlatform().getGuiEnv();
104
-
105
193
  const agent = getAgent(task.frontmatter.agent);
106
-
107
- let lastOutput = "";
108
- let lastOutcome: TaskRunningState = "fail";
109
- let lastReportFiles: string[] = [];
110
- let lastRequiredPermissions: RequiredPermission[] = [];
111
- let lastEndTime = Date.now();
112
-
113
- let retryPrompt: string | undefined;
114
- let transientPermissions: RequiredPermission[] = [];
115
- // eslint-disable-next-line no-constant-condition
116
- while (true) {
117
- const { command, args } = agent.getTaskRunCommandLine(task, retryPrompt, transientPermissions);
118
- const result = await spawnCommand(command, args, {
119
- cwd: taskDir,
120
- env: {
121
- ...guiEnv,
122
- PALMIER_TASK_ID: task.frontmatter.id,
123
- },
124
- echoStdout: true,
125
- forwardSignals: true,
126
- resolveOnFailure: true,
127
- });
128
- lastOutput = result.output;
129
- lastEndTime = Date.now();
130
-
131
- lastOutcome = result.exitCode !== 0 ? "fail" : parseTaskOutcome(lastOutput);
132
- lastReportFiles = parseReportFiles(lastOutput);
133
- lastRequiredPermissions = parsePermissions(lastOutput);
134
-
135
- // If failed with permission requirements, ask user to grant
136
- if (lastOutcome === "fail" && lastRequiredPermissions.length > 0) {
137
- const response = await requestPermission(nc, config, task, taskDir, lastRequiredPermissions, useHttp);
138
- await publishPermissionResolved(nc, config, taskId, response, useHttp);
139
-
140
- if (response === "aborted") {
141
- console.log("Permission request aborted by user.");
142
- break;
143
- }
144
-
145
- const newPerms = lastRequiredPermissions.filter(
146
- (rp) => !task.frontmatter.permissions?.some((ep) => ep.name === rp.name)
147
- && !transientPermissions.some((ep) => ep.name === rp.name),
148
- );
149
-
150
- if (response === "granted_all") {
151
- // Persist permissions to task frontmatter for all future runs
152
- task.frontmatter.permissions = [...(task.frontmatter.permissions ?? []), ...newPerms];
153
- writeTaskFile(taskDir, task);
154
- } else {
155
- // "granted" — allow for this run only
156
- transientPermissions = [...transientPermissions, ...newPerms];
157
- }
158
-
159
- console.log(`Permissions granted, retrying task ${taskId}...`);
160
- retryPrompt = "Permissions granted, please continue.";
161
- continue;
194
+ const ctx: InvocationContext = {
195
+ agent, task, taskDir, guiEnv, nc, config, taskId,
196
+ transientPermissions: [],
197
+ };
198
+
199
+ if (task.frontmatter.command) {
200
+ // Command-triggered mode
201
+ const result = await runCommandTriggeredMode(ctx);
202
+ const outcome = resolveOutcome(taskDir, result.outcome);
203
+ const resultFileName = writeResult(
204
+ taskDir, taskName, taskSnapshotName, outcome,
205
+ startTime, result.endTime, result.output, [], [],
206
+ );
207
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
208
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
209
+ console.log(`Task ${taskId} completed (command-triggered).`);
210
+ } else {
211
+ // Standard execution
212
+ const result = await invokeAgentWithRetry(ctx, task);
213
+ const outcome = resolveOutcome(taskDir, result.outcome);
214
+
215
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
216
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
217
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
218
+
219
+ if (result.reportFiles.length > 0) {
220
+ await publishHostEvent(nc, config.hostId, taskId, {
221
+ event_type: "report-generated",
222
+ name: taskName,
223
+ report_files: result.reportFiles,
224
+ running_state: outcome,
225
+ });
162
226
  }
163
227
 
164
- // Normal completion (success or non-permission failure)
165
- break;
228
+ console.log(`Task ${taskId} completed.`);
166
229
  }
167
-
168
- // Write result and history once after the loop
169
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, lastOutcome, startTime, lastEndTime, lastOutput, lastReportFiles, lastRequiredPermissions);
170
- appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
171
- await publishTaskEvent(nc, config, taskDir, taskId, lastOutcome, useHttp, taskName);
172
- console.log(`Task ${taskId} completed.`);
173
230
  } catch (err) {
174
231
  console.error(`Task ${taskId} failed:`, err);
175
232
  const endTime = Date.now();
233
+ const outcome = resolveOutcome(taskDir, "failed");
176
234
  const errorMsg = err instanceof Error ? err.message : String(err);
177
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "fail", startTime, endTime, errorMsg, [], []);
235
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
178
236
  appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
179
- await publishTaskEvent(nc, config, taskDir, taskId, "fail", useHttp, taskName);
237
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName);
180
238
  process.exitCode = 1;
181
239
  } finally {
182
240
  await cleanup();
183
241
  }
184
242
  }
185
243
 
186
- const sc = StringCodec();
244
+ const MAX_QUEUE_SIZE = 100;
245
+ const MAX_LOG_ENTRIES = 1000;
246
+ /** Max input line length (chars). Long emails can take up to 200k chars. */
247
+ const MAX_LINE_LENGTH = 200_000;
187
248
 
188
249
  /**
189
- * Write status.json and notify connected clients via NATS and/or HTTP SSE.
250
+ * Command-triggered execution mode.
251
+ *
252
+ * Spawns a long-running shell command and, for each line of stdout,
253
+ * invokes the agent CLI with the user's prompt augmented by that line.
254
+ * Processes lines sequentially with a bounded queue.
190
255
  */
191
- async function publishHostEvent(
192
- nc: NatsConnection | undefined,
193
- config: HostConfig,
194
- taskId: string,
195
- payload: Record<string, unknown>,
196
- useHttp: boolean,
197
- ): Promise<void> {
198
- const subject = `host-event.${config.hostId}.${taskId}`;
256
+ async function runCommandTriggeredMode(
257
+ ctx: InvocationContext,
258
+ ): Promise<{ outcome: TaskRunningState; endTime: number; output: string }> {
259
+ const commandStr = ctx.task.frontmatter.command!;
260
+ console.log(`[command-triggered] Spawning: ${commandStr}`);
261
+
262
+ const child = spawnStreamingCommand(commandStr, {
263
+ cwd: ctx.taskDir,
264
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
265
+ });
266
+
267
+ // Stats
268
+ let linesProcessed = 0;
269
+ let invocationsSucceeded = 0;
270
+ let invocationsFailed = 0;
199
271
 
200
- if (nc) {
201
- nc.publish(subject, sc.encode(JSON.stringify(payload)));
202
- console.log(`[nats] ${subject} →`, payload);
272
+ // Bounded queue for incoming lines
273
+ const lineQueue: string[] = [];
274
+ let processing = false;
275
+ let commandExited = false;
276
+ let resolveWhenDone: (() => void) | undefined;
277
+
278
+ // Rolling log of per-line agent outputs
279
+ const logPath = path.join(ctx.taskDir, "command-output.log");
280
+ function appendLog(line: string, agentOutput: string, outcome: string) {
281
+ const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
282
+ fs.appendFileSync(logPath, entry, "utf-8");
283
+
284
+ // Trim log if too large (keep last MAX_LOG_ENTRIES entries)
285
+ try {
286
+ const content = fs.readFileSync(logPath, "utf-8");
287
+ const entries = content.split("\n---\n").filter(Boolean);
288
+ if (entries.length > MAX_LOG_ENTRIES) {
289
+ const trimmed = entries.slice(-MAX_LOG_ENTRIES).join("\n---\n") + "\n---\n";
290
+ fs.writeFileSync(logPath, trimmed, "utf-8");
291
+ }
292
+ } catch { /* ignore trim errors */ }
203
293
  }
204
294
 
205
- if (useHttp && config.directPort) {
295
+ async function processLine(line: string): Promise<void> {
296
+ linesProcessed++;
297
+ if (line.length > MAX_LINE_LENGTH) {
298
+ console.warn(`[command-triggered] Skipping line #${linesProcessed}: ${line.length} chars exceeds limit`);
299
+ invocationsFailed++;
300
+ appendLog(line.slice(0, 200) + "...(truncated)", "", "skipped");
301
+ return;
302
+ }
303
+ console.log(`[command-triggered] Processing line #${linesProcessed}: ${line}`);
304
+
305
+ const perLinePrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this input:\n${line}`;
306
+ const perLineTask: ParsedTask = {
307
+ frontmatter: { ...ctx.task.frontmatter, user_prompt: perLinePrompt },
308
+ body: "",
309
+ };
310
+
311
+ const result = await invokeAgentWithRetry(ctx, perLineTask);
312
+ if (result.outcome === "finished") {
313
+ invocationsSucceeded++;
314
+ } else {
315
+ invocationsFailed++;
316
+ }
317
+ appendLog(line, result.output, result.outcome);
318
+ }
319
+
320
+ async function drainQueue(): Promise<void> {
321
+ if (processing) return;
322
+ processing = true;
206
323
  try {
207
- await fetch(`http://localhost:${config.directPort}/internal/event`, {
208
- method: "POST",
209
- headers: { "Content-Type": "application/json" },
210
- body: JSON.stringify({ task_id: taskId, ...payload }),
211
- });
212
- console.log(`[http] host-event: ${taskId} →`, payload);
213
- } catch (err) {
214
- console.error(`[http] Failed to push event:`, err);
324
+ while (lineQueue.length > 0) {
325
+ const line = lineQueue.shift()!;
326
+ await processLine(line);
327
+ }
328
+ } finally {
329
+ processing = false;
330
+ if (commandExited && lineQueue.length === 0 && resolveWhenDone) {
331
+ resolveWhenDone();
332
+ }
215
333
  }
216
334
  }
335
+
336
+ // Read stdout line by line
337
+ const rl = readline.createInterface({ input: child.stdout! });
338
+ rl.on("line", (line: string) => {
339
+ if (!line.trim()) return; // skip empty lines
340
+ if (lineQueue.length >= MAX_QUEUE_SIZE) {
341
+ console.warn(`[command-triggered] Queue full, dropping oldest line.`);
342
+ lineQueue.shift();
343
+ }
344
+ lineQueue.push(line);
345
+ drainQueue().catch((err) => {
346
+ console.error(`[command-triggered] Error processing line:`, err);
347
+ invocationsFailed++;
348
+ });
349
+ });
350
+
351
+ // Log stderr
352
+ child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
353
+
354
+ // Wait for command to exit
355
+ const exitCode = await new Promise<number | null>((resolve) => {
356
+ child.on("close", (code: number | null) => {
357
+ commandExited = true;
358
+ rl.close();
359
+ resolve(code);
360
+ });
361
+ child.on("error", (err: Error) => {
362
+ console.error(`[command-triggered] Command error:`, err);
363
+ commandExited = true;
364
+ rl.close();
365
+ resolve(1);
366
+ });
367
+ });
368
+
369
+ // Wait for any remaining queued lines to finish processing
370
+ if (lineQueue.length > 0 || processing) {
371
+ await new Promise<void>((resolve) => {
372
+ resolveWhenDone = resolve;
373
+ drainQueue();
374
+ });
375
+ }
376
+
377
+ const endTime = Date.now();
378
+ const summary = [
379
+ `Command: ${commandStr}`,
380
+ `Exit code: ${exitCode}`,
381
+ `Lines processed: ${linesProcessed}`,
382
+ `Agent invocations succeeded: ${invocationsSucceeded}`,
383
+ `Agent invocations failed: ${invocationsFailed}`,
384
+ ].join("\n");
385
+
386
+ // Command-triggered tasks run until the command exits — any exit is a normal finish.
387
+ const outcome: TaskRunningState = "finished";
388
+ return { outcome, endTime, output: summary };
217
389
  }
218
390
 
219
391
  async function publishTaskEvent(
@@ -222,7 +394,6 @@ async function publishTaskEvent(
222
394
  taskDir: string,
223
395
  taskId: string,
224
396
  eventType: TaskRunningState,
225
- useHttp: boolean,
226
397
  taskName?: string,
227
398
  ): Promise<void> {
228
399
  writeTaskStatus(taskDir, {
@@ -232,7 +403,7 @@ async function publishTaskEvent(
232
403
 
233
404
  const payload: Record<string, unknown> = { event_type: "running-state", running_state: eventType };
234
405
  if (taskName) payload.name = taskName;
235
- await publishHostEvent(nc, config, taskId, payload, useHttp);
406
+ await publishHostEvent(nc, config.hostId, taskId, payload);
236
407
  }
237
408
 
238
409
  /**
@@ -243,13 +414,12 @@ async function publishConfirmResolved(
243
414
  config: HostConfig,
244
415
  taskId: string,
245
416
  status: "confirmed" | "aborted",
246
- useHttp: boolean,
247
417
  ): Promise<void> {
248
- await publishHostEvent(nc, config, taskId, {
418
+ await publishHostEvent(nc, config.hostId, taskId, {
249
419
  event_type: "confirm-resolved",
250
420
  host_id: config.hostId,
251
421
  status,
252
- }, useHttp);
422
+ });
253
423
  }
254
424
 
255
425
  async function requestPermission(
@@ -258,7 +428,6 @@ async function requestPermission(
258
428
  task: ParsedTask,
259
429
  taskDir: string,
260
430
  requiredPermissions: RequiredPermission[],
261
- useHttp: boolean,
262
431
  ): Promise<"granted" | "granted_all" | "aborted"> {
263
432
  const taskId = task.frontmatter.id;
264
433
  const statusPath = path.join(taskDir, "status.json");
@@ -266,21 +435,21 @@ async function requestPermission(
266
435
  const currentStatus = readTaskStatus(taskDir)!;
267
436
  writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
268
437
 
269
- await publishHostEvent(nc, config, taskId, {
438
+ await publishHostEvent(nc, config.hostId, taskId, {
270
439
  event_type: "permission-request",
271
440
  host_id: config.hostId,
272
441
  required_permissions: requiredPermissions,
273
442
  name: task.frontmatter.name,
274
- }, useHttp);
443
+ });
275
444
 
276
445
  return new Promise<"granted" | "granted_all" | "aborted">((resolve) => {
277
446
  const watcher = fs.watch(statusPath, () => {
278
447
  const status = readTaskStatus(taskDir);
279
- if (!status || status.user_input === undefined) return;
448
+ if (!status || !status.user_input?.length) return;
280
449
  watcher.close();
281
- const response = status.user_input as "granted" | "granted_all" | "aborted";
450
+ const response = status.user_input[0] as "granted" | "granted_all" | "aborted";
282
451
  writeTaskStatus(taskDir, {
283
- running_state: response === "aborted" ? "abort" : "start",
452
+ running_state: response === "aborted" ? "aborted" : "started",
284
453
  time_stamp: Date.now(),
285
454
  });
286
455
  resolve(response);
@@ -293,13 +462,62 @@ async function publishPermissionResolved(
293
462
  config: HostConfig,
294
463
  taskId: string,
295
464
  status: "granted" | "granted_all" | "aborted",
296
- useHttp: boolean,
297
465
  ): Promise<void> {
298
- await publishHostEvent(nc, config, taskId, {
466
+ await publishHostEvent(nc, config.hostId, taskId, {
299
467
  event_type: "permission-resolved",
300
468
  host_id: config.hostId,
301
469
  status,
302
- }, useHttp);
470
+ });
471
+ }
472
+
473
+ async function requestUserInput(
474
+ nc: NatsConnection | undefined,
475
+ config: HostConfig,
476
+ task: ParsedTask,
477
+ taskDir: string,
478
+ inputDescriptions: string[],
479
+ ): Promise<string[] | "aborted"> {
480
+ const taskId = task.frontmatter.id;
481
+ const statusPath = path.join(taskDir, "status.json");
482
+
483
+ const currentStatus = readTaskStatus(taskDir)!;
484
+ writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
485
+
486
+ await publishHostEvent(nc, config.hostId, taskId, {
487
+ event_type: "input-request",
488
+ host_id: config.hostId,
489
+ input_descriptions: inputDescriptions,
490
+ name: task.frontmatter.name,
491
+ });
492
+
493
+ return new Promise<string[] | "aborted">((resolve) => {
494
+ const watcher = fs.watch(statusPath, () => {
495
+ const status = readTaskStatus(taskDir);
496
+ if (!status || !status.user_input?.length) return;
497
+ watcher.close();
498
+ const response = status.user_input;
499
+ if (response.length === 1 && response[0] === "aborted") {
500
+ writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
501
+ resolve("aborted");
502
+ } else {
503
+ writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
504
+ resolve(response);
505
+ }
506
+ });
507
+ });
508
+ }
509
+
510
+ async function publishInputResolved(
511
+ nc: NatsConnection | undefined,
512
+ config: HostConfig,
513
+ taskId: string,
514
+ status: "provided" | "aborted",
515
+ ): Promise<void> {
516
+ await publishHostEvent(nc, config.hostId, taskId, {
517
+ event_type: "input-resolved",
518
+ host_id: config.hostId,
519
+ status,
520
+ });
303
521
  }
304
522
 
305
523
  async function requestConfirmation(
@@ -307,7 +525,6 @@ async function requestConfirmation(
307
525
  config: HostConfig,
308
526
  task: ParsedTask,
309
527
  taskDir: string,
310
- useHttp: boolean,
311
528
  ): Promise<boolean> {
312
529
  const taskId = task.frontmatter.id;
313
530
  const statusPath = path.join(taskDir, "status.json");
@@ -317,21 +534,21 @@ async function requestConfirmation(
317
534
  writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
318
535
 
319
536
  // Publish confirmation request via NATS and/or HTTP SSE
320
- await publishHostEvent(nc, config, taskId, {
537
+ await publishHostEvent(nc, config.hostId, taskId, {
321
538
  event_type: "confirm-request",
322
539
  host_id: config.hostId,
323
- }, useHttp);
540
+ });
324
541
 
325
542
  // Wait for task.user_input RPC to set user_input in status.json
326
543
  return new Promise<boolean>((resolve) => {
327
544
  const watcher = fs.watch(statusPath, () => {
328
545
  const status = readTaskStatus(taskDir);
329
- if (!status || status.user_input === undefined) return; // still pending
546
+ if (!status || !status.user_input?.length) return; // still pending
330
547
  watcher.close();
331
- const confirmed = status.user_input === "confirmed";
548
+ const confirmed = status.user_input[0] === "confirmed";
332
549
  // Clear pending_confirmation/user_input and update running_state
333
550
  writeTaskStatus(taskDir, {
334
- running_state: confirmed ? "start" : "abort",
551
+ running_state: confirmed ? "started" : "aborted",
335
552
  time_stamp: Date.now(),
336
553
  });
337
554
  resolve(confirmed);
@@ -374,14 +591,29 @@ function parsePermissions(output: string): RequiredPermission[] {
374
591
  return perms;
375
592
  }
376
593
 
594
+ /**
595
+ * Extract user input requests from agent output.
596
+ * Looks for lines matching: [PALMIER_INPUT] <description>
597
+ */
598
+ function parseInputRequests(output: string): string[] {
599
+ const regex = new RegExp(`^\\${TASK_INPUT_PREFIX}\\s+(.+)$`, "gm");
600
+ const inputs: string[] = [];
601
+ let match;
602
+ while ((match = regex.exec(output)) !== null) {
603
+ const desc = match[1].trim();
604
+ if (desc) inputs.push(desc);
605
+ }
606
+ return inputs;
607
+ }
608
+
377
609
  /**
378
610
  * Parse the agent's output for success/failure markers.
379
- * Falls back to "finish" if no marker is found.
611
+ * Falls back to "finished" if no marker is found.
380
612
  */
381
613
  function parseTaskOutcome(output: string): TaskRunningState {
382
614
  const lastChunk = output.slice(-500);
383
- if (lastChunk.includes(TASK_FAILURE_MARKER)) return "fail";
384
- if (lastChunk.includes(TASK_SUCCESS_MARKER)) return "finish";
385
- return "finish";
615
+ if (lastChunk.includes(TASK_FAILURE_MARKER)) return "failed";
616
+ if (lastChunk.includes(TASK_SUCCESS_MARKER)) return "finished";
617
+ return "finished";
386
618
  }
387
619