palmier 0.4.3 → 0.4.5

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.
@@ -13,14 +13,6 @@ const DAEMON_TASK_NAME = "PalmierDaemon";
13
13
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
14
14
  const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
15
15
 
16
- /**
17
- * Build the /tr value for schtasks: a single string with quoted paths
18
- * so Task Scheduler can invoke node with the palmier script + subcommand.
19
- */
20
- function schtasksTr(...subcommand: string[]): string {
21
- const script = process.argv[1] || "palmier";
22
- return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
23
- }
24
16
 
25
17
  const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
26
18
 
@@ -169,7 +161,15 @@ export class WindowsPlatform implements PlatformService {
169
161
  installTaskTimer(config: HostConfig, task: ParsedTask): void {
170
162
  const taskId = task.frontmatter.id;
171
163
  const tn = schtasksTaskName(taskId);
172
- const tr = schtasksTr("run", taskId);
164
+ const script = process.argv[1] || "palmier";
165
+
166
+ // Write a VBS launcher so the task runs without a visible console window
167
+ const vbsPath = path.join(CONFIG_DIR, `task-${taskId}.vbs`);
168
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" run ${taskId}", 0, True`;
169
+ fs.writeFileSync(vbsPath, vbs, "utf-8");
170
+
171
+ const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
172
+ const tr = `"${wscript}" "${vbsPath}"`;
173
173
 
174
174
  // Build trigger XML elements
175
175
  const triggerElements: string[] = [];
@@ -213,6 +213,7 @@ export class WindowsPlatform implements PlatformService {
213
213
  } catch {
214
214
  // Task might not exist — that's fine
215
215
  }
216
+ try { fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`)); } catch { /* ignore */ }
216
217
  }
217
218
 
218
219
  async startTask(taskId: string): Promise<void> {
@@ -250,16 +251,38 @@ export class WindowsPlatform implements PlatformService {
250
251
  }
251
252
 
252
253
  isTaskRunning(taskId: string): boolean {
254
+ // Check Task Scheduler first (for scheduled/on-demand runs)
253
255
  const tn = schtasksTaskName(taskId);
254
256
  try {
255
257
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
256
258
  encoding: "utf-8",
257
259
  windowsHide: true,
258
260
  });
259
- return out.includes('"Running"');
260
- } catch {
261
- return false;
262
- }
261
+ if (out.includes('"Running"')) return true;
262
+ } catch { /* task may not exist in scheduler */ }
263
+
264
+ // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
265
+ try {
266
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
267
+ const status = readTaskStatus(taskDir);
268
+ if (status?.pid) {
269
+ // tasklist exits 0 if the PID is found
270
+ execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
271
+ encoding: "utf-8",
272
+ windowsHide: true,
273
+ stdio: "pipe",
274
+ });
275
+ // tasklist always exits 0; check if output contains the PID
276
+ const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
277
+ encoding: "utf-8",
278
+ windowsHide: true,
279
+ stdio: "pipe",
280
+ });
281
+ if (out.includes(`"${status.pid}"`)) return true;
282
+ }
283
+ } catch { /* ignore */ }
284
+
285
+ return false;
263
286
  }
264
287
 
265
288
  getGuiEnv(): Record<string, string> {
@@ -2,16 +2,18 @@ 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";
16
+ import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
15
17
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
16
18
 
17
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -37,13 +39,27 @@ function parseResultFrontmatter(raw: string): Record<string, unknown> {
37
39
 
38
40
  const messages = parseConversationMessages(fmMatch[2]);
39
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;
55
+ }
56
+
40
57
  return {
41
58
  messages,
42
59
  task_name: meta.task_name,
43
- running_state: meta.running_state,
44
- start_time: meta.start_time ? Number(meta.start_time) : undefined,
45
- end_time: meta.end_time ? Number(meta.end_time) : undefined,
46
- task_file: meta.task_file,
60
+ running_state: runningState,
61
+ start_time: startedMsg?.time || undefined,
62
+ end_time: terminalMsg?.time || undefined,
47
63
  };
48
64
  }
