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.
@@ -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
- // Append assistant message for this invocation
34
- await appendAndNotify(ctx, {
35
- role: "assistant",
36
- time: Date.now(),
37
- content: stripPalmierMarkers(result.output),
38
- attachments: reportFiles.length > 0 ? reportFiles : undefined,
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: "Permissions denied. Task aborted.",
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: `Permissions granted: ${newPerms.map((p) => p.name).join(", ")}`,
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 { response } = await res.json();
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 { confirmed } = await res.json();
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(),
@@ -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
- const nc = await connectNats(config);
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
- private spawnDaemon;
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>;
@@ -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
- // Write a VBS launcher that starts the daemon with no visible console window.
82
- // VBS doesn't use backslash escaping — only quotes need doubling ("").
83
- const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
84
- fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
85
- const regValue = `"${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe" "${DAEMON_VBS_FILE}"`;
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.spawnDaemon(script);
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.spawnDaemon(script);
106
+ this.startDaemonTask();
109
107
  process.exit(0);
110
108
  }
111
- // Kill old daemon first, then spawn new one.
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
- this.spawnDaemon(script);
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
- spawnDaemon(script) {
123
- // Write a VBS launcher that starts the daemon with no visible console window.
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
- // Use `cmd /c start` to break out of the SSH session's job object.
127
- // Without this, the daemon is killed when the SSH session disconnects.
128
- const child = nodeSpawn("cmd", ["/c", "start", "/b", "wscript.exe", DAEMON_VBS_FILE], {
129
- detached: true,
130
- stdio: "ignore",
131
- windowsHide: true,
132
- });
133
- child.unref();
134
- // PID file will be written by the serve command itself when it starts.
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) {
@@ -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: readTaskStatus(taskDir),
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) {
@@ -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.
@@ -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 registerPending(taskId, "input", descriptions);
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 registerPending(taskId, "confirmation");
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 registerPending(taskId, "permission", permissions);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -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
- // Append assistant message for this invocation
64
- await appendAndNotify(ctx, {
65
- role: "assistant",
66
- time: Date.now(),
67
- content: stripPalmierMarkers(result.output),
68
- attachments: reportFiles.length > 0 ? reportFiles : undefined,
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: "Permissions denied. Task aborted.",
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: `Permissions granted: ${newPerms.map((p) => p.name).join(", ")}`,
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 { response } = await res.json() as { response: "granted" | "granted_all" | "aborted" };
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 { confirmed } = await res.json() as { confirmed: boolean };
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(),
@@ -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
- const nc = await connectNats(config);
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);
@@ -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
- // Write a VBS launcher that starts the daemon with no visible console window.
98
- // VBS doesn't use backslash escaping — only quotes need doubling ("").
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.spawnDaemon(script);
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.spawnDaemon(script);
126
+ this.startDaemonTask();
130
127
  process.exit(0);
131
128
  }
132
129
 
133
- // Kill old daemon first, then spawn new one.
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
- this.spawnDaemon(script);
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
- private spawnDaemon(script: string): void {
146
- // Write a VBS launcher that starts the daemon with no visible console window.
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
- // Use `cmd /c start` to break out of the SSH session's job object.
151
- // Without this, the daemon is killed when the SSH session disconnects.
152
- const child = nodeSpawn("cmd", ["/c", "start", "/b", "wscript.exe", DAEMON_VBS_FILE], {
153
- detached: true,
154
- stdio: "ignore",
155
- windowsHide: true,
156
- });
157
- child.unref();
158
- // PID file will be written by the serve command itself when it starts.
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
 
@@ -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: readTaskStatus(taskDir),
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
 
@@ -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 registerPending(taskId, "input", descriptions);
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 registerPending(taskId, "confirmation");
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 registerPending(taskId, "permission", permissions);
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