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.
@@ -3,6 +3,8 @@ import * as path from "path";
3
3
  import { homedir } from "os";
4
4
  import { execSync, exec } from "child_process";
5
5
  import { promisify } from "util";
6
+ import { loadConfig } from "../config.js";
7
+ import { getTaskDir, readTaskStatus } from "../task.js";
6
8
  const execAsync = promisify(exec);
7
9
  const UNIT_DIR = path.join(homedir(), ".config", "systemd", "user");
8
10
  const PATH_FILE = path.join(homedir(), ".config", "palmier", "user-path");
@@ -205,18 +207,26 @@ WantedBy=timers.target
205
207
  await execAsync(`systemctl --user stop ${serviceName}`);
206
208
  }
207
209
  isTaskRunning(taskId) {
210
+ // Check systemd first (for scheduled/on-demand runs)
208
211
  const serviceName = getServiceName(taskId);
209
212
  try {
210
- // is-active exits 0 only for "active". For oneshot services (Type=oneshot),
211
- // the state is "activating" while running, which exits non-zero.
212
- // Use show -p ActiveState to reliably get the state without exit code issues.
213
213
  const out = execSync(`systemctl --user show -p ActiveState --value ${serviceName}`, { encoding: "utf-8" });
214
214
  const state = out.trim();
215
- return state === "active" || state === "activating";
215
+ if (state === "active" || state === "activating")
216
+ return true;
216
217
  }
217
- catch {
218
- return false;
218
+ catch { /* service may not exist */ }
219
+ // Fall back to PID check (for follow-up runs spawned directly)
220
+ try {
221
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
222
+ const status = readTaskStatus(taskDir);
223
+ if (status?.pid) {
224
+ process.kill(status.pid, 0); // signal 0 = check if process exists
225
+ return true;
226
+ }
219
227
  }
228
+ catch { /* process not running or config unavailable */ }
229
+ return false;
220
230
  }
221
231
  getGuiEnv() {
222
232
  const uid = process.getuid?.();
@@ -8,14 +8,6 @@ const TASK_PREFIX = "\\Palmier\\PalmierTask-";
8
8
  const DAEMON_TASK_NAME = "PalmierDaemon";
9
9
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
10
10
  const DAEMON_VBS_FILE = path.join(CONFIG_DIR, "daemon.vbs");
11
- /**
12
- * Build the /tr value for schtasks: a single string with quoted paths
13
- * so Task Scheduler can invoke node with the palmier script + subcommand.
14
- */
15
- function schtasksTr(...subcommand) {
16
- const script = process.argv[1] || "palmier";
17
- return `"${process.execPath}" "${script}" ${subcommand.join(" ")}`;
18
- }
19
11
  const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
20
12
  /**
21
13
  * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
@@ -144,7 +136,13 @@ export class WindowsPlatform {
144
136
  installTaskTimer(config, task) {
145
137
  const taskId = task.frontmatter.id;
146
138
  const tn = schtasksTaskName(taskId);
147
- const tr = schtasksTr("run", taskId);
139
+ const script = process.argv[1] || "palmier";
140
+ // Write a VBS launcher so the task runs without a visible console window
141
+ const vbsPath = path.join(CONFIG_DIR, `task-${taskId}.vbs`);
142
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" run ${taskId}", 0, True`;
143
+ fs.writeFileSync(vbsPath, vbs, "utf-8");
144
+ const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
145
+ const tr = `"${wscript}" "${vbsPath}"`;
148
146
  // Build trigger XML elements
149
147
  const triggerElements = [];
150
148
  if (task.frontmatter.triggers_enabled) {
@@ -192,6 +190,10 @@ export class WindowsPlatform {
192
190
  catch {
193
191
  // Task might not exist — that's fine
194
192
  }
193
+ try {
194
+ fs.unlinkSync(path.join(CONFIG_DIR, `task-${taskId}.vbs`));
195
+ }
196
+ catch { /* ignore */ }
195
197
  }
196
198
  async startTask(taskId) {
197
199
  const tn = schtasksTaskName(taskId);
@@ -228,17 +230,40 @@ export class WindowsPlatform {
228
230
  }
229
231
  }
