palmier 1.1.19 → 1.1.20

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.
@@ -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";
@@ -31,7 +30,7 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
31
30
  notifyPending = true;
32
31
  notifyTimer = setTimeout(() => {
33
32
  notifyPending = false;
34
- publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
33
+ publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
35
34
  }, 500);
36
35
  }
37
36
  function ensureWriter(stream) {
@@ -104,9 +103,9 @@ async function invokeAgentWithRetries(ctx, invokeTask) {
104
103
  ensureWriter("stdout").write(`\n\n**Permissions requested:**\n${permLines}\n`);
105
104
  }
106
105
  writer?.end();
107
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
106
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
108
107
  if (linkedFiles.length > 0) {
109
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, {
108
+ await publishHostEvent(ctx.taskId, {
110
109
  event_type: "report-generated",
111
110
  run_id: ctx.runId,
112
111
  name: taskDisplayName(ctx.task.frontmatter),
@@ -153,7 +152,7 @@ export function stripPalmierMarkers(output) {
153
152
  }
154
153
  async function appendAndNotify(ctx, msg) {
155
154
  appendRunMessage(ctx.taskDir, ctx.runId, msg);
156
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
155
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
157
156
  }
158
157
  /** The latest run dir with no status messages yet — freshly created by the RPC handler. */
159
158
  function findLatestPendingRunId(taskDir) {
@@ -182,7 +181,6 @@ export async function runCommand(taskId) {
182
181
  const taskDir = getTaskDir(config.projectRoot, taskId);
183
182
  const task = parseTaskFile(taskDir);
184
183
  console.log(`Running task: ${taskId}`);
185
- let nc;
186
184
  const taskName = taskDisplayName(task.frontmatter);
187
185
  const existingRunId = findLatestPendingRunId(taskDir);
188
186
  const agentVersion = config.agents?.find((a) => a.key === task.frontmatter.agent)?.version;
@@ -191,9 +189,6 @@ export async function runCommand(taskId) {
191
189
  appendHistory(config.projectRoot, { task_id: taskId, run_id: runId });
192
190
  }
193
191
  const cleanup = async () => {
194
- if (nc && !nc.isClosed()) {
195
- await nc.drain();
196
- }
197
192
  if (task.frontmatter.one_off) {
198
193
  try {
199
194
  getPlatform().removeTaskTimer(taskId);
@@ -202,32 +197,31 @@ export async function runCommand(taskId) {
202
197
  }
203
198
  };
204
199
  try {
205
- nc = await connectNats(config);
206
- await publishTaskEvent(nc, config, taskDir, taskId, "started", taskName, runId);
200
+ await publishTaskEvent(taskDir, taskId, "started", taskName, runId);
207
201
  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 });
202
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
209
203
  if (task.frontmatter.requires_confirmation) {
210
204
  const confirmed = await requestConfirmation(config, task, taskDir);
211
205
  const confirmPrompt = `**Task Confirmation**\n\nRun task "${taskName || task.frontmatter.user_prompt}"?`;
212
206
  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 });
207
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
214
208
  if (!confirmed) {
215
209
  console.log("Task aborted by user.");
216
210
  appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Aborted", type: "confirmation" });
217
211
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
218
- await publishTaskEvent(nc, config, taskDir, taskId, "aborted", taskName, runId);
212
+ await publishTaskEvent(taskDir, taskId, "aborted", taskName, runId);
219
213
  await cleanup();
220
214
  return;
221
215
  }
222
216
  console.log("Task confirmed by user.");
223
217
  appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Confirmed", type: "confirmation" });
224
218
  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 });
219
+ await publishHostEvent(taskId, { event_type: "result-updated", run_id: runId });
226
220
  }
227
221
  const guiEnv = getPlatform().getGuiEnv();
228
222
  const agent = getAgent(task.frontmatter.agent);
229
223
  const ctx = {
230
- agent, task, taskDir, runId, guiEnv, nc, config, taskId,
224
+ agent, task, taskDir, runId, guiEnv, config, taskId,
231
225
  transientPermissions: [],
232
226
  };
233
227
  // Command-output and on_new_* tasks share the same trigger machinery: the
@@ -240,7 +234,7 @@ export async function runCommand(taskId) {
240
234
  const result = await runEventTriggeredMode(ctx);
241
235
  const outcome = resolveOutcome(taskDir, result.outcome);
242
236
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
243
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
237
+ await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
244
238
  console.log(`Task ${taskId} completed (triggered).`);
245
239
  }
246
240
  else {
@@ -252,7 +246,7 @@ export async function runCommand(taskId) {
252
246
  const result = await invokeAgentWithRetries(ctx, task);
253
247
  const outcome = resolveOutcome(taskDir, result.outcome);
254
248
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
255
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
249
+ await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
256
250
  console.log(`Task ${taskId} completed.`);
257
251
  }
258
252
  }
@@ -266,7 +260,7 @@ export async function runCommand(taskId) {
266
260
  content: errorMsg,
267
261
  });
268
262
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
269
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
263
+ await publishTaskEvent(taskDir, taskId, outcome, taskName, runId);
270
264
  process.exitCode = 1;
271
265
  }
272
266
  finally {
@@ -317,12 +311,12 @@ async function runEventTriggeredMode(ctx) {
317
311
  catch (err) {
318
312
  const errorMsg = err instanceof Error ? err.message : String(err);
319
313
  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 });
314
+ await publishHostEvent(ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
321
315
  return { outcome: "failed", endTime: Date.now() };
322
316
  }
323
317
  return { outcome: lastOutcome, endTime: Date.now() };
324
318
  }
325
- async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
319
+ async function publishTaskEvent(taskDir, taskId, eventType, taskName, runId) {
326
320
  writeTaskStatus(taskDir, {
327
321
  running_state: eventType,
328
322
  time_stamp: Date.now(),
@@ -333,7 +327,7 @@ async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName
333
327
  payload.name = taskName;
334
328
  if (runId)
335
329
  payload.run_id = runId;
336
- await publishHostEvent(nc, config.hostId, taskId, payload);
330
+ await publishHostEvent(taskId, payload);
337
331
  }
338
332
  async function requestPermission(config, task, taskDir, requiredPermissions) {
339
333
  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.