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
@@ -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
 
@@ -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
+ }