230
232
  isTaskRunning(taskId) {
233
+ // Check Task Scheduler first (for scheduled/on-demand runs)
231
234
  const tn = schtasksTaskName(taskId);
232
235
  try {
233
236
  const out = execFileSync("schtasks", ["/query", "/tn", tn, "/fo", "CSV", "/nh"], {
234
237
  encoding: "utf-8",
235
238
  windowsHide: true,
236
239
  });
237
- return out.includes('"Running"');
240
+ if (out.includes('"Running"'))
241
+ return true;
238
242
  }
239
- catch {
240
- return false;
243
+ catch { /* task may not exist in scheduler */ }
244
+ // Fall back to PID check (for follow-up runs spawned directly, not via schtasks)
245
+ try {
246
+ const taskDir = getTaskDir(loadConfig().projectRoot, taskId);
247
+ const status = readTaskStatus(taskDir);
248
+ if (status?.pid) {
249
+ // tasklist exits 0 if the PID is found
250
+ execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/nh"], {
251
+ encoding: "utf-8",
252
+ windowsHide: true,
253
+ stdio: "pipe",
254
+ });
255
+ // tasklist always exits 0; check if output contains the PID
256
+ const out = execFileSync("tasklist", ["/fi", `PID eq ${status.pid}`, "/fo", "CSV", "/nh"], {
257
+ encoding: "utf-8",
258
+ windowsHide: true,
259
+ stdio: "pipe",
260
+ });
261
+ if (out.includes(`"${status.pid}"`))
262
+ return true;
263
+ }
241
264
  }
265
+ catch { /* ignore */ }
266
+ return false;
242
267
  }
243
268
  getGuiEnv() {
244
269
  // Windows GUI is always available — no special env vars needed
@@ -4,13 +4,15 @@ 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
  /**
@@ -28,13 +30,26 @@ function parseResultFrontmatter(raw) {
28
30
  meta[line.slice(0, sep).trim()] = line.slice(sep + 2).trim();
29
31
  }
30
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;
46
+ }
31
47
  return {
32
48
  messages,
33
49
  task_name: meta.task_name,
34
- running_state: meta.running_state,
35
- start_time: meta.start_time ? Number(meta.start_time) : undefined,
36
- end_time: meta.end_time ? Number(meta.end_time) : undefined,
37
- task_file: meta.task_file,
50
+ running_state: runningState,
51
+ start_time: startedMsg?.time || undefined,
52
+ end_time: terminalMsg?.time || undefined,
38
53
  };
39
54
  }
40
55
  /**
@@ -101,6 +116,8 @@ async function generatePlan(projectRoot, userPrompt, agentName) {
101
116
  }
102
117
  return { name, body };
103
118
  }
119
+ /** Active follow-up child processes, keyed by "taskId:runId". */
120
+ const activeFollowups = new Map();
104
121
  /**
105
122
  * Create a transport-agnostic RPC handler bound to the given config.
106
123
  */
@@ -242,8 +259,8 @@ export function createRpcHandler(config, nc) {
242
259
  writeTaskFile(taskDir, task);
243
260
  // Do NOT append to tasks.jsonl — this is a one-off run
244
261
  // Create initial result file so it appears in runs list immediately
245
- const resultFileName = createResultFile(taskDir, name, Date.now());
246
- 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 });
247
264
  // Spawn `palmier run <id>` directly as a detached process
248
265
  const script = process.argv[1] || "palmier";
249
266
  const child = spawn(process.execPath, [script, "run", id], {
@@ -252,7 +269,7 @@ export function createRpcHandler(config, nc) {
252
269
  windowsHide: true,
253
270
  });
254
271
  child.unref();
255
- return { ok: true, task_id: id, result_file: resultFileName };
272
+ return { ok: true, task_id: id, run_id: runId };
256
273
  }
257
274
  case "task.run": {
258
275
  const params = request.params;
@@ -260,10 +277,10 @@ export function createRpcHandler(config, nc) {
260
277
  // Create initial result file so it appears in runs list immediately
261
278
  const runTaskDir = getTaskDir(config.projectRoot, params.id);
262
279
  const runTask = parseTaskFile(runTaskDir);
263
- const runResultFileName = createResultFile(runTaskDir, runTask.frontmatter.name, Date.now());
264
- 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 });
265
282
  await getPlatform().startTask(params.id);
266
- return { ok: true, task_id: params.id, result_file: runResultFileName };
283
+ return { ok: true, task_id: params.id, run_id: taskRunId };
267
284
  }
268
285
  catch (err) {
269
286
  const e = err;
@@ -271,15 +288,145 @@ export function createRpcHandler(config, nc) {
271
288
  return { error: `Failed to start task: ${e.stderr || e.message}` };
272
289
  }
273
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
+ }
274
401
  case "task.abort": {
275
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);
276
407
  // Write abort status BEFORE killing so the dying process's signal
277
408
  // handler can detect this was RPC-initiated and skip publishing.
