palmier 0.7.9 → 0.8.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.
@@ -209,6 +209,15 @@ export async function runCommand(taskId) {
209
209
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
210
210
  console.log(`Task ${taskId} completed (command-triggered).`);
211
211
  }
212
+ else if (task.frontmatter.schedule_type === "on_new_notification"
213
+ || task.frontmatter.schedule_type === "on_new_sms") {
214
+ // Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
215
+ const result = await runEventTriggeredMode(ctx);
216
+ const outcome = resolveOutcome(taskDir, result.outcome);
217
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
218
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
219
+ console.log(`Task ${taskId} completed (event-triggered).`);
220
+ }
212
221
  else {
213
222
  // Standard execution — add user prompt as first message
214
223
  await appendAndNotify(ctx, {
@@ -380,6 +389,52 @@ async function runCommandTriggeredMode(ctx) {
380
389
  }
381
390
  return { outcome: "finished", endTime };
382
391
  }
392
+ /**
393
+ * Event-triggered execution mode.
394
+ *
395
+ * Drains the daemon-owned per-task event queue via the local /task-event/pop
396
+ * HTTP endpoint, invoking the agent once per event with the payload spliced
397
+ * into the user prompt. The run process itself holds no NATS subscription;
398
+ * the daemon handles that and atomically clears the active flag when we see
399
+ * an empty pop, so it can fire up a fresh run on the next incoming event.
400
+ */
401
+ async function runEventTriggeredMode(ctx) {
402
+ const scheduleType = ctx.task.frontmatter.schedule_type;
403
+ const label = scheduleType === "on_new_notification" ? "notification" : "SMS";
404
+ const port = ctx.config.httpPort ?? 7256;
405
+ const popUrl = `http://localhost:${port}/task-event/pop?taskId=${encodeURIComponent(ctx.taskId)}`;
406
+ console.log(`[event-triggered] Draining ${label} queue`);
407
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
408
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
409
+ let eventsProcessed = 0;
410
+ try {
411
+ // eslint-disable-next-line no-constant-condition
412
+ while (true) {
413
+ const res = await fetch(popUrl, { method: "POST" });
414
+ if (!res.ok)
415
+ throw new Error(`pop-event failed: ${res.status} ${res.statusText}`);
416
+ const body = await res.json();
417
+ if (body.empty || !body.event)
418
+ break;
419
+ eventsProcessed++;
420
+ console.log(`[event-triggered] Processing ${label} #${eventsProcessed}`);
421
+ const perEventPrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
422
+ const perEventTask = {
423
+ frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
424
+ };
425
+ await invokeAgentWithRetries(ctx, perEventTask);
426
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
427
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
428
+ }
429
+ }
430
+ catch (err) {
431
+ const errorMsg = err instanceof Error ? err.message : String(err);
432
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: errorMsg, type: "error" });
433
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
434
+ return { outcome: "failed", endTime: Date.now() };
435
+ }
436
+ return { outcome: "finished", endTime: Date.now() };
437
+ }
383
438
  async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName, runId) {
384
439
  writeTaskStatus(taskDir, {
385
440
  running_state: eventType,
@@ -14,6 +14,7 @@ import { CONFIG_DIR } from "../config.js";
14
14
  import { StringCodec } from "nats";
15
15
  import { addNotification } from "../notification-store.js";
16
16
  import { addSmsMessage } from "../sms-store.js";
17
+ import { enqueueEvent } from "../event-queues.js";
17
18
  const POLL_INTERVAL_MS = 30_000;
18
19
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
19
20
  /**
@@ -119,28 +120,47 @@ export async function serveCommand() {
119
120
  startNatsTransport(config, handleRpc, nc);
120
121
  // Subscribe to device notifications and SMS from Android
121
122
  const sc = StringCodec();
123
+ // Dispatch a raw event payload to every task whose schedule matches.
124
+ function dispatchDeviceEvent(scheduleType, payload) {
125
+ for (const task of listTasks(config.projectRoot)) {
126
+ if (task.frontmatter.schedule_type !== scheduleType)
127
+ continue;
128
+ if (!task.frontmatter.schedule_enabled)
129
+ continue;
130
+ const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
131
+ if (shouldStart) {
132
+ platform.startTask(task.frontmatter.id).catch((err) => {
133
+ console.error(`[event-trigger] Failed to start ${task.frontmatter.id}:`, err);
134
+ });
135
+ }
136
+ }
137
+ }
122
138
  const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
123
139
  (async () => {
124
140
  for await (const msg of notifSub) {
141
+ const raw = sc.decode(msg.data);
125
142
  try {
126
- const data = JSON.parse(sc.decode(msg.data));
143
+ const data = JSON.parse(raw);
127
144
  addNotification({ ...data, receivedAt: Date.now() });
128
145
  }
129
146
  catch (err) {
130
147
  console.error("[nats] Failed to parse device notification:", err);
131
148
  }
149
+ dispatchDeviceEvent("on_new_notification", raw);
132
150
  }
133
151
  })();
134
152
  const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
135
153
  (async () => {
136
154
  for await (const msg of smsSub) {
155
+ const raw = sc.decode(msg.data);
137
156
  try {
138
- const data = JSON.parse(sc.decode(msg.data));
157
+ const data = JSON.parse(raw);
139
158
  addSmsMessage({ ...data, receivedAt: Date.now() });
140
159
  }
141
160
  catch (err) {
142
161
  console.error("[nats] Failed to parse device SMS:", err);
143
162
  }
163
+ dispatchDeviceEvent("on_new_sms", raw);
144
164
  }
145
165
  })();
146
166
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-task in-memory event queues for event-triggered schedules
3
+ * (schedule_type: "on_new_notification" | "on_new_sms").
4
+ *
5
+ * The daemon owns the NATS subscription and populates these queues; the
6
+ * `palmier run` process drains them via the localhost /task-event/pop HTTP
7
+ * endpoint. `activeRuns` tracks whether a run process is currently draining,
8
+ * so we don't race a fresh startTask with a teardown-phase run.
9
+ *
10
+ * Lifecycle invariants:
11
+ * - activeRuns is cleared atomically inside popEvent when the queue is
12
+ * drained. At that point the calling run has already finished its last
13
+ * agent invocation and is only tearing down.
14
+ * - enqueueEvent returns shouldStart=true only if the task transitioned
15
+ * from idle (no active run) to active — callers must then startTask.
16
+ */
17
+ /**
18
+ * Queue a raw (JSON-string) event payload for a task. Returns whether the
19
+ * caller should now start the run process.
20
+ */
21
+ export declare function enqueueEvent(taskId: string, payload: string): {
22
+ shouldStart: boolean;
23
+ };
24
+ /**
25
+ * Pop the oldest queued event for a task. Returns `{ event }` when one is
26
+ * available (keeps the task marked active), or `{ empty: true }` after
27
+ * clearing the active flag atomically.
28
+ */
29
+ export declare function popEvent(taskId: string): {
30
+ event: string;
31
+ } | {
32
+ empty: true;
33
+ };
34
+ /** Remove any state for a task (called from task.delete). */
35
+ export declare function clearTaskQueue(taskId: string): void;
36
+ //# sourceMappingURL=event-queues.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Per-task in-memory event queues for event-triggered schedules
3
+ * (schedule_type: "on_new_notification" | "on_new_sms").
4
+ *
5
+ * The daemon owns the NATS subscription and populates these queues; the
6
+ * `palmier run` process drains them via the localhost /task-event/pop HTTP
7
+ * endpoint. `activeRuns` tracks whether a run process is currently draining,
8
+ * so we don't race a fresh startTask with a teardown-phase run.
9
+ *
10
+ * Lifecycle invariants:
11
+ * - activeRuns is cleared atomically inside popEvent when the queue is
12
+ * drained. At that point the calling run has already finished its last
13
+ * agent invocation and is only tearing down.
14
+ * - enqueueEvent returns shouldStart=true only if the task transitioned
15
+ * from idle (no active run) to active — callers must then startTask.
16
+ */
17
+ const MAX_QUEUE_SIZE = 100;
18
+ const queues = new Map();
19
+ const activeRuns = new Set();
20
+ /**
21
+ * Queue a raw (JSON-string) event payload for a task. Returns whether the
22
+ * caller should now start the run process.
23
+ */
24
+ export function enqueueEvent(taskId, payload) {
25
+ const queue = queues.get(taskId) ?? [];
26
+ if (queue.length >= MAX_QUEUE_SIZE)
27
+ queue.shift();
28
+ queue.push(payload);
29
+ queues.set(taskId, queue);
30
+ if (activeRuns.has(taskId))
31
+ return { shouldStart: false };
32
+ activeRuns.add(taskId);
33
+ return { shouldStart: true };
34
+ }
35
+ /**
36
+ * Pop the oldest queued event for a task. Returns `{ event }` when one is
37
+ * available (keeps the task marked active), or `{ empty: true }` after
38
+ * clearing the active flag atomically.
39
+ */
40
+ export function popEvent(taskId) {
41
+ const queue = queues.get(taskId);
42
+ if (queue && queue.length > 0) {
43
+ return { event: queue.shift() };
44
+ }
45
+ activeRuns.delete(taskId);
46
+ return { empty: true };
47
+ }
48
+ /** Remove any state for a task (called from task.delete). */
49
+ export function clearTaskQueue(taskId) {
50
+ queues.delete(taskId);
51
+ activeRuns.delete(taskId);
52
+ }
53
+ //# sourceMappingURL=event-queues.js.map
@@ -177,11 +177,14 @@ export class WindowsPlatform {
177
177
  const tn = schtasksTaskName(taskId);
178
178
  const script = process.argv[1] || "palmier";
179
179
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
180
- // Build trigger XML elements
180
+ // Build trigger XML elements. Event-based schedule types (on_new_notification,
181
+ // on_new_sms) carry no values and are driven by the run process, not the OS
182
+ // scheduler — they intentionally produce only the dummy trigger below.
181
183
  const triggerElements = [];
182
184
  const scheduleType = task.frontmatter.schedule_type;
183
185
  const scheduleValues = task.frontmatter.schedule_values;
184
- if (task.frontmatter.schedule_enabled && scheduleType && scheduleValues?.length) {
186
+ const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
187
+ if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
185
188
  for (const value of scheduleValues) {
186
189
  try {
187
190
  triggerElements.push(scheduleValueToXml(scheduleType, value));
@@ -13,6 +13,7 @@ import { publishHostEvent } from "./events.js";
13
13
  import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice } from "./device-capabilities.js";
14
14
  import { currentVersion, performUpdate } from "./update-checker.js";
15
15
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
16
+ import { clearTaskQueue } from "./event-queues.js";
16
17
  /**
17
18
  * Parse RESULT frontmatter and conversation messages.
18
19
  */
@@ -142,7 +143,7 @@ export function createRpcHandler(config, nc) {
142
143
  // is active. Includes any prompts already waiting so a reconnecting
143
144
  // PWA can render their modals without replaying events.
144
145
  const capabilities = {};
145
- for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd"]) {
146
+ for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "email"]) {
146
147
  capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
147
148
  }
148
149
  return {
@@ -183,9 +184,8 @@ export function createRpcHandler(config, nc) {
183
184
  agent: params.agent,
184
185
  schedule_enabled: params.schedule_enabled ?? true,
185
186
  requires_confirmation: params.requires_confirmation ?? true,
186
- ...(params.schedule_type && params.schedule_values?.length
187
- ? { schedule_type: params.schedule_type, schedule_values: params.schedule_values }
188
- : {}),
187
+ ...(params.schedule_type ? { schedule_type: params.schedule_type } : {}),
188
+ ...(params.schedule_values?.length ? { schedule_values: params.schedule_values } : {}),
189
189
  ...(params.yolo_mode ? { yolo_mode: true } : {}),
190
190
  ...(params.foreground_mode ? { foreground_mode: true } : {}),
191
191
  ...(params.command ? { command: params.command } : {}),
@@ -258,6 +258,7 @@ export function createRpcHandler(config, nc) {
258
258
  case "task.delete": {
259
259
  const params = request.params;
260
260
  getPlatform().removeTaskTimer(params.id);
261
+ clearTaskQueue(params.id);
261
262
  removeFromTaskList(config.projectRoot, params.id);
262
263
  return { ok: true, task_id: params.id };
263
264
  }
@@ -8,6 +8,7 @@ import * as fs from "node:fs";
8
8
  import { agentToolMap, agentResources, ToolError } from "../mcp-tools.js";
9
9
  import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
10
10
  import { getTaskDir } from "../task.js";
11
+ import { popEvent } from "../event-queues.js";
11
12
  const assetCache = new Map();
12
13
  const PWA_DIR = path.join(import.meta.dirname, "..", "pwa");
13
14
  const CONTENT_TYPES = {
@@ -247,6 +248,20 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
247
248
  sendJson(res, 200, result);
248
249
  return;
249
250
  }
251
+ // ── Event queue pop (used by event-triggered palmier run) ─────────
252
+ if (req.method === "POST" && pathname === "/task-event/pop") {
253
+ if (!isLocalhost(req)) {
254
+ sendJson(res, 403, { error: "localhost only" });
255
+ return;
256
+ }
257
+ const taskId = url.searchParams.get("taskId");
258
+ if (!taskId) {
259
+ sendJson(res, 400, { error: "taskId query parameter is required" });
260
+ return;
261
+ }
262
+ sendJson(res, 200, popEvent(taskId));
263
+ return;
264
+ }
250
265
  // ── Localhost-only endpoints (no auth) ─────────────────────────────
251
266
  if (req.method === "POST" && pathname === "/event") {
252
267
  if (!isLocalhost(req)) {
package/dist/types.d.ts CHANGED
@@ -20,12 +20,13 @@ export interface TaskFrontmatter {
20
20
  user_prompt: string;
21
21
  agent: string;
22
22
  /**
23
- * Task schedule. `schedule_values` is homogeneous per `schedule_type`:
24
- * - `crons`: array of cron expressions (e.g. "0 9 * * *")
25
- * - `specific_times`: array of local datetime strings (e.g. "2026-04-20T09:00")
26
- * Both fields are present together or absent together.
23
+ * Task schedule.
24
+ * - `crons`: `schedule_values` holds cron expressions (e.g. "0 9 * * *")
25
+ * - `specific_times`: `schedule_values` holds local datetime strings (e.g. "2026-04-20T09:00")
26
+ * - `on_new_notification`: fires on each new Android notification from NATS; no `schedule_values`
27
+ * - `on_new_sms`: fires on each new SMS from NATS; no `schedule_values`
27
28
  */
28
- schedule_type?: "crons" | "specific_times";
29
+ schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
29
30
  schedule_values?: string[];
30
31
  schedule_enabled: boolean;
31
32
  requires_confirmation: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.7.9",
3
+ "version": "0.8.0",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -267,6 +267,14 @@ export async function runCommand(taskId: string): Promise<void> {
267
267
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
268
268
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
269
269
  console.log(`Task ${taskId} completed (command-triggered).`);
270
+ } else if (task.frontmatter.schedule_type === "on_new_notification"
271
+ || task.frontmatter.schedule_type === "on_new_sms") {
272
+ // Event-triggered mode (driven by NATS pub/sub of device notifications/SMS)
273
+ const result = await runEventTriggeredMode(ctx);
274
+ const outcome = resolveOutcome(taskDir, result.outcome);
275
+ appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
276
+ await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
277
+ console.log(`Task ${taskId} completed (event-triggered).`);
270
278
  } else {
271
279
  // Standard execution — add user prompt as first message
272
280
  await appendAndNotify(ctx, {
@@ -455,6 +463,59 @@ async function runCommandTriggeredMode(
455
463
  return { outcome: "finished", endTime };
456
464
  }
457
465
 
466
+ /**
467
+ * Event-triggered execution mode.
468
+ *
469
+ * Drains the daemon-owned per-task event queue via the local /task-event/pop
470
+ * HTTP endpoint, invoking the agent once per event with the payload spliced
471
+ * into the user prompt. The run process itself holds no NATS subscription;
472
+ * the daemon handles that and atomically clears the active flag when we see
473
+ * an empty pop, so it can fire up a fresh run on the next incoming event.
474
+ */
475
+ async function runEventTriggeredMode(
476
+ ctx: InvocationContext,
477
+ ): Promise<{ outcome: TaskRunningState; endTime: number }> {
478
+ const scheduleType = ctx.task.frontmatter.schedule_type!;
479
+ const label = scheduleType === "on_new_notification" ? "notification" : "SMS";
480
+ const port = ctx.config.httpPort ?? 7256;
481
+ const popUrl = `http://localhost:${port}/task-event/pop?taskId=${encodeURIComponent(ctx.taskId)}`;
482
+
483
+ console.log(`[event-triggered] Draining ${label} queue`);
484
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
485
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
486
+
487
+ let eventsProcessed = 0;
488
+ try {
489
+ // eslint-disable-next-line no-constant-condition
490
+ while (true) {
491
+ const res = await fetch(popUrl, { method: "POST" });
492
+ if (!res.ok) throw new Error(`pop-event failed: ${res.status} ${res.statusText}`);
493
+ const body = await res.json() as { event?: string; empty?: true };
494
+ if (body.empty || !body.event) break;
495
+
496
+ eventsProcessed++;
497
+ console.log(`[event-triggered] Processing ${label} #${eventsProcessed}`);
498
+
499
+ const perEventPrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
500
+ const perEventTask: ParsedTask = {
501
+ frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
502
+ };
503
+
504
+ await invokeAgentWithRetries(ctx, perEventTask);
505
+
506
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
507
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
508
+ }
509
+ } catch (err) {
510
+ const errorMsg = err instanceof Error ? err.message : String(err);
511
+ appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: errorMsg, type: "error" });
512
+ await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
513
+ return { outcome: "failed", endTime: Date.now() };
514
+ }
515
+
516
+ return { outcome: "finished", endTime: Date.now() };
517
+ }
518
+
458
519
  async function publishTaskEvent(
459
520
  nc: NatsConnection | undefined,
460
521
  config: HostConfig,
@@ -15,6 +15,7 @@ import { CONFIG_DIR } from "../config.js";
15
15
  import { StringCodec, type NatsConnection } from "nats";
16
16
  import { addNotification } from "../notification-store.js";
17
17
  import { addSmsMessage } from "../sms-store.js";
18
+ import { enqueueEvent } from "../event-queues.js";
18
19
 
19
20
  const POLL_INTERVAL_MS = 30_000;
20
21
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
@@ -135,27 +136,46 @@ export async function serveCommand(): Promise<void> {
135
136
 
136
137
  // Subscribe to device notifications and SMS from Android
137
138
  const sc = StringCodec();
139
+
140
+ // Dispatch a raw event payload to every task whose schedule matches.
141
+ function dispatchDeviceEvent(scheduleType: "on_new_notification" | "on_new_sms", payload: string): void {
142
+ for (const task of listTasks(config.projectRoot)) {
143
+ if (task.frontmatter.schedule_type !== scheduleType) continue;
144
+ if (!task.frontmatter.schedule_enabled) continue;
145
+ const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
146
+ if (shouldStart) {
147
+ platform.startTask(task.frontmatter.id).catch((err) => {
148
+ console.error(`[event-trigger] Failed to start ${task.frontmatter.id}:`, err);
149
+ });
150
+ }
151
+ }
152
+ }
153
+
138
154
  const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
139
155
  (async () => {
140
156
  for await (const msg of notifSub) {
157
+ const raw = sc.decode(msg.data);
141
158
  try {
142
- const data = JSON.parse(sc.decode(msg.data));
159
+ const data = JSON.parse(raw);
143
160
  addNotification({ ...data, receivedAt: Date.now() });
144
161
  } catch (err) {
145
162
  console.error("[nats] Failed to parse device notification:", err);
146
163
  }
164
+ dispatchDeviceEvent("on_new_notification", raw);
147
165
  }
148
166
  })();
149
167
 
150
168
  const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
151
169
  (async () => {
152
170
  for await (const msg of smsSub) {
171
+ const raw = sc.decode(msg.data);
153
172
  try {
154
- const data = JSON.parse(sc.decode(msg.data));
173
+ const data = JSON.parse(raw);
155
174
  addSmsMessage({ ...data, receivedAt: Date.now() });
156
175
  } catch (err) {
157
176
  console.error("[nats] Failed to parse device SMS:", err);
158
177
  }
178
+ dispatchDeviceEvent("on_new_sms", raw);
159
179
  }
160
180
  })();
161
181
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-task in-memory event queues for event-triggered schedules
3
+ * (schedule_type: "on_new_notification" | "on_new_sms").
4
+ *
5
+ * The daemon owns the NATS subscription and populates these queues; the
6
+ * `palmier run` process drains them via the localhost /task-event/pop HTTP
7
+ * endpoint. `activeRuns` tracks whether a run process is currently draining,
8
+ * so we don't race a fresh startTask with a teardown-phase run.
9
+ *
10
+ * Lifecycle invariants:
11
+ * - activeRuns is cleared atomically inside popEvent when the queue is
12
+ * drained. At that point the calling run has already finished its last
13
+ * agent invocation and is only tearing down.
14
+ * - enqueueEvent returns shouldStart=true only if the task transitioned
15
+ * from idle (no active run) to active — callers must then startTask.
16
+ */
17
+
18
+ const MAX_QUEUE_SIZE = 100;
19
+
20
+ const queues = new Map<string, string[]>();
21
+ const activeRuns = new Set<string>();
22
+
23
+ /**
24
+ * Queue a raw (JSON-string) event payload for a task. Returns whether the
25
+ * caller should now start the run process.
26
+ */
27
+ export function enqueueEvent(taskId: string, payload: string): { shouldStart: boolean } {
28
+ const queue = queues.get(taskId) ?? [];
29
+ if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
30
+ queue.push(payload);
31
+ queues.set(taskId, queue);
32
+
33
+ if (activeRuns.has(taskId)) return { shouldStart: false };
34
+ activeRuns.add(taskId);
35
+ return { shouldStart: true };
36
+ }
37
+
38
+ /**
39
+ * Pop the oldest queued event for a task. Returns `{ event }` when one is
40
+ * available (keeps the task marked active), or `{ empty: true }` after
41
+ * clearing the active flag atomically.
42
+ */
43
+ export function popEvent(taskId: string): { event: string } | { empty: true } {
44
+ const queue = queues.get(taskId);
45
+ if (queue && queue.length > 0) {
46
+ return { event: queue.shift()! };
47
+ }
48
+ activeRuns.delete(taskId);
49
+ return { empty: true };
50
+ }
51
+
52
+ /** Remove any state for a task (called from task.delete). */
53
+ export function clearTaskQueue(taskId: string): void {
54
+ queues.delete(taskId);
55
+ activeRuns.delete(taskId);
56
+ }
@@ -192,11 +192,14 @@ export class WindowsPlatform implements PlatformService {
192
192
  const script = process.argv[1] || "palmier";
193
193
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
194
194
 
195
- // Build trigger XML elements
195
+ // Build trigger XML elements. Event-based schedule types (on_new_notification,
196
+ // on_new_sms) carry no values and are driven by the run process, not the OS
197
+ // scheduler — they intentionally produce only the dummy trigger below.
196
198
  const triggerElements: string[] = [];
197
199
  const scheduleType = task.frontmatter.schedule_type;
198
200
  const scheduleValues = task.frontmatter.schedule_values;
199
- if (task.frontmatter.schedule_enabled && scheduleType && scheduleValues?.length) {
201
+ const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
202
+ if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
200
203
  for (const value of scheduleValues) {
201
204
  try {
202
205
  triggerElements.push(scheduleValueToXml(scheduleType, value));
@@ -14,6 +14,7 @@ import { publishHostEvent } from "./events.js";
14
14
  import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type DeviceCapability } from "./device-capabilities.js";
15
15
  import { currentVersion, performUpdate } from "./update-checker.js";
16
16
  import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
17
+ import { clearTaskQueue } from "./event-queues.js";
17
18
  import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
18
19
 
19
20
  /**
@@ -162,7 +163,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
162
163
  // is active. Includes any prompts already waiting so a reconnecting
163
164
  // PWA can render their modals without replaying events.
164
165
  const capabilities: Record<string, string | null> = {};
165
- for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd"] as const) {
166
+ for (const cap of ["location", "notifications", "sms", "contacts", "calendar", "alert", "battery", "dnd", "email"] as const) {
166
167
  capabilities[cap] = getCapabilityDevice(cap)?.clientToken ?? null;
167
168
  }
168
169
  return {
@@ -194,7 +195,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
194
195
  const params = request.params as {
195
196
  user_prompt: string;
196
197
  agent: string;
197
- schedule_type?: "crons" | "specific_times";
198
+ schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
198
199
  schedule_values?: string[];
199
200
  schedule_enabled?: boolean;
200
201
  requires_confirmation?: boolean;
@@ -217,9 +218,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
217
218
  agent: params.agent,
218
219
  schedule_enabled: params.schedule_enabled ?? true,
219
220
  requires_confirmation: params.requires_confirmation ?? true,
220
- ...(params.schedule_type && params.schedule_values?.length
221
- ? { schedule_type: params.schedule_type, schedule_values: params.schedule_values }
222
- : {}),
221
+ ...(params.schedule_type ? { schedule_type: params.schedule_type } : {}),
222
+ ...(params.schedule_values?.length ? { schedule_values: params.schedule_values } : {}),
223
223
  ...(params.yolo_mode ? { yolo_mode: true } : {}),
224
224
  ...(params.foreground_mode ? { foreground_mode: true } : {}),
225
225
  ...(params.command ? { command: params.command } : {}),
@@ -238,7 +238,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
238
238
  id: string;
239
239
  user_prompt?: string;
240
240
  agent?: string;
241
- schedule_type?: "crons" | "specific_times" | null;
241
+ schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms" | null;
242
242
  schedule_values?: string[] | null;
243
243
  schedule_enabled?: boolean;
244
244
  requires_confirmation?: boolean;
@@ -307,6 +307,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
307
307
  const params = request.params as { id: string };
308
308
 
309
309
  getPlatform().removeTaskTimer(params.id);
310
+ clearTaskQueue(params.id);
310
311
  removeFromTaskList(config.projectRoot, params.id);
311
312
 
312
313
  return { ok: true, task_id: params.id };
@@ -9,6 +9,7 @@ import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
9
9
  import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mcp-tools.js";
10
10
  import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
11
11
  import { getTaskDir } from "../task.js";
12
+ import { popEvent } from "../event-queues.js";
12
13
 
13
14
  // ── Bundled PWA asset serving ───────────────────────────────────────────
14
15
 
@@ -279,6 +280,19 @@ export async function startHttpTransport(
279
280
  return;
280
281
  }
281
282
 
283
+ // ── Event queue pop (used by event-triggered palmier run) ─────────
284
+
285
+ if (req.method === "POST" && pathname === "/task-event/pop") {
286
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
287
+ const taskId = url.searchParams.get("taskId");
288
+ if (!taskId) {
289
+ sendJson(res, 400, { error: "taskId query parameter is required" });
290
+ return;
291
+ }
292
+ sendJson(res, 200, popEvent(taskId));
293
+ return;
294
+ }
295
+
282
296
  // ── Localhost-only endpoints (no auth) ─────────────────────────────
283
297
 
284
298
  if (req.method === "POST" && pathname === "/event") {
package/src/types.ts CHANGED
@@ -22,12 +22,13 @@ export interface TaskFrontmatter {
22
22
  user_prompt: string;
23
23
  agent: string;
24
24
  /**
25
- * Task schedule. `schedule_values` is homogeneous per `schedule_type`:
26
- * - `crons`: array of cron expressions (e.g. "0 9 * * *")
27
- * - `specific_times`: array of local datetime strings (e.g. "2026-04-20T09:00")
28
- * Both fields are present together or absent together.
25
+ * Task schedule.
26
+ * - `crons`: `schedule_values` holds cron expressions (e.g. "0 9 * * *")
27
+ * - `specific_times`: `schedule_values` holds local datetime strings (e.g. "2026-04-20T09:00")
28
+ * - `on_new_notification`: fires on each new Android notification from NATS; no `schedule_values`
29
+ * - `on_new_sms`: fires on each new SMS from NATS; no `schedule_values`
29
30
  */
30
- schedule_type?: "crons" | "specific_times";
31
+ schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
31
32
  schedule_values?: string[];
32
33
  schedule_enabled: boolean;
33
34
  requires_confirmation: boolean;