palmier 0.5.6 → 0.5.8

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.
@@ -85,7 +85,7 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
85
85
  await appendAndNotify(ctx, {
86
86
  role: "user",
87
87
  time: Date.now(),
88
- content: "Denied",
88
+ content: "Deny & Abort Task",
89
89
  type: "permission",
90
90
  });
91
91
  return { outcome: "failed" };
@@ -95,7 +95,7 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
95
95
  await appendAndNotify(ctx, {
96
96
  role: "user",
97
97
  time: Date.now(),
98
- content: response === "granted_all" ? "Granted for all" : "Granted",
98
+ content: response === "granted_all" ? "Allow Always" : "Allow Once",
99
99
  type: "permission",
100
100
  });
101
101
  if (response === "granted_all") {
@@ -181,14 +181,19 @@ export async function runCommand(taskId) {
181
181
  // If requires_confirmation, notify clients and wait
182
182
  if (task.frontmatter.requires_confirmation) {
183
183
  const confirmed = await requestConfirmation(config, task, taskDir);
184
+ const confirmPrompt = `**Task Confirmation**\n\nRun task "${taskName || task.frontmatter.user_prompt}"?`;
185
+ appendRunMessage(taskDir, runId, { role: "assistant", time: Date.now(), content: confirmPrompt, type: "confirmation" });
186
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
184
187
  if (!confirmed) {
185
188
  console.log("Task aborted by user.");
189
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Aborted", type: "confirmation" });
186
190
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
187
191
  await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
188
192
  await cleanup();
189
193
  return;
190
194
  }
191
195
  console.log("Task confirmed by user.");
196
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Confirmed", type: "confirmation" });
192
197
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
193
198
  await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
194
199
  }
@@ -302,6 +307,9 @@ async function runCommandTriggeredMode(ctx) {
302
307
  invocationsFailed++;
303
308
  }
304
309
  appendLog(line, "", result.outcome);
310
+ // Append monitoring status so the UI shows the task is waiting for more input
311
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
312
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
305
313
  }
306
314
  async function drainQueue() {
307
315
  if (processing)
@@ -37,10 +37,12 @@ function parseResultFrontmatter(raw) {
37
37
  const startedMsg = statusMessages.find((m) => m.type === "started");
38
38
  const terminalStates = ["finished", "failed", "aborted"];
39
39
  const terminalMsg = [...statusMessages].reverse().find((m) => terminalStates.includes(m.type ?? ""));
40
- // If last status is "started", determine if it's a task run or follow-up
40
+ // If last status is "started" (or continuation like "confirmation"/"monitoring"),
41
+ // determine if it's a task run or follow-up
42
+ const activeStates = ["started", "monitoring", "confirmation"];
41
43
  let runningState;
42
- if (lastStatus?.type === "started" || lastStatus?.type === "monitoring") {
43
- runningState = terminalMsg ? "followup" : (lastStatus?.type ?? "started");
44
+ if (activeStates.includes(lastStatus?.type ?? "")) {
45
+ runningState = terminalMsg ? "followup" : "started";
44
46
  }
45
47
  else {
46
48
  runningState = lastStatus?.type;
@@ -61,7 +61,6 @@ export function spawnCommand(command, args, opts) {
61
61
  opts.onData(d.toString("utf-8"));
62
62
  });
63
63
  child.stderr.on("data", (d) => {
64
- chunks.push(d);
65
64
  process.stderr.write(d);
66
65
  if (opts.onData)
67
66
  opts.onData(d.toString("utf-8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -123,7 +123,7 @@ async function invokeAgentWithRetries(
123
123
  await appendAndNotify(ctx, {
124
124
  role: "user",
125
125
  time: Date.now(),
126
- content: "Denied",
126
+ content: "Deny & Abort Task",
127
127
  type: "permission",
128
128
  });
129
129
  return { outcome: "failed" };
@@ -137,7 +137,7 @@ async function invokeAgentWithRetries(
137
137
  await appendAndNotify(ctx, {
138
138
  role: "user",
139
139
  time: Date.now(),
140
- content: response === "granted_all" ? "Granted for all" : "Granted",
140
+ content: response === "granted_all" ? "Allow Always" : "Allow Once",
141
141
  type: "permission",
142
142
  });
143
143
 
@@ -237,14 +237,20 @@ export async function runCommand(taskId: string): Promise<void> {
237
237
  // If requires_confirmation, notify clients and wait
238
238
  if (task.frontmatter.requires_confirmation) {
239
239
  const confirmed = await requestConfirmation(config, task, taskDir);
240
+ const confirmPrompt = `**Task Confirmation**\n\nRun task "${taskName || task.frontmatter.user_prompt}"?`;
241
+ appendRunMessage(taskDir, runId, { role: "assistant", time: Date.now(), content: confirmPrompt, type: "confirmation" });
242
+ await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
243
+
240
244
  if (!confirmed) {
241
245
  console.log("Task aborted by user.");
246
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Aborted", type: "confirmation" });
242
247
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
243
248
  await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
244
249
  await cleanup();
245
250
  return;
246
251
  }
247
252
  console.log("Task confirmed by user.");
253
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Confirmed", type: "confirmation" });
248
254
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
249
255
  await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
250
256
  }
@@ -369,6 +375,10 @@ async function runCommandTriggeredMode(
369
375
  invocationsFailed++;
370
376
  }
371
377
  appendLog(line, "", result.outcome);
378
+
379
+ // Append monitoring status so the UI shows the task is waiting for more input
380
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
381
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
372
382
  }
373
383
 
374
384
  async function drainQueue(): Promise<void> {
@@ -47,10 +47,12 @@ function parseResultFrontmatter(raw: string): Record<string, unknown> {
47
47
  const terminalStates = ["finished", "failed", "aborted"];
48
48
  const terminalMsg = [...statusMessages].reverse().find((m: ConversationMessage) => terminalStates.includes(m.type ?? ""));
49
49
 
50
- // If last status is "started", determine if it's a task run or follow-up
50
+ // If last status is "started" (or continuation like "confirmation"/"monitoring"),
51
+ // determine if it's a task run or follow-up
52
+ const activeStates = ["started", "monitoring", "confirmation"];
51
53
  let runningState: string | undefined;
52
- if (lastStatus?.type === "started" || lastStatus?.type === "monitoring") {
53
- runningState = terminalMsg ? "followup" : (lastStatus?.type ?? "started");
54
+ if (activeStates.includes(lastStatus?.type ?? "")) {
55
+ runningState = terminalMsg ? "followup" : "started";
54
56
  } else {
55
57
  runningState = lastStatus?.type;
56
58
  }
@@ -110,7 +110,6 @@ export function spawnCommand(
110
110
  if (opts.onData) opts.onData(d.toString("utf-8"));
111
111
  });
112
112
  child.stderr!.on("data", (d: Buffer) => {
113
- chunks.push(d);
114
113
  process.stderr.write(d);
115
114
  if (opts.onData) opts.onData(d.toString("utf-8"));
116
115
  });
@@ -0,0 +1,224 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ import {
7
+ createRunDir,
8
+ appendRunMessage,
9
+ readRunMessages,
10
+ beginStreamingMessage,
11
+ spliceUserMessage,
12
+ } from "../src/task.js";
13
+
14
+ let taskDir: string;
15
+ let runId: string;
16
+
17
+ function setup() {
18
+ taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "palmier-test-"));
19
+ runId = createRunDir(taskDir, "Test Task", 1000, "claude");
20
+ }
21
+
22
+ describe("appendRunMessage + readRunMessages", () => {
23
+ beforeEach(setup);
24
+
25
+ it("writes and reads a user message", () => {
26
+ appendRunMessage(taskDir, runId, { role: "user", time: 1001, content: "Hello" });
27
+ const msgs = readRunMessages(taskDir, runId);
28
+ assert.equal(msgs.length, 1);
29
+ assert.equal(msgs[0].role, "user");
30
+ assert.equal(msgs[0].content, "Hello");
31
+ assert.equal(msgs[0].time, 1001);
32
+ });
33
+
34
+ it("writes and reads an assistant message", () => {
35
+ appendRunMessage(taskDir, runId, { role: "assistant", time: 1002, content: "Hi there" });
36
+ const msgs = readRunMessages(taskDir, runId);
37
+ assert.equal(msgs.length, 1);
38
+ assert.equal(msgs[0].role, "assistant");
39
+ assert.equal(msgs[0].content, "Hi there");
40
+ });
41
+
42
+ it("writes and reads a status message", () => {
43
+ appendRunMessage(taskDir, runId, { role: "status", time: 1003, content: "", type: "started" });
44
+ const msgs = readRunMessages(taskDir, runId);
45
+ assert.equal(msgs.length, 1);
46
+ assert.equal(msgs[0].role, "status");
47
+ assert.equal(msgs[0].type, "started");
48
+ });
49
+
50
+ it("preserves message type", () => {
51
+ appendRunMessage(taskDir, runId, { role: "user", time: 1004, content: "Confirmed", type: "confirmation" });
52
+ const msgs = readRunMessages(taskDir, runId);
53
+ assert.equal(msgs[0].type, "confirmation");
54
+ });
55
+
56
+ it("preserves attachments", () => {
57
+ appendRunMessage(taskDir, runId, { role: "assistant", time: 1005, content: "Done", attachments: ["report.md", "chart.png"] });
58
+ const msgs = readRunMessages(taskDir, runId);
59
+ assert.deepEqual(msgs[0].attachments, ["report.md", "chart.png"]);
60
+ });
61
+
62
+ it("reads multiple messages in order", () => {
63
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
64
+ appendRunMessage(taskDir, runId, { role: "user", time: 1001, content: "Do something" });
65
+ appendRunMessage(taskDir, runId, { role: "assistant", time: 1002, content: "Done" });
66
+ appendRunMessage(taskDir, runId, { role: "status", time: 1003, content: "", type: "finished" });
67
+ const msgs = readRunMessages(taskDir, runId);
68
+ assert.equal(msgs.length, 4);
69
+ assert.equal(msgs[0].type, "started");
70
+ assert.equal(msgs[1].role, "user");
71
+ assert.equal(msgs[2].role, "assistant");
72
+ assert.equal(msgs[3].type, "finished");
73
+ });
74
+ });
75
+
76
+ describe("confirmation flow", () => {
77
+ beforeEach(setup);
78
+
79
+ it("records confirmation with assistant prompt, user response, and status", () => {
80
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
81
+ appendRunMessage(taskDir, runId, { role: "assistant", time: 1001, content: '**Task Confirmation**\n\nRun task "My Task"?', type: "confirmation" });
82
+ appendRunMessage(taskDir, runId, { role: "user", time: 1002, content: "Confirmed", type: "confirmation" });
83
+ appendRunMessage(taskDir, runId, { role: "status", time: 1003, content: "", type: "confirmation" });
84
+
85
+ const msgs = readRunMessages(taskDir, runId);
86
+ assert.equal(msgs.length, 4);
87
+ assert.equal(msgs[1].role, "assistant");
88
+ assert.ok(msgs[1].content.includes("Task Confirmation"));
89
+ assert.equal(msgs[2].role, "user");
90
+ assert.equal(msgs[2].content, "Confirmed");
91
+ assert.equal(msgs[3].role, "status");
92
+ assert.equal(msgs[3].type, "confirmation");
93
+ });
94
+
95
+ it("records aborted confirmation", () => {
96
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
97
+ appendRunMessage(taskDir, runId, { role: "assistant", time: 1001, content: '**Task Confirmation**\n\nRun task "My Task"?', type: "confirmation" });
98
+ appendRunMessage(taskDir, runId, { role: "user", time: 1002, content: "Aborted", type: "confirmation" });
99
+ appendRunMessage(taskDir, runId, { role: "status", time: 1003, content: "", type: "aborted" });
100
+
101
+ const msgs = readRunMessages(taskDir, runId);
102
+ assert.equal(msgs.length, 4);
103
+ assert.equal(msgs[2].content, "Aborted");
104
+ assert.equal(msgs[3].type, "aborted");
105
+ });
106
+ });
107
+
108
+ describe("beginStreamingMessage", () => {
109
+ beforeEach(setup);
110
+
111
+ it("streams chunks and finalizes", () => {
112
+ const writer = beginStreamingMessage(taskDir, runId, 2000);
113
+ writer.write("Hello ");
114
+ writer.write("world");
115
+ writer.end();
116
+
117
+ const msgs = readRunMessages(taskDir, runId);
118
+ assert.equal(msgs.length, 1);
119
+ assert.equal(msgs[0].role, "assistant");
120
+ assert.equal(msgs[0].content, "Hello world");
121
+ });
122
+
123
+ it("attaches report files to the last assistant message", () => {
124
+ const writer = beginStreamingMessage(taskDir, runId, 2000);
125
+ writer.write("Generated report.");
126
+ writer.end(["report.md", "chart.png"]);
127
+
128
+ const msgs = readRunMessages(taskDir, runId);
129
+ assert.equal(msgs.length, 1);
130
+ assert.deepEqual(msgs[0].attachments, ["report.md", "chart.png"]);
131
+ });
132
+ });
133
+
134
+ describe("spliceUserMessage", () => {
135
+ beforeEach(setup);
136
+
137
+ it("splits assistant stream for user input", () => {
138
+ const writer = beginStreamingMessage(taskDir, runId, 2000);
139
+ writer.write("Working on it...");
140
+
141
+ spliceUserMessage(taskDir, runId, { role: "user", time: 2001, content: "my-api-key", type: "input" });
142
+
143
+ writer.write("Continuing with key.");
144
+ writer.end();
145
+
146
+ const msgs = readRunMessages(taskDir, runId);
147
+ assert.equal(msgs.length, 3);
148
+ assert.equal(msgs[0].role, "assistant");
149
+ assert.equal(msgs[0].content, "Working on it...");
150
+ assert.equal(msgs[1].role, "user");
151
+ assert.equal(msgs[1].content, "my-api-key");
152
+ assert.equal(msgs[1].type, "input");
153
+ assert.equal(msgs[2].role, "assistant");
154
+ assert.equal(msgs[2].content, "Continuing with key.");
155
+ });
156
+
157
+ it("appends assistant text before splicing", () => {
158
+ const writer = beginStreamingMessage(taskDir, runId, 2000);
159
+ writer.write("Processing");
160
+
161
+ spliceUserMessage(
162
+ taskDir, runId,
163
+ { role: "user", time: 2001, content: "answer1", type: "input" },
164
+ "\n\n**What is your key?**",
165
+ );
166
+
167
+ writer.write("Done.");
168
+ writer.end();
169
+
170
+ const msgs = readRunMessages(taskDir, runId);
171
+ assert.equal(msgs.length, 3);
172
+ assert.ok(msgs[0].content.includes("What is your key?"));
173
+ assert.equal(msgs[1].content, "answer1");
174
+ assert.equal(msgs[2].content, "Done.");
175
+ });
176
+
177
+ it("attaches reports to last assistant message after splice", () => {
178
+ const writer = beginStreamingMessage(taskDir, runId, 2000);
179
+ writer.write("Part 1");
180
+
181
+ spliceUserMessage(taskDir, runId, { role: "user", time: 2001, content: "input", type: "input" });
182
+
183
+ writer.write("Part 2");
184
+ writer.end(["report.md"]);
185
+
186
+ const msgs = readRunMessages(taskDir, runId);
187
+ // Attachments should be on the last assistant message (after splice), not the first
188
+ assert.equal(msgs[0].attachments, undefined);
189
+ assert.deepEqual(msgs[2].attachments, ["report.md"]);
190
+ });
191
+ });
192
+
193
+ describe("permission flow", () => {
194
+ beforeEach(setup);
195
+
196
+ it("records permission grant as user message", () => {
197
+ appendRunMessage(taskDir, runId, { role: "status", time: 1000, content: "", type: "started" });
198
+ appendRunMessage(taskDir, runId, { role: "user", time: 1001, content: "Do something" });
199
+ // Simulate agent output with permission request (via streaming)
200
+ const writer = beginStreamingMessage(taskDir, runId, 1002);
201
+ writer.write("I need permission.\n\n**Permissions requested:**\n- **Read** Read files\n");
202
+ writer.end();
203
+ // Permission granted
204
+ appendRunMessage(taskDir, runId, { role: "user", time: 1003, content: "Granted", type: "permission" });
205
+
206
+ const msgs = readRunMessages(taskDir, runId);
207
+ assert.equal(msgs.length, 4);
208
+ assert.equal(msgs[3].role, "user");
209
+ assert.equal(msgs[3].content, "Granted");
210
+ assert.equal(msgs[3].type, "permission");
211
+ });
212
+
213
+ it("records permission denial", () => {
214
+ appendRunMessage(taskDir, runId, { role: "user", time: 1001, content: "Do something" });
215
+ const writer = beginStreamingMessage(taskDir, runId, 1002);
216
+ writer.write("Need permission.");
217
+ writer.end();
218
+ appendRunMessage(taskDir, runId, { role: "user", time: 1003, content: "Denied", type: "permission" });
219
+
220
+ const msgs = readRunMessages(taskDir, runId);
221
+ assert.equal(msgs[2].content, "Denied");
222
+ assert.equal(msgs[2].type, "permission");
223
+ });
224
+ });