palmier 1.1.19 → 1.1.21

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 CHANGED
@@ -182,7 +182,7 @@ The wizard:
182
182
  - Offers to install the **Playwright CLI** for browser automation (see [Browser Automation](#browser-automation)). Skipped on re-init if it's already managed.
183
183
  - Asks for the HTTP port
184
184
  - Detects the default network interface (used for auto-LAN)
185
- - Shows a summary (including any existing scheduled tasks to recover) and asks for confirmation
185
+ - Shows a summary (including the Palmier daemon version and any existing scheduled tasks to recover) and asks for confirmation
186
186
  - Registers with the Palmier server, saves configuration to `~/.config/palmier/host.json`
187
187
  - Installs a background daemon (systemd user service on Linux, LaunchAgent on macOS, Task Scheduler on Windows). On re-init, also restarts the daemon so the running process picks up the new agents/versions for `host.info`.
188
188
  - Auto-enters pair mode to connect your first device
@@ -30,7 +30,7 @@ export interface AgentTool {
30
30
  supportsPermissions?: boolean;
31
31
  /** Whether this agent supports yolo mode (auto-approve all tools). */
32
32
  supportsYolo?: boolean;
33
- /** When true, the run loop will not persist the agent's stderr output. */
33
+ /** When true, the run loop persists the agent's stderr output only if the invocation fails. */
34
34
  suppressStdErr?: boolean;
35
35
  /** Matches the model name (capture group 1) in the banner the CLI prints on stderr.
36
36
  * Takes precedence over the agent's self-reported model marker. */
@@ -10,6 +10,7 @@ import { getPlatform } from "../platform/index.js";
10
10
  import { pairCommand } from "./pair.js";
11
11
  import { detectDefaultInterface, getInterfaceIpv4 } from "../network.js";
12
12
  import { listTasks, taskDisplayName } from "../task.js";
13
+ import { currentVersion } from "../update-checker.js";
13
14
  const { bold, dim, green, cyan, red } = colors;
14
15
  export async function initCommand() {
15
16
  console.log(`\n${bold("=== Palmier Host Setup ===")}\n`);
@@ -74,6 +75,7 @@ export async function initCommand() {
74
75
  const defaultInterface = (await detectDefaultInterface()) ?? undefined;
75
76
  const lanIp = defaultInterface ? getInterfaceIpv4(defaultInterface) : null;
76
77
  console.log(`\n${bold("Setup summary:")}\n`);
78
+ console.log(` ${dim("Daemon:")} Palmier v${currentVersion}\n`);
77
79
  console.log(` ${dim("Task storage:")} ${bold(projectRoot)}`);
78
80
  console.log(` All tasks and execution data will be stored here.\n`);
79
81
  console.log(` ${dim("Local:")} ${cyan(`http://localhost:${httpPort}`)}`);
@@ -2,7 +2,6 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { spawnCommand } from "../spawn-command.js";
4
4
  import { loadConfig } from "../config.js";
5
- import { connectNats } from "../nats-client.js";
6
5
  import { parseTaskFile, taskDisplayName, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir, beginStreamingMessage, formatAgentModelComment } from "../task.js";
7
6
  import { getAgent } from "../agents/agent.js";
8
7
  import { getPlatform } from "../platform/index.js";
@@ -20,6 +19,7 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
20
19
  let writer;
21
20
  let activeStream;
22
21
  const lineBufs = { stdout: "", stderr: "" };
22
+ let suppressedStderr = "";
23
23
  let notifyPending = false;
24
24
  let notifyTimer;
25
25
  let agentModel;
@@ -31,7 +31,7 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
31
31
  notifyPending = true;
32
32
  notifyTimer = setTimeout(() => {
33
33
  notifyPending = false;
34
- publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
34
+ publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
35
35
  }, 500);
36
36
  }
37
37
  function ensureWriter(stream) {
@@ -51,7 +51,9 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
51
51
  throttledNotify();
52
52
  }
53
53
  function onStderr(chunk) {
54
- if (!ctx.agent.suppressStdErr)
54
+ if (ctx.agent.suppressStdErr)
55
+ suppressedStderr += chunk;
56
+ else
55
57
  emit("stderr", chunk);
56
58
  const cliModel = captureCliModel(chunk);
57
59
  if (cliModel && !agentModelWritten)
@@ -72,6 +74,12 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
72
74
  }
73
75
  flushAgentModel();
74
76
  }
77
+ function flushTrailingLine(stream) {
78
+ const trailing = lineBufs[stream];
79
+ if (trailing && !trailing.startsWith("[PALMIER")) {
80
+ ensureWriter(stream).write(trailing);
81
+ }
82
+ }
75
83
  const { args, stdin, env: agentEnv, files } = ctx.agent.getTaskRunCommandLine(invokeTask, undefined, ctx.task.frontmatter.yolo_mode ? "yolo" : ctx.transientPermissions);
76
84
  const command = ctx.agent.command;
77
85
  const runDir = getRunDir(ctx.taskDir, ctx.runId);
@@ -88,25 +96,23 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
88
96
  onStdout: (chunk) => emit("stdout", chunk),
89
97
  onStderr,
90
98
  });
91
- if (notifyTimer)
92
- clearTimeout(notifyTimer);
93
99
  const outcome = result.exitCode !== 0 ? "failed" : parseTaskOutcome(result.output);
94
100
  const linkedFiles = listLinkedTaskFiles(ctx.taskDir, ctx.runId, result.output);
95
101
  const requiredPermissions = parsePermissions(result.output);
96
- for (const stream of ["stdout", "stderr"]) {
97
- const trailing = lineBufs[stream];
98
- if (trailing && !trailing.startsWith("[PALMIER")) {
99
- ensureWriter(stream).write(trailing);
100
- }
101
- }
102
+ flushTrailingLine("stdout");
103
+ if (outcome === "failed")
104
+ emit("stderr", suppressedStderr);
105
+ flushTrailingLine("stderr");
102
106
  if (requiredPermissions.length > 0) {
103
107
  const permLines = requiredPermissions.map((p) => `- **${p.name}** ${p.description}`).join("\n");
104
108
  ensureWriter("stdout").write(`\n\n**Permissions requested:**\n${permLines}\n`);
105
109
  }
110
+ if (notifyTimer)
111
+ clearTimeout(notifyTimer);
106
112
  writer?.end();
107
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
113
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
108
114
  if (linkedFiles.length > 0) {
109
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, {
115
+ await publishHostEvent(ctx.taskId, {
110
116
  event_type: "report-generated",
111
117
  run_id: ctx.runId,
112
118
  name: taskDisplayName(ctx.task.frontmatter),
@@ -153,7 +159,7 @@ export function stripPalmierMarkers(output) {
153
159
  }
154
160
  async function appendAndNotify(ctx, msg) {
155
161
  appendRunMessage(ctx.taskDir, ctx.runId, msg);
156
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
162
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
157
163
  }
158
164
  /** The latest run dir with no status messages yet — freshly created by the RPC handler. */
159
165
  function findLatestPendingRunId(taskDir) {
@@ -182,7 +188,6 @@ export async function runCommand(taskId) {
182
188
  const taskDir = getTaskDir(config.projectRoot, taskId);
183
189
  const task = parseTaskFile(taskDir);
184
190
  console.log(`Running task: ${taskId}`);
185
- let nc;
186
191
  const taskName = taskDisplayName(task.frontmatter);
187
192
  const existingRunId = findLatestPendingRunId(taskDir);
188
193
  const agentVersion = config.agents?.find((a) => a.key === task.frontmatter.agent)?.version;
@@ -191,9 +196,6 @@ export async function runCommand(taskId) {
191
196
  appendHistory(config.projectRoot, { task_id: taskId, run_id: runId });
192
197
  }
193
198
  const cleanup = async () => {
194
- if (nc && !nc.isClosed()) {
195
- await nc.drain();
196
- }
197
199
  if (task.frontmatter.one_off) {
198
200
  try {
199
201
  getPlatform().removeTaskTimer(taskId);
@@ -202,32 +204,31 @@ export async function runCommand(taskId) {
202
204
  }
203
205
  };
204
206
  try {
205
- nc = await connectNats(config);
206
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, runId);
207
+ await publishTaskEvent(taskDir, taskId, "started", taskName, runId);
207
208
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "started" });
208
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
209
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
209
210
  if (task.frontmatter.requires_confirmation) {
210
211
  const confirmed = await requestConfirmation(config, task, taskDir);
211
212
  const confirmPrompt = `**Task Confirmation**\n\nRun task "${taskName || task.frontmatter.user_prompt}"?`;
212
213
  appendRunMessage(taskDir, runId, { role: "assistant", time: Date.now(), content: confirmPrompt, type: "confirmation" });
213
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
214
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
214
215
  if (!confirmed) {
215
216
  console.log("Task aborted by user.");
216
217
  appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Aborted", type: "confirmation" });
217
218
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
218
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
219
+ await publishTaskEvent(taskDir, taskId, "aborted", taskName, runId);
219
220
  await cleanup();
220
221
  return;
221
222
  }
222
223
  console.log("Task confirmed by user.");
223
224
  appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Confirmed", type: "confirmation" });
224
225
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "confirmation" });
225
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
226
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
226
227
  }
227
228
  const guiEnv = getPlatform().getGuiEnv();
228
229
  const agent = getAgent(task.frontmatter.agent);
229
230
  const ctx = {
230
- agent, task, taskDir, runId, guiEnv, nc, config, taskId,
231
+ agent, task, taskDir, runId, guiEnv, config, taskId,
231
232
  transientPermissions: [],
232
233
  };
233
234
  // Command-output and on_new_* tasks share the same trigger machinery: the
@@ -240,7 +241,7 @@ export async function runCommand(taskId) {
240
241
  const result = await runEventTriggeredMode(ctx);
241
242
  const outcome = resolveOutcome(taskDir, result.outcome);
242
243
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
243
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
244
+ await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
244
245
  console.log(`Task ${taskId} completed (triggered).`);
245
246
  }
246
247
  else {
@@ -252,7 +253,7 @@ export async function runCommand(taskId) {
252
253
  const result = await invokeAgentWithRetries(ctx, task);
253
254
  const outcome = resolveOutcome(taskDir, result.outcome);
254
255
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
255
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
256
+ await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
256
257
  console.log(`Task ${taskId} completed.`);
257
258
  }
258
259
  }
@@ -266,7 +267,7 @@ export async function runCommand(taskId) {
266
267
  content: errorMsg,
267
268
  });
268
269
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
269
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
270
+ await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
270
271
  process.exitCode = 1;
271
272
  }
272
273
  finally {
@@ -317,12 +318,12 @@ async function runEventTriggeredMode(ctx) {
317
318
  catch (err) {
318
319
  const errorMsg = err instanceof Error ? err.message : String(err);
319
320
  appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: errorMsg, type: "error" });
320
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
321
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
321
322
  return { outcome: "failed", endTime: Date.now() };
322
323
  }
323
324
  return { outcome: lastOutcome, endTime: Date.now() };
324
325
  }
325
- async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
326
+ async function publishTaskEvent(taskDir, taskId, eventType, taskName, runId) {
326
327
  writeTaskStatus(taskDir, {
327
328
  running_state: eventType,
328
329
  time_stamp: Date.now(),
@@ -333,7 +334,7 @@ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName
333
334
  payload.name = taskName;
334
335
  if (runId)
335
336
  payload.run_id = runId;
336
- await publishHostEvent(nc, config.hostId, taskId, payload);
337
+ await publishHostEvent(taskId, payload);
337
338
  }
338
339
  async function requestPermission(config, task, taskDir, requiredPermissions) {
339
340
  const port = config.httpPort ?? 7256;
@@ -25,7 +25,7 @@ const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
25
25
  * clean up OS scheduler units for one-off tasks that have already terminated.
26
26
  * The system scheduler (Task Scheduler / systemd) is the authoritative source.
27
27
  */
28
- async function checkStaleTasks(config, nc) {
28
+ async function checkStaleTasks(config) {
29
29
  const tasksRoot = path.join(config.projectRoot, "tasks");
30
30
  if (!fs.existsSync(tasksRoot))
31
31
  return;
@@ -59,7 +59,7 @@ async function checkStaleTasks(config, nc) {
59
59
  type: "failed",
60
60
  });
61
61
  }
62
- await publishHostEvent(nc, config.hostId, taskId, {
62
+ await publishHostEvent(taskId, {
63
63
  event_type: "running-state",
64
64
  running_state: "failed",
65
65
  name: taskDisplayName(task.frontmatter) || taskId,
@@ -91,7 +91,7 @@ async function checkStaleTasks(config, nc) {
91
91
  content: "",
92
92
  type: "failed",
93
93
  });
94
- await publishHostEvent(nc, config.hostId, taskId, { event_type: "result-updated", run_id: runId });
94
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
95
95
  }
96
96
  }
97
97
  }
@@ -112,37 +112,18 @@ export async function serveCommand() {
112
112
  saveConfig(config);
113
113
  console.log(`Detected agents: ${agents.map((a) => a.key).join(", ") || "none"}`);
114
114
  let nc;
115
- try {
116
- nc = await connectNats(config);
115
+ const handleRpc = createRpcHandler(config);
116
+ function attachNats(conn) {
117
+ nc = conn;
117
118
  console.log("[nats] Connected");
118
- }
119
- catch (err) {
120
- console.warn(`[nats] Connection failed (server mode unavailable): ${err}`);
121
- }
122
- await checkStaleTasks(config, nc);
123
- // Reinstall scheduler entries for all tasks (recovery after init/reinstall)
124
- const platform = getPlatform();
125
- const allTasks = listTasks(config.projectRoot);
126
- for (const task of allTasks) {
127
- try {
128
- platform.installTaskTimer(config, task);
129
- }
130
- catch (err) {
131
- console.error(`Warning: failed to install timer for task ${task.frontmatter.id}: ${err}`);
132
- }
133
- }
134
- // Spawn the long-running command process for every enabled command task —
135
- // the daemon owns these the same way it owns the device-event subscriptions.
136
- startEnabledCommandRunners(config);
137
- setInterval(() => {
138
- checkStaleTasks(config, nc).catch((err) => {
139
- console.error("[monitor] Error checking stale tasks:", err);
140
- });
141
- }, POLL_INTERVAL_MS);
142
- const handleRpc = createRpcHandler(config, nc);
143
- const httpPort = config.httpPort ?? 7256;
144
- if (nc) {
145
- startNatsTransport(config, handleRpc, nc);
119
+ (async () => {
120
+ for await (const s of conn.status()) {
121
+ if (s.type === "pingTimer")
122
+ continue;
123
+ console.log(`[nats] Status: ${s.type}${typeof s.data === "string" ? ` (${s.data})` : ""}`);
124
+ }
125
+ })();
126
+ startNatsTransport(config, handleRpc, conn);
146
127
  const sc = StringCodec();
147
128
  // Match phone numbers regardless of formatting; letters preserved for shortcodes.
148
129
  function normalizeSender(raw) {
@@ -168,7 +149,7 @@ export async function serveCommand() {
168
149
  dispatchTrigger(task.frontmatter.id, payload);
169
150
  }
170
151
  }
171
- const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
152
+ const notifSub = conn.subscribe(`host.${config.hostId}.device.notifications`);
172
153
  (async () => {
173
154
  for await (const msg of notifSub) {
174
155
  const raw = sc.decode(msg.data);
@@ -183,7 +164,7 @@ export async function serveCommand() {
183
164
  dispatchDeviceEvent("on_new_notification", raw, parsed);
184
165
  }
185
166
  })();
186
- const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
167
+ const smsSub = conn.subscribe(`host.${config.hostId}.device.sms`);
187
168
  (async () => {
188
169
  for await (const msg of smsSub) {
189
170
  const raw = sc.decode(msg.data);
@@ -199,5 +180,42 @@ export async function serveCommand() {
199
180
  }
200
181
  })();
201
182
  }
202
- await startHttpTransport(config, handleRpc, httpPort, nc);
183
+ // The client's own reconnect only covers a connection that was once up, so a
184
+ // daemon started while offline (e.g. at logon before Wi-Fi) must keep dialing.
185
+ async function connectWithRetry() {
186
+ for (let delayMs = 5_000;; delayMs = Math.min(delayMs * 2, 60_000)) {
187
+ try {
188
+ attachNats(await connectNats(config));
189
+ return;
190
+ }
191
+ catch (err) {
192
+ console.warn(`[nats] Connection failed, retrying in ${delayMs / 1000}s: ${err}`);
193
+ }
194
+ await new Promise((r) => setTimeout(r, delayMs));
195
+ }
196
+ }
197
+ // Don't block startup on the relay: local mode must work offline.
198
+ connectWithRetry();
199
+ await checkStaleTasks(config);
200
+ // Reinstall scheduler entries for all tasks (recovery after init/reinstall)
201
+ const platform = getPlatform();
202
+ const allTasks = listTasks(config.projectRoot);
203
+ for (const task of allTasks) {
204
+ try {
205
+ platform.installTaskTimer(config, task);
206
+ }
207
+ catch (err) {
208
+ console.error(`Warning: failed to install timer for task ${task.frontmatter.id}: ${err}`);
209
+ }
210
+ }
211
+ // Spawn the long-running command process for every enabled command task —
212
+ // the daemon owns these the same way it owns the device-event subscriptions.
213
+ startEnabledCommandRunners(config);
214
+ setInterval(() => {
215
+ checkStaleTasks(config).catch((err) => {
216
+ console.error("[monitor] Error checking stale tasks:", err);
217
+ });
218
+ }, POLL_INTERVAL_MS);
219
+ const httpPort = config.httpPort ?? 7256;
220
+ await startHttpTransport(config, handleRpc, httpPort, () => nc);
203
221
  }
package/dist/events.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { type NatsConnection } from "nats";
2
- export declare function publishHostEvent(nc: NatsConnection | undefined, hostId: string, taskId: string, payload: Record<string, unknown>): Promise<void>;
1
+ /** Hands the event to the daemon, which forwards it to the relay and local SSE clients. */
2
+ export declare function publishHostEvent(taskId: string, payload: Record<string, unknown>): Promise<void>;
package/dist/events.js CHANGED
@@ -1,12 +1,6 @@
1
- import { StringCodec } from "nats";
2
1
  import { loadConfig } from "./config.js";
3
- const sc = StringCodec();
4
- export async function publishHostEvent(nc, hostId, taskId, payload) {
5
- const subject = `host-event.${hostId}.${taskId}`;
6
- if (nc) {
7
- nc.publish(subject, sc.encode(JSON.stringify(payload)));
8
- console.log(`[nats] ${subject} →`, payload);
9
- }
2
+ /** Hands the event to the daemon, which forwards it to the relay and local SSE clients. */
3
+ export async function publishHostEvent(taskId, payload) {
10
4
  const config = loadConfig();
11
5
  const port = config.httpPort ?? 7256;
12
6
  try {
@@ -6,6 +6,12 @@ export async function connectNats(config) {
6
6
  const nc = await connect({
7
7
  servers: config.natsUrl,
8
8
  authenticator: jwtAuthenticator(config.natsJwt, new TextEncoder().encode(config.natsNkeySeed)),
9
+ // Sleep or a network switch kills the socket silently; the 2-min default
10
+ // ping leaves the host unreachable for minutes, and the default 10
11
+ // reconnect attempts close the connection for good after a short outage.
12
+ maxReconnectAttempts: -1,
13
+ pingInterval: 20_000,
14
+ maxPingOut: 2,
9
15
  });
10
16
  return nc;
11
17
  }
@@ -12,7 +12,10 @@ import type { HostConfig, ParsedTask } from "../types.js";
12
12
  * monthly: "MM HH D * *"
13
13
  */
14
14
  export declare function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string;
15
- export declare function buildTaskXml(tr: string, triggers: string[], foreground?: boolean): string;
15
+ export declare function buildTaskXml(tr: string, triggers: string[], opts?: {
16
+ foreground?: boolean;
17
+ persistent?: boolean;
18
+ }): string;
16
19
  export declare class WindowsPlatform implements PlatformService {
17
20
  installDaemon(config: HostConfig): void;
18
21
  uninstallDaemon(): void;
@@ -40,7 +40,7 @@ export function scheduleValueToXml(scheduleType, value) {
40
40
  }
41
41
  return `<CalendarTrigger><StartBoundary>${base}</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>`;
42
42
  }
43
- export function buildTaskXml(tr, triggers, foreground) {
43
+ export function buildTaskXml(tr, triggers, opts = {}) {
44
44
  const [command, ...argParts] = tr.match(/"[^"]*"|[^\s]+/g) ?? [];
45
45
  const commandStr = command?.replace(/"/g, "") ?? "";
46
46
  const argsStr = argParts.map((a) => a.replace(/"/g, "")).join(" ");
@@ -49,7 +49,7 @@ export function buildTaskXml(tr, triggers, foreground) {
49
49
  `<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">`,
50
50
  ` <Principals>`,
51
51
  ` <Principal>`,
52
- ` <LogonType>${foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
52
+ ` <LogonType>${opts.foreground ? "InteractiveToken" : "S4U"}</LogonType>`,
53
53
  ` <RunLevel>LeastPrivilege</RunLevel>`,
54
54
  ` </Principal>`,
55
55
  ` </Principals>`,
@@ -58,6 +58,12 @@ export function buildTaskXml(tr, triggers, foreground) {
58
58
  ` <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`,
59
59
  ` <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`,
60
60
  ` <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>`,
61
+ // Omitting ExecutionTimeLimit means Task Scheduler's 72h default, which
62
+ // kills a long-running process; PT0S disables the limit.
63
+ ...(opts.persistent ? [
64
+ ` <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>`,
65
+ ` <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>`,
66
+ ] : []),
61
67
  ` </Settings>`,
62
68
  ` <Triggers>${triggers.join("")}</Triggers>`,
63
69
  ` <Actions>`,
@@ -171,7 +177,7 @@ export class WindowsPlatform {
171
177
  ensureDaemonTask(script) {
172
178
  const tn = `\\Palmier\\${DAEMON_TASK_NAME}`;
173
179
  const tr = `"${process.execPath}" "${script}" serve`;
174
- const xml = buildTaskXml(tr, [`<BootTrigger><Enabled>true</Enabled></BootTrigger>`]);
180
+ const xml = buildTaskXml(tr, [`<BootTrigger><Enabled>true</Enabled></BootTrigger>`], { persistent: true });
175
181
  const xmlPath = path.join(CONFIG_DIR, "daemon-task.xml");
176
182
  try {
177
183
  const bom = Buffer.from([0xFF, 0xFE]);
@@ -234,7 +240,7 @@ export class WindowsPlatform {
234
240
  // MultipleInstancesPolicy. S4U keeps the console hidden unless
235
241
  // foreground_mode is set. Works unelevated because the caller (daemon)
236
242
  // runs elevated.
237
- const xml = buildTaskXml(tr, triggerElements, task.frontmatter.foreground_mode);
243
+ const xml = buildTaskXml(tr, triggerElements, { foreground: task.frontmatter.foreground_mode });
238
244
  const xmlPath = path.join(CONFIG_DIR, `task-${taskId}.xml`);
239
245
  try {
240
246
  // schtasks /xml requires UTF-16LE with BOM.