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.
- package/README.md +1 -1
- 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 +15 -12
- 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 +23 -8
- package/dist/task.js +1 -1
- package/dist/transports/http-transport.js +3 -5
- package/dist/types.d.ts +9 -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/mcp-tools.ts +6 -2
- package/src/platform/linux.ts +10 -8
- package/src/platform/windows.ts +15 -12
- package/src/rpc-handler.ts +26 -10
- package/src/task.ts +1 -1
- package/src/transports/http-transport.ts +3 -5
- package/src/types.ts +9 -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/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];
|
package/src/platform/linux.ts
CHANGED
|
@@ -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
|
|
200
|
-
if (!task.frontmatter.
|
|
201
|
-
const
|
|
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
|
|
204
|
-
if (
|
|
205
|
-
onCalendarLines.push(`OnCalendar=${cronToOnCalendar(
|
|
206
|
-
} else if (
|
|
207
|
-
onCalendarLines.push(`OnActiveSec=${
|
|
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
|
|
package/src/platform/windows.ts
CHANGED
|
@@ -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
|
|
17
|
+
* Convert a single schedule value to a Task Scheduler XML trigger element.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
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
|
|
26
|
-
if (
|
|
27
|
-
|
|
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 =
|
|
32
|
-
if (parts.length !== 5) throw new Error(`Invalid cron expression: ${
|
|
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
|
-
|
|
197
|
-
|
|
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(
|
|
202
|
+
triggerElements.push(scheduleValueToXml(scheduleType, value));
|
|
200
203
|
} catch (err) {
|
|
201
|
-
console.error(`Invalid
|
|
204
|
+
console.error(`Invalid schedule value: ${err}`);
|
|
202
205
|
}
|
|
203
206
|
}
|
|
204
207
|
}
|
package/src/rpc-handler.ts
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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.
|
|
257
|
-
|
|
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
|
-
|
|
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 } : {}),
|