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.
- package/README.md +1 -1
- package/dist/commands/run.js +55 -0
- package/dist/commands/serve.js +22 -2
- package/dist/event-queues.d.ts +36 -0
- package/dist/event-queues.js +53 -0
- package/dist/mcp-tools.d.ts +2 -0
- package/dist/mcp-tools.js +4 -2
- package/dist/platform/linux.js +11 -8
- package/dist/platform/windows.d.ts +5 -6
- package/dist/platform/windows.js +19 -13
- package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
- package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
- package/dist/pwa/assets/{web-BNr628AV.js → web-BpM3fNCn.js} +1 -1
- package/dist/pwa/assets/{web-DyQPewAi.js → web-CF-N8Di6.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +25 -9
- package/dist/task.js +1 -1
- package/dist/transports/http-transport.js +18 -5
- package/dist/types.d.ts +10 -6
- package/package.json +1 -1
- package/palmier-server/README.md +3 -3
- package/palmier-server/pwa/src/App.css +117 -36
- package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
- package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
- package/palmier-server/pwa/src/components/SessionComposer.tsx +20 -10
- package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +33 -25
- package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
- package/palmier-server/pwa/src/components/TaskForm.tsx +274 -293
- package/palmier-server/pwa/src/components/{TaskListView.tsx → TasksView.tsx} +20 -13
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
- package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +9 -26
- package/palmier-server/pwa/src/types.ts +5 -9
- package/palmier-server/spec.md +23 -23
- package/src/commands/run.ts +61 -0
- package/src/commands/serve.ts +22 -2
- package/src/event-queues.ts +56 -0
- package/src/mcp-tools.ts +6 -2
- package/src/platform/linux.ts +10 -8
- package/src/platform/windows.ts +19 -13
- package/src/rpc-handler.ts +28 -11
- package/src/task.ts +1 -1
- package/src/transports/http-transport.ts +17 -5
- package/src/types.ts +10 -7
- package/dist/pwa/assets/index-8cTctVnD.js +0 -120
- 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
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
7
|
+
import TasksView from "../components/TasksView";
|
|
8
8
|
import TabBar from "../components/TabBar";
|
|
9
9
|
import HostMenu from "../components/HostMenu";
|
|
10
|
-
import
|
|
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
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
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
|
|
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
|
-
<
|
|
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(
|
|
356
|
+
runId={decodeURIComponent(params.runId!)}
|
|
374
357
|
/>
|
|
375
358
|
) : !isTasksTab ? (
|
|
376
|
-
<
|
|
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
|
|
5
|
-
supportsYolo
|
|
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
|
-
|
|
15
|
-
|
|
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;
|
package/palmier-server/spec.md
CHANGED
|
@@ -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`, `
|
|
123
|
-
| `task.update` | `id`, `user_prompt?`, `agent?`, `
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
####
|
|
234
|
+
#### Schedule Lifecycle
|
|
235
235
|
|
|
236
|
-
* **`
|
|
237
|
-
* **`
|
|
238
|
-
* **`
|
|
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
|
|
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 `
|
|
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`.
|
|
281
|
+
4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. `task.list` is not called at startup — it 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.
|
|
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).
|
|
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 (`
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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/commands/run.ts
CHANGED
|
@@ -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,
|
package/src/commands/serve.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
+
}
|