palmier 0.4.9 → 0.5.1
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/dist/commands/run.js +52 -13
- package/dist/commands/serve.js +8 -1
- package/dist/platform/windows.d.ts +4 -1
- package/dist/platform/windows.js +58 -22
- package/dist/rpc-handler.js +10 -3
- package/dist/spawn-command.d.ts +2 -0
- package/dist/spawn-command.js +4 -0
- package/dist/task.d.ts +14 -0
- package/dist/task.js +31 -0
- package/dist/transports/http-transport.js +6 -3
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/commands/run.ts +56 -13
- package/src/commands/serve.ts +7 -1
- package/src/platform/windows.ts +50 -23
- package/src/rpc-handler.ts +10 -3
- package/src/spawn-command.ts +4 -0
- package/src/task.ts +37 -0
- package/src/transports/http-transport.ts +9 -3
- package/src/types.ts +1 -1
package/dist/commands/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as readline from "readline";
|
|
|
4
4
|
import { spawnCommand, spawnStreamingCommand } from "../spawn-command.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { connectNats } from "../nats-client.js";
|
|
7
|
-
import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir } from "../task.js";
|
|
7
|
+
import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir, beginStreamingMessage } from "../task.js";
|
|
8
8
|
import { getAgent } from "../agents/agent.js";
|
|
9
9
|
import { getPlatform } from "../platform/index.js";
|
|
10
10
|
import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
|
|
@@ -19,6 +19,20 @@ import { publishHostEvent } from "../events.js";
|
|
|
19
19
|
async function invokeAgentWithRetries(ctx, invokeTask) {
|
|
20
20
|
// eslint-disable-next-line no-constant-condition
|
|
21
21
|
while (true) {
|
|
22
|
+
// Stream agent output to TASKRUN.md in real-time, throttled to 500ms
|
|
23
|
+
const writer = beginStreamingMessage(ctx.taskDir, ctx.runId, Date.now());
|
|
24
|
+
let lineBuf = "";
|
|
25
|
+
let notifyPending = false;
|
|
26
|
+
let notifyTimer;
|
|
27
|
+
function throttledNotify() {
|
|
28
|
+
if (notifyPending)
|
|
29
|
+
return;
|
|
30
|
+
notifyPending = true;
|
|
31
|
+
notifyTimer = setTimeout(() => {
|
|
32
|
+
notifyPending = false;
|
|
33
|
+
publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
34
|
+
}, 500);
|
|
35
|
+
}
|
|
22
36
|
const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, undefined, ctx.transientPermissions);
|
|
23
37
|
const result = await spawnCommand(command, args, {
|
|
24
38
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
@@ -26,17 +40,33 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
|
|
|
26
40
|
echoStdout: true,
|
|
27
41
|
resolveOnFailure: true,
|
|
28
42
|
stdin,
|
|
43
|
+
onData: (chunk) => {
|
|
44
|
+
lineBuf += chunk;
|
|
45
|
+
const lines = lineBuf.split("\n");
|
|
46
|
+
lineBuf = lines.pop() ?? "";
|
|
47
|
+
const filtered = lines.filter((l) => !l.startsWith("[PALMIER"));
|
|
48
|
+
if (filtered.length > 0) {
|
|
49
|
+
writer.write(filtered.join("\n") + "\n");
|
|
50
|
+
throttledNotify();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
29
53
|
});
|
|
54
|
+
if (notifyTimer)
|
|
55
|
+
clearTimeout(notifyTimer);
|
|
30
56
|
const outcome = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
|
|
31
57
|
const reportFiles = parseReportFiles(result.output);
|
|
32
58
|
const requiredPermissions = parsePermissions(result.output);
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
// Flush remaining buffered content
|
|
60
|
+
if (lineBuf && !lineBuf.startsWith("[PALMIER")) {
|
|
61
|
+
writer.write(lineBuf);
|
|
62
|
+
}
|
|
63
|
+
// Include permission requests in the assistant message
|
|
64
|
+
if (requiredPermissions.length > 0) {
|
|
65
|
+
const permLines = requiredPermissions.map((p) => `- **${p.name}** ${p.description}`).join("\n");
|
|
66
|
+
writer.write(`\n\n**Permissions requested:**\n${permLines}\n`);
|
|
67
|
+
}
|
|
68
|
+
writer.end(reportFiles.length > 0 ? reportFiles : undefined);
|
|
69
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
40
70
|
// Permission handling — agent requested permissions
|
|
41
71
|
if (requiredPermissions.length > 0) {
|
|
42
72
|
const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
|
|
@@ -44,18 +74,17 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
|
|
|
44
74
|
await appendAndNotify(ctx, {
|
|
45
75
|
role: "user",
|
|
46
76
|
time: Date.now(),
|
|
47
|
-
content: "
|
|
77
|
+
content: "Denied",
|
|
48
78
|
type: "permission",
|
|
49
79
|
});
|
|
50
80
|
return { outcome: "failed" };
|
|
51
81
|
}
|
|
52
82
|
const newPerms = requiredPermissions.filter((rp) => !ctx.task.frontmatter.permissions?.some((ep) => ep.name === rp.name)
|
|
53
83
|
&& !ctx.transientPermissions.some((ep) => ep.name === rp.name));
|
|
54
|
-
// Append user message for permission grant
|
|
55
84
|
await appendAndNotify(ctx, {
|
|
56
85
|
role: "user",
|
|
57
86
|
time: Date.now(),
|
|
58
|
-
content:
|
|
87
|
+
content: response === "granted_all" ? "Granted for all" : "Granted",
|
|
59
88
|
type: "permission",
|
|
60
89
|
});
|
|
61
90
|
if (response === "granted_all") {
|
|
@@ -212,6 +241,8 @@ const MAX_LINE_LENGTH = 200_000;
|
|
|
212
241
|
async function runCommandTriggeredMode(ctx) {
|
|
213
242
|
const commandStr = ctx.task.frontmatter.command;
|
|
214
243
|
console.log(`[command-triggered] Spawning: ${commandStr}`);
|
|
244
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
245
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
215
246
|
const child = spawnStreamingCommand(commandStr, {
|
|
216
247
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
217
248
|
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7400) },
|
|
@@ -341,7 +372,11 @@ async function requestPermission(config, task, taskDir, requiredPermissions) {
|
|
|
341
372
|
permissions: requiredPermissions,
|
|
342
373
|
}),
|
|
343
374
|
});
|
|
344
|
-
const
|
|
375
|
+
const body = await res.json();
|
|
376
|
+
const response = body.response;
|
|
377
|
+
if (!response || !["granted", "granted_all", "aborted"].includes(response)) {
|
|
378
|
+
throw new Error(`Permission request failed: ${body.error ?? `unexpected response: ${JSON.stringify(body)}`}`);
|
|
379
|
+
}
|
|
345
380
|
writeTaskStatus(taskDir, {
|
|
346
381
|
running_state: response === "aborted" ? "aborted" : "started",
|
|
347
382
|
time_stamp: Date.now(),
|
|
@@ -355,7 +390,11 @@ async function requestConfirmation(config, task, taskDir) {
|
|
|
355
390
|
headers: { "Content-Type": "application/json" },
|
|
356
391
|
body: JSON.stringify({ taskId: task.frontmatter.id, taskName: task.frontmatter.name }),
|
|
357
392
|
});
|
|
358
|
-
const
|
|
393
|
+
const body = await res.json();
|
|
394
|
+
if (typeof body.confirmed !== "boolean") {
|
|
395
|
+
throw new Error(`Confirmation request failed: ${body.error ?? `unexpected response: ${JSON.stringify(body)}`}`);
|
|
396
|
+
}
|
|
397
|
+
const { confirmed } = body;
|
|
359
398
|
writeTaskStatus(taskDir, {
|
|
360
399
|
running_state: confirmed ? "started" : "aborted",
|
|
361
400
|
time_stamp: Date.now(),
|
package/dist/commands/serve.js
CHANGED
|
@@ -82,7 +82,14 @@ export async function serveCommand() {
|
|
|
82
82
|
config.agents = agents;
|
|
83
83
|
saveConfig(config);
|
|
84
84
|
console.log(`Detected agents: ${agents.map((a) => a.key).join(", ") || "none"}`);
|
|
85
|
-
|
|
85
|
+
let nc;
|
|
86
|
+
try {
|
|
87
|
+
nc = await connectNats(config);
|
|
88
|
+
console.log("[nats] Connected");
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.warn(`[nats] Connection failed (server mode unavailable): ${err}`);
|
|
92
|
+
}
|
|
86
93
|
// Reconcile any tasks stuck from before daemon started
|
|
87
94
|
await checkStaleTasks(config, nc);
|
|
88
95
|
// Poll for crashed tasks every 30 seconds
|
|
@@ -20,7 +20,10 @@ export declare function buildTaskXml(tr: string, triggers: string[]): string;
|
|
|
20
20
|
export declare class WindowsPlatform implements PlatformService {
|
|
21
21
|
installDaemon(config: HostConfig): void;
|
|
22
22
|
restartDaemon(): Promise<void>;
|
|
23
|
-
|
|
23
|
+
/** Create or update the Task Scheduler entry for the daemon. */
|
|
24
|
+
private ensureDaemonTask;
|
|
25
|
+
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
26
|
+
private startDaemonTask;
|
|
24
27
|
installTaskTimer(config: HostConfig, task: ParsedTask): void;
|
|
25
28
|
removeTaskTimer(taskId: string): void;
|
|
26
29
|
startTask(taskId: string): Promise<void>;
|
package/dist/platform/windows.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { execFileSync } from "child_process";
|
|
4
|
-
import { spawn as nodeSpawn } from "child_process";
|
|
5
4
|
import { CONFIG_DIR, loadConfig } from "../config.js";
|
|
6
5
|
import { getTaskDir, readTaskStatus } from "../task.js";
|
|
7
6
|
const TASK_PREFIX = "\\Palmier\\PalmierTask-";
|
|
@@ -78,11 +77,11 @@ function schtasksTaskName(taskId) {
|
|
|
78
77
|
export class WindowsPlatform {
|
|
79
78
|
installDaemon(config) {
|
|
80
79
|
const script = process.argv[1] || "palmier";
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const regValue = `
|
|
80
|
+
// Create the Task Scheduler entry for the daemon
|
|
81
|
+
this.ensureDaemonTask(script);
|
|
82
|
+
// Registry Run key triggers the Task Scheduler entry on logon,
|
|
83
|
+
// so the daemon always runs outside any session's job object.
|
|
84
|
+
const regValue = `schtasks /run /tn "\\Palmier\\${DAEMON_TASK_NAME}"`;
|
|
86
85
|
try {
|
|
87
86
|
execFileSync("reg", [
|
|
88
87
|
"add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
@@ -95,20 +94,19 @@ export class WindowsPlatform {
|
|
|
95
94
|
console.error("You may need to start palmier serve manually.");
|
|
96
95
|
}
|
|
97
96
|
// Start the daemon now
|
|
98
|
-
this.
|
|
97
|
+
this.startDaemonTask();
|
|
99
98
|
console.log("\nHost initialization complete!");
|
|
100
99
|
}
|
|
101
100
|
async restartDaemon() {
|
|
102
|
-
const script = process.argv[1] || "palmier";
|
|
103
101
|
const oldPid = fs.existsSync(DAEMON_PID_FILE)
|
|
104
102
|
? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
|
|
105
103
|
: null;
|
|
106
104
|
if (oldPid && oldPid === String(process.pid)) {
|
|
107
105
|
// We ARE the old daemon (auto-update) — spawn replacement then exit.
|
|
108
|
-
this.
|
|
106
|
+
this.startDaemonTask();
|
|
109
107
|
process.exit(0);
|
|
110
108
|
}
|
|
111
|
-
// Kill old daemon
|
|
109
|
+
// Kill old daemon by PID
|
|
112
110
|
if (oldPid) {
|
|
113
111
|
try {
|
|
114
112
|
execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
|
|
@@ -117,21 +115,59 @@ export class WindowsPlatform {
|
|
|
117
115
|
// Process may have already exited
|
|
118
116
|
}
|
|
119
117
|
}
|
|
120
|
-
|
|
118
|
+
// Also kill any stale palmier serve processes (e.g. leftover from a previous daemon)
|
|
119
|
+
try {
|
|
120
|
+
const out = execFileSync("wmic", ["process", "where", `CommandLine like '%palmier%serve%' and ProcessId != '${process.pid}'`, "get", "ProcessId"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
121
|
+
for (const line of out.split("\n")) {
|
|
122
|
+
const pid = line.trim();
|
|
123
|
+
if (pid && /^\d+$/.test(pid)) {
|
|
124
|
+
try {
|
|
125
|
+
execFileSync("taskkill", ["/pid", pid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// wmic may not be available on all Windows versions
|
|
133
|
+
}
|
|
134
|
+
this.startDaemonTask();
|
|
121
135
|
}
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
/** Create or update the Task Scheduler entry for the daemon. */
|
|
137
|
+
ensureDaemonTask(script) {
|
|
124
138
|
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
125
139
|
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
141
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
142
|
+
const tr = `"${wscript}" "${DAEMON_VBS_FILE}"`;
|
|
143
|
+
const xml = buildTaskXml(tr, [`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`]);
|
|
144
|
+
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
145
|
+
try {
|
|
146
|
+
const bom = Buffer.from([0xFF, 0xFE]);
|
|
147
|
+
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
148
|
+
execFileSync("schtasks", ["/create", "/tn", tn, "/xml", xmlPath, "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
const e = err;
|
|
152
|
+
console.error(`Failed to create daemon task: ${e.stderr || err}`);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(xmlPath);
|
|
157
|
+
}
|
|
158
|
+
catch { /* ignore */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
162
|
+
startDaemonTask() {
|
|
163
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
164
|
+
try {
|
|
165
|
+
execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
const e = err;
|
|
169
|
+
console.error(`Failed to start daemon via Task Scheduler: ${e.stderr || err}`);
|
|
170
|
+
}
|
|
135
171
|
console.log("Palmier daemon started.");
|
|
136
172
|
}
|
|
137
173
|
installTaskTimer(config, task) {
|
package/dist/rpc-handler.js
CHANGED
|
@@ -39,8 +39,8 @@ function parseResultFrontmatter(raw) {
|
|
|
39
39
|
const terminalMsg = [...statusMessages].reverse().find((m) => terminalStates.includes(m.type ?? ""));
|
|
40
40
|
// If last status is "started", determine if it's a task run or follow-up
|
|
41
41
|
let runningState;
|
|
42
|
-
if (lastStatus?.type === "started") {
|
|
43
|
-
runningState = terminalMsg ? "followup" : "started";
|
|
42
|
+
if (lastStatus?.type === "started" || lastStatus?.type === "monitoring") {
|
|
43
|
+
runningState = terminalMsg ? "followup" : (lastStatus?.type ?? "started");
|
|
44
44
|
}
|
|
45
45
|
else {
|
|
46
46
|
runningState = lastStatus?.type;
|
|
@@ -126,10 +126,17 @@ const activeFollowups = new Map();
|
|
|
126
126
|
export function createRpcHandler(config, nc) {
|
|
127
127
|
function flattenTask(task) {
|
|
128
128
|
const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
|
|
129
|
+
const status = readTaskStatus(taskDir);
|
|
130
|
+
const pending = getPending(task.frontmatter.id);
|
|
129
131
|
return {
|
|
130
132
|
...task.frontmatter,
|
|
131
133
|
body: task.body,
|
|
132
|
-
status:
|
|
134
|
+
status: status ? {
|
|
135
|
+
...status,
|
|
136
|
+
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
137
|
+
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
138
|
+
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
139
|
+
} : undefined,
|
|
133
140
|
};
|
|
134
141
|
}
|
|
135
142
|
async function handleRpc(request) {
|
package/dist/spawn-command.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface SpawnCommandOptions {
|
|
|
29
29
|
resolveOnFailure?: boolean;
|
|
30
30
|
/** If provided, write this string to the process's stdin and then close the pipe. */
|
|
31
31
|
stdin?: string;
|
|
32
|
+
/** Called on each chunk of output (stdout + stderr combined). */
|
|
33
|
+
onData?: (chunk: string) => void;
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
34
36
|
* Spawn a command with additional arguments.
|
package/dist/spawn-command.js
CHANGED
|
@@ -57,10 +57,14 @@ export function spawnCommand(command, args, opts) {
|
|
|
57
57
|
chunks.push(d);
|
|
58
58
|
if (opts.echoStdout)
|
|
59
59
|
process.stdout.write(d);
|
|
60
|
+
if (opts.onData)
|
|
61
|
+
opts.onData(d.toString("utf-8"));
|
|
60
62
|
});
|
|
61
63
|
child.stderr.on("data", (d) => {
|
|
62
64
|
chunks.push(d);
|
|
63
65
|
process.stderr.write(d);
|
|
66
|
+
if (opts.onData)
|
|
67
|
+
opts.onData(d.toString("utf-8"));
|
|
64
68
|
});
|
|
65
69
|
let timer;
|
|
66
70
|
if (opts.timeout) {
|
package/dist/task.d.ts
CHANGED
|
@@ -51,6 +51,20 @@ export declare function getRunDir(taskDir: string, runId: string): string;
|
|
|
51
51
|
* Append a conversation message to a run's TASKRUN.md file.
|
|
52
52
|
*/
|
|
53
53
|
export declare function appendRunMessage(taskDir: string, runId: string, msg: ConversationMessage): void;
|
|
54
|
+
/**
|
|
55
|
+
* Begin a streaming assistant message — writes the delimiter only.
|
|
56
|
+
* Returns a writer that appends content chunks and finalizes the message.
|
|
57
|
+
*/
|
|
58
|
+
export declare function beginStreamingMessage(taskDir: string, runId: string, time: number): StreamingMessageWriter;
|
|
59
|
+
export declare class StreamingMessageWriter {
|
|
60
|
+
private filePath;
|
|
61
|
+
private delimiter;
|
|
62
|
+
constructor(filePath: string, delimiter: string);
|
|
63
|
+
/** Append a chunk of content to the current message. */
|
|
64
|
+
write(chunk: string): void;
|
|
65
|
+
/** Finalize the message. If attachments are provided, rewrites the delimiter to include them. */
|
|
66
|
+
end(attachments?: string[]): void;
|
|
67
|
+
}
|
|
54
68
|
/**
|
|
55
69
|
* Read conversation messages from a run's TASKRUN.md file.
|
|
56
70
|
*/
|
package/dist/task.js
CHANGED
|
@@ -161,6 +161,37 @@ export function appendRunMessage(taskDir, runId, msg) {
|
|
|
161
161
|
const entry = `${delimiter}\n\n${msg.content}\n\n`;
|
|
162
162
|
fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Begin a streaming assistant message — writes the delimiter only.
|
|
166
|
+
* Returns a writer that appends content chunks and finalizes the message.
|
|
167
|
+
*/
|
|
168
|
+
export function beginStreamingMessage(taskDir, runId, time) {
|
|
169
|
+
const filePath = path.join(taskDir, runId, "TASKRUN.md");
|
|
170
|
+
const delimiter = `<!-- palmier:message role="assistant" time="${time}" -->`;
|
|
171
|
+
fs.appendFileSync(filePath, `${delimiter}\n\n`, "utf-8");
|
|
172
|
+
return new StreamingMessageWriter(filePath, delimiter);
|
|
173
|
+
}
|
|
174
|
+
export class StreamingMessageWriter {
|
|
175
|
+
filePath;
|
|
176
|
+
delimiter;
|
|
177
|
+
constructor(filePath, delimiter) {
|
|
178
|
+
this.filePath = filePath;
|
|
179
|
+
this.delimiter = delimiter;
|
|
180
|
+
}
|
|
181
|
+
/** Append a chunk of content to the current message. */
|
|
182
|
+
write(chunk) {
|
|
183
|
+
fs.appendFileSync(this.filePath, chunk, "utf-8");
|
|
184
|
+
}
|
|
185
|
+
/** Finalize the message. If attachments are provided, rewrites the delimiter to include them. */
|
|
186
|
+
end(attachments) {
|
|
187
|
+
fs.appendFileSync(this.filePath, "\n\n", "utf-8");
|
|
188
|
+
if (attachments?.length) {
|
|
189
|
+
const raw = fs.readFileSync(this.filePath, "utf-8");
|
|
190
|
+
const updated = raw.replace(this.delimiter, `${this.delimiter.slice(0, -4)} attachments="${attachments.join(",")}" -->`);
|
|
191
|
+
fs.writeFileSync(this.filePath, updated, "utf-8");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
164
195
|
/**
|
|
165
196
|
* Read conversation messages from a run's TASKRUN.md file.
|
|
166
197
|
*/
|
|
@@ -242,13 +242,14 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
242
242
|
}
|
|
243
243
|
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
244
244
|
const task = parseTaskFile(taskDir);
|
|
245
|
+
const pendingPromise = registerPending(taskId, "input", descriptions);
|
|
245
246
|
await publishEvent(taskId, {
|
|
246
247
|
event_type: "input-request",
|
|
247
248
|
host_id: config.hostId,
|
|
248
249
|
input_descriptions: descriptions,
|
|
249
250
|
name: task.frontmatter.name,
|
|
250
251
|
});
|
|
251
|
-
const response = await
|
|
252
|
+
const response = await pendingPromise;
|
|
252
253
|
if (response.length === 1 && response[0] === "aborted") {
|
|
253
254
|
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
254
255
|
if (runId) {
|
|
@@ -283,11 +284,12 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
283
284
|
sendJson(res, 400, { error: "taskId is required" });
|
|
284
285
|
return;
|
|
285
286
|
}
|
|
287
|
+
const pendingPromise = registerPending(taskId, "confirmation");
|
|
286
288
|
await publishEvent(taskId, {
|
|
287
289
|
event_type: "confirm-request",
|
|
288
290
|
host_id: config.hostId,
|
|
289
291
|
});
|
|
290
|
-
const response = await
|
|
292
|
+
const response = await pendingPromise;
|
|
291
293
|
const confirmed = response[0] === "confirmed";
|
|
292
294
|
await publishEvent(taskId, {
|
|
293
295
|
event_type: "confirm-resolved",
|
|
@@ -314,13 +316,14 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
314
316
|
sendJson(res, 400, { error: "taskId and permissions are required" });
|
|
315
317
|
return;
|
|
316
318
|
}
|
|
319
|
+
const pendingPromise = registerPending(taskId, "permission", permissions);
|
|
317
320
|
await publishEvent(taskId, {
|
|
318
321
|
event_type: "permission-request",
|
|
319
322
|
host_id: config.hostId,
|
|
320
323
|
required_permissions: permissions,
|
|
321
324
|
name: taskName,
|
|
322
325
|
});
|
|
323
|
-
const response = await
|
|
326
|
+
const response = await pendingPromise;
|
|
324
327
|
const status = response[0];
|
|
325
328
|
await publishEvent(taskId, {
|
|
326
329
|
event_type: "permission-resolved",
|
package/dist/types.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface ConversationMessage {
|
|
|
60
60
|
role: "assistant" | "user" | "status";
|
|
61
61
|
time: number;
|
|
62
62
|
content: string;
|
|
63
|
-
type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted" | "stopped";
|
|
63
|
+
type?: "input" | "permission" | "confirmation" | "monitoring" | "started" | "finished" | "failed" | "aborted" | "stopped";
|
|
64
64
|
attachments?: string[];
|
|
65
65
|
}
|
|
66
66
|
export interface RpcMessage {
|
package/package.json
CHANGED
package/src/commands/run.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as readline from "readline";
|
|
|
4
4
|
import { spawnCommand, spawnStreamingCommand } from "../spawn-command.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { connectNats } from "../nats-client.js";
|
|
7
|
-
import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir } from "../task.js";
|
|
7
|
+
import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir, beginStreamingMessage } from "../task.js";
|
|
8
8
|
import { getAgent } from "../agents/agent.js";
|
|
9
9
|
import { getPlatform } from "../platform/index.js";
|
|
10
10
|
import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
|
|
@@ -47,6 +47,21 @@ async function invokeAgentWithRetries(
|
|
|
47
47
|
): Promise<InvocationResult> {
|
|
48
48
|
// eslint-disable-next-line no-constant-condition
|
|
49
49
|
while (true) {
|
|
50
|
+
// Stream agent output to TASKRUN.md in real-time, throttled to 500ms
|
|
51
|
+
const writer = beginStreamingMessage(ctx.taskDir, ctx.runId, Date.now());
|
|
52
|
+
let lineBuf = "";
|
|
53
|
+
let notifyPending = false;
|
|
54
|
+
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
|
|
56
|
+
function throttledNotify() {
|
|
57
|
+
if (notifyPending) return;
|
|
58
|
+
notifyPending = true;
|
|
59
|
+
notifyTimer = setTimeout(() => {
|
|
60
|
+
notifyPending = false;
|
|
61
|
+
publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
62
|
+
}, 500);
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, undefined, ctx.transientPermissions);
|
|
51
66
|
const result = await spawnCommand(command, args, {
|
|
52
67
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
@@ -54,19 +69,37 @@ async function invokeAgentWithRetries(
|
|
|
54
69
|
echoStdout: true,
|
|
55
70
|
resolveOnFailure: true,
|
|
56
71
|
stdin,
|
|
72
|
+
onData: (chunk) => {
|
|
73
|
+
lineBuf += chunk;
|
|
74
|
+
const lines = lineBuf.split("\n");
|
|
75
|
+
lineBuf = lines.pop() ?? "";
|
|
76
|
+
const filtered = lines.filter((l) => !l.startsWith("[PALMIER"));
|
|
77
|
+
if (filtered.length > 0) {
|
|
78
|
+
writer.write(filtered.join("\n") + "\n");
|
|
79
|
+
throttledNotify();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
57
82
|
});
|
|
58
83
|
|
|
84
|
+
if (notifyTimer) clearTimeout(notifyTimer);
|
|
85
|
+
|
|
59
86
|
const outcome: TaskRunningState = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
|
|
60
87
|
const reportFiles = parseReportFiles(result.output);
|
|
61
88
|
const requiredPermissions = parsePermissions(result.output);
|
|
62
89
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
// Flush remaining buffered content
|
|
91
|
+
if (lineBuf && !lineBuf.startsWith("[PALMIER")) {
|
|
92
|
+
writer.write(lineBuf);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Include permission requests in the assistant message
|
|
96
|
+
if (requiredPermissions.length > 0) {
|
|
97
|
+
const permLines = requiredPermissions.map((p) => `- **${p.name}** ${p.description}`).join("\n");
|
|
98
|
+
writer.write(`\n\n**Permissions requested:**\n${permLines}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
writer.end(reportFiles.length > 0 ? reportFiles : undefined);
|
|
102
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
70
103
|
|
|
71
104
|
// Permission handling — agent requested permissions
|
|
72
105
|
if (requiredPermissions.length > 0) {
|
|
@@ -76,7 +109,7 @@ async function invokeAgentWithRetries(
|
|
|
76
109
|
await appendAndNotify(ctx, {
|
|
77
110
|
role: "user",
|
|
78
111
|
time: Date.now(),
|
|
79
|
-
content: "
|
|
112
|
+
content: "Denied",
|
|
80
113
|
type: "permission",
|
|
81
114
|
});
|
|
82
115
|
return { outcome: "failed" };
|
|
@@ -87,11 +120,10 @@ async function invokeAgentWithRetries(
|
|
|
87
120
|
&& !ctx.transientPermissions.some((ep) => ep.name === rp.name),
|
|
88
121
|
);
|
|
89
122
|
|
|
90
|
-
// Append user message for permission grant
|
|
91
123
|
await appendAndNotify(ctx, {
|
|
92
124
|
role: "user",
|
|
93
125
|
time: Date.now(),
|
|
94
|
-
content:
|
|
126
|
+
content: response === "granted_all" ? "Granted for all" : "Granted",
|
|
95
127
|
type: "permission",
|
|
96
128
|
});
|
|
97
129
|
|
|
@@ -267,6 +299,9 @@ async function runCommandTriggeredMode(
|
|
|
267
299
|
const commandStr = ctx.task.frontmatter.command!;
|
|
268
300
|
console.log(`[command-triggered] Spawning: ${commandStr}`);
|
|
269
301
|
|
|
302
|
+
appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
|
|
303
|
+
await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
|
|
304
|
+
|
|
270
305
|
const child = spawnStreamingCommand(commandStr, {
|
|
271
306
|
cwd: getRunDir(ctx.taskDir, ctx.runId),
|
|
272
307
|
env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7400) },
|
|
@@ -419,7 +454,11 @@ async function requestPermission(
|
|
|
419
454
|
permissions: requiredPermissions,
|
|
420
455
|
}),
|
|
421
456
|
});
|
|
422
|
-
const
|
|
457
|
+
const body = await res.json() as { response?: string; error?: string };
|
|
458
|
+
const response = body.response as "granted" | "granted_all" | "aborted" | undefined;
|
|
459
|
+
if (!response || !["granted", "granted_all", "aborted"].includes(response)) {
|
|
460
|
+
throw new Error(`Permission request failed: ${body.error ?? `unexpected response: ${JSON.stringify(body)}`}`);
|
|
461
|
+
}
|
|
423
462
|
writeTaskStatus(taskDir, {
|
|
424
463
|
running_state: response === "aborted" ? "aborted" : "started",
|
|
425
464
|
time_stamp: Date.now(),
|
|
@@ -439,7 +478,11 @@ async function requestConfirmation(
|
|
|
439
478
|
headers: { "Content-Type": "application/json" },
|
|
440
479
|
body: JSON.stringify({ taskId: task.frontmatter.id, taskName: task.frontmatter.name }),
|
|
441
480
|
});
|
|
442
|
-
const
|
|
481
|
+
const body = await res.json() as { confirmed?: boolean; error?: string };
|
|
482
|
+
if (typeof body.confirmed !== "boolean") {
|
|
483
|
+
throw new Error(`Confirmation request failed: ${body.error ?? `unexpected response: ${JSON.stringify(body)}`}`);
|
|
484
|
+
}
|
|
485
|
+
const { confirmed } = body;
|
|
443
486
|
writeTaskStatus(taskDir, {
|
|
444
487
|
running_state: confirmed ? "started" : "aborted",
|
|
445
488
|
time_stamp: Date.now(),
|
package/src/commands/serve.ts
CHANGED
|
@@ -95,7 +95,13 @@ export async function serveCommand(): Promise<void> {
|
|
|
95
95
|
saveConfig(config);
|
|
96
96
|
console.log(`Detected agents: ${agents.map((a) => a.key).join(", ") || "none"}`);
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
let nc: NatsConnection | undefined;
|
|
99
|
+
try {
|
|
100
|
+
nc = await connectNats(config);
|
|
101
|
+
console.log("[nats] Connected");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.warn(`[nats] Connection failed (server mode unavailable): ${err}`);
|
|
104
|
+
}
|
|
99
105
|
|
|
100
106
|
// Reconcile any tasks stuck from before daemon started
|
|
101
107
|
await checkStaleTasks(config, nc);
|
package/src/platform/windows.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { execFileSync } from "child_process";
|
|
4
|
-
import { spawn as nodeSpawn } from "child_process";
|
|
5
4
|
import type { PlatformService } from "./platform.js";
|
|
6
5
|
import type { HostConfig, ParsedTask } from "../types.js";
|
|
7
6
|
import { CONFIG_DIR, loadConfig } from "../config.js";
|
|
@@ -94,13 +93,12 @@ export class WindowsPlatform implements PlatformService {
|
|
|
94
93
|
installDaemon(config: HostConfig): void {
|
|
95
94
|
const script = process.argv[1] || "palmier";
|
|
96
95
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
100
|
-
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
101
|
-
|
|
102
|
-
const regValue = `"${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe" "${DAEMON_VBS_FILE}"`;
|
|
96
|
+
// Create the Task Scheduler entry for the daemon
|
|
97
|
+
this.ensureDaemonTask(script);
|
|
103
98
|
|
|
99
|
+
// Registry Run key triggers the Task Scheduler entry on logon,
|
|
100
|
+
// so the daemon always runs outside any session's job object.
|
|
101
|
+
const regValue = `schtasks /run /tn "\\Palmier\\${DAEMON_TASK_NAME}"`;
|
|
104
102
|
try {
|
|
105
103
|
execFileSync("reg", [
|
|
106
104
|
"add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
@@ -113,24 +111,23 @@ export class WindowsPlatform implements PlatformService {
|
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
// Start the daemon now
|
|
116
|
-
this.
|
|
114
|
+
this.startDaemonTask();
|
|
117
115
|
|
|
118
116
|
console.log("\nHost initialization complete!");
|
|
119
117
|
}
|
|
120
118
|
|
|
121
119
|
async restartDaemon(): Promise<void> {
|
|
122
|
-
const script = process.argv[1] || "palmier";
|
|
123
120
|
const oldPid = fs.existsSync(DAEMON_PID_FILE)
|
|
124
121
|
? fs.readFileSync(DAEMON_PID_FILE, "utf-8").trim()
|
|
125
122
|
: null;
|
|
126
123
|
|
|
127
124
|
if (oldPid && oldPid === String(process.pid)) {
|
|
128
125
|
// We ARE the old daemon (auto-update) — spawn replacement then exit.
|
|
129
|
-
this.
|
|
126
|
+
this.startDaemonTask();
|
|
130
127
|
process.exit(0);
|
|
131
128
|
}
|
|
132
129
|
|
|
133
|
-
// Kill old daemon
|
|
130
|
+
// Kill old daemon by PID
|
|
134
131
|
if (oldPid) {
|
|
135
132
|
try {
|
|
136
133
|
execFileSync("taskkill", ["/pid", oldPid, "/f", "/t"], { windowsHide: true, stdio: "pipe" });
|
|
@@ -139,23 +136,53 @@ export class WindowsPlatform implements PlatformService {
|
|
|
139
136
|
}
|
|
140
137
|
}
|
|
141
138
|
|
|
142
|
-
|
|
139
|
+
// Also kill any stale palmier serve processes (e.g. leftover from a previous daemon)
|
|
140
|
+
try {
|
|
141
|
+
const out = execFileSync("wmic", ["process", "where", `CommandLine like '%palmier%serve%' and ProcessId != '${process.pid}'`, "get", "ProcessId"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
142
|
+
for (const line of out.split("\n")) {
|
|
143
|
+
const pid = line.trim();
|
|
144
|
+
if (pid && /^\d+$/.test(pid)) {
|
|
145
|
+
try { execFileSync("taskkill", ["/pid", pid, "/f", "/t"], { windowsHide: true, stdio: "pipe" }); } catch {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// wmic may not be available on all Windows versions
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.startDaemonTask();
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
/** Create or update the Task Scheduler entry for the daemon. */
|
|
156
|
+
private ensureDaemonTask(script: string): void {
|
|
147
157
|
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
148
158
|
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
149
159
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
161
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
162
|
+
const tr = `"${wscript}" "${DAEMON_VBS_FILE}"`;
|
|
163
|
+
const xml = buildTaskXml(tr, [`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`]);
|
|
164
|
+
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
165
|
+
try {
|
|
166
|
+
const bom = Buffer.from([0xFF, 0xFE]);
|
|
167
|
+
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
168
|
+
execFileSync("schtasks", ["/create", "/tn", tn, "/xml", xmlPath, "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
169
|
+
} catch (err: unknown) {
|
|
170
|
+
const e = err as { stderr?: string };
|
|
171
|
+
console.error(`Failed to create daemon task: ${e.stderr || err}`);
|
|
172
|
+
} finally {
|
|
173
|
+
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
178
|
+
private startDaemonTask(): void {
|
|
179
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
180
|
+
try {
|
|
181
|
+
execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
182
|
+
} catch (err: unknown) {
|
|
183
|
+
const e = err as { stderr?: string };
|
|
184
|
+
console.error(`Failed to start daemon via Task Scheduler: ${e.stderr || err}`);
|
|
185
|
+
}
|
|
159
186
|
console.log("Palmier daemon started.");
|
|
160
187
|
}
|
|
161
188
|
|
package/src/rpc-handler.ts
CHANGED
|
@@ -49,8 +49,8 @@ function parseResultFrontmatter(raw: string): Record<string, unknown> {
|
|
|
49
49
|
|
|
50
50
|
// If last status is "started", determine if it's a task run or follow-up
|
|
51
51
|
let runningState: string | undefined;
|
|
52
|
-
if (lastStatus?.type === "started") {
|
|
53
|
-
runningState = terminalMsg ? "followup" : "started";
|
|
52
|
+
if (lastStatus?.type === "started" || lastStatus?.type === "monitoring") {
|
|
53
|
+
runningState = terminalMsg ? "followup" : (lastStatus?.type ?? "started");
|
|
54
54
|
} else {
|
|
55
55
|
runningState = lastStatus?.type;
|
|
56
56
|
}
|
|
@@ -151,10 +151,17 @@ const activeFollowups = new Map<string, ChildProcess>();
|
|
|
151
151
|
export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
152
152
|
function flattenTask(task: ParsedTask) {
|
|
153
153
|
const taskDir = getTaskDir(config.projectRoot, task.frontmatter.id);
|
|
154
|
+
const status = readTaskStatus(taskDir);
|
|
155
|
+
const pending = getPending(task.frontmatter.id);
|
|
154
156
|
return {
|
|
155
157
|
...task.frontmatter,
|
|
156
158
|
body: task.body,
|
|
157
|
-
status:
|
|
159
|
+
status: status ? {
|
|
160
|
+
...status,
|
|
161
|
+
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
162
|
+
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
163
|
+
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
164
|
+
} : undefined,
|
|
158
165
|
};
|
|
159
166
|
}
|
|
160
167
|
|
package/src/spawn-command.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface SpawnCommandOptions {
|
|
|
55
55
|
resolveOnFailure?: boolean;
|
|
56
56
|
/** If provided, write this string to the process's stdin and then close the pipe. */
|
|
57
57
|
stdin?: string;
|
|
58
|
+
/** Called on each chunk of output (stdout + stderr combined). */
|
|
59
|
+
onData?: (chunk: string) => void;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/**
|
|
@@ -105,10 +107,12 @@ export function spawnCommand(
|
|
|
105
107
|
child.stdout!.on("data", (d: Buffer) => {
|
|
106
108
|
chunks.push(d);
|
|
107
109
|
if (opts.echoStdout) process.stdout.write(d);
|
|
110
|
+
if (opts.onData) opts.onData(d.toString("utf-8"));
|
|
108
111
|
});
|
|
109
112
|
child.stderr!.on("data", (d: Buffer) => {
|
|
110
113
|
chunks.push(d);
|
|
111
114
|
process.stderr.write(d);
|
|
115
|
+
if (opts.onData) opts.onData(d.toString("utf-8"));
|
|
112
116
|
});
|
|
113
117
|
|
|
114
118
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
package/src/task.ts
CHANGED
|
@@ -190,6 +190,43 @@ export function appendRunMessage(
|
|
|
190
190
|
fs.appendFileSync(path.join(taskDir, runId, "TASKRUN.md"), entry, "utf-8");
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Begin a streaming assistant message — writes the delimiter only.
|
|
195
|
+
* Returns a writer that appends content chunks and finalizes the message.
|
|
196
|
+
*/
|
|
197
|
+
export function beginStreamingMessage(
|
|
198
|
+
taskDir: string,
|
|
199
|
+
runId: string,
|
|
200
|
+
time: number,
|
|
201
|
+
): StreamingMessageWriter {
|
|
202
|
+
const filePath = path.join(taskDir, runId, "TASKRUN.md");
|
|
203
|
+
const delimiter = `<!-- palmier:message role="assistant" time="${time}" -->`;
|
|
204
|
+
fs.appendFileSync(filePath, `${delimiter}\n\n`, "utf-8");
|
|
205
|
+
return new StreamingMessageWriter(filePath, delimiter);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export class StreamingMessageWriter {
|
|
209
|
+
private delimiter: string;
|
|
210
|
+
constructor(private filePath: string, delimiter: string) {
|
|
211
|
+
this.delimiter = delimiter;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Append a chunk of content to the current message. */
|
|
215
|
+
write(chunk: string): void {
|
|
216
|
+
fs.appendFileSync(this.filePath, chunk, "utf-8");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Finalize the message. If attachments are provided, rewrites the delimiter to include them. */
|
|
220
|
+
end(attachments?: string[]): void {
|
|
221
|
+
fs.appendFileSync(this.filePath, "\n\n", "utf-8");
|
|
222
|
+
if (attachments?.length) {
|
|
223
|
+
const raw = fs.readFileSync(this.filePath, "utf-8");
|
|
224
|
+
const updated = raw.replace(this.delimiter, `${this.delimiter.slice(0, -4)} attachments="${attachments.join(",")}" -->`);
|
|
225
|
+
fs.writeFileSync(this.filePath, updated, "utf-8");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
193
230
|
/**
|
|
194
231
|
* Read conversation messages from a run's TASKRUN.md file.
|
|
195
232
|
*/
|
|
@@ -262,6 +262,8 @@ export async function startHttpTransport(
|
|
|
262
262
|
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
263
263
|
const task = parseTaskFile(taskDir);
|
|
264
264
|
|
|
265
|
+
const pendingPromise = registerPending(taskId, "input", descriptions);
|
|
266
|
+
|
|
265
267
|
await publishEvent(taskId, {
|
|
266
268
|
event_type: "input-request",
|
|
267
269
|
host_id: config.hostId,
|
|
@@ -269,7 +271,7 @@ export async function startHttpTransport(
|
|
|
269
271
|
name: task.frontmatter.name,
|
|
270
272
|
});
|
|
271
273
|
|
|
272
|
-
const response = await
|
|
274
|
+
const response = await pendingPromise;
|
|
273
275
|
|
|
274
276
|
if (response.length === 1 && response[0] === "aborted") {
|
|
275
277
|
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
@@ -300,12 +302,14 @@ export async function startHttpTransport(
|
|
|
300
302
|
const { taskId } = JSON.parse(body) as { taskId: string };
|
|
301
303
|
if (!taskId) { sendJson(res, 400, { error: "taskId is required" }); return; }
|
|
302
304
|
|
|
305
|
+
const pendingPromise = registerPending(taskId, "confirmation");
|
|
306
|
+
|
|
303
307
|
await publishEvent(taskId, {
|
|
304
308
|
event_type: "confirm-request",
|
|
305
309
|
host_id: config.hostId,
|
|
306
310
|
});
|
|
307
311
|
|
|
308
|
-
const response = await
|
|
312
|
+
const response = await pendingPromise;
|
|
309
313
|
const confirmed = response[0] === "confirmed";
|
|
310
314
|
|
|
311
315
|
await publishEvent(taskId, {
|
|
@@ -335,6 +339,8 @@ export async function startHttpTransport(
|
|
|
335
339
|
return;
|
|
336
340
|
}
|
|
337
341
|
|
|
342
|
+
const pendingPromise = registerPending(taskId, "permission", permissions);
|
|
343
|
+
|
|
338
344
|
await publishEvent(taskId, {
|
|
339
345
|
event_type: "permission-request",
|
|
340
346
|
host_id: config.hostId,
|
|
@@ -342,7 +348,7 @@ export async function startHttpTransport(
|
|
|
342
348
|
name: taskName,
|
|
343
349
|
});
|
|
344
350
|
|
|
345
|
-
const response = await
|
|
351
|
+
const response = await pendingPromise;
|
|
346
352
|
const status = response[0] as "granted" | "granted_all" | "aborted";
|
|
347
353
|
|
|
348
354
|
await publishEvent(taskId, {
|
package/src/types.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface ConversationMessage {
|
|
|
71
71
|
role: "assistant" | "user" | "status";
|
|
72
72
|
time: number;
|
|
73
73
|
content: string;
|
|
74
|
-
type?: "input" | "permission" | "confirmation" | "started" | "finished" | "failed" | "aborted" | "stopped";
|
|
74
|
+
type?: "input" | "permission" | "confirmation" | "monitoring" | "started" | "finished" | "failed" | "aborted" | "stopped";
|
|
75
75
|
attachments?: string[];
|
|
76
76
|
}
|
|
77
77
|
|