palmier 0.7.8 → 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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/run.js +55 -0
  3. package/dist/commands/serve.js +22 -2
  4. package/dist/event-queues.d.ts +36 -0
  5. package/dist/event-queues.js +53 -0
  6. package/dist/mcp-tools.d.ts +2 -0
  7. package/dist/mcp-tools.js +4 -2
  8. package/dist/platform/linux.js +11 -8
  9. package/dist/platform/windows.d.ts +5 -6
  10. package/dist/platform/windows.js +19 -13
  11. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  12. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  13. package/dist/pwa/assets/{web-BNr628AV.js → web-BpM3fNCn.js} +1 -1
  14. package/dist/pwa/assets/{web-DyQPewAi.js → web-CF-N8Di6.js} +1 -1
  15. package/dist/pwa/index.html +2 -2
  16. package/dist/pwa/service-worker.js +1 -1
  17. package/dist/rpc-handler.js +25 -9
  18. package/dist/task.js +1 -1
  19. package/dist/transports/http-transport.js +18 -5
  20. package/dist/types.d.ts +10 -6
  21. package/package.json +1 -1
  22. package/palmier-server/README.md +3 -3
  23. package/palmier-server/pwa/src/App.css +117 -36
  24. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  25. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  26. package/palmier-server/pwa/src/components/SessionComposer.tsx +20 -10
  27. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +33 -25
  28. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  29. package/palmier-server/pwa/src/components/TaskForm.tsx +274 -293
  30. package/palmier-server/pwa/src/components/{TaskListView.tsx → TasksView.tsx} +20 -13
  31. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  32. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  33. package/palmier-server/pwa/src/pages/Dashboard.tsx +9 -26
  34. package/palmier-server/pwa/src/types.ts +5 -9
  35. package/palmier-server/spec.md +23 -23
  36. package/src/commands/run.ts +61 -0
  37. package/src/commands/serve.ts +22 -2
  38. package/src/event-queues.ts +56 -0
  39. package/src/mcp-tools.ts +6 -2
  40. package/src/platform/linux.ts +10 -8
  41. package/src/platform/windows.ts +19 -13
  42. package/src/rpc-handler.ts +28 -11
  43. package/src/task.ts +1 -1
  44. package/src/transports/http-transport.ts +17 -5
  45. package/src/types.ts +10 -7
  46. package/dist/pwa/assets/index-8cTctVnD.js +0 -120
  47. package/dist/pwa/assets/index-CSUkBBsQ.css +0 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  You have AI agents on your machine. But you have to sit at your desk to use them. Palmier lets you dispatch, schedule, and monitor them from any device, anywhere.
10
10
 
