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
@@ -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,113 @@ 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
+ useHttp: boolean;
52
+ /** Mutable — accumulates across invocations within a run. */
53
+ transientPermissions: RequiredPermission[];
54
+ }
55
+
56
+ interface InvocationResult {
57
+ output: string;
58
+ outcome: TaskRunningState;
59
+ reportFiles: string[];
60
+ requiredPermissions: RequiredPermission[];
61
+ }
62
+
63
+ /**
64
+ * Invoke the agent CLI with a retry loop for permissions and user input.
65
+ *
66
+ * Both standard and command-triggered execution use this.
67
+ * The `invokeTask` is the ParsedTask whose prompt is passed to the agent
68
+ * (for command-triggered mode this is the per-line augmented task).
69
+ */
70
+ async function invokeAgentWithRetry(
71
+ ctx: InvocationContext,
72
+ invokeTask: ParsedTask,
73
+ ): Promise<InvocationResult> {
74
+ let retryPrompt: string | undefined;
75
+ // eslint-disable-next-line no-constant-condition
76
+ while (true) {
77
+ const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
78
+ const result = await spawnCommand(command, args, {
79
+ cwd: ctx.taskDir,
80
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
81
+ echoStdout: true,
82
+ resolveOnFailure: true,
83
+ stdin,
84
+ });
85
+
86
+ const outcome: TaskRunningState = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
87
+ const reportFiles = parseReportFiles(result.output);
88
+ const requiredPermissions = parsePermissions(result.output);
89
+
90
+ // Permission retry
91
+ if (outcome === "failed" && requiredPermissions.length > 0) {
92
+ const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions, ctx.useHttp);
93
+ await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response, ctx.useHttp);
94
+
95
+ if (response === "aborted") {
96
+ return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
97
+ }
98
+
99
+ const newPerms = requiredPermissions.filter(
100
+ (rp) => !ctx.task.frontmatter.permissions?.some((ep) => ep.name === rp.name)
101
+ && !ctx.transientPermissions.some((ep) => ep.name === rp.name),
102
+ );
103
+
104
+ if (response === "granted_all") {
105
+ ctx.task.frontmatter.permissions = [...(ctx.task.frontmatter.permissions ?? []), ...newPerms];
106
+ invokeTask.frontmatter.permissions = ctx.task.frontmatter.permissions;
107
+ writeTaskFile(ctx.taskDir, ctx.task);
108
+ } else {
109
+ ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
110
+ }
111
+
112
+ retryPrompt = "Permissions granted, please continue.";
113
+ continue;
114
+ }
115
+
116
+ // Input retry
117
+ const inputRequests = parseInputRequests(result.output);
118
+ if (outcome === "failed" && inputRequests.length > 0) {
119
+ const response = await requestUserInput(ctx.nc, ctx.config, ctx.task, ctx.taskDir, inputRequests, ctx.useHttp);
120
+ await publishInputResolved(ctx.nc, ctx.config, ctx.taskId, response === "aborted" ? "aborted" : "provided", ctx.useHttp);
121
+
122
+ if (response === "aborted") {
123
+ return { output: result.output, outcome: "failed", reportFiles, requiredPermissions };
124
+ }
125
+
126
+ const inputLines = inputRequests.map((desc, i) => `- ${desc} → ${response[i]}`).join("\n");
127
+ retryPrompt = `The user provided the following inputs:\n${inputLines}\nPlease continue with these values.`;
128
+ continue;
129
+ }
130
+
131
+ // Normal completion (success or non-retryable failure)
132
+ return { output: result.output, outcome, reportFiles, requiredPermissions };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * If the RPC handler already wrote "aborted" to status.json (e.g. via task.abort),
138
+ * respect that instead of overwriting with the process's own outcome.
139
+ */
140
+ function resolveOutcome(taskDir: string, outcome: TaskRunningState): TaskRunningState {
141
+ const current = readTaskStatus(taskDir);
142
+ if (current?.running_state === "aborted") return "aborted";
143
+ return outcome;
144
+ }
145
+
37
146
  /**
38
147
  * Execute a task by ID.
39
148
  */
