palmier 0.4.2 → 0.4.4

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 (45) hide show
  1. package/README.md +18 -30
  2. package/dist/agents/agent-instructions.md +40 -0
  3. package/dist/agents/claude.js +2 -8
  4. package/dist/agents/codex.js +0 -6
  5. package/dist/agents/copilot.js +0 -20
  6. package/dist/agents/gemini.js +0 -6
  7. package/dist/agents/shared-prompt.d.ts +1 -2
  8. package/dist/agents/shared-prompt.js +5 -18
  9. package/dist/commands/notify.d.ts +9 -0
  10. package/dist/commands/notify.js +43 -0
  11. package/dist/commands/request-input.d.ts +10 -0
  12. package/dist/commands/request-input.js +49 -0
  13. package/dist/commands/run.d.ts +4 -5
  14. package/dist/commands/run.js +90 -105
  15. package/dist/commands/serve.js +31 -28
  16. package/dist/index.js +15 -5
  17. package/dist/platform/linux.js +16 -6
  18. package/dist/platform/windows.js +54 -14
  19. package/dist/rpc-handler.js +217 -54
  20. package/dist/spawn-command.d.ts +1 -1
  21. package/dist/spawn-command.js +13 -1
  22. package/dist/task.d.ts +18 -7
  23. package/dist/task.js +70 -27
  24. package/dist/types.d.ts +10 -1
  25. package/package.json +2 -3
  26. package/src/agents/agent-instructions.md +40 -0
  27. package/src/agents/claude.ts +2 -7
  28. package/src/agents/codex.ts +0 -5
  29. package/src/agents/copilot.ts +0 -19
  30. package/src/agents/gemini.ts +0 -5
  31. package/src/agents/shared-prompt.ts +10 -18
  32. package/src/commands/notify.ts +44 -0
  33. package/src/commands/request-input.ts +51 -0
  34. package/src/commands/run.ts +98 -129
  35. package/src/commands/serve.ts +34 -36
  36. package/src/index.ts +16 -5
  37. package/src/platform/linux.ts +17 -7
  38. package/src/platform/windows.ts +53 -15
  39. package/src/rpc-handler.ts +244 -57
  40. package/src/spawn-command.ts +13 -2
  41. package/src/task.ts +79 -29
  42. package/src/types.ts +11 -1
  43. package/dist/commands/mcpserver.d.ts +0 -2
  44. package/dist/commands/mcpserver.js +0 -93
  45. package/src/commands/mcpserver.ts +0 -113
@@ -4,57 +4,88 @@ import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { spawn } from "child_process";
6
6
  import { parse as parseYaml } from "yaml";
7
- import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createResultFile } from "./task.js";
7
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
8
8
  import { getPlatform } from "./platform/index.js";
9
9
  import { spawnCommand } from "./spawn-command.js";
10
+ import crossSpawn from "cross-spawn";
10
11
  import { getAgent } from "./agents/agent.js";
11
12
  import { validateSession } from "./session-store.js";
12
13
  import { publishHostEvent } from "./events.js";
13
14
  import { currentVersion, performUpdate } from "./update-checker.js";
15
+ import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
14
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
17
  const PLAN_GENERATION_PROMPT = fs.readFileSync(path.join(__dirname, "commands", "plan-generation.md"), "utf-8");
16
18
  /**
17
- * Parse RESULT frontmatter into a metadata object.
19
+ * Parse RESULT frontmatter and conversation messages.
18
20
  */
