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.
- package/README.md +18 -30
- package/dist/agents/agent-instructions.md +40 -0
- package/dist/agents/claude.js +2 -8
- package/dist/agents/codex.js +0 -6
- package/dist/agents/copilot.js +0 -20
- package/dist/agents/gemini.js +0 -6
- package/dist/agents/shared-prompt.d.ts +1 -2
- package/dist/agents/shared-prompt.js +5 -18
- package/dist/commands/notify.d.ts +9 -0
- package/dist/commands/notify.js +43 -0
- package/dist/commands/request-input.d.ts +10 -0
- package/dist/commands/request-input.js +49 -0
- package/dist/commands/run.d.ts +4 -5
- package/dist/commands/run.js +90 -105
- package/dist/commands/serve.js +31 -28
- package/dist/index.js +15 -5
- package/dist/platform/linux.js +16 -6
- package/dist/platform/windows.js +54 -14
- package/dist/rpc-handler.js +217 -54
- package/dist/spawn-command.d.ts +1 -1
- package/dist/spawn-command.js +13 -1
- package/dist/task.d.ts +18 -7
- package/dist/task.js +70 -27
- package/dist/types.d.ts +10 -1
- package/package.json +2 -3
- package/src/agents/agent-instructions.md +40 -0
- package/src/agents/claude.ts +2 -7
- package/src/agents/codex.ts +0 -5
- package/src/agents/copilot.ts +0 -19
- package/src/agents/gemini.ts +0 -5
- package/src/agents/shared-prompt.ts +10 -18
- package/src/commands/notify.ts +44 -0
- package/src/commands/request-input.ts +51 -0
- package/src/commands/run.ts +98 -129
- package/src/commands/serve.ts +34 -36
- package/src/index.ts +16 -5
- package/src/platform/linux.ts +17 -7
- package/src/platform/windows.ts +53 -15
- package/src/rpc-handler.ts +244 -57
- package/src/spawn-command.ts +13 -2
- package/src/task.ts +79 -29
- package/src/types.ts +11 -1
- package/dist/commands/mcpserver.d.ts +0 -2
- package/dist/commands/mcpserver.js +0 -93
- package/src/commands/mcpserver.ts +0 -113
package/src/rpc-handler.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
58
|
+
messages,
|
|
55
59
|
task_name: meta.task_name,
|
|
56
|
-
running_state:
|
|
57
|
-
start_time:
|
|
58
|
-
end_time:
|
|
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
|
|
276
|
-
appendHistory(config.projectRoot, { task_id: id,
|
|
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,
|
|
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
|
|
297
|
-
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 });
|
|
298
343
|
|
|
299
344
|
await getPlatform().startTask(params.id);
|
|
300
|
-
return { ok: true, task_id: params.id,
|
|
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;
|
|
342
|
-
if (!params.
|
|
343
|
-
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" };
|
|
344
531
|
}
|
|
345
|
-
const
|
|
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(
|
|
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: "
|
|
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: "
|
|
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(
|
|
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 "
|
|
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
|
|
595
|
+
const taskrunPath = path.join(config.projectRoot, "tasks", entry.task_id, entry.run_id, "TASKRUN.md");
|
|
408
596
|
try {
|
|
409
|
-
const raw = fs.readFileSync(
|
|
597
|
+
const raw = fs.readFileSync(taskrunPath, "utf-8");
|
|
410
598
|
const meta = parseResultFrontmatter(raw);
|
|
411
|
-
|
|
412
|
-
const { content: _, ...rest } = meta;
|
|
599
|
+
const { messages: _, ...rest } = meta;
|
|
413
600
|
return { ...entry, ...rest };
|
|
414
601
|
} catch {
|
|
415
|
-
return { ...entry, error: "
|
|
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 "
|
|
423
|
-
const params = request.params as { task_id: string;
|
|
424
|
-
if (!params.task_id || !params.
|
|
425
|
-
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" };
|
|
426
613
|
}
|
|
427
|
-
const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.
|
|
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,
|
|
618
|
+
return { ok: true, task_id: params.task_id, run_id: params.run_id };
|
|
432
619
|
}
|
|
433
620
|
|
|
434
621
|
case "host.update": {
|
package/src/spawn-command.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import crossSpawn from "cross-spawn";
|
|
2
|
-
import type
|
|
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
|
|
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
|
|
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
|
+
}
|
|
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
|
|
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
|
-
|
|
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.
|
|
255
|
+
if (entry.task_id === taskId && entry.run_id === runId) {
|
|
195
256
|
found = true;
|
|
196
|
-
continue;
|
|
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
|
|
208
|
-
const
|
|
209
|
-
if (fs.existsSync(
|
|
210
|
-
fs.
|
|
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
|
-
|
|
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>;
|