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
@@ -2,17 +2,19 @@ import { randomUUID } from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { spawn } from "child_process";
5
+ import { spawn, type ChildProcess } from "child_process";
6
6
  import { parse as parseYaml } from "yaml";
7
7
  import { type NatsConnection } from "nats";
8
- import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createResultFile } from "./task.js";
8
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
9
9
  import { getPlatform } from "./platform/index.js";
10
10
  import { spawnCommand } from "./spawn-command.js";
11
+ import crossSpawn from "cross-spawn";
11
12
  import { getAgent } from "./agents/agent.js";
12
13
  import { validateSession } from "./session-store.js";
13
14
  import { publishHostEvent } from "./events.js";
14
15
  import { currentVersion, performUpdate } from "./update-checker.js";
15
- import type { HostConfig, ParsedTask, RpcMessage } from "./types.js";
16
+ import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
17
+ import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
16
18
 
17
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
20
 
@@ -22,46 +24,86 @@ const PLAN_GENERATION_PROMPT = fs.readFileSync(
22
24
  );
23
25
 
24
26
  /**
25
- * Parse RESULT frontmatter into a metadata object.
27
+ * Parse RESULT frontmatter and conversation messages.
26
28
  */
27
29
  function parseResultFrontmatter(raw: string): Record<string, unknown> {
28
30
  const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
29
- if (!fmMatch) return { content: raw };
31
+ if (!fmMatch) return { messages: [] };
30
32
 
31
33
  const meta: Record<string, string> = {};
32
- const requiredPermissions: Array<{ name: string; description: string }> = [];
33
34
  for (const line of fmMatch[1].split("\n")) {
34
35
  const sep = line.indexOf(": ");
35
36
  if (sep === -1) continue;
36
- const key = line.slice(0, sep).trim();
37
- const value = line.slice(sep + 2).trim();
38
- if (key === "required_permission") {
39
- const pipeSep = value.indexOf("|");
40
- if (pipeSep !== -1) {
41
- requiredPermissions.push({ name: value.slice(0, pipeSep).trim(), description: value.slice(pipeSep + 1).trim() });
42
- } else {
43
- requiredPermissions.push({ name: value, description: "" });
44
- }
45
- } else {
46
- meta[key] = value;
47
- }
37
+ meta[line.slice(0, sep).trim()] = line.slice(sep + 2).trim();
38
+ }
39
+
40
+ const messages = parseConversationMessages(fmMatch[2]);
41
+
42
+ // Derive state from status messages just look at the last one
43
+ const statusMessages = messages.filter((m: ConversationMessage) => m.role === "status");
44
+ const lastStatus = statusMessages[statusMessages.length - 1];
45
+ const startedMsg = statusMessages.find((m: ConversationMessage) => m.type === "started");
46
+ const terminalStates = ["finished", "failed", "aborted"];
47
+ const terminalMsg = [...statusMessages].reverse().find((m: ConversationMessage) => terminalStates.includes(m.type ?? ""));
48
+
49
+ // If last status is "started", determine if it's a task run or follow-up
50
+ let runningState: string | undefined;
51
+ if (lastStatus?.type === "started") {
52
+ runningState = terminalMsg ? "followup" : "started";
53
+ } else {
54
+ runningState = lastStatus?.type;
48
55
  }
49
- const reportFiles = meta.report_files
50
- ? meta.report_files.split(",").map((f: string) => f.trim()).filter(Boolean)
51
- : [];
52
56
 
53
57
  return {
54
- content: fmMatch[2],
58
+ messages,
55
59
  task_name: meta.task_name,
56
- running_state: meta.running_state,
57
- start_time: meta.start_time ? Number(meta.start_time) : undefined,
58
- end_time: meta.end_time ? Number(meta.end_time) : undefined,
59
- task_file: meta.task_file,
60
- report_files: reportFiles.length > 0 ? reportFiles : undefined,
61
- required_permissions: requiredPermissions.length > 0 ? requiredPermissions : undefined,
60
+ running_state: runningState,
61
+ start_time: startedMsg?.time || undefined,
62
+ end_time: terminalMsg?.time || undefined,
62
63
  };
63
64
  }
