palmier 0.7.8 → 0.7.9

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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/mcp-tools.d.ts +2 -0
  3. package/dist/mcp-tools.js +4 -2
  4. package/dist/platform/linux.js +11 -8
  5. package/dist/platform/windows.d.ts +5 -6
  6. package/dist/platform/windows.js +15 -12
  7. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  8. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  9. package/dist/pwa/assets/{web-BNr628AV.js → web-BpM3fNCn.js} +1 -1
  10. package/dist/pwa/assets/{web-DyQPewAi.js → web-CF-N8Di6.js} +1 -1
  11. package/dist/pwa/index.html +2 -2
  12. package/dist/pwa/service-worker.js +1 -1
  13. package/dist/rpc-handler.js +23 -8
  14. package/dist/task.js +1 -1
  15. package/dist/transports/http-transport.js +3 -5
  16. package/dist/types.d.ts +9 -6
  17. package/package.json +1 -1
  18. package/palmier-server/README.md +3 -3
  19. package/palmier-server/pwa/src/App.css +117 -36
  20. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  21. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  22. package/palmier-server/pwa/src/components/SessionComposer.tsx +20 -10
  23. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +33 -25
  24. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  25. package/palmier-server/pwa/src/components/TaskForm.tsx +274 -293
  26. package/palmier-server/pwa/src/components/{TaskListView.tsx → TasksView.tsx} +20 -13
  27. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  28. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  29. package/palmier-server/pwa/src/pages/Dashboard.tsx +9 -26
  30. package/palmier-server/pwa/src/types.ts +5 -9
  31. package/palmier-server/spec.md +23 -23
  32. package/src/mcp-tools.ts +6 -2
  33. package/src/platform/linux.ts +10 -8
  34. package/src/platform/windows.ts +15 -12
  35. package/src/rpc-handler.ts +26 -10
  36. package/src/task.ts +1 -1
  37. package/src/transports/http-transport.ts +3 -5
  38. package/src/types.ts +9 -7
  39. package/dist/pwa/assets/index-8cTctVnD.js +0 -120
  40. package/dist/pwa/assets/index-CSUkBBsQ.css +0 -1
@@ -1,9 +1,11 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import TaskCard from "./TaskCard";
3
3
  import TaskForm from "./TaskForm";
4
+ import PullToRefreshIndicator from "./PullToRefreshIndicator";
5
+ import { usePullToRefresh } from "../hooks/usePullToRefresh";
4
6
  import type { AgentInfo, Task, TaskStatus } from "../types";
5
7
 
6
- interface TaskListViewProps {
8
+ interface TasksViewProps {
7
9
  connected: boolean;
8
10
  hostId: string | null;
9
11
  request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
@@ -13,7 +15,7 @@ interface TaskListViewProps {
13
15
  onViewRun(taskId: string, runId?: string): void;
14
16
  }
15
17
 
16
- export default function TaskListView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, onViewRun }: TaskListViewProps) {
18
+ export default function TasksView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, onViewRun }: TasksViewProps) {
17
19
  const [tasks, setTasks] = useState<Task[]>([]);
18
20
  const [loadingTasks, setLoadingTasks] = useState(false);
19
21
  const [taskError, setTaskError] = useState<string | null>(null);
@@ -55,6 +57,8 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
55
57
  else { setTasks([]); setLoadingTasks(false); }
56
58
  }, [connected, hostId, loadTasks]);
57
59
 
60
+ const ptr = usePullToRefresh({ onRefresh: loadTasks, enabled: connected });
61
+
58
62
  // While mounted, keep task-card state fresh by refetching task.status on
59
63
  // running-state / permission-resolved events. Dashboard owns the modal
60
64
  // subscription; this one only drives the card indicators.
@@ -98,19 +102,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
98
102
 