278
- const abortTaskDir = getTaskDir(config.projectRoot, params.id);
279
409
  writeTaskStatus(abortTaskDir, {
280
410
  running_state: "aborted",
281
411
  time_stamp: Date.now(),
412
+ ...(abortPrevStatus?.pid ? { pid: abortPrevStatus.pid } : {}),
282
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 */ }
283
430
  try {
284
431
  await getPlatform().stopTask(params.id);
285
432
  }
@@ -304,25 +451,26 @@ export function createRpcHandler(config, nc) {
304
451
  }
305
452
  case "task.result": {
306
453
  const params = request.params;
307
- if (!params.result_file) {
308
- return { error: "result_file is required" };
454
+ if (!params.run_id) {
455
+ return { error: "run_id is required" };
309
456
  }
310
- 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");
311
458
  try {
312
- const raw = fs.readFileSync(resultPath, "utf-8");
459
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
313
460
  const meta = parseResultFrontmatter(raw);
314
461
  return { task_id: params.id, ...meta };
315
462
  }
316
463
  catch {
317
- return { task_id: params.id, error: "No result file found" };
464
+ return { task_id: params.id, error: "Run not found" };
318
465
  }
319
466
  }
320
467
  case "task.reports": {
321
468
  const params = request.params;
322
- if (!Array.isArray(params.report_files) || params.report_files.length === 0) {
323
- 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" };
324
471
  }
325
472
  const reports = [];
473
+ const runDir = path.join(config.projectRoot, "tasks", params.id, params.run_id);
326
474
  for (const file of params.report_files) {
327
475
  if (!file.endsWith(".md")) {
328
476
  reports.push({ file, error: "must end with .md" });
@@ -333,7 +481,7 @@ export function createRpcHandler(config, nc) {
333
481
  reports.push({ file, error: "must be a plain filename" });
334
482
  continue;
335
483
  }
336
- const reportPath = path.join(config.projectRoot, "tasks", params.id, basename);
484
+ const reportPath = path.join(runDir, basename);
337
485
  try {
338
486
  const content = fs.readFileSync(reportPath, "utf-8");
339
487
  reports.push({ file, content });
@@ -355,7 +503,7 @@ export function createRpcHandler(config, nc) {
355
503
  console.log(`[task.user_input] ${params.id} → ${params.value}`);
356
504
  return { ok: true };
357
505
  }
358
- case "activity.list": {
506
+ case "taskrun.list": {
359
507
  const params = request.params;
360
508
  const { entries, total } = readHistory(config.projectRoot, {
361
509
  offset: params.offset ?? 0,
@@ -363,30 +511,29 @@ export function createRpcHandler(config, nc) {
363
511
  task_id: params.task_id,
364
512
  });
365
513
  const enriched = entries.map((entry) => {
366
- 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");
367
515
  try {
368
- const raw = fs.readFileSync(resultPath, "utf-8");
516
+ const raw = fs.readFileSync(taskrunPath, "utf-8");
369
517
  const meta = parseResultFrontmatter(raw);
370
- // Exclude messages from list response
371
518
  const { messages: _, ...rest } = meta;
372
519
  return { ...entry, ...rest };
373
520
  }
374
521
  catch {
375
- return { ...entry, error: "Result file not found" };
522
+ return { ...entry, error: "Run not found" };
376
523
  }
377
524
  });
378
525
  return { entries: enriched, total };
379
526
  }
380
- case "activity.delete": {
527
+ case "taskrun.delete": {
381
528
  const params = request.params;
382
- if (!params.task_id || !params.result_file) {
383
- 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" };
384
531
  }
385
- const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.result_file);
532
+ const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.run_id);
386
533
  if (!deleted) {
387
534
  return { error: "History entry not found" };
388
535
  }
389
- 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 };
390
537
  }
391
538
  case "host.update": {
392
539
  const error = await performUpdate();
package/dist/task.d.ts CHANGED
@@ -39,31 +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;
47
46
  /**
48
- * Append a conversation message to a RESULT file.
47
+ * Get the path to a run directory.
49
48
  */
50
- export declare function appendResultMessage(taskDir: string, resultFile: string, msg: ConversationMessage): void;
49
+ export declare function getRunDir(taskDir: string, runId: string): string;
51
50
  /**
52
- * Update frontmatter fields in a RESULT file without touching the body.
51
+ * Append a conversation message to a run's TASKRUN.md file.
53
52
  */
54
- export declare function finalizeResultFrontmatter(taskDir: string, resultFile: string, updates: {
55
- end_time?: number;
56
- running_state?: string;
57
- }): void;
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[];
58
58
  /**
59
59
  * Append a history entry to the project-level history.jsonl file.
60
60
  */
61
61
  export declare function appendHistory(projectRoot: string, entry: HistoryEntry): void;
62
62
  /**
63
- * Delete a history entry and its associated result/task-snapshot files.
63
+ * Delete a history entry and its associated run directory.
64
64
  * Returns true if the entry was found and removed.
65
65
  */
66
- export declare function deleteHistoryEntry(projectRoot: string, taskId: string, resultFile: string): boolean;
66
+ export declare function deleteHistoryEntry(projectRoot: string, taskId: string, runId: string): boolean;
67
67
  /**
68
68
  * Read history entries from history.jsonl with pagination.
69
69
  * Returns entries sorted most-recent-first.
package/dist/task.js CHANGED
@@ -130,21 +130,27 @@ 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\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
143
  }
144
144
  /**
145
- * Append a conversation message to a RESULT file.
145
+ * Get the path to a run directory.
146
146
  */
147
- export function appendResultMessage(taskDir, resultFile, msg) {
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) {
148
154
  const attrs = [`role="${msg.role}"`, `time="${msg.time}"`];
149
155
  if (msg.type)
150
156
  attrs.push(`type="${msg.type}"`);
@@ -152,29 +158,41 @@ export function appendResultMessage(taskDir, resultFile, msg) {
152
158
  attrs.push(`attachments="${msg.attachments.join(",")}"`);
153
159
  const delimiter = `<!-- palmier:message ${attrs.join(" ")} -->`;
154
160
  const entry = `${delimiter}\n\n${msg.content}\n\n`;
155
- fs.appendFileSync(path.join(taskDir, resultFile), entry, "utf-8");
156
- }
157
- /**
158
- * Update frontmatter fields in a RESULT file without touching the body.
159
- */
160
- export function finalizeResultFrontmatter(taskDir, resultFile, updates) {
161
- const filePath = path.join(taskDir, resultFile);
162
- const raw = fs.readFileSync(filePath, "utf-8");
163
- const fmEnd = raw.indexOf("\n---\n", 4); // skip opening ---
164
- if (fmEnd === -1)
165
- return;
166
- let frontmatter = raw.slice(0, fmEnd);
167
- const body = raw.slice(fmEnd);
168
- for (const [key, value] of Object.entries(updates)) {
169
- const regex = new RegExp(`^${key}:.*$`, "m");
170
- if (regex.test(frontmatter)) {
171
- frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
172
- }
173
- else {
174
- frontmatter += `\n${key}: ${value}`;
175
- }
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
+ });
176
194
  }
177
- fs.writeFileSync(filePath, frontmatter + body, "utf-8");
195
+ return messages;
178
196
  }
179
197
  /**
180
198
  * Append a history entry to the project-level history.jsonl file.
@@ -184,10 +202,10 @@ export function appendHistory(projectRoot, entry) {
184
202
  fs.appendFileSync(historyPath, JSON.stringify(entry) + "\n", "utf-8");
185
203
  }
186
204
  /**
187
- * Delete a history entry and its associated result/task-snapshot files.
205
+ * Delete a history entry and its associated run directory.
188
206
  * Returns true if the entry was found and removed.
189
207
  */
190
- export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
208
+ export function deleteHistoryEntry(projectRoot, taskId, runId) {
191
209
  const historyPath = path.join(projectRoot, "history.jsonl");
192
210
  if (!fs.existsSync(historyPath))
193
211
  return false;
@@ -197,9 +215,9 @@ export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
197
215
  for (const line of lines) {
198
216
  try {
199
217
  const entry = JSON.parse(line);
200
- if (entry.task_id === taskId && entry.result_file === resultFile) {
218
+ if (entry.task_id === taskId && entry.run_id === runId) {
201
219
  found = true;
202
- continue; // skip this entry
220
+ continue;
203
221
  }
204
222
  }
205
223
  catch { /* keep malformed lines */ }
@@ -207,21 +225,11 @@ export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
207
225
  }
208
226
  if (!found)
209
227
  return false;
210
- // Rewrite history.jsonl without the deleted entry
211
228
  fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
212
- // Delete the result file
213
- const resultPath = path.join(projectRoot, "tasks", taskId, resultFile);
214
- if (fs.existsSync(resultPath)) {
215
- fs.unlinkSync(resultPath);
216
- }
217
- // Delete the corresponding task snapshot (TASK-<timestamp>.md)
218
- const tsMatch = resultFile.match(/^RESULT-(\d+)\.md$/);
219
- if (tsMatch) {
220
- const snapshotFile = `TASK-${tsMatch[1]}.md`;
221
- const snapshotPath = path.join(projectRoot, "tasks", taskId, snapshotFile);
222
- if (fs.existsSync(snapshotPath)) {
223
- fs.unlinkSync(snapshotPath);
224
- }
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 });
225
233
  }
226
234
  return true;
227
235
  }