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.
- package/README.md +7 -4
- package/dist/agents/agent.d.ts +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claude.js +4 -4
- package/dist/agents/codex.d.ts +1 -1
- package/dist/agents/codex.js +4 -4
- package/dist/agents/copilot.d.ts +1 -1
- package/dist/agents/copilot.js +3 -3
- package/dist/agents/gemini.d.ts +1 -1
- package/dist/agents/gemini.js +4 -4
- package/dist/agents/openclaw.d.ts +1 -1
- package/dist/agents/openclaw.js +2 -2
- package/dist/commands/request-input.d.ts +1 -2
- package/dist/commands/request-input.js +7 -21
- package/dist/commands/run.d.ts +4 -0
- package/dist/commands/run.js +57 -64
- package/dist/commands/serve.js +31 -33
- package/dist/platform/linux.js +16 -6
- package/dist/platform/windows.js +37 -12
- package/dist/rpc-handler.js +177 -30
- package/dist/task.d.ts +13 -13
- package/dist/task.js +59 -51
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
- package/src/agents/agent.ts +2 -2
- package/src/agents/claude.ts +3 -3
- package/src/agents/codex.ts +3 -3
- package/src/agents/copilot.ts +3 -3
- package/src/agents/gemini.ts +3 -3
- package/src/agents/openclaw.ts +2 -2
- package/src/commands/request-input.ts +7 -21
- package/src/commands/run.ts +57 -67
- package/src/commands/serve.ts +34 -41
- package/src/platform/linux.ts +17 -7
- package/src/platform/windows.ts +36 -13
- package/src/rpc-handler.ts +195 -34
- package/src/task.ts +60 -55
- package/src/types.ts +2 -2
- package/test/agent-output-parsing.test.ts +1 -14
package/src/platform/windows.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
260
|
-
} catch {
|
|
261
|
-
|
|
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> {
|
package/src/rpc-handler.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
44
|
-
start_time:
|
|
45
|
-
end_time:
|
|
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
|
|
302
|
-
appendHistory(config.projectRoot, { task_id: id,
|
|
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,
|
|
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
|
|
323
|
-
appendHistory(config.projectRoot, { task_id: params.id,
|
|
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,
|
|
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;
|
|
368
|
-
if (!params.
|
|
369
|
-
return { error: "
|
|
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
|
|
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(
|
|
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: "
|
|
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: "
|
|
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(
|
|
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 "
|
|
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
|
|
595
|
+
const taskrunPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.run_id, "TASKRUN.md");
|
|
434
596
|
try {
|
|
435
|
-
const raw = fs.readFileSync(
|
|
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: "
|
|
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 "
|
|
449
|
-
const params = request.params as { task_id: string;
|
|
450
|
-
if (!params.task_id || !params.
|
|
451
|
-
return { error: "task_id and
|
|
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.
|
|
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,
|
|
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
|
|
152
|
-
*
|
|
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
|
|
154
|
+
export function createRunDir(
|
|
156
155
|
taskDir: string,
|
|
157
156
|
taskName: string,
|
|
158
157
|
startTime: number,
|
|
159
158
|
): string {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
*
|
|
168
|
+
* Get the path to a run directory.
|
|
169
169
|
*/
|
|
170
|
-
export function
|
|
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
|
-
|
|
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,
|
|
188
|
+
fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
/**
|
|
185
|
-
*
|
|
192
|
+
* Read conversation messages from a run's TASKRUN.md file.
|
|
186
193
|
*/
|
|
187
|
-
export function
|
|
188
|
-
taskDir
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
255
|
+
if (entry.task_id === taskId && entry.run_id === runId) {
|
|
240
256
|
found = true;
|
|
241
|
-
continue;
|
|
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
|
|
253
|
-
const
|
|
254
|
-
if (fs.existsSync(
|
|
255
|
-
fs.
|
|
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
|
-
|
|
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
|
|
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
|
-
});
|