@@ -61,26 +170,13 @@ export async function runCommand(taskId: string): Promise<void> {
61
170
  }
62
171
  };
63
172
 
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
173
  try {
78
174
  if (useNats) {
79
175
  nc = await connectNats(config);
80
176
  }
81
177
 
82
178
  // Mark as started immediately
83
- await publishTaskEvent(nc, config, taskDir, taskId, "start", useHttp, taskName);
179
+ await publishTaskEvent(nc, config, taskDir, taskId, "started", useHttp, taskName);
84
180
 
85
181
  // If requires_confirmation, notify clients and wait
86
182
  if (task.frontmatter.requires_confirmation) {
@@ -90,130 +186,213 @@ export async function runCommand(taskId: string): Promise<void> {
90
186
  if (!confirmed) {
91
187
  console.log("Task aborted by user.");
92
188
  const endTime = Date.now();
93
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "abort", startTime, endTime, "", [], []);
189
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "aborted", startTime, endTime, "", [], []);
94
190
  appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
95
- await publishTaskEvent(nc, config, taskDir, taskId, "abort", useHttp, taskName);
191
+ await publishTaskEvent(nc, config, taskDir, taskId, "aborted", useHttp, taskName);
96
192
  await cleanup();
97
193
  return;
98
194
  }
99
195
  console.log("Task confirmed by user.");
100
196
  }
101
197
 
102
- // Execution loop: retry on permission failure if user grants
198
+ // Shared invocation context
103
199
  const guiEnv = getPlatform().getGuiEnv();
104
-
105
200
  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;
201
+ const ctx: InvocationContext = {
202
+ agent, task, taskDir, guiEnv, nc, config, taskId, useHttp,
203
+ transientPermissions: [],
204
+ };
205
+
206
+ if (task.frontmatter.command) {
207
+ // Command-triggered mode
208
+ const result = await runCommandTriggeredMode(ctx);
209
+ const outcome = resolveOutcome(taskDir, result.outcome);
210
+ const resultFileName = writeResult(
211
+ taskDir, taskName, taskSnapshotName, outcome,
212
+ startTime, result.endTime, result.output, [], [],
213
+ );
214
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
215
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, useHttp, taskName);
216
+ console.log(`Task ${taskId} completed (command-triggered).`);
217
+ } else {
218
+ // Standard execution
219
+ const result = await invokeAgentWithRetry(ctx, task);
220
+ const outcome = resolveOutcome(taskDir, result.outcome);
221
+
222
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, Date.now(), result.output, result.reportFiles, result.requiredPermissions);
223
+ appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
224
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, useHttp, taskName);
225
+
226
+ if (result.reportFiles.length > 0) {
227
+ await publishHostEvent(nc, config, taskId, {
228
+ event_type: "report-generated",
229
+ name: taskName,
230
+ report_files: result.reportFiles,
231
+ running_state: outcome,
232
+ }, useHttp);
162
233
  }
163
234
 
164
- // Normal completion (success or non-permission failure)
165
- break;
235
+ console.log(`Task ${taskId} completed.`);
166
236
  }
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
237
  } catch (err) {
174
238
  console.error(`Task ${taskId} failed:`, err);
175
239
  const endTime = Date.now();
240
+ const outcome = resolveOutcome(taskDir, "failed");
176
241
  const errorMsg = err instanceof Error ? err.message : String(err);
177
- const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, "fail", startTime, endTime, errorMsg, [], []);
242
+ const resultFileName = writeResult(taskDir, taskName, taskSnapshotName, outcome, startTime, endTime, errorMsg, [], []);
178
243
  appendHistory(config.projectRoot, { task_id: taskId, result_file: resultFileName });
