palmier 0.7.7 → 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/agents/agent.d.ts +3 -0
- package/dist/agents/agent.js +1 -1
- package/dist/agents/aider.d.ts +1 -0
- package/dist/agents/aider.js +1 -0
- package/dist/agents/claude.d.ts +1 -0
- package/dist/agents/claude.js +1 -0
- package/dist/agents/cline.d.ts +1 -0
- package/dist/agents/cline.js +1 -0
- package/dist/agents/codex.d.ts +1 -0
- package/dist/agents/codex.js +1 -0
- package/dist/agents/copilot.d.ts +1 -0
- package/dist/agents/copilot.js +1 -0
- package/dist/agents/cursor.d.ts +1 -0
- package/dist/agents/cursor.js +1 -0
- package/dist/agents/deepagents.d.ts +1 -0
- package/dist/agents/deepagents.js +1 -0
- package/dist/agents/droid.d.ts +1 -0
- package/dist/agents/droid.js +1 -0
- package/dist/agents/gemini.d.ts +1 -0
- package/dist/agents/gemini.js +1 -0
- package/dist/agents/goose.d.ts +1 -0
- package/dist/agents/goose.js +1 -0
- package/dist/agents/hermes.d.ts +1 -0
- package/dist/agents/hermes.js +1 -0
- package/dist/agents/kimi.d.ts +1 -0
- package/dist/agents/kimi.js +1 -0
- package/dist/agents/kiro.d.ts +1 -0
- package/dist/agents/kiro.js +1 -0
- package/dist/agents/openclaw.d.ts +1 -0
- package/dist/agents/openclaw.js +2 -2
- package/dist/agents/opencode.d.ts +1 -0
- package/dist/agents/opencode.js +1 -0
- package/dist/agents/qoder.d.ts +1 -0
- package/dist/agents/qoder.js +1 -0
- package/dist/agents/qwen.d.ts +1 -0
- package/dist/agents/qwen.js +1 -0
- package/dist/commands/pair.js +2 -2
- package/dist/mcp-tools.d.ts +2 -0
- package/dist/mcp-tools.js +20 -9
- package/dist/pending-requests.d.ts +30 -8
- package/dist/pending-requests.js +28 -15
- 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-CkWrlNwc.js → web-BpM3fNCn.js} +1 -1
- package/dist/pwa/assets/{web-lx34oBi7.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 +35 -24
- package/dist/task.js +1 -1
- package/dist/transports/http-transport.js +9 -8
- package/dist/types.d.ts +11 -6
- package/package.json +1 -1
- package/palmier-server/README.md +3 -3
- package/palmier-server/pwa/src/App.css +175 -28
- package/palmier-server/pwa/src/App.tsx +1 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +7 -2
- 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 +147 -0
- package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +79 -45
- package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
- package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
- package/palmier-server/pwa/src/components/TaskForm.tsx +275 -349
- package/palmier-server/pwa/src/components/TasksView.tsx +172 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
- package/palmier-server/pwa/src/draftGuard.ts +24 -0
- package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +343 -37
- package/palmier-server/pwa/src/types.ts +5 -14
- package/palmier-server/spec.md +39 -26
- package/src/agents/agent.ts +5 -1
- package/src/agents/aider.ts +1 -0
- package/src/agents/claude.ts +1 -0
- package/src/agents/cline.ts +1 -0
- package/src/agents/codex.ts +1 -0
- package/src/agents/copilot.ts +1 -0
- package/src/agents/cursor.ts +1 -0
- package/src/agents/deepagents.ts +1 -0
- package/src/agents/droid.ts +1 -0
- package/src/agents/gemini.ts +1 -0
- package/src/agents/goose.ts +1 -0
- package/src/agents/hermes.ts +1 -0
- package/src/agents/kimi.ts +1 -0
- package/src/agents/kiro.ts +1 -0
- package/src/agents/openclaw.ts +2 -2
- package/src/agents/opencode.ts +1 -0
- package/src/agents/qoder.ts +1 -0
- package/src/agents/qwen.ts +1 -0
- package/src/commands/pair.ts +2 -2
- package/src/mcp-tools.ts +22 -9
- package/src/pending-requests.ts +47 -15
- package/src/platform/linux.ts +10 -8
- package/src/platform/windows.ts +15 -12
- package/src/rpc-handler.ts +39 -26
- package/src/task.ts +1 -1
- package/src/transports/http-transport.ts +9 -8
- package/src/types.ts +10 -8
- package/test/pairing.test.ts +2 -2
- package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
- package/dist/pwa/assets/index-Bt8Hhaw3.js +0 -118
- package/palmier-server/pwa/src/components/TaskListView.tsx +0 -431
|
@@ -1,15 +1,47 @@
|
|
|
1
1
|
import { useState, useEffect } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { useNavigate, useLocation, useParams } from "react-router-dom";
|
|
3
4
|
|
|
4
5
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
5
6
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
6
|
-
import
|
|
7
|
+
import TasksView from "../components/TasksView";
|
|
7
8
|
import TabBar from "../components/TabBar";
|
|
8
9
|
import HostMenu from "../components/HostMenu";
|
|
9
|
-
import
|
|
10
|
+
import SessionsView from "../components/SessionsView";
|
|
10
11
|
import RunDetailView from "../components/RunDetailView";
|
|
11
12
|
import { usePushSubscription } from "../hooks/usePushSubscription";
|
|
12
13
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
14
|
+
import { setAgentLabels } from "../agentLabels";
|
|
15
|
+
import { confirmLeaveDraft } from "../draftGuard";
|
|
16
|
+
import { MIN_HOST_VERSION } from "../constants";
|
|
17
|
+
import type { AgentInfo, RequiredPermission } from "../types";
|
|
18
|
+
|
|
19
|
+
function isOlderThan(current: string, minimum: string): boolean {
|
|
20
|
+
if (current.includes("-")) return false;
|
|
21
|
+
const a = current.split(".").map(Number);
|
|
22
|
+
const b = minimum.split(".").map(Number);
|
|
23
|
+
for (let i = 0; i < 3; i++) {
|
|
24
|
+
if ((a[i] ?? 0) < (b[i] ?? 0)) return true;
|
|
25
|
+
if ((a[i] ?? 0) > (b[i] ?? 0)) return false;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PendingPrompt {
|
|
31
|
+
key: string;
|
|
32
|
+
type: "confirmation" | "permission" | "input";
|
|
33
|
+
params?: RequiredPermission[] | string[];
|
|
34
|
+
meta?: {
|
|
35
|
+
session_id?: string;
|
|
36
|
+
session_name?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
input_questions?: string[];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ConfirmPrompt { description: string; sessionName?: string }
|
|
43
|
+
interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: string }
|
|
44
|
+
interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
|
|
13
45
|
|
|
14
46
|
export default function Dashboard() {
|
|
15
47
|
const { pairedHosts, activeHostId, removePairedHost } = useHostStore();
|
|
@@ -19,45 +51,217 @@ export default function Dashboard() {
|
|
|
19
51
|
const params = useParams<{ taskId?: string; runId?: string }>();
|
|
20
52
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
21
53
|
|
|
22
|
-
const
|
|
54
|
+
const isTasksTab = location.pathname.startsWith("/tasks");
|
|
23
55
|
const runsFilterTaskId = params.runId ? undefined : params.taskId;
|
|
24
56
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const isLatest = rawRunId === "latest";
|
|
29
|
-
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
if (!isLatest || !params.taskId || !connected) {
|
|
32
|
-
setResolvedRunId(null);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
request<{ entries?: Array<{ run_id: string }> }>("taskrun.list", { task_id: params.taskId, limit: 1 })
|
|
36
|
-
.then((result) => {
|
|
37
|
-
const latest = result.entries?.[0]?.run_id;
|
|
38
|
-
setResolvedRunId(latest ?? null);
|
|
39
|
-
})
|
|
40
|
-
.catch(() => setResolvedRunId(null));
|
|
41
|
-
}, [isLatest, params.taskId, connected]);
|
|
42
|
-
|
|
43
|
-
const effectiveRunId = isLatest ? resolvedRunId : rawRunId;
|
|
44
|
-
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);
|
|
45
60
|
|
|
46
61
|
const [updateRequired, setUpdateRequired] = useState(false);
|
|
47
62
|
const [updating, setUpdating] = useState(false);
|
|
48
63
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
49
64
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
50
65
|
const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
|
|
66
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
67
|
+
const [hostPlatform, setHostPlatform] = useState<string | undefined>();
|
|
68
|
+
|
|
69
|
+
// Pending prompt state — owned by Dashboard because these modals must show
|
|
70
|
+
// regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
|
|
71
|
+
const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
|
|
72
|
+
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PermissionPrompt>>(new Map());
|
|
73
|
+
const [pendingInputs, setPendingInputs] = useState<Map<string, InputPrompt>>(new Map());
|
|
74
|
+
const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
|
|
51
75
|
|
|
52
|
-
// Register push subscription for the active host
|
|
53
76
|
usePushSubscription();
|
|
54
77
|
|
|
55
|
-
// Reset scroll when switching hosts
|
|
56
78
|
useEffect(() => {
|
|
57
79
|
window.scrollTo(0, 0);
|
|
58
80
|
}, [activeHostId]);
|
|
59
81
|
|
|
82
|
+
// host.info bootstrap: agents/version/platform + any prompts that were already
|
|
83
|
+
// pending when this PWA connected. Runs once per (host, connection).
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!connected) return;
|
|
86
|
+
request<{
|
|
87
|
+
agents?: AgentInfo[];
|
|
88
|
+
version?: string | null;
|
|
89
|
+
host_platform?: string;
|
|
90
|
+
capability_tokens?: Record<string, string | null>;
|
|
91
|
+
pending_prompts?: PendingPrompt[];
|
|
92
|
+
}>("host.info")
|
|
93
|
+
.then((result) => {
|
|
94
|
+
setAgents(result.agents ?? []);
|
|
95
|
+
setHostPlatform(result.host_platform);
|
|
96
|
+
setCapabilityTokens(result.capability_tokens ?? {});
|
|
97
|
+
setAgentLabels(result.agents ?? []);
|
|
98
|
+
const version = result.version ?? null;
|
|
99
|
+
setDaemonVersion(version);
|
|
100
|
+
setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
101
|
+
|
|
102
|
+
// Seed modal state from already-pending prompts.
|
|
103
|
+
const confirms = new Map<string, ConfirmPrompt>();
|
|
104
|
+
const perms = new Map<string, PermissionPrompt>();
|
|
105
|
+
const inputs = new Map<string, InputPrompt>();
|
|
106
|
+
const inputVals = new Map<string, string[]>();
|
|
107
|
+
for (const p of result.pending_prompts ?? []) {
|
|
108
|
+
if (p.type === "confirmation") {
|
|
109
|
+
confirms.set(p.key, {
|
|
110
|
+
description: p.meta?.description ?? "",
|
|
111
|
+
sessionName: p.meta?.session_name,
|
|
112
|
+
});
|
|
113
|
+
} else if (p.type === "permission") {
|
|
114
|
+
perms.set(p.key, {
|
|
115
|
+
permissions: (p.params as RequiredPermission[]) ?? [],
|
|
116
|
+
sessionName: p.meta?.session_name,
|
|
117
|
+
});
|
|
118
|
+
} else if (p.type === "input") {
|
|
119
|
+
const questions = (p.params as string[]) ?? p.meta?.input_questions ?? [];
|
|
120
|
+
inputs.set(p.key, {
|
|
121
|
+
questions,
|
|
122
|
+
description: p.meta?.description,
|
|
123
|
+
sessionName: p.meta?.session_name,
|
|
124
|
+
});
|
|
125
|
+
inputVals.set(p.key, new Array(questions.length).fill(""));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setPendingConfirms(confirms);
|
|
129
|
+
setPendingPermissions(perms);
|
|
130
|
+
setPendingInputs(inputs);
|
|
131
|
+
setInputValues(inputVals);
|
|
132
|
+
})
|
|
133
|
+
.catch(() => { /* silent — update-required prompt guards the broken case */ });
|
|
134
|
+
}, [connected, activeHostId, request]);
|
|
135
|
+
|
|
136
|
+
// Always-on event subscription for modal lifecycle. Independent of which tab
|
|
137
|
+
// is active. Task-card status updates happen inside TasksView while mounted.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!connected || !activeHostId) return;
|
|
140
|
+
const unsubscribe = subscribeEvents(activeHostId, (msg) => {
|
|
141
|
+
const tokens = msg.subject.split(".");
|
|
142
|
+
if (tokens.length < 3) return;
|
|
143
|
+
const taskId = tokens.slice(2).join(".");
|
|
144
|
+
|
|
145
|
+
let parsed: Record<string, unknown> = {};
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
|
|
148
|
+
} catch { return; }
|
|
149
|
+
const eventType = parsed.event_type as string | undefined;
|
|
150
|
+
const sessionId = parsed.session_id as string | undefined;
|
|
151
|
+
|
|
152
|
+
if (eventType === "input-request" && sessionId) {
|
|
153
|
+
const questions = parsed.input_questions as string[] | undefined;
|
|
154
|
+
const sessionName = parsed.session_name as string | undefined;
|
|
155
|
+
const description = parsed.description as string | undefined;
|
|
156
|
+
if (questions?.length) {
|
|
157
|
+
setPendingInputs((prev) => {
|
|
158
|
+
if (prev.has(sessionId)) return prev;
|
|
159
|
+
const next = new Map(prev);
|
|
160
|
+
next.set(sessionId, { questions, description, sessionName });
|
|
161
|
+
return next;
|
|
162
|
+
});
|
|
163
|
+
setInputValues((prev) => {
|
|
164
|
+
if (prev.has(sessionId)) return prev;
|
|
165
|
+
const next = new Map(prev);
|
|
166
|
+
next.set(sessionId, new Array(questions.length).fill(""));
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (eventType === "input-resolved" && sessionId) {
|
|
174
|
+
setPendingInputs((prev) => {
|
|
175
|
+
if (!prev.has(sessionId)) return prev;
|
|
176
|
+
const next = new Map(prev);
|
|
177
|
+
next.delete(sessionId);
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
setInputValues((prev) => {
|
|
181
|
+
const next = new Map(prev);
|
|
182
|
+
next.delete(sessionId);
|
|
183
|
+
return next;
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (eventType === "confirm-request" && sessionId) {
|
|
189
|
+
const description = parsed.description as string | undefined;
|
|
190
|
+
const sessionName = parsed.session_name as string | undefined;
|
|
191
|
+
if (description) {
|
|
192
|
+
setPendingConfirms((prev) => {
|
|
193
|
+
if (prev.has(sessionId)) return prev;
|
|
194
|
+
const next = new Map(prev);
|
|
195
|
+
next.set(sessionId, { description, sessionName });
|
|
196
|
+
return next;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (eventType === "confirm-resolved" && sessionId) {
|
|
203
|
+
setPendingConfirms((prev) => {
|
|
204
|
+
if (!prev.has(sessionId)) return prev;
|
|
205
|
+
const next = new Map(prev);
|
|
206
|
+
next.delete(sessionId);
|
|
207
|
+
return next;
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (eventType === "permission-request") {
|
|
213
|
+
const permissions = parsed.required_permissions as RequiredPermission[] | undefined;
|
|
214
|
+
const sessionName = parsed.session_name as string | undefined;
|
|
215
|
+
if (permissions?.length) {
|
|
216
|
+
setPendingPermissions((prev) => {
|
|
217
|
+
if (prev.has(taskId)) return prev;
|
|
218
|
+
const next = new Map(prev);
|
|
219
|
+
next.set(taskId, { permissions, sessionName });
|
|
220
|
+
return next;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (eventType === "permission-resolved") {
|
|
227
|
+
setPendingPermissions((prev) => {
|
|
228
|
+
if (!prev.has(taskId)) return prev;
|
|
229
|
+
const next = new Map(prev);
|
|
230
|
+
next.delete(taskId);
|
|
231
|
+
return next;
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return unsubscribe;
|
|
237
|
+
}, [connected, activeHostId, subscribeEvents]);
|
|
238
|
+
|
|
239
|
+
async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
|
|
240
|
+
try {
|
|
241
|
+
await request("task.user_input", { id: sessionId, value: [response] });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error("[Dashboard] Failed to respond to confirmation:", err);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function respondToPermission(taskId: string, response: "granted" | "granted_all" | "aborted") {
|
|
248
|
+
try {
|
|
249
|
+
await request("task.user_input", { id: taskId, value: [response] });
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error("[Dashboard] Failed to respond to permission request:", err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function respondToInput(sessionId: string, values: string[]) {
|
|
256
|
+
try {
|
|
257
|
+
await request("task.user_input", { id: sessionId, value: values });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error("[Dashboard] Failed to respond to input request:", err);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
60
263
|
function handleViewRun(taskId: string, runId?: string) {
|
|
264
|
+
if (!confirmLeaveDraft()) return;
|
|
61
265
|
if (runId) {
|
|
62
266
|
navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
|
|
63
267
|
} else {
|
|
@@ -78,7 +282,6 @@ export default function Dashboard() {
|
|
|
78
282
|
} catch {
|
|
79
283
|
// Expected: connection drops during daemon restart
|
|
80
284
|
}
|
|
81
|
-
// Daemon will restart; reload after it has time to come back up.
|
|
82
285
|
setTimeout(() => window.location.reload(), 10000);
|
|
83
286
|
}
|
|
84
287
|
|
|
@@ -116,7 +319,7 @@ export default function Dashboard() {
|
|
|
116
319
|
<div className="revoked-actions">
|
|
117
320
|
<button
|
|
118
321
|
className="btn btn-primary"
|
|
119
|
-
onClick={() => navigate("/pair")}
|
|
322
|
+
onClick={() => { if (confirmLeaveDraft()) navigate("/pair"); }}
|
|
120
323
|
>
|
|
121
324
|
Re-pair Device
|
|
122
325
|
</button>
|
|
@@ -132,18 +335,17 @@ export default function Dashboard() {
|
|
|
132
335
|
</div>
|
|
133
336
|
) : showTaskContent ? (
|
|
134
337
|
<>
|
|
135
|
-
|
|
136
|
-
<
|
|
338
|
+
{isTasksTab && !isRunDetail && (
|
|
339
|
+
<TasksView
|
|
137
340
|
connected={connected}
|
|
138
341
|
hostId={activeHostId}
|
|
139
342
|
request={request}
|
|
140
343
|
subscribeEvents={subscribeEvents}
|
|
344
|
+
agents={agents}
|
|
345
|
+
hostPlatform={hostPlatform}
|
|
141
346
|
onViewRun={handleViewRun}
|
|
142
|
-
onUpdateRequired={setUpdateRequired}
|
|
143
|
-
onVersion={setDaemonVersion}
|
|
144
|
-
onCapabilityTokens={setCapabilityTokens}
|
|
145
347
|
/>
|
|
146
|
-
|
|
348
|
+
)}
|
|
147
349
|
{isRunDetail ? (
|
|
148
350
|
<RunDetailView
|
|
149
351
|
connected={connected}
|
|
@@ -151,16 +353,17 @@ export default function Dashboard() {
|
|
|
151
353
|
request={request}
|
|
152
354
|
subscribeEvents={subscribeEvents}
|
|
153
355
|
taskId={params.taskId!}
|
|
154
|
-
runId={decodeURIComponent(
|
|
356
|
+
runId={decodeURIComponent(params.runId!)}
|
|
155
357
|
/>
|
|
156
|
-
) :
|
|
157
|
-
<
|
|
358
|
+
) : !isTasksTab ? (
|
|
359
|
+
<SessionsView
|
|
158
360
|
connected={connected}
|
|
159
361
|
hostId={activeHostId}
|
|
160
362
|
request={request}
|
|
161
363
|
subscribeEvents={subscribeEvents}
|
|
364
|
+
agents={agents}
|
|
162
365
|
filterTaskId={runsFilterTaskId}
|
|
163
|
-
onClearFilter={() => navigate("/
|
|
366
|
+
onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
|
|
164
367
|
/>
|
|
165
368
|
) : null}
|
|
166
369
|
</>
|
|
@@ -221,6 +424,109 @@ export default function Dashboard() {
|
|
|
221
424
|
)}
|
|
222
425
|
|
|
223
426
|
</div>
|
|
427
|
+
|
|
428
|
+
{createPortal(<>
|
|
429
|
+
{[...pendingConfirms.entries()].map(([sessionId, { description, sessionName }]) => (
|
|
430
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
431
|
+
<div className="confirm-modal">
|
|
432
|
+
<h2 className="confirm-modal-title">Confirmation Required</h2>
|
|
433
|
+
{sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
|
|
434
|
+
<p className="confirm-modal-message">{description}</p>
|
|
435
|
+
<div className="confirm-modal-actions">
|
|
436
|
+
<button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
|
|
437
|
+
Confirm
|
|
438
|
+
</button>
|
|
439
|
+
<button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
|
|
440
|
+
Abort
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
))}
|
|
446
|
+
|
|
447
|
+
{[...pendingPermissions.entries()].map(([taskId, { permissions, sessionName }]) => (
|
|
448
|
+
<div key={taskId} className="confirm-modal-overlay">
|
|
449
|
+
<div className="confirm-modal permission-modal">
|
|
450
|
+
<h2 className="confirm-modal-title">Permission Required</h2>
|
|
451
|
+
<p className="confirm-modal-message">
|
|
452
|
+
<strong>{sessionName || taskId}</strong>
|
|
453
|
+
</p>
|
|
454
|
+
<div className="permission-list">
|
|
455
|
+
{permissions.map((p, i) => (
|
|
456
|
+
<div key={i} className="permission-item">
|
|
457
|
+
<span className="permission-name">{p.name}</span>
|
|
458
|
+
{p.description && <span className="permission-desc">{p.description}</span>}
|
|
459
|
+
</div>
|
|
460
|
+
))}
|
|
461
|
+
</div>
|
|
462
|
+
<div className="permission-actions">
|
|
463
|
+
<button className="btn btn-primary" onClick={() => respondToPermission(taskId, "granted")}>
|
|
464
|
+
Allow Once
|
|
465
|
+
</button>
|
|
466
|
+
<button className="btn btn-secondary" onClick={() => respondToPermission(taskId, "granted_all")}>
|
|
467
|
+
Allow Always
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
<button
|
|
471
|
+
className="permission-abort-link"
|
|
472
|
+
onClick={() => respondToPermission(taskId, "aborted")}
|
|
473
|
+
>
|
|
474
|
+
Deny & Abort Task
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
))}
|
|
479
|
+
|
|
480
|
+
{[...pendingInputs.entries()].map(([sessionId, { questions, description, sessionName }]) => {
|
|
481
|
+
const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
|
|
482
|
+
return (
|
|
483
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
484
|
+
<div className="confirm-modal input-modal">
|
|
485
|
+
<h2 className="confirm-modal-title">Input Required</h2>
|
|
486
|
+
{sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
|
|
487
|
+
{description && <p className="confirm-modal-message">{description}</p>}
|
|
488
|
+
<div className="input-list">
|
|
489
|
+
{questions.map((desc: string, i: number) => (
|
|
490
|
+
<div key={i} className="input-item">
|
|
491
|
+
<label className="input-label">{desc}</label>
|
|
492
|
+
<input
|
|
493
|
+
type="text"
|
|
494
|
+
className="input-field"
|
|
495
|
+
value={values[i] ?? ""}
|
|
496
|
+
onChange={(e) => {
|
|
497
|
+
setInputValues((prev) => {
|
|
498
|
+
const next = new Map(prev);
|
|
499
|
+
const arr = [...(next.get(sessionId) ?? [])];
|
|
500
|
+
arr[i] = e.target.value;
|
|
501
|
+
next.set(sessionId, arr);
|
|
502
|
+
return next;
|
|
503
|
+
});
|
|
504
|
+
}}
|
|
505
|
+
autoFocus={i === 0}
|
|
506
|
+
/>
|
|
507
|
+
</div>
|
|
508
|
+
))}
|
|
509
|
+
</div>
|
|
510
|
+
<div className="input-actions">
|
|
511
|
+
<button
|
|
512
|
+
className="btn btn-primary"
|
|
513
|
+
disabled={values.some((v) => !v.trim())}
|
|
514
|
+
onClick={() => respondToInput(sessionId, values)}
|
|
515
|
+
>
|
|
516
|
+
Submit
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
<button
|
|
520
|
+
className="permission-abort-link"
|
|
521
|
+
onClick={() => respondToInput(sessionId, ["aborted"])}
|
|
522
|
+
>
|
|
523
|
+
Cancel
|
|
524
|
+
</button>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
})}
|
|
529
|
+
</>, document.body)}
|
|
224
530
|
</div>
|
|
225
531
|
);
|
|
226
532
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export interface AgentInfo {
|
|
2
2
|
key: string;
|
|
3
3
|
label: string;
|
|
4
|
-
supportsPermissions
|
|
4
|
+
supportsPermissions: boolean;
|
|
5
|
+
supportsYolo: boolean;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
|
|
@@ -10,8 +11,9 @@ export interface Task {
|
|
|
10
11
|
name: string;
|
|
11
12
|
user_prompt: string;
|
|
12
13
|
agent?: string;
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
schedule_type?: "crons" | "specific_times";
|
|
15
|
+
schedule_values?: string[];
|
|
16
|
+
schedule_enabled: boolean;
|
|
15
17
|
requires_confirmation: boolean;
|
|
16
18
|
yolo_mode?: boolean;
|
|
17
19
|
foreground_mode?: boolean;
|
|
@@ -19,11 +21,6 @@ export interface Task {
|
|
|
19
21
|
command?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export interface Trigger {
|
|
23
|
-
type: "cron" | "once";
|
|
24
|
-
value: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
24
|
export interface RequiredPermission {
|
|
28
25
|
name: string;
|
|
29
26
|
description: string;
|
|
@@ -41,12 +38,6 @@ export interface TaskStatus {
|
|
|
41
38
|
running_state: TaskRunningState;
|
|
42
39
|
/** UTC time in milliseconds since epoch */
|
|
43
40
|
time_stamp: number;
|
|
44
|
-
/** Whether this task is awaiting user confirmation */
|
|
45
|
-
pending_confirmation?: boolean;
|
|
46
|
-
/** Permissions the task needs granted to continue */
|
|
47
|
-
pending_permission?: RequiredPermission[];
|
|
48
|
-
pending_input?: string[];
|
|
49
|
-
user_input?: string[];
|
|
50
41
|
}
|
|
51
42
|
|
|
52
43
|
export interface HistoryEntry {
|
package/palmier-server/spec.md
CHANGED
|
@@ -116,10 +116,11 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
|
|
|
116
116
|
|
|
117
117
|
| Method | Params | Description |
|
|
118
118
|
|---|---|---|
|
|
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
|
+
| `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
|
|
120
121
|
| `task.get` | `id` | Get a single task with frontmatter and current status. |
|
|
121
|
-
| `task.create` | `user_prompt`, `agent`, `
|
|
122
|
-
| `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. |
|
|
123
124
|
| `task.delete` | `id` | Delete a task and its systemd timers |
|
|
124
125
|
| `task.run` | `id` | Start a task via system scheduler (`systemctl --user start` / `schtasks /run`) |
|
|
125
126
|
| `task.abort` | `id` | Stop a running task via system scheduler (`systemctl --user stop` / `schtasks /end`) |
|
|
@@ -214,27 +215,27 @@ Messages are appended incrementally during execution.
|
|
|
214
215
|
id: "uuid-v4"
|
|
215
216
|
user_prompt: "Run a system audit and summarize large files..."
|
|
216
217
|
agent: "claude"
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
value: "2026-03-20T15:00:00Z"
|
|
222
|
-
triggers_enabled: true
|
|
218
|
+
schedule_type: "crons"
|
|
219
|
+
schedule_values:
|
|
220
|
+
- "0 9 * * 1"
|
|
221
|
+
schedule_enabled: true
|
|
223
222
|
requires_confirmation: true
|
|
224
223
|
---
|
|
225
224
|
```
|
|
226
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
|
+
|
|
227
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.
|
|
228
229
|
|
|
229
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.
|
|
230
231
|
|
|
231
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`.
|
|
232
233
|
|
|
233
|
-
####
|
|
234
|
+
#### Schedule Lifecycle
|
|
234
235
|
|
|
235
|
-
* **`
|
|
236
|
-
* **`
|
|
237
|
-
* **`
|
|
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).
|
|
238
239
|
|
|
239
240
|
### Task Events
|
|
240
241
|
|
|
@@ -249,7 +250,7 @@ Task lifecycle status is persisted to a `status.json` file in the task directory
|
|
|
249
250
|
|
|
250
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.
|
|
251
252
|
|
|
252
|
-
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.
|
|
253
254
|
|
|
254
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.
|
|
255
256
|
|
|
@@ -275,40 +276,52 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
275
276
|
|
|
276
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.
|
|
277
278
|
|
|
278
|
-
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.
|
|
279
280
|
|
|
280
|
-
4.
|
|
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.
|
|
281
282
|
|
|
282
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.
|
|
283
284
|
|
|
284
|
-
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
|
+
|
|
287
|
+
### 4.2 UI Layout: Sessions & Tasks Tabs
|
|
288
|
+
|
|
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
|
+
|
|
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). 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
|
+
|
|
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
|
+
|
|
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.
|
|
285
297
|
|
|
286
|
-
### 4.
|
|
298
|
+
### 4.3 Task Creation & Update
|
|
287
299
|
|
|
288
|
-
1. User
|
|
300
|
+
1. User taps the floating `+` button on the Tasks tab, which opens the task form.
|
|
289
301
|
|
|
290
|
-
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).
|
|
291
303
|
|
|
292
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.
|
|
293
305
|
|
|
294
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.
|
|
295
307
|
|
|
296
|
-
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.
|
|
297
309
|
|
|
298
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.
|
|
299
311
|
|
|
300
|
-
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.
|
|
301
313
|
|
|
302
|
-
### 4.
|
|
314
|
+
### 4.4 On-Demand Execution
|
|
303
315
|
|
|
304
316
|
Any task that is not currently running can be executed immediately:
|
|
305
317
|
|
|
306
|
-
* **PWA:** A "Run Now" button is shown on each task card when the task is not already running. Clicking it sends a `task.run` request via NATS request-reply to the Host, which starts execution via the system scheduler (`systemctl --user start` on Linux, `schtasks /run` on Windows).
|
|
318
|
+
* **PWA (saved task):** A "Run Now" button is shown on each task card when the task is not already running. Clicking it sends a `task.run` request via NATS request-reply to the Host, which starts execution via the system scheduler (`systemctl --user start` on Linux, `schtasks /run` on Windows).
|
|
319
|
+
* **PWA (one-off session):** The session composer on the Sessions tab sends `task.run_oneoff` with `{ user_prompt, agent, yolo_mode }`. The host creates an ephemeral task, runs it, and returns `{ task_id, run_id }`; the PWA navigates to the run detail view.
|
|
307
320
|
* **CLI:** `palmier run <task-id>` executes the task directly (outside the system scheduler).
|
|
308
321
|
|
|
309
322
|
Both paths follow the same execution loop described in §5.2 (including confirmation checks if configured). The system scheduler prevents concurrent runs of the same task — if the service/task is already active, the start command is a no-op.
|
|
310
323
|
|
|
311
|
-
### 4.
|
|
324
|
+
### 4.5 Task Deletion
|
|
312
325
|
|
|
313
326
|
1. PWA sends `task.delete` via NATS request-reply.
|
|
314
327
|
|
|
@@ -366,7 +379,7 @@ When a task has a `command` field set, `palmier run` enters command-triggered mo
|
|
|
366
379
|
|
|
367
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.
|
|
368
381
|
|
|
369
|
-
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.
|
|
370
383
|
|
|
371
384
|
### 5.4 Failsafes & Constraints
|
|
372
385
|
|