99
103
  return (
100
104
  <>
105
+ <PullToRefreshIndicator pullDistance={ptr.pullDistance} refreshing={ptr.refreshing} threshold={ptr.threshold} />
101
106
  {taskError && <div className="form-error">{taskError}{taskError.toLowerCase().includes("failed to fetch") && <>{" "}<a href="#" onClick={(e) => { e.preventDefault(); window.location.reload(); }}>Reload</a></>}</div>}
102
107
 
103
- <div
104
- className="new-task-input-card"
105
- onClick={() => { setEditingTask(undefined); setShowForm(true); }}
106
- role="button"
107
- tabIndex={0}
108
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { setEditingTask(undefined); setShowForm(true); } }}
109
- >
110
- <span className="new-task-placeholder">Describe your new task...</span>
111
- </div>
112
-
113
-
114
108
  {loadingTasks && !tasks.length ? (
115
109
  <div className="task-list">
116
110
  {[0, 1, 2].map((i) => (
@@ -151,12 +145,25 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
151
145
  </div>
152
146
  )}
153
147
 
148
+ <button
149
+ className="fab"
150
+ onClick={() => { setEditingTask(undefined); setShowForm(true); }}
151
+ aria-label="New task"
152
+ title="New task"
153
+ >
154
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
155
+ <line x1="12" y1="5" x2="12" y2="19" />
156
+ <line x1="5" y1="12" x2="19" y2="12" />
157
+ </svg>
158
+ </button>
159
+
154
160
  {showForm && (
155
161
  <TaskForm
156
162
  initial={editingTask}
157
163
  agents={agents}
158
164
  hostPlatform={hostPlatform}
159
165
  onSaved={handleTaskSaved}
166
+ onRun={onViewRun}
160
167
  onCancel={closeForm}
161
168
  />
162
169
  )}
@@ -88,17 +88,25 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
88
88
  }, []);
89
89
 
90
90
  const removePairedHost = useCallback((hostId: string) => {
91
- setPairedHosts((prev) => {
92
- const filtered = prev.filter((h) => h.hostId !== hostId);
93
- if (filtered.length > 0) {
94
- setActiveHostIdState(filtered[0].hostId);
95
- } else {
96
- setActiveHostIdState(null);
97
- }
98
- return filtered;
91
+ setPairedHosts((prev) => prev.filter((h) => h.hostId !== hostId));
92
+ // Only change the active host when the active one is the one being deleted.
93
+ setActiveHostIdState((current) => {
94
+ if (current !== hostId) return current;
95
+ // The deleted host was active; we'll re-select once pairedHosts settles.
96
+ return null;
99
97
  });
100
98
  }, []);
101
99
 
100
+ // When the active host disappears (e.g. it was just unpaired), fall back to
101
+ // any remaining paired host, otherwise null.
102
+ useEffect(() => {
103
+ if (activeHostId !== null && !pairedHosts.find((h) => h.hostId === activeHostId)) {
104
+ setActiveHostIdState(pairedHosts[0]?.hostId ?? null);
105
+ } else if (activeHostId === null && pairedHosts.length > 0) {
106
+ setActiveHostIdState(pairedHosts[0].hostId);
107
+ }
108
+ }, [pairedHosts, activeHostId]);
109
+
102
110
  const renamePairedHost = useCallback((hostId: string, name: string) => {
103
111
  setPairedHosts((prev) =>
104
112
  prev.map((h) => (h.hostId === hostId ? { ...h, name } : h))
@@ -0,0 +1,102 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ interface Options {
4
+ onRefresh: () => Promise<unknown> | void;
5
+ /** Pixels the user must pull past before a release triggers a refresh. */
6
+ threshold?: number;
7
+ /** Maximum visual pull distance after resistance dampening. */
8
+ maxPull?: number;
9
+ enabled?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Window-level pull-to-refresh for the tasks/sessions lists. Activates only
14
+ * when the user starts a touch at `scrollY === 0` and drags downward.
15
+ * Returns `pullDistance` (dampened pixels to visually translate an indicator)
16
+ * and `refreshing` (true while the consumer's onRefresh promise is in flight).
17
+ */
18
+ export function usePullToRefresh({
19
+ onRefresh,
20
+ threshold = 70,
21
+ maxPull = 110,
22
+ enabled = true,
23
+ }: Options) {
24
+ const [pullDistance, setPullDistance] = useState(0);
25
+ const [refreshing, setRefreshing] = useState(false);
26
+
27
+ const startY = useRef<number | null>(null);
28
+ const pulling = useRef(false);
29
+ const distance = useRef(0);
30
+ const refreshingRef = useRef(false);
31
+ const onRefreshRef = useRef(onRefresh);
32
+ useEffect(() => { onRefreshRef.current = onRefresh; });
33
+
34
+ useEffect(() => {
35
+ if (!enabled) return;
36
+
37
+ const onTouchStart = (e: TouchEvent) => {
38
+ if (refreshingRef.current || window.scrollY > 0) {
39
+ startY.current = null;
40
+ return;
41
+ }
42
+ startY.current = e.touches[0].clientY;
43
+ pulling.current = false;
44
+ distance.current = 0;
45
+ };
46
+
47
+ const onTouchMove = (e: TouchEvent) => {
48
+ if (refreshingRef.current || startY.current == null) return;
49
+ const dy = e.touches[0].clientY - startY.current;
50
+ if (dy <= 0) {
51
+ pulling.current = false;
52
+ distance.current = 0;
53
+ setPullDistance(0);
54
+ return;
55
+ }
56
+ pulling.current = true;
57
+ const adjusted = Math.min(maxPull, dy * 0.5);
58
+ distance.current = adjusted;
59
+ setPullDistance(adjusted);
60
+ if (e.cancelable) e.preventDefault();
61
+ };
62
+
63
+ const onTouchEnd = () => {
64
+ if (!pulling.current) {
65
+ startY.current = null;
66
+ setPullDistance(0);
67
+ return;
68
+ }
69
+ const pulled = distance.current;
70
+ startY.current = null;
71
+ pulling.current = false;
72
+ distance.current = 0;
73
+ if (pulled >= threshold) {
74
+ refreshingRef.current = true;
75
+ setRefreshing(true);
76
+ setPullDistance(threshold);
77
+ Promise.resolve(onRefreshRef.current())
78
+ .catch(() => { /* swallow — indicator still closes */ })
79
+ .finally(() => {
80
+ refreshingRef.current = false;
81
+ setRefreshing(false);
82
+ setPullDistance(0);
83
+ });
84
+ } else {
85
+ setPullDistance(0);
86
+ }
87
+ };
88
+
89
+ window.addEventListener("touchstart", onTouchStart, { passive: true });
90
+ window.addEventListener("touchmove", onTouchMove, { passive: false });
91
+ window.addEventListener("touchend", onTouchEnd, { passive: true });
92
+ window.addEventListener("touchcancel", onTouchEnd, { passive: true });
93
+ return () => {
94
+ window.removeEventListener("touchstart", onTouchStart);
95
+ window.removeEventListener("touchmove", onTouchMove);
96
+ window.removeEventListener("touchend", onTouchEnd);
97
+ window.removeEventListener("touchcancel", onTouchEnd);
98
+ };
99
+ }, [enabled, threshold, maxPull]);
100
+
101
+ return { pullDistance, refreshing, threshold };
102
+ }
@@ -4,10 +4,10 @@ import { useNavigate, useLocation, useParams } from "react-router-dom";
4
4
 
5
5
  import { useHostStore } from "../contexts/HostStoreContext";
6
6
  import { useHostConnection } from "../contexts/HostConnectionContext";
7
- import TaskListView from "../components/TaskListView";
7
+ import TasksView from "../components/TasksView";
8
8
  import TabBar from "../components/TabBar";
9
9
  import HostMenu from "../components/HostMenu";
10
- import RunsView from "../components/RunsView";
10
+ import SessionsView from "../components/SessionsView";
11
11
  import RunDetailView from "../components/RunDetailView";
12
12
  import { usePushSubscription } from "../hooks/usePushSubscription";
13
13
  import { useMediaQuery } from "../hooks/useMediaQuery";
@@ -54,26 +54,9 @@ export default function Dashboard() {
54
54
  const isTasksTab = location.pathname.startsWith("/tasks");
55
55
  const runsFilterTaskId = params.runId ? undefined : params.taskId;
56
56
 
57
- // Resolve "latest" run ID to the actual run ID
58
- const [resolvedRunId, setResolvedRunId] = useState<string | null>(null);
59
- const rawRunId = params.runId;
60
- const isLatest = rawRunId === "latest";
61
-
62
- useEffect(() => {
63
- if (!isLatest || !params.taskId || !connected) {
64
- setResolvedRunId(null);
65
- return;
66
- }
67
- request<{ entries?: Array<{ run_id: string }> }>("taskrun.list", { task_id: params.taskId, limit: 1 })
68
- .then((result) => {
69
- const latest = result.entries?.[0]?.run_id;
70
- setResolvedRunId(latest ?? null);
71
- })
72
- .catch(() => setResolvedRunId(null));
73
- }, [isLatest, params.taskId, connected]);
74
-
75
- const effectiveRunId = isLatest ? resolvedRunId : rawRunId;
76
- const isRunDetail = !!(params.taskId && effectiveRunId && effectiveRunId !== "latest");
57
+ // "latest" is passed through to RunDetailView, which does its own resolution
58
+ // and renders an empty state if the task has no runs.
59
+ const isRunDetail = !!(params.taskId && params.runId);
77
60
 
78
61
  const [updateRequired, setUpdateRequired] = useState(false);
79
62
  const [updating, setUpdating] = useState(false);
@@ -151,7 +134,7 @@ export default function Dashboard() {
151
134
  }, [connected, activeHostId, request]);
152
135
 
153
136
  // Always-on event subscription for modal lifecycle. Independent of which tab
154
- // is active. Task-card status updates happen inside TaskListView while mounted.
137
+ // is active. Task-card status updates happen inside TasksView while mounted.
155
138
  useEffect(() => {
156
139
  if (!connected || !activeHostId) return;
157
140
  const unsubscribe = subscribeEvents(activeHostId, (msg) => {
@@ -353,7 +336,7 @@ export default function Dashboard() {
353
336
  ) : showTaskContent ? (
354
337
  <>
355
338
  {isTasksTab && !isRunDetail && (
356
- <TaskListView
339
+ <TasksView
357
340
  connected={connected}
358
341
  hostId={activeHostId}
359
342
  request={request}
@@ -370,10 +353,10 @@ export default function Dashboard() {
370
353
  request={request}
371
354
  subscribeEvents={subscribeEvents}
372
355
  taskId={params.taskId!}
373
- runId={decodeURIComponent(effectiveRunId!)}
356
+ runId={decodeURIComponent(params.runId!)}
374
357
  />
375
358
  ) : !isTasksTab ? (
376
- <RunsView
359
+ <SessionsView
377
360
  connected={connected}
378
361
  hostId={activeHostId}
379
362
  request={request}
@@ -1,8 +1,8 @@
1
1
  export interface AgentInfo {
2
2
  key: string;
3
3
  label: string;
4
- supportsPermissions?: boolean;
5
- supportsYolo?: boolean;
4
+ supportsPermissions: boolean;
5
+ supportsYolo: boolean;
6
6
  }
7
7
 
8
8
 
@@ -11,8 +11,9 @@ export interface Task {
11
11
  name: string;
12
12
  user_prompt: string;
13
13
  agent?: string;
14
- triggers: Trigger[];
15
- triggers_enabled: boolean;
14
+ schedule_type?: "crons" | "specific_times";
15
+ schedule_values?: string[];
16
+ schedule_enabled: boolean;
16
17
  requires_confirmation: boolean;
17
18
  yolo_mode?: boolean;
18
19
  foreground_mode?: boolean;
@@ -20,11 +21,6 @@ export interface Task {
20
21
  command?: string;
21
22
  }
22
23
 
23
- export interface Trigger {
24
- type: "cron" | "once";
25
- value: string;
26
- }
27
-
28
24
  export interface RequiredPermission {
29
25
  name: string;
30
26
  description: string;
@@ -119,8 +119,8 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
119
119
  | `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform, capability_tokens, pending_prompts }`. `pending_prompts` is an array of prompts already waiting when the PWA reconnects (each `{ key, type, params?, meta? }`), so modals can render without replaying events. |
120
120
  | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
121
121
  | `task.get` | `id` | Get a single task with frontmatter and current status. |
122
- | `task.create` | `user_prompt`, `agent`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if triggers present. |
123
- | `task.update` | `id`, `user_prompt?`, `agent?`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. |
122
+ | `task.create` | `user_prompt`, `agent`, `schedule_type?`, `schedule_values?`, `schedule_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if a schedule is present. |
123
+ | `task.update` | `id`, `user_prompt?`, `agent?`, `schedule_type?`, `schedule_values?`, `schedule_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. Pass `null` for `schedule_type` or `schedule_values` to clear them. |
124
124
  | `task.delete` | `id` | Delete a task and its systemd timers |
125
125
  | `task.run` | `id` | Start a task via system scheduler (`systemctl --user start` / `schtasks /run`) |
126
126
  | `task.abort` | `id` | Stop a running task via system scheduler (`systemctl --user stop` / `schtasks /end`) |
@@ -215,27 +215,27 @@ Messages are appended incrementally during execution.
215
215
  id: "uuid-v4"
216
216
  user_prompt: "Run a system audit and summarize large files..."
217
217
  agent: "claude"
218
- triggers:
219
- - type: "cron"
220
- value: "0 9 * * 1"
221
- - type: "once"
222
- value: "2026-03-20T15:00:00Z"
223
- triggers_enabled: true
218
+ schedule_type: "crons"
219
+ schedule_values:
220
+ - "0 9 * * 1"
221
+ schedule_enabled: true
224
222
  requires_confirmation: true
225
223
  ---
226
224
  ```
227
225
 
226
+ `schedule_type` is either `"crons"` (cron expressions) or `"specific_times"` (local datetime strings like `"2026-04-20T09:00"`). `schedule_values` is a homogeneous array whose elements match the type. Both fields are present together or absent together; absence means the task has no schedule and can only be run manually.
227
+
228
228
  The `name` field is auto-generated by spawning the configured agent CLI with a short prompt (for prompts > 50 chars). For shorter prompts, the `user_prompt` is used directly as the name.
229
229
 
230
230
  The `agent` field stores the agent name (e.g., `"claude"`, `"codex"`). The corresponding `AgentTool` implementation is responsible for constructing the full command and arguments at execution time.
231
231
 
232
232
  The optional `command` field stores a shell command for command-triggered tasks. When set, the task runs in command-triggered mode: the command is spawned with `shell: true`, and each line of its stdout triggers a separate agent invocation with `user_prompt + "\n\nProcess this input:\n" + line`.
233
233
 
234
- #### Trigger Lifecycle
234
+ #### Schedule Lifecycle
235
235
 
236
- * **`triggers_enabled`:** Controls whether systemd timers are installed for the task's triggers. When `false`, all timers are removed; when toggled back to `true`, timers are reinstalled. Defaults to `true`. The task can still be run manually via "Run Now" regardless of this setting. The "Enable Triggers" checkbox only appears in the UI when the task has at least one trigger.
237
- * **`cron` triggers:** Persist indefinitely. The systemd timer remains active until the task is deleted or triggers are disabled.
238
- * **`once` triggers:** After firing, the trigger is removed from the `TASK.md` frontmatter and its corresponding systemd timer/service files are cleaned up. The task itself remains in the `tasks/` directory as a manual task (can still be executed on-demand via the PWA or CLI, but will not fire automatically again).
236
+ * **`schedule_enabled`:** Controls whether systemd timers are installed for the task's schedule. When `false`, all timers are removed; when toggled back to `true`, timers are reinstalled. Defaults to `true`. The task can still be run manually via "Run Now" regardless of this setting. The "Enable Schedule" checkbox only appears in the UI when the task has a schedule.
237
+ * **`crons` schedules:** Persist indefinitely. The systemd timer remains active until the task is deleted or the schedule is disabled.
238
+ * **`specific_times` schedules:** After a value fires, it is removed from `schedule_values` and its corresponding systemd timer/service files are cleaned up. Once all values have fired the schedule is cleared, and the task remains in the `tasks/` directory as a manual task (can still be executed on-demand via the PWA or CLI, but will not fire automatically again).
239
239
 
240
240
  ### Task Events
241
241
 
@@ -250,7 +250,7 @@ Task lifecycle status is persisted to a `status.json` file in the task directory
250
250
 
251
251
  The `task.list` RPC includes each task's current status (read from `status.json`). The `task.status` RPC returns the status for a single task.
252
252
 
253
- The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<activeHostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when triggers are disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The "once" trigger date/time picker only allows selecting future dates and times.
253
+ The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<activeHostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when the schedule is disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The `specific_times` date/time picker only allows selecting future dates and times.
254
254
 
255
255
  The Web Server subscribes to `host-event.>` and sends push notifications based on `event_type`: confirmation pushes for `confirm-request`, dismiss pushes for `confirm-resolved`, permission pushes for `permission-request`, dismiss pushes for `permission-resolved`, and report-ready/failure pushes for `report-generated` events.
256
256
 
@@ -276,40 +276,40 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
276
276
 
277
277
  2. If hosts are paired, PWA fetches host-scoped NATS credentials from `GET /api/nats-credentials/<hostId>` (returns `{ natsWsUrl, natsJwt, natsNkeySeed }`) and connects to NATS via WebSocket using JWT auth. The credentials are scoped to the paired host's subjects only.
278
278
 
279
- 3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload. The PWA uses the response for bootstrap metadata (agents, host platform, version, capability tokens) that both the Sessions tab's composer and the Tasks tab depend on.
279
+ 3. PWA sends a `host.info` request using NATS request-reply, including the `clientToken` in the payload. The response carries bootstrap metadata (`agents`, `host_platform`, `version`, `capability_tokens`) and `pending_prompts` any prompts already open on the host when the PWA connected. The Dashboard consumes this once per connection; both tabs read from it.
280
280
 
281
- 4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. If the host responds to `task.list`, it returns `{ tasks: [...] }` an array of **flat task objects** (frontmatter fields spread to the top level) used by the Tasks tab. If the request fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
281
+ 4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. `task.list` is not called at startupit fires lazily the first time the user opens the Tasks tab. If either RPC fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
282
282
 
283
283
  5. PWA registers the service worker and subscribes the browser for Web Push notifications (via `pushManager.subscribe` with the server's VAPID public key). The push subscription is sent to `POST /api/push/subscribe` with the `hostId` so the server can relay notifications to the device.
284
284
 
285
- 6. PWA discovers pending confirmations from the `task.list` RPC response tasks with a pending confirmation, permission, or input request are shown as interactive modals. The PWA responds by calling the `task.user_input` RPC on the host, which resolves the in-memory pending request held by the serve daemon. The `run` process (blocked on an HTTP call to the serve daemon) receives the response and proceeds or exits accordingly.
285
+ 6. Initial pending-prompt discovery uses `host.info.pending_prompts`: each entry is `{ key, type, params?, meta? }` where `meta` carries the display context (`session_id`, `session_name`, `description`, `input_questions`) needed to render the modal cold. The PWA seeds its modal state maps from this array. After connection, live arrivals come through the NATS event subscription. The PWA responds by calling the `task.user_input` RPC on the host, which resolves the in-memory pending request held by the serve daemon. The `run` process (blocked on an HTTP call to the serve daemon) receives the response and proceeds or exits accordingly.
286
286
 
287
287
  ### 4.2 UI Layout: Sessions & Tasks Tabs
288
288
 
289
289
  The PWA has two tabs: **Sessions** (default, at `/`) and **Tasks** (secondary, at `/tasks`). Sessions is the primary workflow — it lists all run history across tasks (a "session" is a single run) and includes a session composer at the top of the list.
290
290
 
291
291
  * **Session composer:** An inline textarea with an agent picker, a yolo-mode toggle, and a round play button. Entering text and clicking play dispatches `task.run_oneoff`, starting an immediate unsaved session. The composer never opens a dialog; typing is direct. When the textarea has content, navigating away (tab switch, host switch, browser reload, clicking a session row) triggers a confirmation dialog so the draft isn't lost silently.
292
- * **Tasks tab:** Lists saved tasks (scheduled or reusable). The "Describe your new task..." card opens the full task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when triggers are configured).
292
+ * **Tasks tab:** Lists saved tasks (scheduled or reusable). A floating round `+` button in the bottom-right of the screen opens the task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when a schedule is configured).
293
293
 
294
294
  Bootstrap data (agents, host version, host platform, capability tokens) is fetched once per connection at the Dashboard level via `host.info` — independent of which tab is active. `task.list` is called lazily on the Tasks tab mount; `taskrun.list` is called lazily on the Sessions tab mount. Neither list RPC carries bootstrap metadata.
295
295
 
296
- Dashboard owns the always-on NATS event subscription and renders pending `confirm-request` / `permission-request` / `input-request` modals via React portal, so prompts surface regardless of which tab is active. Initial pending prompts (those already open when the PWA connects) are seeded from `host.info`'s `pending_prompts` field — each entry carries the display context (`description`, `agent_name`, `task_name`) needed to render the modal cold, since the task list is no longer available at bootstrap.
296
+ Dashboard owns the always-on NATS event subscription and renders pending `confirm-request` / `permission-request` / `input-request` modals via React portal, so prompts surface regardless of which tab is active. Initial pending prompts (those already open when the PWA connects) are seeded from `host.info`'s `pending_prompts` field — each entry carries the display context (`session_name`, `description`, `input_questions`) needed to render the modal cold, since the task list is no longer available at bootstrap. `session_name` is a unified label: agent name for confirm/input, task name for permission.
297
297
 
298
298
  ### 4.3 Task Creation & Update
299
299
 
300
- 1. User clicks the "Describe your new task..." placeholder in the Tasks tab, which opens the task form directly.
300
+ 1. User taps the floating `+` button on the Tasks tab, which opens the task form.
301
301
 
302
- 2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Save" (no schedule) or "Schedule" (with triggers).
302
+ 2. User enters a prompt, selects an agent, configures the schedule (UI translates human-readable times to cron expressions or ISO datetime strings) and confirmation settings, and clicks "Save" (no schedule) or "Schedule" (with a schedule).
303
303
 
304
304
  3. PWA sends `task.create` (or `task.update`) via NATS request-reply to Host (45s timeout). For prompts > 50 chars, the host generates a concise task name by running the configured agent CLI in non-interactive mode (e.g., `claude -p "Generate a concise 3-6 word name for this task..."`). For shorter prompts, the prompt is used directly as the name.
305
305
 
306
306
  4. For updates: if the user changes the `user_prompt` or `agent`, the name is regenerated. If neither changed, the existing name is preserved. Existing tasks with granted permissions show a clickable "Granted Permissions" link to view them.
307
307
 
308
- 5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. The `triggers` field defaults to `[]` if omitted or undefined.
308
+ 5. PWA sends `task.create` (or `task.update` with `id`) with the task fields as the message body. The `id` field is **not sent on create** — the host generates a UUID. `schedule_type` and `schedule_values` are omitted when the task has no schedule.
309
309
 
310
310
  6. Host creates/updates the `tasks/<task-id>/TASK.md` file and returns the **full flat task object** (all frontmatter fields at the top level). The PWA uses this response directly to update the UI.
311
311
 
312
- 7. **OS Integration:** Host translates triggers into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
312
+ 7. **OS Integration:** Host translates the schedule into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
313
313
 
314
314
  ### 4.4 On-Demand Execution
315
315
 
@@ -379,7 +379,7 @@ When a task has a `command` field set, `palmier run` enters command-triggered mo
379
379
 
380
380
  5. **On command exit or signal**: each agent invocation is written as a conversation entry in the RESULT file. Per-line agent outputs are also logged to `command-output.log` in the task directory.
381
381
 
382
- 6. **Composable with triggers**: cron/once triggers start `palmier run` on schedule, which spawns the command. The command runs until it exits or the task is aborted.
382
+ 6. **Composable with schedules**: the configured schedule starts `palmier run` at the scheduled time, which spawns the command. The command runs until it exits or the task is aborted.
383
383
 
384
384
  ### 5.4 Failsafes & Constraints
385
385
 
package/src/mcp-tools.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { StringCodec, type NatsConnection } 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
  import type { HostConfig } from "./types.js";
7
7
 
8
8
  export class ToolError extends Error {
@@ -750,6 +750,8 @@ export interface ResourceDefinition {
750
750
  restPath: string;
751
751
  /** Return the current resource content. */
752
752
  read: () => unknown;
753
+ /** Register a listener for content changes. Returns an unsubscribe function. */
754
+ subscribe: (listener: () => void) => () => void;
753
755
  }
754
756
 
755
757
  const deviceNotificationsResource: ResourceDefinition = {
@@ -762,6 +764,7 @@ const deviceNotificationsResource: ResourceDefinition = {
762
764
  mimeType: "application/json",
763
765
  restPath: "/notifications",
764
766
  read: getNotifications,
767
+ subscribe: onNotificationsChanged,
765
768
  };
766
769
 
767
770
  const deviceSmsResource: ResourceDefinition = {
@@ -774,6 +777,7 @@ const deviceSmsResource: ResourceDefinition = {
774
777
  mimeType: "application/json",
775
778
  restPath: "/sms-messages",
776
779
  read: getSmsMessages,
780
+ subscribe: onSmsChanged,
777
781
  };
778
782
 
779
783
  export const agentResources: ResourceDefinition[] = [deviceNotificationsResource, deviceSmsResource];
@@ -196,15 +196,17 @@ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
196
196
  fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
197
197
  daemonReload();
198
198
 
199
- // Only create and enable a timer if triggers exist and are enabled
200
- if (!task.frontmatter.triggers_enabled) return;
201
- const triggers = task.frontmatter.triggers || [];
199
+ // Only create and enable a timer if the schedule exists and is enabled
200
+ if (!task.frontmatter.schedule_enabled) return;
201
+ const scheduleType = task.frontmatter.schedule_type;
202
+ const scheduleValues = task.frontmatter.schedule_values;
203
+ if (!scheduleType || !scheduleValues?.length) return;
202
204
  const onCalendarLines: string[] = [];
203
- for (const trigger of triggers) {
204
- if (trigger.type === "cron") {
205
- onCalendarLines.push(`OnCalendar=${cronToOnCalendar(trigger.value)}`);
206
- } else if (trigger.type === "once") {
207
- onCalendarLines.push(`OnActiveSec=${trigger.value}`);
205
+ for (const value of scheduleValues) {
206
+ if (scheduleType === "crons") {
207
+ onCalendarLines.push(`OnCalendar=${cronToOnCalendar(value)}`);
208
+ } else if (scheduleType === "specific_times") {
209
+ onCalendarLines.push(`OnActiveSec=${value}`);
208
210
  }
209
211
  }
210
212
 
@@ -14,22 +14,23 @@ const DAEMON_TASK_NAME = "PalmierDaemon";
14
14
  const DOW_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
15
15
 
16
16
  /**
17
- * Convert a cron expression or "once" trigger to Task Scheduler XML trigger elements.
17
+ * Convert a single schedule value to a Task Scheduler XML trigger element.
18
18
  *
19
- * Only these cron patterns (produced by the PWA UI) are handled:
19
+ * `specific_times` values are ISO datetime strings like "2026-03-28T09:00".
20
+ *
21
+ * `crons` values are cron expressions. Only these patterns (produced by the PWA UI) are handled:
20
22
  * hourly: "0 * * * *"
21
23
  * daily: "MM HH * * *"
22
24
  * weekly: "MM HH * * D"
23
25
  * monthly: "MM HH D * *"
24
26
  */
25
- export function triggerToXml(trigger: { type: string; value: string }): string {
26
- if (trigger.type === "once") {
27
- // ISO datetime "2026-03-28T09:00"
28
- return `<TimeTrigger><StartBoundary>${trigger.value}:00</StartBoundary></TimeTrigger>`;
27
+ export function scheduleValueToXml(scheduleType: "crons" | "specific_times", value: string): string {
28
+ if (scheduleType === "specific_times") {
29
+ return `<TimeTrigger><StartBoundary>${value}:00</StartBoundary></TimeTrigger>`;
29
30
  }
30
31
 
31
- const parts = trigger.value.trim().split(/\s+/);
32
- if (parts.length !== 5) throw new Error(`Invalid cron expression: ${trigger.value}`);
32
+ const parts = value.trim().split(/\s+/);
33
+ if (parts.length !== 5) throw new Error(`Invalid cron expression: ${value}`);
33
34
  const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
34
35
  const st = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
35
36
  // StartBoundary needs a full date; use a past date as the anchor
@@ -193,12 +194,14 @@ export class WindowsPlatform implements PlatformService {
193
194
 
194
195
  // Build trigger XML elements
195
196
  const triggerElements: string[] = [];
196
- if (task.frontmatter.triggers_enabled) {
197
- for (const trigger of task.frontmatter.triggers ?? []) {
197
+ const scheduleType = task.frontmatter.schedule_type;
198
+ const scheduleValues = task.frontmatter.schedule_values;
199
+ if (task.frontmatter.schedule_enabled && scheduleType && scheduleValues?.length) {
200
+ for (const value of scheduleValues) {
198
201
  try {
199
- triggerElements.push(triggerToXml(trigger));
202
+ triggerElements.push(scheduleValueToXml(scheduleType, value));
200
203
  } catch (err) {
201
- console.error(`Invalid trigger: ${err}`);
204
+ console.error(`Invalid schedule value: ${err}`);
202
205
  }
203
206
  }
204
207
  }
@@ -194,8 +194,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
194
194
  const params = request.params as {
195
195
  user_prompt: string;
196
196
  agent: string;
197
- triggers?: Array<{ type: "cron" | "once"; value: string }>;
198
- triggers_enabled?: boolean;
197
+ schedule_type?: "crons" | "specific_times";
198
+ schedule_values?: string[];
199
+ schedule_enabled?: boolean;
199
200
  requires_confirmation?: boolean;
200
201
  yolo_mode?: boolean;
201
202
  foreground_mode?: boolean;
@@ -214,9 +215,11 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
214
215
  name,
215
216
  user_prompt: params.user_prompt,
216
217
  agent: params.agent,
217
- triggers: params.triggers ?? [],
218
- triggers_enabled: params.triggers_enabled ?? true,
218
+ schedule_enabled: params.schedule_enabled ?? true,
219
219
  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
+ : {}),
220
223
  ...(params.yolo_mode ? { yolo_mode: true } : {}),
221
224
  ...(params.foreground_mode ? { foreground_mode: true } : {}),
222
225
  ...(params.command ? { command: params.command } : {}),
@@ -235,8 +238,9 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
235
238
  id: string;
236
239
  user_prompt?: string;
237
240
  agent?: string;
238
- triggers?: Array<{ type: "cron" | "once"; value: string }>;
239
- triggers_enabled?: boolean;
241
+ schedule_type?: "crons" | "specific_times" | null;
242
+ schedule_values?: string[] | null;
243
+ schedule_enabled?: boolean;
240
244
  requires_confirmation?: boolean;
241
245
  yolo_mode?: boolean;
242
246
  foreground_mode?: boolean;
@@ -253,8 +257,21 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
253
257
  // Merge updates
254
258
  if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
255
259
  if (params.agent !== undefined) existing.frontmatter.agent = params.agent;
256
- if (params.triggers !== undefined) existing.frontmatter.triggers = params.triggers;
257
- if (params.triggers_enabled !== undefined) existing.frontmatter.triggers_enabled = params.triggers_enabled;
260
+ if (params.schedule_type !== undefined) {
261
+ if (params.schedule_type) {
262
+ existing.frontmatter.schedule_type = params.schedule_type;
263
+ } else {
264
+ delete existing.frontmatter.schedule_type;
265
+ }
266
+ }
267
+ if (params.schedule_values !== undefined) {
268
+ if (params.schedule_values && params.schedule_values.length > 0) {
269
+ existing.frontmatter.schedule_values = params.schedule_values;
270
+ } else {
271
+ delete existing.frontmatter.schedule_values;
272
+ }
273
+ }
274
+ if (params.schedule_enabled !== undefined) existing.frontmatter.schedule_enabled = params.schedule_enabled;
258
275
  if (params.requires_confirmation !== undefined)
259
276
  existing.frontmatter.requires_confirmation = params.requires_confirmation;
260
277
  if (params.yolo_mode !== undefined) {
@@ -314,8 +331,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
314
331
  name,
315
332
  user_prompt: params.user_prompt,
316
333
  agent: params.agent,
317
- triggers: [],
318
- triggers_enabled: false,
334
+ schedule_enabled: false,
319
335
  requires_confirmation: params.requires_confirmation ?? false,
320
336
  ...(params.yolo_mode ? { yolo_mode: true } : {}),
321
337
  ...(params.foreground_mode ? { foreground_mode: true } : {}),