179
- await publishTaskEvent(nc, config, taskDir, taskId, "fail", useHttp, taskName);
244
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, useHttp, taskName);
180
245
  process.exitCode = 1;
181
246
  } finally {
182
247
  await cleanup();
183
248
  }
184
249
  }
185
250
 
186
- const sc = StringCodec();
251
+ const MAX_QUEUE_SIZE = 100;
252
+ const MAX_LOG_ENTRIES = 1000;
253
+ /** Max input line length (chars). Long emails can take up to 200k chars. */
254
+ const MAX_LINE_LENGTH = 200_000;
187
255
 
188
256
  /**
189
- * Write status.json and notify connected clients via NATS and/or HTTP SSE.
257
+ * Command-triggered execution mode.
258
+ *
259
+ * Spawns a long-running shell command and, for each line of stdout,
260
+ * invokes the agent CLI with the user's prompt augmented by that line.
261
+ * Processes lines sequentially with a bounded queue.
190
262
  */
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}`;
263
+ async function runCommandTriggeredMode(
264
+ ctx: InvocationContext,
265
+ ): Promise<{ outcome: TaskRunningState; endTime: number; output: string }> {
266
+ const commandStr = ctx.task.frontmatter.command!;
267
+ console.log(`[command-triggered] Spawning: ${commandStr}`);
268
+
269
+ const child = spawnStreamingCommand(commandStr, {
270
+ cwd: ctx.taskDir,
271
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
272
+ });
273
+
274
+ // Stats
275
+ let linesProcessed = 0;
276
+ let invocationsSucceeded = 0;
277
+ let invocationsFailed = 0;
278
+
279
+ // Bounded queue for incoming lines
280
+ const lineQueue: string[] = [];
281
+ let processing = false;
282
+ let commandExited = false;
283
+ let resolveWhenDone: (() => void) | undefined;
284
+
285
+ // Rolling log of per-line agent outputs
286
+ const logPath = path.join(ctx.taskDir, "command-output.log");
287
+ function appendLog(line: string, agentOutput: string, outcome: string) {
288
+ const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
289
+ fs.appendFileSync(logPath, entry, "utf-8");
290
+
291
+ // Trim log if too large (keep last MAX_LOG_ENTRIES entries)
292
+ try {
293
+ const content = fs.readFileSync(logPath, "utf-8");
294
+ const entries = content.split("\n---\n").filter(Boolean);
295
+ if (entries.length > MAX_LOG_ENTRIES) {
296
+ const trimmed = entries.slice(-MAX_LOG_ENTRIES).join("\n---\n") + "\n---\n";
297
+ fs.writeFileSync(logPath, trimmed, "utf-8");
298
+ }
299
+ } catch { /* ignore trim errors */ }
300
+ }
199
301
 
200
- if (nc) {
201
- nc.publish(subject, sc.encode(JSON.stringify(payload)));
202
- console.log(`[nats] ${subject} →`, payload);
302
+ async function processLine(line: string): Promise<void> {
303
+ linesProcessed++;
304
+ if (line.length > MAX_LINE_LENGTH) {
305
+ console.warn(`[command-triggered] Skipping line #${linesProcessed}: ${line.length} chars exceeds limit`);
306
+ invocationsFailed++;
307
+ appendLog(line.slice(0, 200) + "...(truncated)", "", "skipped");
308
+ return;
309
+ }
310
+ console.log(`[command-triggered] Processing line #${linesProcessed}: ${line}`);
311
+
312
+ const perLinePrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this input:\n${line}`;
313
+ const perLineTask: ParsedTask = {
314
+ frontmatter: { ...ctx.task.frontmatter, user_prompt: perLinePrompt },
315
+ body: "",
316
+ };
317
+
318
+ const result = await invokeAgentWithRetry(ctx, perLineTask);
319
+ if (result.outcome === "finished") {
320
+ invocationsSucceeded++;
321
+ } else {
322
+ invocationsFailed++;
323
+ }
324
+ appendLog(line, result.output, result.outcome);
203
325
  }
204
326
 
205
- if (useHttp && config.directPort) {
327
+ async function drainQueue(): Promise<void> {
328
+ if (processing) return;
329
+ processing = true;
206
330
  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);
331
+ while (lineQueue.length > 0) {
332
+ const line = lineQueue.shift()!;
333
+ await processLine(line);
334
+ }
335
+ } finally {
336
+ processing = false;
337
+ if (commandExited && lineQueue.length === 0 && resolveWhenDone) {
338
+ resolveWhenDone();
339
+ }
215
340
  }
216
341
  }
342
+
343
+ // Read stdout line by line
344
+ const rl = readline.createInterface({ input: child.stdout! });
345
+ rl.on("line", (line: string) => {
346
+ if (!line.trim()) return; // skip empty lines
347
+ if (lineQueue.length >= MAX_QUEUE_SIZE) {
348
+ console.warn(`[command-triggered] Queue full, dropping oldest line.`);
349
+ lineQueue.shift();
350
+ }
351
+ lineQueue.push(line);
352
+ drainQueue().catch((err) => {
353
+ console.error(`[command-triggered] Error processing line:`, err);
354
+ invocationsFailed++;
355
+ });
356
+ });
357
+
358
+ // Log stderr
359
+ child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
360
+
361
+ // Wait for command to exit
362
+ const exitCode = await new Promise<number | null>((resolve) => {
363
+ child.on("close", (code: number | null) => {
364
+ commandExited = true;
365
+ rl.close();
366
+ resolve(code);
367
+ });
368
+ child.on("error", (err: Error) => {
369
+ console.error(`[command-triggered] Command error:`, err);
370
+ commandExited = true;
371
+ rl.close();
372
+ resolve(1);
373
+ });
374
+ });
375
+
376
+ // Wait for any remaining queued lines to finish processing
377
+ if (lineQueue.length > 0 || processing) {
378
+ await new Promise<void>((resolve) => {
379
+ resolveWhenDone = resolve;
380
+ drainQueue();
381
+ });
382
+ }
383
+
384
+ const endTime = Date.now();
385
+ const summary = [
386
+ `Command: ${commandStr}`,
387
+ `Exit code: ${exitCode}`,
388
+ `Lines processed: ${linesProcessed}`,
389
+ `Agent invocations succeeded: ${invocationsSucceeded}`,
390
+ `Agent invocations failed: ${invocationsFailed}`,
391
+ ].join("\n");
392
+
393
+ // Command-triggered tasks run until the command exits — any exit is a normal finish.
394
+ const outcome: TaskRunningState = "finished";
395
+ return { outcome, endTime, output: summary };
217
396
  }
218
397
 
219
398
  async function publishTaskEvent(
@@ -276,11 +455,11 @@ async function requestPermission(
276
455
  return new Promise<"granted" | "granted_all" | "aborted">((resolve) => {
277
456
  const watcher = fs.watch(statusPath, () => {
278
457
  const status = readTaskStatus(taskDir);
279
- if (!status || status.user_input === undefined) return;
458
+ if (!status || !status.user_input?.length) return;
280
459
  watcher.close();
281
- const response = status.user_input as "granted" | "granted_all" | "aborted";
460
+ const response = status.user_input[0] as "granted" | "granted_all" | "aborted";
282
461
  writeTaskStatus(taskDir, {
283
- running_state: response === "aborted" ? "abort" : "start",
462
+ running_state: response === "aborted" ? "aborted" : "started",
284
463
  time_stamp: Date.now(),
285
464
  });
286
465
  resolve(response);
@@ -302,6 +481,58 @@ async function publishPermissionResolved(
302
481
  }, useHttp);
303
482
  }
304
483
 
484
+ async function requestUserInput(
485
+ nc: NatsConnection | undefined,
486
+ config: HostConfig,
487
+ task: ParsedTask,
488
+ taskDir: string,
489
+ inputDescriptions: string[],
490
+ useHttp: boolean,
491
+ ): Promise<string[] | "aborted"> {
492
+ const taskId = task.frontmatter.id;
493
+ const statusPath = path.join(taskDir, "status.json");
494
+
495
+ const currentStatus = readTaskStatus(taskDir)!;
496
+ writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
497
+
498
+ await publishHostEvent(nc, config, taskId, {
499
+ event_type: "input-request",
500
+ host_id: config.hostId,
501
+ input_descriptions: inputDescriptions,
502
+ name: task.frontmatter.name,
503
+ }, useHttp);
504
+
505
+ return new Promise<string[] | "aborted">((resolve) => {
506
+ const watcher = fs.watch(statusPath, () => {
507
+ const status = readTaskStatus(taskDir);
508
+ if (!status || !status.user_input?.length) return;
509
+ watcher.close();
510
+ const response = status.user_input;
511
+ if (response.length === 1 && response[0] === "aborted") {
512
+ writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
513
+ resolve("aborted");
514
+ } else {
515
+ writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
516
+ resolve(response);
517
+ }
518
+ });
519
+ });
520
+ }
521
+
522
+ async function publishInputResolved(
523
+ nc: NatsConnection | undefined,
524
+ config: HostConfig,
525
+ taskId: string,
526
+ status: "provided" | "aborted",
527
+ useHttp: boolean,
528
+ ): Promise<void> {
529
+ await publishHostEvent(nc, config, taskId, {
530
+ event_type: "input-resolved",
531
+ host_id: config.hostId,
532
+ status,
533
+ }, useHttp);
534
+ }
535
+
305
536
  async function requestConfirmation(
306
537
  nc: NatsConnection | undefined,
307
538
  config: HostConfig,
@@ -326,12 +557,12 @@ async function requestConfirmation(
326
557
  return new Promise<boolean>((resolve) => {
327
558
  const watcher = fs.watch(statusPath, () => {
328
559
  const status = readTaskStatus(taskDir);
329
- if (!status || status.user_input === undefined) return; // still pending
560
+ if (!status || !status.user_input?.length) return; // still pending
330
561
  watcher.close();
331
- const confirmed = status.user_input === "confirmed";
562
+ const confirmed = status.user_input[0] === "confirmed";
332
563
  // Clear pending_confirmation/user_input and update running_state
333
564
  writeTaskStatus(taskDir, {
334
- running_state: confirmed ? "start" : "abort",
565
+ running_state: confirmed ? "started" : "aborted",
335
566
  time_stamp: Date.now(),
336
567
  });
337
568
  resolve(confirmed);
@@ -374,14 +605,29 @@ function parsePermissions(output: string): RequiredPermission[] {
374
605
  return perms;
375
606
  }
376
607
 
608
+ /**
609
+ * Extract user input requests from agent output.
610
+ * Looks for lines matching: [PALMIER_INPUT] <description>
611
+ */
612
+ function parseInputRequests(output: string): string[] {
613
+ const regex = new RegExp(`^\\${TASK_INPUT_PREFIX}\\s+(.+)$`, "gm");
614
+ const inputs: string[] = [];
615
+ let match;
616
+ while ((match = regex.exec(output)) !== null) {
617
+ const desc = match[1].trim();
618
+ if (desc) inputs.push(desc);
619
+ }
620
+ return inputs;
621
+ }
622
+
377
623
  /**
378
624
  * Parse the agent's output for success/failure markers.
379
- * Falls back to "finish" if no marker is found.
625
+ * Falls back to "finished" if no marker is found.
380
626
  */
381
627
  function parseTaskOutcome(output: string): TaskRunningState {
382
628
  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";
629
+ if (lastChunk.includes(TASK_FAILURE_MARKER)) return "failed";
630
+ if (lastChunk.includes(TASK_SUCCESS_MARKER)) return "finished";
631
+ return "finished";
386
632
  }
387
633