19
21
  function parseResultFrontmatter(raw) {
20
22
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
21
23
  if (!fmMatch)
22
- return { content: raw };
24
+ return { messages: [] };
23
25
  const meta = {};
24
- const requiredPermissions = [];
25
26
  for (const line of fmMatch[1].split("\n")) {
26
27
  const sep = line.indexOf(": ");
27
28
  if (sep === -1)
28
29
  continue;
29
- const key = line.slice(0, sep).trim();
30
- const value = line.slice(sep + 2).trim();
31
- if (key === "required_permission") {
32
- const pipeSep = value.indexOf("|");
33
- if (pipeSep !== -1) {
34
- requiredPermissions.push({ name: value.slice(0, pipeSep).trim(), description: value.slice(pipeSep + 1).trim() });
35
- }
36
- else {
37
- requiredPermissions.push({ name: value, description: "" });
38
- }
39
- }
40
- else {
41
- meta[key] = value;
42
- }
30
+ meta[line.slice(0, sep).trim()] = line.slice(sep + 2).trim();
31
+ }
32
+ const messages = parseConversationMessages(fmMatch[2]);
33
+ // Derive state from status messages — just look at the last one
34
+ const statusMessages = messages.filter((m) => m.role === "status");
35
+ const lastStatus = statusMessages[statusMessages.length - 1];
36
+ const startedMsg = statusMessages.find((m) => m.type === "started");
37
+ const terminalStates = ["finished", "failed", "aborted"];
38
+ const terminalMsg = [...statusMessages].reverse().find((m) => terminalStates.includes(m.type ?? ""));
39
+ // If last status is "started", determine if it's a task run or follow-up
40
+ let runningState;
41
+ if (lastStatus?.type === "started") {
42
+ runningState = terminalMsg ? "followup" : "started";
43
+ }
44
+ else {
45
+ runningState = lastStatus?.type;
43
46
  }
44
- const reportFiles = meta.report_files
45
- ? meta.report_files.split(",").map((f) => f.trim()).filter(Boolean)
46
- : [];
47
47
  return {
48
- content: fmMatch[2],
48
+ messages,
49
49
  task_name: meta.task_name,
50
- running_state: meta.running_state,
51
- start_time: meta.start_time ? Number(meta.start_time) : undefined,
52
- end_time: meta.end_time ? Number(meta.end_time) : undefined,
53
- task_file: meta.task_file,
54
- report_files: reportFiles.length > 0 ? reportFiles : undefined,
55
- required_permissions: requiredPermissions.length > 0 ? requiredPermissions : undefined,
50
+ running_state: runningState,
51
+ start_time: startedMsg?.time || undefined,
52
+ end_time: terminalMsg?.time || undefined,
56
53
  };
57
54
  }
55
+ /**
56
+ * Parse conversation messages from the body of a RESULT file.
57
+ */
58
+ function parseConversationMessages(body) {
59
+ const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
60
+ const messages = [];
61
+ const matches = [...body.matchAll(delimiterRegex)];
62
+ if (matches.length === 0) {
63
+ // No delimiters — treat entire body as single assistant message if non-empty
64
+ const content = body.trim();
65
+ if (content) {
66
+ messages.push({ role: "assistant", time: 0, content });
67
+ }
68
+ return messages;
69
+ }
70
+ for (let i = 0; i < matches.length; i++) {
71
+ const match = matches[i];
72
+ const attrs = match[1];
73
+ const start = match.index + match[0].length;
74
+ const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
75
+ const content = body.slice(start, end).trim();
76
+ const role = (parseAttr(attrs, "role") ?? "assistant");
77
+ const time = Number(parseAttr(attrs, "time") ?? "0");
78
+ const type = parseAttr(attrs, "type");
79
+ const attachmentsRaw = parseAttr(attrs, "attachments");
80
+ const attachments = attachmentsRaw ? attachmentsRaw.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
81
+ messages.push({ role, time, content, ...(type ? { type } : {}), ...(attachments ? { attachments } : {}) });
82
+ }
83
+ return messages;
84
+ }
85
+ function parseAttr(attrs, name) {
86
+ const match = attrs.match(new RegExp(`${name}="([^"]*)"`));
87
+ return match ? match[1] : undefined;
88
+ }
58
89
  /**
59
90
  * Run plan generation for a task prompt using the given agent.
60
91
  * Returns the generated plan body and task name.
@@ -85,6 +116,8 @@ async function generatePlan(projectRoot, userPrompt, agentName) {
85
116
  }
86
117
  return { name, body };
87
118
  }
119
+ /** Active follow-up child processes, keyed by "taskId:runId". */
120
+ const activeFollowups = new Map();
88
121
  /**
89
122
  * Create a transport-agnostic RPC handler bound to the given config.
90
123
  */
