palmier 0.4.8 → 0.5.0
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 +3 -68
- package/dist/agents/agent-instructions.md +1 -1
- package/dist/agents/gemini.js +5 -5
- package/dist/commands/run.js +45 -14
- package/dist/commands/serve.js +8 -1
- package/dist/platform/windows.d.ts +4 -1
- package/dist/platform/windows.js +40 -19
- package/dist/rpc-handler.js +2 -2
- package/dist/spawn-command.d.ts +2 -0
- package/dist/spawn-command.js +8 -1
- package/dist/task.d.ts +14 -0
- package/dist/task.js +31 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/agents/agent-instructions.md +1 -1
- package/src/agents/gemini.ts +5 -5
- package/src/commands/run.ts +49 -14
- package/src/commands/serve.ts +7 -1
- package/src/platform/windows.ts +35 -20
- package/src/rpc-handler.ts +2 -2
- package/src/spawn-command.ts +8 -1
- package/src/task.ts +37 -0
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -85,7 +85,9 @@ palmier sessions revoke-all
|
|
|
85
85
|
|
|
86
86
|
The `init` command:
|
|
87
87
|
- Detects installed agent CLIs (Claude Code, Gemini CLI, Codex CLI, GitHub Copilot) and caches the result
|
|
88
|
-
-
|
|
88
|
+
- Configures access modes (HTTP port, LAN access)
|
|
89
|
+
- Shows a summary and asks for confirmation before making changes
|
|
90
|
+
- Registers with the Palmier server, saves configuration to `~/.config/palmier/host.json`
|
|
89
91
|
- Installs a background daemon (systemd user service on Linux, Registry Run key on Windows)
|
|
90
92
|
- Auto-enters pair mode to connect your first device
|
|
91
93
|
|
|
@@ -128,76 +130,9 @@ palmier restart
|
|
|
128
130
|
- **Tasks** are stored locally as Markdown files in a `tasks/` directory. Each task has a name, prompt, execution plan, and optional schedules (cron schedules or one-time dates).
|
|
129
131
|
- **Plan generation** is automatic — when you create or update a task, the host invokes your chosen agent CLI to generate an execution plan and name.
|
|
130
132
|
- **Schedules** are backed by systemd timers (Linux) or Task Scheduler (Windows). You can enable/disable them without deleting the task, and any task can still be run manually at any time.
|
|
131
|
-
- **Task execution** uses the system scheduler on both platforms — `systemctl --user start` on Linux, `schtasks /run` on Windows. On Windows, tasks run via a VBS wrapper (`wscript.exe`) to avoid visible console windows. The daemon polls every 30 seconds to detect crashed tasks (processes that exited without updating status) and marks them as failed, broadcasting the failure to connected clients.
|
|
132
133
|
- **Command-triggered tasks** — optionally specify a shell command (e.g., `tail -f /var/log/app.log`). Palmier runs the command continuously and invokes the agent for each line of stdout, passing it alongside your prompt. Useful for log monitoring, event-driven automation, and reactive workflows.
|
|
133
|
-
- **Task confirmation** — tasks can optionally require your approval before running. You'll get a push notification (server mode) or a prompt in the PWA to confirm or abort.
|
|
134
|
-
- **Conversational run history** — each run gets its own directory (`tasks/<id>/<timestamp>/`) with a `TASKRUN.md` file containing a conversational thread: assistant messages (agent output), user messages (input responses, permission grants, confirmations), and status entries (started, finished, failed, aborted, stopped). The agent runs inside the run directory, so each run's session files and artifacts are isolated. The PWA displays runs as a chat-like thread with follow-up support.
|
|
135
|
-
- **Follow-up messages** — after a task run completes, users can send follow-up messages from the run detail view. The agent is invoked inline by the serve daemon (no new process spawning), and the response is appended to the same conversation thread.
|
|
136
|
-
- **Real-time updates** — task status changes and result updates are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (local/LAN mode). The run detail view live-updates as the agent produces output. Events are scoped to specific runs.
|
|
137
134
|
- **Agent HTTP endpoints** — the serve daemon exposes localhost-only endpoints (`/notify`, `/request-input`) that agents call to send push notifications and request user input during task execution.
|
|
138
135
|
|
|
139
|
-
## NATS Subjects
|
|
140
|
-
|
|
141
|
-
| Subject | Direction | Description |
|
|
142
|
-
|---|---|---|
|
|
143
|
-
| `host.<hostId>.rpc.<method>` | Client → Host | RPC request/reply (e.g., `task.list`, `task.create`) |
|
|
144
|
-
| `host-event.<hostId>.<taskId>` | Host → Client | Real-time task events (`running-state`, `result-updated`, `confirm-request`, `permission-request`, `input-request`) |
|
|
145
|
-
| `host.<hostId>.push.send` | Host → Server | Request server to deliver a push notification |
|
|
146
|
-
| `pair.<code>` | Client → Host | OTP pairing request/reply |
|
|
147
|
-
|
|
148
|
-
## Project Structure
|
|
149
|
-
|
|
150
|
-
```
|
|
151
|
-
src/
|
|
152
|
-
index.ts # CLI entrypoint (commander setup)
|
|
153
|
-
config.ts # Host configuration (read/write ~/.config/palmier)
|
|
154
|
-
rpc-handler.ts # Transport-agnostic RPC handler (with session validation)
|
|
155
|
-
session-store.ts # Session token management (~/.config/palmier/sessions.json)
|
|
156
|
-
nats-client.ts # NATS connection helper
|
|
157
|
-
spawn-command.ts # Shared helper for spawning CLI tools
|
|
158
|
-
task.ts # Task file management
|
|
159
|
-
types.ts # Shared type definitions
|
|
160
|
-
pending-requests.ts # In-memory registry for held HTTP connections (confirmation, permission, input)
|
|
161
|
-
events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
|
|
162
|
-
agents/
|
|
163
|
-
agent.ts # AgentTool interface, registry, and agent detection
|
|
164
|
-
shared-prompt.ts # Agent instructions loader
|
|
165
|
-
agent-instructions.md # System prompt injected into every agent invocation
|
|
166
|
-
claude.ts # Claude Code agent implementation
|
|
167
|
-
gemini.ts # Gemini CLI agent implementation
|
|
168
|
-
codex.ts # Codex CLI agent implementation
|
|
169
|
-
copilot.ts # GitHub Copilot agent implementation
|
|
170
|
-
openclaw.ts # OpenClaw agent implementation
|
|
171
|
-
commands/
|
|
172
|
-
init.ts # Interactive setup wizard (auto-pair)
|
|
173
|
-
pair.ts # OTP code generation and pairing handler
|
|
174
|
-
sessions.ts # Session token management CLI (list, revoke, revoke-all)
|
|
175
|
-
info.ts # Print host connection info
|
|
176
|
-
|
|
177
|
-
serve.ts # NATS + HTTP transport startup, crash detection polling
|
|
178
|
-
restart.ts # Daemon restart (cross-platform)
|
|
179
|
-
run.ts # Single task execution
|
|
180
|
-
platform/
|
|
181
|
-
platform.ts # PlatformService interface
|
|
182
|
-
index.ts # Platform factory (Linux vs Windows)
|
|
183
|
-
linux.ts # Linux: systemd daemon, timers, systemctl task control
|
|
184
|
-
windows.ts # Windows: Registry Run key, Task Scheduler, schtasks-based task control
|
|
185
|
-
transports/
|
|
186
|
-
nats-transport.ts # NATS subscription loop (host.<hostId>.rpc.>)
|
|
187
|
-
http-transport.ts # HTTP server with RPC, SSE, PWA reverse proxy, and internal event endpoints
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
## Agent HTTP Endpoints
|
|
191
|
-
|
|
192
|
-
The serve daemon exposes localhost-only HTTP endpoints for agents during task execution. The port is baked into the agent's system prompt automatically.
|
|
193
|
-
|
|
194
|
-
| Endpoint | Method | Description |
|
|
195
|
-
|---|---|---|
|
|
196
|
-
| `/notify` | POST | Send a push notification (requires server mode) |
|
|
197
|
-
| `/request-input` | POST | Request user input; blocks until a response is provided |
|
|
198
|
-
|
|
199
|
-
See [agent-instructions.md](src/agents/agent-instructions.md) for usage examples.
|
|
200
|
-
|
|
201
136
|
## Uninstalling
|
|
202
137
|
|
|
203
138
|
To fully remove Palmier from a machine:
|
|
@@ -24,7 +24,7 @@ If the task fails because a tool was denied or you lack the required permissions
|
|
|
24
24
|
|
|
25
25
|
The following HTTP endpoints are available at http://localhost:{{PORT}} during task execution. Use curl to call them.
|
|
26
26
|
|
|
27
|
-
**Requesting user input** — When you need information from the user (credentials, questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout. Instead, POST to `/request-input` with:
|
|
27
|
+
**Requesting user input** — When you need information from the user (credentials, answers to questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout, even in a non-interactive environment. Instead, POST to `/request-input` with:
|
|
28
28
|
```json
|
|
29
29
|
{"taskId":"{{TASK_ID}}","descriptions":["question 1","question 2"]}
|
|
30
30
|
```
|
package/dist/agents/gemini.js
CHANGED
|
@@ -5,12 +5,12 @@ export class GeminiAgent {
|
|
|
5
5
|
getPlanGenerationCommandLine(prompt) {
|
|
6
6
|
return {
|
|
7
7
|
command: "gemini",
|
|
8
|
-
args: ["--
|
|
8
|
+
args: ["--prompt", prompt],
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
11
|
getTaskRunCommandLine(task, followupPrompt, extraPermissions) {
|
|
12
|
-
const
|
|
13
|
-
const args = ["--allowed-tools", "web_fetch"];
|
|
12
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
13
|
+
const args = ["--approval-mode", "auto_edit", "--allowed-tools", "web_fetch"];
|
|
14
14
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
15
15
|
if (allPerms.length > 0) {
|
|
16
16
|
for (const p of allPerms) {
|
|
@@ -20,8 +20,8 @@ export class GeminiAgent {
|
|
|
20
20
|
if (followupPrompt) {
|
|
21
21
|
args.push("--resume");
|
|
22
22
|
} // continue mode for followups
|
|
23
|
-
args.push("--prompt",
|
|
24
|
-
return { command: "gemini", args
|
|
23
|
+
args.push("--prompt", prompt);
|
|
24
|
+
return { command: "gemini", args };
|
|
25
25
|
}
|
|
26
26
|
async init() {
|
|
27
27
|
try {
|
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";
|
|
@@ -16,9 +16,23 @@ import { publishHostEvent } from "../events.js";
|
|
|
16
16
|
* The `invokeTask` is the ParsedTask whose prompt is passed to the agent
|
|
17
17
|
* (for command-triggered mode this is the per-line augmented task).
|
|
18
18
|
*/
|
|
19
|
-
async function
|
|
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 invokeAgentWithContinuation(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 invokeAgentWithContinuation(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") {
|
|
@@ -174,7 +203,7 @@ export async function runCommand(taskId) {
|
|
|
174
203
|
time: Date.now(),
|
|
175
204
|
content: task.body || task.frontmatter.user_prompt,
|
|
176
205
|
});
|
|
177
|
-
const result = await
|
|
206
|
+
const result = await invokeAgentWithRetries(ctx, task);
|
|
178
207
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
179
208
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
180
209
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
@@ -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) },
|
|
@@ -252,7 +283,7 @@ async function runCommandTriggeredMode(ctx) {
|
|
|
252
283
|
frontmatter: { ...ctx.task.frontmatter, user_prompt: perLinePrompt },
|
|
253
284
|
body: "",
|
|
254
285
|
};
|
|
255
|
-
const result = await
|
|
286
|
+
const result = await invokeAgentWithRetries(ctx, perLineTask);
|
|
256
287
|
if (result.outcome === "finished") {
|
|
257
288
|
invocationsSucceeded++;
|
|
258
289
|
}
|
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,17 +94,16 @@ 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
109
|
// Kill old daemon first, then spawn new one.
|
|
@@ -117,20 +115,43 @@ export class WindowsPlatform {
|
|
|
117
115
|
// Process may have already exited
|
|
118
116
|
}
|
|
119
117
|
}
|
|
120
|
-
this.
|
|
118
|
+
this.startDaemonTask();
|
|
121
119
|
}
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
/** Create or update the Task Scheduler entry for the daemon. */
|
|
121
|
+
ensureDaemonTask(script) {
|
|
124
122
|
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
125
123
|
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
126
124
|
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
126
|
+
const tr = `"${wscript}" "${DAEMON_VBS_FILE}"`;
|
|
127
|
+
const xml = buildTaskXml(tr, [`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`]);
|
|
128
|
+
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
129
|
+
try {
|
|
130
|
+
const bom = Buffer.from([0xFF, 0xFE]);
|
|
131
|
+
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
132
|
+
execFileSync("schtasks", ["/create", "/tn", tn, "/xml", xmlPath, "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const e = err;
|
|
136
|
+
console.error(`Failed to create daemon task: ${e.stderr || err}`);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
try {
|
|
140
|
+
fs.unlinkSync(xmlPath);
|
|
141
|
+
}
|
|
142
|
+
catch { /* ignore */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
146
|
+
startDaemonTask() {
|
|
147
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
148
|
+
try {
|
|
149
|
+
execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const e = err;
|
|
153
|
+
console.error(`Failed to start daemon via Task Scheduler: ${e.stderr || err}`);
|
|
154
|
+
}
|
|
134
155
|
console.log("Palmier daemon started.");
|
|
135
156
|
}
|
|
136
157
|
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;
|
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,8 +57,15 @@ 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"));
|
|
62
|
+
});
|
|
63
|
+
child.stderr.on("data", (d) => {
|
|
64
|
+
chunks.push(d);
|
|
65
|
+
process.stderr.write(d);
|
|
66
|
+
if (opts.onData)
|
|
67
|
+
opts.onData(d.toString("utf-8"));
|
|
60
68
|
});
|
|
61
|
-
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
62
69
|
let timer;
|
|
63
70
|
if (opts.timeout) {
|
|
64
71
|
timer = setTimeout(() => {
|
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
|
*/
|
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
|
@@ -24,7 +24,7 @@ If the task fails because a tool was denied or you lack the required permissions
|
|
|
24
24
|
|
|
25
25
|
The following HTTP endpoints are available at http://localhost:{{PORT}} during task execution. Use curl to call them.
|
|
26
26
|
|
|
27
|
-
**Requesting user input** — When you need information from the user (credentials, questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout. Instead, POST to `/request-input` with:
|
|
27
|
+
**Requesting user input** — When you need information from the user (credentials, answers to questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout, even in a non-interactive environment. Instead, POST to `/request-input` with:
|
|
28
28
|
```json
|
|
29
29
|
{"taskId":"{{TASK_ID}}","descriptions":["question 1","question 2"]}
|
|
30
30
|
```
|
package/src/agents/gemini.ts
CHANGED
|
@@ -8,13 +8,13 @@ export class GeminiAgent implements AgentTool {
|
|
|
8
8
|
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
|
9
9
|
return {
|
|
10
10
|
command: "gemini",
|
|
11
|
-
args: ["--
|
|
11
|
+
args: ["--prompt", prompt],
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
-
const
|
|
17
|
-
const args = ["--allowed-tools", "web_fetch"];
|
|
16
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
17
|
+
const args = ["--approval-mode", "auto_edit", "--allowed-tools", "web_fetch"];
|
|
18
18
|
|
|
19
19
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
20
20
|
if (allPerms.length > 0) {
|
|
@@ -24,9 +24,9 @@ export class GeminiAgent implements AgentTool {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
if (followupPrompt) {args.push("--resume");} // continue mode for followups
|
|
27
|
-
args.push("--prompt",
|
|
27
|
+
args.push("--prompt", prompt);
|
|
28
28
|
|
|
29
|
-
return { command: "gemini", args
|
|
29
|
+
return { command: "gemini", args };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
async init(): Promise<boolean> {
|
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";
|
|
@@ -41,12 +41,27 @@ interface InvocationResult {
|
|
|
41
41
|
* The `invokeTask` is the ParsedTask whose prompt is passed to the agent
|
|
42
42
|
* (for command-triggered mode this is the per-line augmented task).
|
|
43
43
|
*/
|
|
44
|
-
async function
|
|
44
|
+
async function invokeAgentWithRetries(
|
|
45
45
|
ctx: InvocationContext,
|
|
46
46
|
invokeTask: ParsedTask,
|
|
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 invokeAgentWithContinuation(
|
|
|
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 invokeAgentWithContinuation(
|
|
|
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 invokeAgentWithContinuation(
|
|
|
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
|
|
|
@@ -226,7 +258,7 @@ export async function runCommand(taskId: string): Promise<void> {
|
|
|
226
258
|
content: task.body || task.frontmatter.user_prompt,
|
|
227
259
|
});
|
|
228
260
|
|
|
229
|
-
const result = await
|
|
261
|
+
const result = await invokeAgentWithRetries(ctx, task);
|
|
230
262
|
const outcome = resolveOutcome(taskDir, result.outcome);
|
|
231
263
|
appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
|
|
232
264
|
await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
|
|
@@ -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) },
|
|
@@ -313,7 +348,7 @@ async function runCommandTriggeredMode(
|
|
|
313
348
|
body: "",
|
|
314
349
|
};
|
|
315
350
|
|
|
316
|
-
const result = await
|
|
351
|
+
const result = await invokeAgentWithRetries(ctx, perLineTask);
|
|
317
352
|
if (result.outcome === "finished") {
|
|
318
353
|
invocationsSucceeded++;
|
|
319
354
|
} else {
|
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,20 +111,19 @@ 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
|
|
|
@@ -139,22 +136,40 @@ export class WindowsPlatform implements PlatformService {
|
|
|
139
136
|
}
|
|
140
137
|
}
|
|
141
138
|
|
|
142
|
-
this.
|
|
139
|
+
this.startDaemonTask();
|
|
143
140
|
}
|
|
144
141
|
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
/** Create or update the Task Scheduler entry for the daemon. */
|
|
143
|
+
private ensureDaemonTask(script: string): void {
|
|
147
144
|
const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${script}"" serve", 0, False`;
|
|
148
145
|
fs.writeFileSync(DAEMON_VBS_FILE, vbs, "utf-8");
|
|
149
146
|
|
|
150
147
|
const wscript = `${process.env.SYSTEMROOT || "C:\\Windows"}\\System32\\wscript.exe`;
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
148
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
149
|
+
const tr = `"${wscript}" "${DAEMON_VBS_FILE}"`;
|
|
150
|
+
const xml = buildTaskXml(tr, [`<TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary></TimeTrigger>`]);
|
|
151
|
+
const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
|
|
152
|
+
try {
|
|
153
|
+
const bom = Buffer.from([0xFF, 0xFE]);
|
|
154
|
+
fs.writeFileSync(xmlPath, Buffer.concat([bom, Buffer.from(xml, "utf16le")]));
|
|
155
|
+
execFileSync("schtasks", ["/create", "/tn", tn, "/xml", xmlPath, "/f"], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
156
|
+
} catch (err: unknown) {
|
|
157
|
+
const e = err as { stderr?: string };
|
|
158
|
+
console.error(`Failed to create daemon task: ${e.stderr || err}`);
|
|
159
|
+
} finally {
|
|
160
|
+
try { fs.unlinkSync(xmlPath); } catch { /* ignore */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Start the daemon via Task Scheduler (runs outside any session's job object). */
|
|
165
|
+
private startDaemonTask(): void {
|
|
166
|
+
const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
|
|
167
|
+
try {
|
|
168
|
+
execFileSync("schtasks", ["/run", "/tn", tn], { encoding: "utf-8", windowsHide: true, stdio: "pipe" });
|
|
169
|
+
} catch (err: unknown) {
|
|
170
|
+
const e = err as { stderr?: string };
|
|
171
|
+
console.error(`Failed to start daemon via Task Scheduler: ${e.stderr || err}`);
|
|
172
|
+
}
|
|
158
173
|
console.log("Palmier daemon started.");
|
|
159
174
|
}
|
|
160
175
|
|
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
|
}
|
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,8 +107,13 @@ 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"));
|
|
111
|
+
});
|
|
112
|
+
child.stderr!.on("data", (d: Buffer) => {
|
|
113
|
+
chunks.push(d);
|
|
114
|
+
process.stderr.write(d);
|
|
115
|
+
if (opts.onData) opts.onData(d.toString("utf-8"));
|
|
108
116
|
});
|
|
109
|
-
child.stderr!.on("data", (d: Buffer) => process.stderr.write(d));
|
|
110
117
|
|
|
111
118
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
112
119
|
if (opts.timeout) {
|
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
|
*/
|
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
|
|