49
65
 
@@ -124,6 +140,9 @@ async function generatePlan(
124
140
  return { name, body };
125
141
  }
126
142
 
143
+ /** Active follow-up child processes, keyed by "taskId:runId". */
144
+ const activeFollowups = new Map<string, ChildProcess>();
145
+
127
146
  /**
128
147
  * Create a transport-agnostic RPC handler bound to the given config.
129
148
  */
@@ -298,8 +317,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
298
317
  // Do NOT append to tasks.jsonl — this is a one-off run
299
318
 
300
319
  // Create initial result file so it appears in runs list immediately
301
- const resultFileName = createResultFile(taskDir, name, Date.now());
302
- 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 });
303
322
 
304
323
  // Spawn `palmier run <id>` directly as a detached process
305
324
  const script = process.argv[1] || "palmier";
@@ -310,7 +329,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
310
329
  });
311
330
  child.unref();
312
331
 
313
- return { ok: true, task_id: id, result_file: resultFileName };
332
+ return { ok: true, task_id: id, run_id: runId };
314
333
  }
315
334
 
316
335
  case "task.run": {
@@ -319,11 +338,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
319
338
  // Create initial result file so it appears in runs list immediately
320
339
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
321
340
  const runTask = parseTaskFile(runTaskDir);
322
- const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
323
- 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 });
324
343
 
325
344
  await getPlatform().startTask(params.id);
326
- return { ok: true, task_id: params.id, result_file: runResultFileName };
345
+ return { ok: true, task_id: params.id, run_id: taskRunId };
327
346
  } catch (err: unknown) {
328
347
  const e = err as { stderr?: string; message?: string };
329
348
  console.error(`task.run failed for ${params.id}: ${e.stderr || e.message}`);
@@ -331,15 +350,157 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
331
350
  }
332
351
  }
333
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
+
334
475
  case "task.abort": {
335
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);
336
481
  // Write abort status BEFORE killing so the dying process's signal
337
482
  // handler can detect this was RPC-initiated and skip publishing.