@@ -226,8 +259,8 @@ export function createRpcHandler(config, nc) {
226
259
  writeTaskFile(taskDir, task);
227
260
  // Do NOT append to tasks.jsonl — this is a one-off run
228
261
  // Create initial result file so it appears in runs list immediately
229
- const resultFileName = createResultFile(taskDir, name, Date.now());
230
- appendHistory(config.projectRoot, { task_id: id, result_file: resultFileName });
262
+ const runId = createRunDir(taskDir, name, Date.now());
263
+ appendHistory(config.projectRoot, { task_id: id, run_id: runId });
231
264
  // Spawn `palmier run <id>` directly as a detached process
232
265
  const script = process.argv[1] || "palmier";
233
266
  const child = spawn(process.execPath, [script, "run", id], {
@@ -236,7 +269,7 @@ export function createRpcHandler(config, nc) {
236
269
  windowsHide: true,
237
270
  });
238
271
  child.unref();
239
- return { ok: true, task_id: id, result_file: resultFileName };
272
+ return { ok: true, task_id: id, run_id: runId };
240
273
  }
241
274
  case "task.run": {
242
275
  const params = request.params;
@@ -244,10 +277,10 @@ export function createRpcHandler(config, nc) {
244
277
  // Create initial result file so it appears in runs list immediately
245
278
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
246
279
  const runTask = parseTaskFile(runTaskDir);
247
- const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
248
- appendHistory(config.projectRoot, { task_id: params.id, result_file: runResultFileName });
280
+ const taskRunId = createRunDir(runTaskDir, runTask.frontmatter.name, Date.now());
281
+ appendHistory(config.projectRoot, { task_id: params.id, run_id: taskRunId });
249
282
  await getPlatform().startTask(params.id);
250
- return { ok: true, task_id: params.id, result_file: runResultFileName };
283
+ return { ok: true, task_id: params.id, run_id: taskRunId };
251
284
  }
252
285
  catch (err) {
253
286
  const e = err;
@@ -255,15 +288,145 @@ export function createRpcHandler(config, nc) {
255
288
  return { error: `Failed to start task: ${e.stderr || e.message}` };
256
289
  }
257
290
  }
291
+ case "task.followup": {
292
+ const params = request.params;
293
+ if (!params.run_id || !params.message?.trim()) {
294
+ return { error: "run_id and message are required" };
295
+ }
296
+ const followupKey = `${params.id}:${params.run_id}`;
297
+ if (activeFollowups.has(followupKey)) {
298
+ return { error: "A follow-up is already running for this run" };
299
+ }
300
+ const followupTaskDir = getTaskDir(config.projectRoot, params.id);
301
+ const followupTask = parseTaskFile(followupTaskDir);
302
+ const followupRunDir = getRunDir(followupTaskDir, params.run_id);
303
+ // Append user message + started status
304
+ appendRunMessage(followupTaskDir, params.run_id, {
305
+ role: "user",
306
+ time: Date.now(),
307
+ content: params.message,
308
+ });
309
+ appendRunMessage(followupTaskDir, params.run_id, {
310
+ role: "status",
311
+ time: Date.now(),
312
+ content: "",
313
+ type: "started",
314
+ });
315
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
316
+ // Fire-and-forget: invoke agent inline as a child of the serve process
317
+ const followupAgent = getAgent(followupTask.frontmatter.agent);
318
+ const { command: cmd, args: cmdArgs, stdin } = followupAgent.getTaskRunCommandLine(followupTask, params.message, followupTask.frontmatter.permissions);
319
+ // Spawn directly via crossSpawn so we can track and kill the child
320
+ const child = crossSpawn(cmd, cmdArgs, {
321
+ cwd: followupRunDir,
322
+ stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
323
+ env: { ...process.env, PALMIER_TASK_ID: params.id },
324
+ windowsHide: true,
325
+ });
326
+ if (stdin != null)
327
+ child.stdin.end(stdin);
328
+ activeFollowups.set(followupKey, child);
329
+ // Collect output
330
+ const chunks = [];
331
+ child.stdout?.on("data", (d) => chunks.push(d));
332
+ child.stderr?.on("data", (d) => process.stderr.write(d));
333
+ child.on("close", async (code) => {
334
+ activeFollowups.delete(followupKey);
335
+ // If killed by stop_followup, the stopped status is already written
336
+ if (child.killed)
337
+ return;
338
+ const output = Buffer.concat(chunks).toString("utf-8");
339
+ const outcome = code !== 0 ? "failed" : parseTaskOutcome(output);
340
+ const reportFiles = parseReportFiles(output);
341
+ appendRunMessage(followupTaskDir, params.run_id, {
342
+ role: "assistant",
343
+ time: Date.now(),
344
+ content: stripPalmierMarkers(output),
345
+ attachments: reportFiles.length > 0 ? reportFiles : undefined,
346
+ });
347
+ appendRunMessage(followupTaskDir, params.run_id, {
348
+ role: "status",
349
+ time: Date.now(),
350
+ content: "",
351
+ type: outcome,
352
+ });
353
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
354
+ });
355
+ child.on("error", async (err) => {
356
+ activeFollowups.delete(followupKey);
357
+ console.error(`Follow-up failed for ${followupKey}:`, err);
358
+ appendRunMessage(followupTaskDir, params.run_id, {
359
+ role: "status",
360
+ time: Date.now(),
361
+ content: "",
362
+ type: "failed",
363
+ });
364
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
365
+ });
366
+ return { ok: true, task_id: params.id, run_id: params.run_id };
367
+ }
368
+ case "task.stop_followup": {
369
+ const params = request.params;
370
+ if (!params.run_id) {
371
+ return { error: "run_id is required" };
372
+ }
373
+ const stopKey = `${params.id}:${params.run_id}`;
374
+ const child = activeFollowups.get(stopKey);
375
+ if (!child) {
376
+ return { error: "No active follow-up for this run" };
377
+ }
378
+ // Kill the child process tree
379
+ if (process.platform === "win32" && child.pid) {
380
+ try {
381
+ const { execFileSync } = await import("child_process");
382
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
383
+ }
384
+ catch { /* may have already exited */ }
385
+ }
386
+ else {
387
+ child.kill();
388
+ }
389
+ // Append stopped status (child.killed prevents the close handler from writing)
390
+ const stopTaskDir = getTaskDir(config.projectRoot, params.id);
391
+ appendRunMessage(stopTaskDir, params.run_id, {
392
+ role: "status",
393
+ time: Date.now(),
394
+ content: "",
395
+ type: "stopped",
396
+ });
397
+ activeFollowups.delete(stopKey);
398
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
399
+ return { ok: true, task_id: params.id, run_id: params.run_id };
400
+ }
258
401
  case "task.abort": {
259
402
  const params = request.params;
403
+ const abortTaskDir = getTaskDir(config.projectRoot, params.id);
404
+ // Read the PID before overwriting status — stopTask needs it to
405
+ // kill the entire process tree on Windows.
406
+ const abortPrevStatus = readTaskStatus(abortTaskDir);
260
407
  // Write abort status BEFORE killing so the dying process's signal
261
408
  // handler can detect this was RPC-initiated and skip publishing.
262
- const abortTaskDir = getTaskDir(config.projectRoot, params.id);
263
409
  writeTaskStatus(abortTaskDir, {
264
410
  running_state: "aborted",
265
411
  time_stamp: Date.now(),
412
+ ...(abortPrevStatus?.pid ? { pid: abortPrevStatus.pid } : {}),
266
413
  });
414
+ // Append aborted status to the latest run
415
+ try {
416
+ const runDirs = fs.readdirSync(abortTaskDir)
417
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(abortTaskDir, f, "TASKRUN.md")))
418
+ .sort();
419
+ const latestRunId = runDirs[runDirs.length - 1];
420
+ if (latestRunId) {
421
+ appendRunMessage(abortTaskDir, latestRunId, {
422
+ role: "status",
423
+ time: Date.now(),
424
+ content: "",
425
+ type: "aborted",
426
+ });
427
+ }
428
+ }
429
+ catch { /* best-effort */ }
267
430
  try {
268
431
  await getPlatform().stopTask(params.id);
269
432
  }
@@ -288,25 +451,26 @@ export function createRpcHandler(config, nc) {
288
451
  }
289
452
  case "task.result": {
290
453
  const params = request.params;
291
- if (!params.result_file) {
292
- return { error: "result_file is required" };
454
+ if (!params.run_id) {
455
+ return { error: "run_id is required" };
293
456
  }
294
- const resultPath = path.join(config.projectRoot, "tasks", params.id, params.result_file);
457
+ const taskrunPath = path.join(config.projectRoot, "tasks", params.id, params.run_id, "TASKRUN.md");
295
458
  try {
296
- const raw = fs.readFileSync(resultPath, "utf-8");
459
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
297
460
  const meta = parseResultFrontmatter(raw);
298
461
  return { task_id: params.id, ...meta };
299
462
  }
300
463
  catch {
301
- return { task_id: params.id, error: "No result file found" };
464
+ return { task_id: params.id, error: "Run not found" };
302
465
  }
303
466
  }
304
467
  case "task.reports": {
305
468
  const params = request.params;
306
- if (!Array.isArray(params.report_files) || params.report_files.length === 0) {
307
- return { error: "report_files must be a non-empty array" };
469
+ if (!params.run_id || !Array.isArray(params.report_files) || params.report_files.length === 0) {
470
+ return { error: "run_id and report_files are required" };
308
471
  }
309
472
  const reports = [];
473
+ const runDir = path.join(config.projectRoot, "tasks", params.id, params.run_id);
310
474
  for (const file of params.report_files) {
311
475
  if (!file.endsWith(".md")) {
312
476
  reports.push({ file, error: "must end with .md" });
@@ -317,7 +481,7 @@ export function createRpcHandler(config, nc) {
317
481
  reports.push({ file, error: "must be a plain filename" });
318
482
  continue;
319
483
  }
320
- const reportPath = path.join(config.projectRoot, "tasks", params.id, basename);
484
+ const reportPath = path.join(runDir, basename);
321
485
  try {
322
486
  const content = fs.readFileSync(reportPath, "utf-8");
323
487
  reports.push({ file, content });
@@ -339,7 +503,7 @@ export function createRpcHandler(config, nc) {
339
503
  console.log(`[task.user_input] ${params.id} → ${params.value}`);
340
504
  return { ok: true };
341
505
  }
342
- case "activity.list": {
506
+ case "taskrun.list": {
343
507
  const params = request.params;
344
508
  const { entries, total } = readHistory(config.projectRoot, {
345
509
  offset: params.offset ?? 0,
@@ -347,30 +511,29 @@ export function createRpcHandler(config, nc) {
347
511
  task_id: params.task_id,
348
512
  });
349
513
  const enriched = entries.map((entry) => {
350
- const resultPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.result_file);
514
+ const taskrunPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.run_id, "TASKRUN.md");
351
515
  try {
352
- const raw = fs.readFileSync(resultPath, "utf-8");
516
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
353
517
  const meta = parseResultFrontmatter(raw);
354
- // Exclude full content from list response
355
- const { content: _, ...rest } = meta;
518
+ const { messages: _, ...rest } = meta;
356
519
  return { ...entry, ...rest };
357
520
  }
358
521
  catch {
359
- return { ...entry, error: "Result file not found" };
522
+ return { ...entry, error: "Run not found" };
360
523
  }
361
524
  });
362
525
  return { entries: enriched, total };
363
526
  }
364
- case "activity.delete": {
527
+ case "taskrun.delete": {
365
528
  const params = request.params;
366
- if (!params.task_id || !params.result_file) {
367
- return { error: "task_id and result_file are required" };
529
+ if (!params.task_id || !params.run_id) {
530
+ return { error: "task_id and run_id are required" };
368
531
  }
369
- const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.result_file);
532
+ const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.run_id);
370
533
  if (!deleted) {
371
534
  return { error: "History entry not found" };
372
535
  }
373
- return { ok: true, task_id: params.task_id, result_file: params.result_file };
536
+ return { ok: true, task_id: params.task_id, run_id: params.run_id };
374
537
  }
375
538
  case "host.update": {
376
539
  const error = await performUpdate();
@@ -1,4 +1,4 @@
1
- import type { ChildProcess } from "child_process";
1
+ import { type ChildProcess } from "child_process";
2
2
  export interface SpawnStreamingOptions {
3
3
  cwd: string;
4
4
  env?: Record<string, string>;
@@ -1,4 +1,16 @@
1
1
  import crossSpawn from "cross-spawn";
2
+ import { execFileSync } from "child_process";
3
+ /** Kill a child process and its entire tree on Windows; plain kill elsewhere. */
4
+ function treeKill(child) {
5
+ if (process.platform === "win32" && child.pid) {
6
+ try {
7
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
8
+ return;
9
+ }
10
+ catch { /* fall through */ }
11
+ }
12
+ child.kill();
13
+ }
2
14
  /**
3
15
  * Spawn a command with shell interpretation, returning the ChildProcess
4
16
  * with stdout piped for line-by-line reading.
@@ -50,7 +62,7 @@ export function spawnCommand(command, args, opts) {
50
62
  let timer;
51
63
  if (opts.timeout) {
52
64
  timer = setTimeout(() => {
53
- child.kill();
65
+ treeKill(child);
54
66
  reject(new Error("command timed out"));
55
67
  }, opts.timeout);
56
68
  }
package/dist/task.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ParsedTask, TaskStatus, HistoryEntry } from "./types.js";
1
+ import type { ParsedTask, TaskStatus, HistoryEntry, ConversationMessage } from "./types.js";
2
2
  /**
3
3
  * Parse a TASK.md file from the given task directory.
4
4
  */
@@ -39,20 +39,31 @@ export declare function writeTaskStatus(taskDir: string, status: TaskStatus): vo
39
39
  */
40
40
  export declare function readTaskStatus(taskDir: string): TaskStatus | undefined;
41
41
  /**
42
- * Create the initial result file when a task starts running.
43
- * Contains only start_time and running_state=started; no end_time or content yet.
44
- * Returns the result file name.
42
+ * Create a run directory with an initial TASKRUN.md file.
43
+ * Returns the run ID (timestamp string used as directory name).
45
44
  */
46
- export declare function createResultFile(taskDir: string, taskName: string, startTime: number): string;
45
+ export declare function createRunDir(taskDir: string, taskName: string, startTime: number): string;
46
+ /**
47
+ * Get the path to a run directory.
48
+ */
49
+ export declare function getRunDir(taskDir: string, runId: string): string;
50
+ /**
51
+ * Append a conversation message to a run's TASKRUN.md file.
52
+ */
53
+ export declare function appendRunMessage(taskDir: string, runId: string, msg: ConversationMessage): void;
54
+ /**
55
+ * Read conversation messages from a run's TASKRUN.md file.
56
+ */
57
+ export declare function readRunMessages(taskDir: string, runId: string): ConversationMessage[];
47
58
  /**
48
59
  * Append a history entry to the project-level history.jsonl file.
49
60
  */
50
61
  export declare function appendHistory(projectRoot: string, entry: HistoryEntry): void;
51
62
  /**
52
- * Delete a history entry and its associated result/task-snapshot files.
63
+ * Delete a history entry and its associated run directory.
53
64
  * Returns true if the entry was found and removed.
54
65
  */
55
- export declare function deleteHistoryEntry(projectRoot: string, taskId: string, resultFile: string): boolean;
66
+ export declare function deleteHistoryEntry(projectRoot: string, taskId: string, runId: string): boolean;
56
67
  /**
57
68
  * Read history entries from history.jsonl with pagination.
58
69
  * Returns entries sorted most-recent-first.
package/dist/task.js CHANGED
@@ -130,16 +130,69 @@ export function readTaskStatus(taskDir) {
130
130
  }
131
131
  }
132
132
  /**
133
- * Create the initial result file when a task starts running.
134
- * Contains only start_time and running_state=started; no end_time or content yet.
135
- * Returns the result file name.
133
+ * Create a run directory with an initial TASKRUN.md file.
134
+ * Returns the run ID (timestamp string used as directory name).
136
135
  */
137
- export function createResultFile(taskDir, taskName, startTime) {
138
- const resultFileName = `RESULT-${startTime}.md`;
139
- const taskSnapshotName = `TASK-${startTime}.md`;
140
- const content = `---\ntask_name: ${taskName}\nrunning_state: started\nstart_time: ${startTime}\ntask_file: ${taskSnapshotName}\n---\n`;
141
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
142
- return resultFileName;
136
+ export function createRunDir(taskDir, taskName, startTime) {
137
+ const runId = String(startTime);
138
+ const runDir = path.join(taskDir, runId);
139
+ fs.mkdirSync(runDir, { recursive: true });
140
+ const content = `---\ntask_name: ${taskName}\n---\n\n`;
141
+ fs.writeFileSync(path.join(runDir, "TASKRUN.md"), content, "utf-8");
142
+ return runId;
143
+ }
144
+ /**
145
+ * Get the path to a run directory.
146
+ */
147
+ export function getRunDir(taskDir, runId) {
148
+ return path.join(taskDir, runId);
149
+ }
150
+ /**
151
+ * Append a conversation message to a run's TASKRUN.md file.
152
+ */
153
+ export function appendRunMessage(taskDir, runId, msg) {
154
+ const attrs = [`role="${msg.role}"`, `time="${msg.time}"`];
155
+ if (msg.type)
156
+ attrs.push(`type="${msg.type}"`);
157
+ if (msg.attachments?.length)
158
+ attrs.push(`attachments="${msg.attachments.join(",")}"`);
159
+ const delimiter = `<!-- palmier:message ${attrs.join(" ")} -->`;
160
+ const entry = `${delimiter}\n\n${msg.content}\n\n`;
161
+ fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
162
+ }
163
+ /**
164
+ * Read conversation messages from a run's TASKRUN.md file.
165
+ */
166
+ export function readRunMessages(taskDir, runId) {
167
+ const raw = fs.readFileSync(path.join(taskDir, runId, "TASKRUN.md"), "utf-8");
168
+ const fmMatch = raw.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
169
+ if (!fmMatch)
170
+ return [];
171
+ const body = fmMatch[1];
172
+ const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
173
+ const matches = [...body.matchAll(delimiterRegex)];
174
+ if (matches.length === 0)
175
+ return [];
176
+ const messages = [];
177
+ for (let i = 0; i < matches.length; i++) {
178
+ const match = matches[i];
179
+ const attrs = match[1];
180
+ const start = match.index + match[0].length;
181
+ const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
182
+ const content = body.slice(start, end).trim();
183
+ const roleAttr = attrs.match(/role="([^"]*)"/)?.[1] ?? "assistant";
184
+ const timeAttr = attrs.match(/time="([^"]*)"/)?.[1] ?? "0";
185
+ const typeAttr = attrs.match(/type="([^"]*)"/)?.[1];
186
+ const attachmentsAttr = attrs.match(/attachments="([^"]*)"/)?.[1];
187
+ messages.push({
188
+ role: roleAttr,
189
+ time: Number(timeAttr),
190
+ content,
191
+ ...(typeAttr ? { type: typeAttr } : {}),
192
+ ...(attachmentsAttr ? { attachments: attachmentsAttr.split(",").map((f) => f.trim()).filter(Boolean) } : {}),
193
+ });
194
+ }
195
+ return messages;
143
196
  }
144
197
  /**
145
198
  * Append a history entry to the project-level history.jsonl file.
@@ -149,10 +202,10 @@ export function appendHistory(projectRoot, entry) {
149
202
  fs.appendFileSync(historyPath, JSON.stringify(entry) + "\n", "utf-8");
150
203
  }
151
204
  /**
152
- * Delete a history entry and its associated result/task-snapshot files.
205
+ * Delete a history entry and its associated run directory.
153
206
  * Returns true if the entry was found and removed.
154
207
  */
155
- export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
208
+ export function deleteHistoryEntry(projectRoot, taskId, runId) {
156
209
  const historyPath = path.join(projectRoot, "history.jsonl");
157
210
  if (!fs.existsSync(historyPath))
158
211
  return false;
@@ -162,9 +215,9 @@ export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
162
215
  for (const line of lines) {
163
216
  try {
164
217
  const entry = JSON.parse(line);
165
- if (entry.task_id === taskId && entry.result_file === resultFile) {
218
+ if (entry.task_id === taskId && entry.run_id === runId) {
166
219
  found = true;
167
- continue; // skip this entry
220
+ continue;
168
221
  }
169
222
  }
170
223
  catch { /* keep malformed lines */ }
@@ -172,21 +225,11 @@ export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
172
225
  }
173
226
  if (!found)
174
227
  return false;
175
- // Rewrite history.jsonl without the deleted entry
176
228
  fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
177
- // Delete the result file
178
- const resultPath = path.join(projectRoot, "tasks", taskId, resultFile);
179
- if (fs.existsSync(resultPath)) {
180
- fs.unlinkSync(resultPath);
181
- }
182
- // Delete the corresponding task snapshot (TASK-<timestamp>.md)
183
- const tsMatch = resultFile.match(/^RESULT-(\d+)\.md$/);
184
- if (tsMatch) {
185
- const snapshotFile = `TASK-${tsMatch[1]}.md`;
186
- const snapshotPath = path.join(projectRoot, "tasks", taskId, snapshotFile);
187
- if (fs.existsSync(snapshotPath)) {
188
- fs.unlinkSync(snapshotPath);
189
- }
229
+ // Delete the run directory
230
+ const runDir = path.join(projectRoot, "tasks", taskId, runId);
231
+ if (fs.existsSync(runDir)) {
232
+ fs.rmSync(runDir, { recursive: true, force: true });
190
233
  }
191
234
  return true;
192
235
  }
package/dist/types.d.ts CHANGED
@@ -48,6 +48,8 @@ export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
48
48
  export interface TaskStatus {
49
49
  running_state: TaskRunningState;
50
50
  time_stamp: number;
51
+ /** PID of the palmier run process (used on Windows to kill the process tree). */
52
+ pid?: number;
51
53
  /** Set when the task has `requires_confirmation` and is awaiting user approval. */
52
54
  pending_confirmation?: boolean;
53
55
  /** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
@@ -59,12 +61,19 @@ export interface TaskStatus {
59
61
  }
60
62
  export interface HistoryEntry {
61
63
  task_id: string;
62
- result_file: string;
64
+ run_id: string;
63
65
  }
64
66
  export interface RequiredPermission {
65
67
  name: string;
66
68
  description: string;
67
69
  }
70
+ export interface ConversationMessage {
71
+ role: "assistant" | "user" | "status";
72
+ time: number;
73
+ content: string;
74
+ type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted" | "stopped";
75
+ attachments?: string[];
76
+ }
68
77
  export interface RpcMessage {
69
78
  method: string;
70
79
  params: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -20,13 +20,12 @@
20
20
  },
21
21
  "scripts": {
22
22
  "dev": "tsx src/index.ts",
23
- "build": "tsc && node -e \"require('fs').cpSync('src/commands/plan-generation.md','dist/commands/plan-generation.md')\"",
23
+ "build": "tsc && node -e \"require('fs').cpSync('src/commands/plan-generation.md','dist/commands/plan-generation.md');require('fs').cpSync('src/agents/agent-instructions.md','dist/agents/agent-instructions.md')\"",
24
24
  "test": "tsx --test test/**/*.test.ts",
25
25
  "prepare": "npm run build",
26
26
  "start": "node dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@modelcontextprotocol/sdk": "^1.27.1",
30
29
  "commander": "^13.1.0",
31
30
  "cross-spawn": "^7.0.6",
32
31
  "dotenv": "^16.4.7",