11
- It runs on your machine as a background daemon and connects to a mobile-friendly PWA, so you can create tasks, approve permissions, and check results without being at your computer.
11
+ It runs on your machine as a background daemon and connects to a mobile-friendly PWA, so you can start one-off sessions, schedule recurring tasks, approve permissions, and check results without being at your computer.
12
12
  > **Important:** By using Palmier, you agree to the [Terms of Service](https://www.palmier.me/terms) and [Privacy Policy](https://www.palmier.me/privacy). See the [Disclaimer](#disclaimer) section below.
13
13
 
14
14
  ## Quick Start
@@ -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
@@ -32,6 +32,8 @@ export interface ResourceDefinition {
32
32
  restPath: string;
33
33
  /** Return the current resource content. */
34
34
  read: () => unknown;
35
+ /** Register a listener for content changes. Returns an unsubscribe function. */
36
+ subscribe: (listener: () => void) => () => void;
35
37
  }
36
38
  export declare const agentResources: ResourceDefinition[];
37
39
  export declare const agentResourceMap: Map<string, ResourceDefinition>;
package/dist/mcp-tools.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { StringCodec } from "nats";
2
2
  import { registerPending } from "./pending-requests.js";
3
3
  import { getCapabilityDevice } from "./device-capabilities.js";
4
- import { getNotifications } from "./notification-store.js";
5
- import { getSmsMessages } from "./sms-store.js";
4
+ import { getNotifications, onNotificationsChanged } from "./notification-store.js";
5
+ import { getSmsMessages, onSmsChanged } from "./sms-store.js";
6
6
  export class ToolError extends Error {
7
7
  statusCode;
8
8
  constructor(message, statusCode = 500) {
@@ -651,6 +651,7 @@ const deviceNotificationsResource = {
651
651
  mimeType: "application/json",
652
652
  restPath: "/notifications",
653
653
  read: getNotifications,
654
+ subscribe: onNotificationsChanged,
654
655
  };
655
656
  const deviceSmsResource = {
656
657
  uri: "sms-messages://device",
@@ -662,6 +663,7 @@ const deviceSmsResource = {
662
663
  mimeType: "application/json",
663
664
  restPath: "/sms-messages",
664
665
  read: getSmsMessages,
666
+ subscribe: onSmsChanged,
665
667
  };
666
668
  export const agentResources = [deviceNotificationsResource, deviceSmsResource];
667
669
  export const agentResourceMap = new Map(agentResources.map((r) => [r.uri, r]));
@@ -181,17 +181,20 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
181
181
  `;
182
182
  fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
183
183
  daemonReload();
184
- // Only create and enable a timer if triggers exist and are enabled
185
- if (!task.frontmatter.triggers_enabled)
184
+ // Only create and enable a timer if the schedule exists and is enabled
185
+ if (!task.frontmatter.schedule_enabled)
186
+ return;
187
+ const scheduleType = task.frontmatter.schedule_type;
188
+ const scheduleValues = task.frontmatter.schedule_values;
189
+ if (!scheduleType || !scheduleValues?.length)
186
190
  return;
187
- const triggers = task.frontmatter.triggers || [];
188
191
  const onCalendarLines = [];
189
- for (const trigger of triggers) {
190
- if (trigger.type === "cron") {
191
- onCalendarLines.push(`OnCalendar=${cronToOnCalendar(trigger.value)}`);
192
+ for (const value of scheduleValues) {
193
+ if (scheduleType === "crons") {
194
+ onCalendarLines.push(`OnCalendar=${cronToOnCalendar(value)}`);
192
195
  }
193
- else if (trigger.type === "once") {
194
- onCalendarLines.push(`OnActiveSec=${trigger.value}`);
196
+ else if (scheduleType === "specific_times") {
197
+ onCalendarLines.push(`OnActiveSec=${value}`);
195
198
  }
196
199
  }
197
200
  if (onCalendarLines.length > 0) {
@@ -1,18 +1,17 @@
1
1
  import type { PlatformService } from "./platform.js";
2
2
  import type { HostConfig, ParsedTask } from "../types.js";
3
3
  /**
4
- * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
4
+ * Convert a single schedule value to a Task Scheduler XML trigger element.
5
5
  *
6
- * Only these cron patterns (produced by the PWA UI) are handled:
6
+ * `specific_times` values are ISO datetime strings like "2026-03-28T09:00".
7
+ *
8
+ * `crons` values are cron expressions. Only these patterns (produced by the PWA UI) are handled:
7
9
  * hourly: "0 * * * *"
8
10
  * daily: "MM HH * * *"
9
11
  * weekly: "MM HH * * D"
10
12
  * monthly: "MM HH D * *"
11
13
  */
12
- export declare function triggerToXml(trigger: {
13
- type: string;
14
- value: string;
15
- }): string;
14
+ export declare function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string;
16
15
  /**
17
16
  * Build a complete Task Scheduler XML definition.
18
17
  */
@@ -7,22 +7,23 @@ const TASK_PREFIX = "\\Palmier\\PalmierTask-";
7
7
  const DAEMON_TASK_NAME = "PalmierDaemon";
8
8
  const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
9
9
  /**
10
- * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
10
+ * Convert a single schedule value to a Task Scheduler XML trigger element.
11
11
  *
12
- * Only these cron patterns (produced by the PWA UI) are handled:
12
+ * `specific_times` values are ISO datetime strings like "2026-03-28T09:00".
13
+ *
14
+ * `crons` values are cron expressions. Only these patterns (produced by the PWA UI) are handled:
13
15
  * hourly: "0 * * * *"
14
16
  * daily: "MM HH * * *"
15
17
  * weekly: "MM HH * * D"
16
18
  * monthly: "MM HH D * *"
17
19
  */
18
- export function triggerToXml(trigger) {
19
- if (trigger.type === "once") {
20
- // ISO datetime "2026-03-28T09:00"
21
- return `<TimeTrigger><StartBoundary>${trigger.value}:00</StartBoundary></TimeTrigger>`;
20
+ export function scheduleValueToXml(scheduleType, value) {
21
+ if (scheduleType === "specific_times") {
22
+ return `<TimeTrigger><StartBoundary>${value}:00</StartBoundary></TimeTrigger>`;
22
23
  }
23
- const parts = trigger.value.trim().split(/\s+/);
24
+ const parts = value.trim().split(/\s+/);
24
25
  if (parts.length !== 5)
25
- throw new Error(`Invalid cron expression: ${trigger.value}`);
26
+ throw new Error(`Invalid cron expression: ${value}`);
26
27
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
27
28
  const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
28
29
  // StartBoundary needs a full date; use a past date as the anchor
@@ -176,15 +177,20 @@ export class WindowsPlatform {
176
177
  const tn = schtasksTaskName(taskId);
177
178
  const script = process.argv[1] || "palmier";
178
179
  const tr = `"${process.execPath}" "${script}" run ${taskId}`;
179
- // 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.
180
183
  const triggerElements = [];
181
- if (task.frontmatter.triggers_enabled) {
182
- for (const trigger of task.frontmatter.triggers ?? []) {
184
+ const scheduleType = task.frontmatter.schedule_type;
185
+ const scheduleValues = task.frontmatter.schedule_values;
186
+ const isTimerSchedule = scheduleType === "crons" || scheduleType === "specific_times";
187
+ if (task.frontmatter.schedule_enabled && isTimerSchedule && scheduleValues?.length) {
188
+ for (const value of scheduleValues) {
183
189
  try {
184
- triggerElements.push(triggerToXml(trigger));
190
+ triggerElements.push(scheduleValueToXml(scheduleType, value));
185
191
  }
186
192
  catch (err) {
187
- console.error(`Invalid trigger: ${err}`);
193
+ console.error(`Invalid schedule value: ${err}`);
188
194
  }
189
195
  }
190
196
  }