338
- const abortTaskDir = getTaskDir(config.projectRoot, params.id);
339
483
  writeTaskStatus(abortTaskDir, {
340
484
  running_state: "aborted",
341
485
  time_stamp: Date.now(),
486
+ ...(abortPrevStatus?.pid ? { pid: abortPrevStatus.pid } : {}),
342
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
+
343
504
  try {
344
505
  await getPlatform().stopTask(params.id);
345
506
  } catch (err: unknown) {
@@ -364,27 +525,28 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
364
525
  }
365
526
 
366
527
  case "task.result": {
367
- const params = request.params as { id: string; result_file: string };
368
- if (!params.result_file) {
369
- 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" };
370
531
  }
371
- 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");
372
533
 
373
534
  try {
374
- const raw = fs.readFileSync(resultPath, "utf-8");
535
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
375
536
  const meta = parseResultFrontmatter(raw);
376
537
  return { task_id: params.id, ...meta };
377
538
  } catch {
378
- return { task_id: params.id, error: "No result file found" };
539
+ return { task_id: params.id, error: "Run not found" };
379
540
  }
380
541
  }
381
542
 
382
543
  case "task.reports": {
383
- const params = request.params as { id: string; report_files: string[] };
384
- if (!Array.isArray(params.report_files) || params.report_files.length === 0) {
385
- 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" };
386
547
  }
387
548
  const reports: Array<{ file: string; content?: string; error?: string }> = [];
549
+ const runDir = path.join(config.projectRoot, "tasks", params.id, params.run_id);
388
550
  for (const file of params.report_files) {
389
551
  if (!file.endsWith(".md")) {
390
552
  reports.push({ file, error: "must end with .md" });
@@ -395,7 +557,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
395
557
  reports.push({ file, error: "must be a plain filename" });
396
558
  continue;
397
559
  }
398
- const reportPath = path.join(config.projectRoot, "tasks", params.id, basename);
560
+ const reportPath = path.join(runDir, basename);
399
561
  try {
400
562
  const content = fs.readFileSync(reportPath, "utf-8");
401
563
  reports.push({ file, content });
@@ -421,7 +583,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
421
583
  return { ok: true };
422
584
  }
423
585
 
424
- case "activity.list": {
586
+ case "taskrun.list": {
425
587
  const params = request.params as { offset?: number; limit?: number; task_id?: string };
426
588
  const { entries, total } = readHistory(config.projectRoot, {
427
589
  offset: params.offset ?? 0,
@@ -430,31 +592,30 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
430
592
  });
431
593
 
432
594
  const enriched = entries.map((entry) => {
433
- 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");
434
596
  try {
435
- const raw = fs.readFileSync(resultPath, "utf-8");
597
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
436
598
  const meta = parseResultFrontmatter(raw);
437
- // Exclude messages from list response
438
599
  const { messages: _, ...rest } = meta;
439
600
  return { ...entry, ...rest };
440
601
  } catch {
441
- return { ...entry, error: "Result file not found" };
602
+ return { ...entry, error: "Run not found" };
442
603
  }
443
604
  });
444
605
 
445
606
  return { entries: enriched, total };
446
607
  }
447
608
 
448
- case "activity.delete": {
449
- const params = request.params as { task_id: string; result_file: string };
450
- if (!params.task_id || !params.result_file) {
451
- 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" };
452
613
  }
453
- const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.result_file);
614
+ const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.run_id);
454
615
  if (!deleted) {
455
616
  return { error: "History entry not found" };
456
617
  }
457
- 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 };
458
619
  }
459
620
 
460
621
  case "host.update": {
package/src/task.ts CHANGED
@@ -148,28 +148,35 @@ 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\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
165
  }
166
166
 
167
167
  /**
168
- * Append a conversation message to a RESULT file.
168
+ * Get the path to a run directory.
169
169
  */
170
- export function appendResultMessage(
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(
171
178
  taskDir: string,
172
- resultFile: string,
179
+ runId: string,
173
180
  msg: ConversationMessage,
174
181
  ): void {
175
182
  const attrs = [`role="${msg.role}"`, `time="${msg.time}"`];
@@ -178,35 +185,44 @@ export function appendResultMessage(
178
185
 
179
186
  const delimiter = `<!-- palmier:message ${attrs.join(" ")} -->`;
180
187
  const entry = `${delimiter}\n\n${msg.content}\n\n`;
181
- fs.appendFileSync(path.join(taskDir, resultFile), entry, "utf-8");
188
+ fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
182
189
  }
183
190
 
184
191
  /**
185
- * Update frontmatter fields in a RESULT file without touching the body.
192
+ * Read conversation messages from a run's TASKRUN.md file.
186
193
  */
187
- export function finalizeResultFrontmatter(
188
- taskDir: string,
189
- resultFile: string,
190
- updates: { end_time?: number; running_state?: string },
191
- ): void {
192
- const filePath = path.join(taskDir, resultFile);
193
- const raw = fs.readFileSync(filePath, "utf-8");
194
- const fmEnd = raw.indexOf("\n---\n", 4); // skip opening ---
195
- if (fmEnd === -1) return;
196
-
197
- let frontmatter = raw.slice(0, fmEnd);
198
- const body = raw.slice(fmEnd);
199
-
200
- for (const [key, value] of Object.entries(updates)) {
201
- const regex = new RegExp(`^${key}:.*$`, "m");
202
- if (regex.test(frontmatter)) {
203
- frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
204
- } else {
205
- frontmatter += `\n${key}: ${value}`;
206
- }
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
+ });
207
224
  }
208
-
209
- fs.writeFileSync(filePath, frontmatter + body, "utf-8");
225
+ return messages;
210
226
  }
211
227
 
212
228
  /**
@@ -218,13 +234,13 @@ export function appendHistory(projectRoot: string, entry: HistoryEntry): void {
218
234
  }
219
235
 
220
236
  /**
221
- * Delete a history entry and its associated result/task-snapshot files.
237
+ * Delete a history entry and its associated run directory.
222
238
  * Returns true if the entry was found and removed.
223
239
  */
224
240
  export function deleteHistoryEntry(
225
241
  projectRoot: string,
226
242
  taskId: string,
227
- resultFile: string,
243
+ runId: string,
228
244
  ): boolean {
229
245
  const historyPath = path.join(projectRoot, "history.jsonl");
230
246
  if (!fs.existsSync(historyPath)) return false;
@@ -236,9 +252,9 @@ export function deleteHistoryEntry(
236
252
  for (const line of lines) {
237
253
  try {
238
254
  const entry = JSON.parse(line) as HistoryEntry;
239
- if (entry.task_id === taskId && entry.result_file === resultFile) {
255
+ if (entry.task_id === taskId && entry.run_id === runId) {
240
256
  found = true;
241
- continue; // skip this entry
257
+ continue;
242
258
  }
243
259
  } catch { /* keep malformed lines */ }
244
260
  remaining.push(line);
@@ -246,23 +262,12 @@ export function deleteHistoryEntry(
246
262
 
247
263
  if (!found) return false;
248
264
 
249
- // Rewrite history.jsonl without the deleted entry
250
265
  fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
251
266
 
252
- // Delete the result file
253
- const resultPath = path.join(projectRoot, "tasks", taskId, resultFile);
254
- if (fs.existsSync(resultPath)) {
255
- fs.unlinkSync(resultPath);
256
- }
257
-
258
- // Delete the corresponding task snapshot (TASK-<timestamp>.md)
259
- const tsMatch = resultFile.match(/^RESULT-(\d+)\.md$/);
260
- if (tsMatch) {
261
- const snapshotFile = `TASK-${tsMatch[1]}.md`;
262
- const snapshotPath = path.join(projectRoot, "tasks", taskId, snapshotFile);
263
- if (fs.existsSync(snapshotPath)) {
264
- fs.unlinkSync(snapshotPath);
265
- }
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 });
266
271
  }
267
272
 
268
273
  return true;
package/src/types.ts CHANGED
@@ -67,7 +67,7 @@ export interface TaskStatus {
67
67
 
68
68
  export interface HistoryEntry {
69
69
  task_id: string;
70
- result_file: string;
70
+ run_id: string;
71
71
  }
72
72
 
73
73
  export interface RequiredPermission {
@@ -79,7 +79,7 @@ export interface ConversationMessage {
79
79
  role: "assistant" | "user" | "status";
80
80
  time: number;
81
81
  content: string;
82
- type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted";
82
+ type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted" | "stopped";
83
83
  attachments?: string[];
84
84
  }
85
85
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { parseTaskOutcome, parseReportFiles, parsePermissions, parseInputRequests } from "../src/commands/run.js";
3
+ import { parseTaskOutcome, parseReportFiles, parsePermissions } from "../src/commands/run.js";
4
4
 
5
5
  describe("parseTaskOutcome", () => {
6
6
  it("returns 'finished' for success marker", () => {
@@ -59,16 +59,3 @@ describe("parsePermissions", () => {
59
59
  });
60
60
  });
61
61
 
62
- describe("parseInputRequests", () => {
63
- it("extracts input descriptions", () => {
64
- const output = "[PALMIER_INPUT] What is the API key?\n[PALMIER_INPUT] Database connection string?";
65
- assert.deepEqual(parseInputRequests(output), [
66
- "What is the API key?",
67
- "Database connection string?",
68
- ]);
69
- });
70
-
71
- it("returns empty array when no inputs", () => {
72
- assert.deepEqual(parseInputRequests("no inputs"), []);
73
- });
74
- });