64
65
 
66
+ /**
67
+ * Parse conversation messages from the body of a RESULT file.
68
+ */
69
+ function parseConversationMessages(body: string): ConversationMessage[] {
70
+ const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
71
+ const messages: ConversationMessage[] = [];
72
+ const matches = [...body.matchAll(delimiterRegex)];
73
+
74
+ if (matches.length === 0) {
75
+ // No delimiters — treat entire body as single assistant message if non-empty
76
+ const content = body.trim();
77
+ if (content) {
78
+ messages.push({ role: "assistant", time: 0, content });
79
+ }
80
+ return messages;
81
+ }
82
+
83
+ for (let i = 0; i < matches.length; i++) {
84
+ const match = matches[i];
85
+ const attrs = match[1];
86
+ const start = match.index! + match[0].length;
87
+ const end = i + 1 < matches.length ? matches[i + 1].index! : body.length;
88
+ const content = body.slice(start, end).trim();
89
+
90
+ const role = (parseAttr(attrs, "role") ?? "assistant") as "assistant" | "user";
91
+ const time = Number(parseAttr(attrs, "time") ?? "0");
92
+ const type = parseAttr(attrs, "type") as ConversationMessage["type"];
93
+ const attachmentsRaw = parseAttr(attrs, "attachments");
94
+ const attachments = attachmentsRaw ? attachmentsRaw.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
95
+
96
+ messages.push({ role, time, content, ...(type ? { type } : {}), ...(attachments ? { attachments } : {}) });
97
+ }
98
+
99
+ return messages;
100
+ }
101
+
102
+ function parseAttr(attrs: string, name: string): string | undefined {
103
+ const match = attrs.match(new RegExp(`${name}="([^"]*)"`));
104
+ return match ? match[1] : undefined;
105
+ }
106
+
65
107
  /**
66
108
  * Run plan generation for a task prompt using the given agent.
67
109
  * Returns the generated plan body and task name.
@@ -98,6 +140,9 @@ async function generatePlan(
98
140
  return { name, body };
99
141
  }
100
142
 
143
+ /** Active follow-up child processes, keyed by "taskId:runId". */
144
+ const activeFollowups = new Map<string, ChildProcess>();
145
+
101
146
  /**
102
147
  * Create a transport-agnostic RPC handler bound to the given config.
103
148
  */
@@ -272,8 +317,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
272
317
  // Do NOT append to tasks.jsonl — this is a one-off run
273
318
 
274
319
  // Create initial result file so it appears in runs list immediately
275
- const resultFileName = createResultFile(taskDir, name, Date.now());
276
- appendHistory(config.projectRoot, { task_id: id, result_file: resultFileName });
320
+ const runId = createRunDir(taskDir, name, Date.now());
321
+ appendHistory(config.projectRoot, { task_id: id, run_id: runId });
277
322
 
278
323
  // Spawn `palmier run <id>` directly as a detached process
279
324
  const script = process.argv[1] || "palmier";
@@ -284,7 +329,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
284
329
  });
285
330
  child.unref();
286
331
 
287
- return { ok: true, task_id: id, result_file: resultFileName };
332
+ return { ok: true, task_id: id, run_id: runId };
288
333
  }
289
334
 
290
335
  case "task.run": {
@@ -293,11 +338,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
293
338
  // Create initial result file so it appears in runs list immediately
294
339
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
295
340
  const runTask = parseTaskFile(runTaskDir);
296
- const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
297
- appendHistory(config.projectRoot, { task_id: params.id, result_file: runResultFileName });
341
+ const taskRunId = createRunDir(runTaskDir, runTask.frontmatter.name, Date.now());
342
+ appendHistory(config.projectRoot, { task_id: params.id, run_id: taskRunId });
298
343
 
299
344
  await getPlatform().startTask(params.id);
300
- return { ok: true, task_id: params.id, result_file: runResultFileName };
345
+ return { ok: true, task_id: params.id, run_id: taskRunId };
301
346
  } catch (err: unknown) {
302
347
  const e = err as { stderr?: string; message?: string };
303
348
  console.error(`task.run failed for ${params.id}: ${e.stderr || e.message}`);
@@ -305,15 +350,157 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
305
350
  }
306
351
  }
307
352
 
353
+ case "task.followup": {
354
+ const params = request.params as { id: string; run_id: string; message: string };
355
+ if (!params.run_id || !params.message?.trim()) {
356
+ return { error: "run_id and message are required" };
357
+ }
358
+ const followupKey = `${params.id}:${params.run_id}`;
359
+ if (activeFollowups.has(followupKey)) {
360
+ return { error: "A follow-up is already running for this run" };
361
+ }
362
+
363
+ const followupTaskDir = getTaskDir(config.projectRoot, params.id);
364
+ const followupTask = parseTaskFile(followupTaskDir);
365
+ const followupRunDir = getRunDir(followupTaskDir, params.run_id);
366
+
367
+ // Append user message + started status
368
+ appendRunMessage(followupTaskDir, params.run_id, {
369
+ role: "user",
370
+ time: Date.now(),
371
+ content: params.message,
372
+ });
373
+ appendRunMessage(followupTaskDir, params.run_id, {
374
+ role: "status",
375
+ time: Date.now(),
376
+ content: "",
377
+ type: "started",
378
+ });
379
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
380
+
381
+ // Fire-and-forget: invoke agent inline as a child of the serve process
382
+ const followupAgent = getAgent(followupTask.frontmatter.agent);
383
+ const { command: cmd, args: cmdArgs, stdin } = followupAgent.getTaskRunCommandLine(
384
+ followupTask, params.message, followupTask.frontmatter.permissions,
385
+ );
386
+
387
+ // Spawn directly via crossSpawn so we can track and kill the child
388
+ const child = crossSpawn(cmd, cmdArgs, {
389
+ cwd: followupRunDir,
390
+ stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
391
+ env: { ...process.env, PALMIER_TASK_ID: params.id },
392
+ windowsHide: true,
393
+ });
394
+ if (stdin != null) child.stdin!.end(stdin);
395
+ activeFollowups.set(followupKey, child);
396
+
397
+ // Collect output
398
+ const chunks: Buffer[] = [];
399
+ child.stdout?.on("data", (d: Buffer) => chunks.push(d));
400
+ child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
401
+
402
+ child.on("close", async (code: number | null) => {
403
+ activeFollowups.delete(followupKey);
404
+ // If killed by stop_followup, the stopped status is already written
405
+ if (child.killed) return;
406
+
407
+ const output = Buffer.concat(chunks).toString("utf-8");
408
+ const outcome = code !== 0 ? "failed" : parseTaskOutcome(output);
409
+ const reportFiles = parseReportFiles(output);
410
+
411
+ appendRunMessage(followupTaskDir, params.run_id, {
412
+ role: "assistant",
413
+ time: Date.now(),
414
+ content: stripPalmierMarkers(output),
415
+ attachments: reportFiles.length > 0 ? reportFiles : undefined,
416
+ });
417
+ appendRunMessage(followupTaskDir, params.run_id, {
418
+ role: "status",
419
+ time: Date.now(),
420
+ content: "",
421
+ type: outcome,
422
+ });
423
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
424
+ });
425
+
426
+ child.on("error", async (err: Error) => {
427
+ activeFollowups.delete(followupKey);
428
+ console.error(`Follow-up failed for ${followupKey}:`, err);
429
+ appendRunMessage(followupTaskDir, params.run_id, {
430
+ role: "status",
431
+ time: Date.now(),
432
+ content: "",
433
+ type: "failed",
434
+ });
435
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
436
+ });
437
+
438
+ return { ok: true, task_id: params.id, run_id: params.run_id };
439
+ }
440
+
441
+ case "task.stop_followup": {
442
+ const params = request.params as { id: string; run_id: string };
443
+ if (!params.run_id) {
444
+ return { error: "run_id is required" };
445
+ }
446
+ const stopKey = `${params.id}:${params.run_id}`;
447
+ const child = activeFollowups.get(stopKey);
448
+ if (!child) {
449
+ return { error: "No active follow-up for this run" };
450
+ }
451
+
452
+ // Kill the child process tree
453
+ if (process.platform === "win32" && child.pid) {
454
+ try {
455
+ const { execFileSync } = await import("child_process");
456
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
457
+ } catch { /* may have already exited */ }
458
+ } else {
459
+ child.kill();
460
+ }
461
+
462
+ // Append stopped status (child.killed prevents the close handler from writing)
463
+ const stopTaskDir = getTaskDir(config.projectRoot, params.id);
464
+ appendRunMessage(stopTaskDir, params.run_id, {
465
+ role: "status",
466
+ time: Date.now(),
467
+ content: "",
468
+ type: "stopped",
469
+ });
470
+ activeFollowups.delete(stopKey);
471
+ await publishHostEvent(nc, config.hostId, params.id, { event_type: "result-updated", run_id: params.run_id });
472
+ return { ok: true, task_id: params.id, run_id: params.run_id };
473
+ }
474
+
308
475
  case "task.abort": {
309
476
  const params = request.params as { id: string };
477
+ const abortTaskDir = getTaskDir(config.projectRoot, params.id);
478
+ // Read the PID before overwriting status — stopTask needs it to
479
+ // kill the entire process tree on Windows.
480
+ const abortPrevStatus = readTaskStatus(abortTaskDir);
310
481
  // Write abort status BEFORE killing so the dying process's signal
311
482
  // handler can detect this was RPC-initiated and skip publishing.
312
- const abortTaskDir = getTaskDir(config.projectRoot, params.id);
313
483
  writeTaskStatus(abortTaskDir, {
314
484
  running_state: "aborted",
315
485
  time_stamp: Date.now(),
486
+ ...(abortPrevStatus?.pid ? { pid: abortPrevStatus.pid } : {}),
316
487
  });
488
+ // Append aborted status to the latest run
489
+ try {
490
+ const runDirs = fs.readdirSync(abortTaskDir)
491
+ .filter((f) => /^\d+$/.test(f) && fs.existsSync(path.join(abortTaskDir, f, "TASKRUN.md")))
492
+ .sort();
493
+ const latestRunId = runDirs[runDirs.length - 1];
494
+ if (latestRunId) {
495
+ appendRunMessage(abortTaskDir, latestRunId, {
496
+ role: "status",
497
+ time: Date.now(),
498
+ content: "",
499
+ type: "aborted",
500
+ });
501
+ }
502
+ } catch { /* best-effort */ }
503
+
317
504
  try {
318
505
  await getPlatform().stopTask(params.id);
319
506
  } catch (err: unknown) {
@@ -338,27 +525,28 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
338
525
  }
339
526
 
340
527
  case "task.result": {
341
- const params = request.params as { id: string; result_file: string };
342
- if (!params.result_file) {
343
- return { error: "result_file is required" };
528
+ const params = request.params as { id: string; run_id: string };
529
+ if (!params.run_id) {
530
+ return { error: "run_id is required" };
344
531
  }
345
- const resultPath = path.join(config.projectRoot, "tasks", params.id, params.result_file);
532
+ const taskrunPath = path.join(config.projectRoot, "tasks", params.id, params.run_id, "TASKRUN.md");
346
533
 
347
534
  try {
348
- const raw = fs.readFileSync(resultPath, "utf-8");
535
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
349
536
  const meta = parseResultFrontmatter(raw);
350
537
  return { task_id: params.id, ...meta };
351
538
  } catch {
352
- return { task_id: params.id, error: "No result file found" };
539
+ return { task_id: params.id, error: "Run not found" };
353
540
  }
354
541
  }
355
542
 
356
543
  case "task.reports": {
357
- const params = request.params as { id: string; report_files: string[] };
358
- if (!Array.isArray(params.report_files) || params.report_files.length === 0) {
359
- return { error: "report_files must be a non-empty array" };
544
+ const params = request.params as { id: string; run_id: string; report_files: string[] };
545
+ if (!params.run_id || !Array.isArray(params.report_files) || params.report_files.length === 0) {
546
+ return { error: "run_id and report_files are required" };
360
547
  }
361
548
  const reports: Array<{ file: string; content?: string; error?: string }> = [];
549
+ const runDir = path.join(config.projectRoot, "tasks", params.id, params.run_id);
362
550
  for (const file of params.report_files) {
363
551
  if (!file.endsWith(".md")) {
364
552
  reports.push({ file, error: "must end with .md" });
@@ -369,7 +557,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
369
557
  reports.push({ file, error: "must be a plain filename" });
370
558
  continue;
371
559
  }
372
- const reportPath = path.join(config.projectRoot, "tasks", params.id, basename);
560
+ const reportPath = path.join(runDir, basename);
373
561
  try {
374
562
  const content = fs.readFileSync(reportPath, "utf-8");
375
563
  reports.push({ file, content });
@@ -395,7 +583,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
395
583
  return { ok: true };
396
584
  }
397
585
 
398
- case "activity.list": {
586
+ case "taskrun.list": {
399
587
  const params = request.params as { offset?: number; limit?: number; task_id?: string };
400
588
  const { entries, total } = readHistory(config.projectRoot, {
401
589
  offset: params.offset ?? 0,
@@ -404,31 +592,30 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
404
592
  });
405
593
 
406
594
  const enriched = entries.map((entry) => {
407
- const resultPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.result_file);
595
+ const taskrunPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.run_id, "TASKRUN.md");
408
596
  try {
409
- const raw = fs.readFileSync(resultPath, "utf-8");
597
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
410
598
  const meta = parseResultFrontmatter(raw);
411
- // Exclude full content from list response
412
- const { content: _, ...rest } = meta;
599
+ const { messages: _, ...rest } = meta;
413
600
  return { ...entry, ...rest };
414
601
  } catch {
415
- return { ...entry, error: "Result file not found" };
602
+ return { ...entry, error: "Run not found" };
416
603
  }
417
604
  });
418
605
 
419
606
  return { entries: enriched, total };
420
607
  }
421
608
 
422
- case "activity.delete": {
423
- const params = request.params as { task_id: string; result_file: string };
424
- if (!params.task_id || !params.result_file) {
425
- return { error: "task_id and result_file are required" };
609
+ case "taskrun.delete": {
610
+ const params = request.params as { task_id: string; run_id: string };
611
+ if (!params.task_id || !params.run_id) {
612
+ return { error: "task_id and run_id are required" };
426
613
  }
427
- const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.result_file);
614
+ const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.run_id);
428
615
  if (!deleted) {
429
616
  return { error: "History entry not found" };
430
617
  }
431
- return { ok: true, task_id: params.task_id, result_file: params.result_file };
618
+ return { ok: true, task_id: params.task_id, run_id: params.run_id };
432
619
  }
433
620
 
434
621
  case "host.update": {
@@ -1,5 +1,16 @@
1
1
  import crossSpawn from "cross-spawn";
2
- import type { ChildProcess } from "child_process";
2
+ import { execFileSync, type ChildProcess } from "child_process";
3
+
4
+ /** Kill a child process and its entire tree on Windows; plain kill elsewhere. */
5
+ function treeKill(child: ChildProcess): void {
6
+ if (process.platform === "win32" && child.pid) {
7
+ try {
8
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
9
+ return;
10
+ } catch { /* fall through */ }
11
+ }
12
+ child.kill();
13
+ }
3
14
 
4
15
  export interface SpawnStreamingOptions {
5
16
  cwd: string;
@@ -100,7 +111,7 @@ export function spawnCommand(
100
111
  let timer: ReturnType<typeof setTimeout> | undefined;
101
112
  if (opts.timeout) {
102
113
  timer = setTimeout(() => {
103
- child.kill();
114
+ treeKill(child);
104
115
  reject(new Error("command timed out"));
105
116
  }, opts.timeout);
106
117
  }
package/src/task.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
- import type { ParsedTask, TaskFrontmatter, TaskStatus, HistoryEntry } from "./types.js";
4
+ import type { ParsedTask, TaskFrontmatter, TaskStatus, HistoryEntry, ConversationMessage } from "./types.js";
5
5
 
6
6
  /**
7
7
  * Parse a TASK.md file from the given task directory.
@@ -148,20 +148,81 @@ export function readTaskStatus(taskDir: string): TaskStatus | undefined {
148
148
  }
149
149
 
150
150
  /**
151
- * Create the initial result file when a task starts running.
152
- * Contains only start_time and running_state=started; no end_time or content yet.
153
- * Returns the result file name.
151
+ * Create a run directory with an initial TASKRUN.md file.
152
+ * Returns the run ID (timestamp string used as directory name).
154
153
  */
155
- export function createResultFile(
154
+ export function createRunDir(
156
155
  taskDir: string,
157
156
  taskName: string,
158
157
  startTime: number,
159
158
  ): string {
160
- const resultFileName = `RESULT-${startTime}.md`;
161
- const taskSnapshotName = `TASK-${startTime}.md`;
162
- const content = `---\ntask_name: ${taskName}\nrunning_state: started\nstart_time: ${startTime}\ntask_file: ${taskSnapshotName}\n---\n`;
163
- fs.writeFileSync(path.join(taskDir, resultFileName), content, "utf-8");
164
- return resultFileName;
159
+ const runId = String(startTime);
160
+ const runDir = path.join(taskDir, runId);
161
+ fs.mkdirSync(runDir, { recursive: true });
162
+ const content = `---\ntask_name: ${taskName}\n---\n\n`;
163
+ fs.writeFileSync(path.join(runDir, "TASKRUN.md"), content, "utf-8");
164
+ return runId;
165
+ }
166
+
167
+ /**
168
+ * Get the path to a run directory.
169
+ */
170
+ export function getRunDir(taskDir: string, runId: string): string {
171
+ return path.join(taskDir, runId);
172
+ }
173
+
174
+ /**
175
+ * Append a conversation message to a run's TASKRUN.md file.
176
+ */
177
+ export function appendRunMessage(
178
+ taskDir: string,
179
+ runId: string,
180
+ msg: ConversationMessage,
181
+ ): void {
182
+ const attrs = [`role="${msg.role}"`, `time="${msg.time}"`];
183
+ if (msg.type) attrs.push(`type="${msg.type}"`);
184
+ if (msg.attachments?.length) attrs.push(`attachments="${msg.attachments.join(",")}"`);
185
+
186
+ const delimiter = `<!-- palmier:message ${attrs.join(" ")} -->`;
187
+ const entry = `${delimiter}\n\n${msg.content}\n\n`;
188
+ fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
189
+ }
190
+
191
+ /**
192
+ * Read conversation messages from a run's TASKRUN.md file.
193
+ */
194
+ export function readRunMessages(taskDir: string, runId: string): ConversationMessage[] {
195
+ const raw = fs.readFileSync(path.join(taskDir, runId, "TASKRUN.md"), "utf-8");
196
+ const fmMatch = raw.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
197
+ if (!fmMatch) return [];
198
+
199
+ const body = fmMatch[1];
200
+ const delimiterRegex = /<!-- palmier:message\s+(.*?)\s*-->/g;
201
+ const matches = [...body.matchAll(delimiterRegex)];
202
+ if (matches.length === 0) return [];
203
+
204
+ const messages: ConversationMessage[] = [];
205
+ for (let i = 0; i < matches.length; i++) {
206
+ const match = matches[i];
207
+ const attrs = match[1];
208
+ const start = match.index! + match[0].length;
209
+ const end = i + 1 < matches.length ? matches[i + 1].index! : body.length;
210
+ const content = body.slice(start, end).trim();
211
+
212
+ const roleAttr = attrs.match(/role="([^"]*)"/)?.[1] ?? "assistant";
213
+ const timeAttr = attrs.match(/time="([^"]*)"/)?.[1] ?? "0";
214
+ const typeAttr = attrs.match(/type="([^"]*)"/)?.[1];
215
+ const attachmentsAttr = attrs.match(/attachments="([^"]*)"/)?.[1];
216
+
217
+ messages.push({
218
+ role: roleAttr as ConversationMessage["role"],
219
+ time: Number(timeAttr),
220
+ content,
221
+ ...(typeAttr ? { type: typeAttr as ConversationMessage["type"] } : {}),
222
+ ...(attachmentsAttr ? { attachments: attachmentsAttr.split(",").map((f) => f.trim()).filter(Boolean) } : {}),
223
+ });
224
+ }
225
+ return messages;
165
226
  }
166
227
 
167
228
  /**
@@ -173,13 +234,13 @@ export function appendHistory(projectRoot: string, entry: HistoryEntry): void {
173
234
  }
174
235
 
175
236
  /**
176
- * Delete a history entry and its associated result/task-snapshot files.
237
+ * Delete a history entry and its associated run directory.
177
238
  * Returns true if the entry was found and removed.
178
239
  */
179
240
  export function deleteHistoryEntry(
180
241
  projectRoot: string,
181
242
  taskId: string,
182
- resultFile: string,
243
+ runId: string,
183
244
  ): boolean {
184
245
  const historyPath = path.join(projectRoot, "history.jsonl");
185
246
  if (!fs.existsSync(historyPath)) return false;
@@ -191,9 +252,9 @@ export function deleteHistoryEntry(
191
252
  for (const line of lines) {
192
253
  try {
193
254
  const entry = JSON.parse(line) as HistoryEntry;
194
- if (entry.task_id === taskId && entry.result_file === resultFile) {
255
+ if (entry.task_id === taskId && entry.run_id === runId) {
195
256
  found = true;
196
- continue; // skip this entry
257
+ continue;
197
258
  }
198
259
  } catch { /* keep malformed lines */ }
199
260
  remaining.push(line);
@@ -201,23 +262,12 @@ export function deleteHistoryEntry(
201
262
 
202
263
  if (!found) return false;
203
264
 
204
- // Rewrite history.jsonl without the deleted entry
205
265
  fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
206
266
 
207
- // Delete the result file
208
- const resultPath = path.join(projectRoot, "tasks", taskId, resultFile);
209
- if (fs.existsSync(resultPath)) {
210
- fs.unlinkSync(resultPath);
211
- }
212
-
213
- // Delete the corresponding task snapshot (TASK-<timestamp>.md)
214
- const tsMatch = resultFile.match(/^RESULT-(\d+)\.md$/);
215
- if (tsMatch) {
216
- const snapshotFile = `TASK-${tsMatch[1]}.md`;
217
- const snapshotPath = path.join(projectRoot, "tasks", taskId, snapshotFile);
218
- if (fs.existsSync(snapshotPath)) {
219
- fs.unlinkSync(snapshotPath);
220
- }
267
+ // Delete the run directory
268
+ const runDir = path.join(projectRoot, "tasks", taskId, runId);
269
+ if (fs.existsSync(runDir)) {
270
+ fs.rmSync(runDir, { recursive: true, force: true });
221
271
  }
222
272
 
223
273
  return true;
package/src/types.ts CHANGED
@@ -53,6 +53,8 @@ export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
53
53
  export interface TaskStatus {
54
54
  running_state: TaskRunningState;
55
55
  time_stamp: number;
56
+ /** PID of the palmier run process (used on Windows to kill the process tree). */
57
+ pid?: number;
56
58
  /** Set when the task has `requires_confirmation` and is awaiting user approval. */
57
59
  pending_confirmation?: boolean;
58
60
  /** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
@@ -65,7 +67,7 @@ export interface TaskStatus {
65
67
 
66
68
  export interface HistoryEntry {
67
69
  task_id: string;
68
- result_file: string;
70
+ run_id: string;
69
71
  }
70
72
 
71
73
  export interface RequiredPermission {
@@ -73,6 +75,14 @@ export interface RequiredPermission {
73
75
  description: string;
74
76
  }
75
77
 
78
+ export interface ConversationMessage {
79
+ role: "assistant" | "user" | "status";
80
+ time: number;
81
+ content: string;
82
+ type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted" | "stopped";
83
+ attachments?: string[];
84
+ }
85
+
76
86
  export interface RpcMessage {
77
87
  method: string;
78
88
  params: Record<string, unknown>;
@@ -1,2 +0,0 @@
1
- export declare function mcpserverCommand(): Promise<void>;
2
- //# sourceMappingURL=mcpserver.d.ts.map