palmier 0.7.7 → 0.7.8
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/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.js +16 -7
- package/dist/pending-requests.d.ts +30 -8
- package/dist/pending-requests.js +28 -15
- package/dist/pwa/assets/index-8cTctVnD.js +120 -0
- package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
- package/dist/pwa/assets/{web-CkWrlNwc.js → web-BNr628AV.js} +1 -1
- package/dist/pwa/assets/{web-lx34oBi7.js → web-DyQPewAi.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +12 -16
- package/dist/transports/http-transport.js +6 -3
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +66 -0
- 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/RunsView.tsx +48 -22
- package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
- package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
- package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
- package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/draftGuard.ts +24 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
- package/palmier-server/pwa/src/types.ts +1 -6
- package/palmier-server/spec.md +22 -9
- 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 +16 -7
- package/src/pending-requests.ts +47 -15
- package/src/rpc-handler.ts +13 -16
- package/src/transports/http-transport.ts +6 -3
- package/src/types.ts +1 -1
- 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
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
@@ -10,6 +11,37 @@ import RunsView from "../components/RunsView";
|
|
|
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,7 +51,7 @@ 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
57
|
// Resolve "latest" run ID to the actual run ID
|
|
@@ -48,16 +80,205 @@ export default function Dashboard() {
|
|
|
48
80
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
49
81
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
50
82
|
const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
|
|
83
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
84
|
+
const [hostPlatform, setHostPlatform] = useState<string | undefined>();
|
|
85
|
+
|
|
86
|
+
// Pending prompt state — owned by Dashboard because these modals must show
|
|
87
|
+
// regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
|
|
88
|
+
const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
|
|
89
|
+
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PermissionPrompt>>(new Map());
|
|
90
|
+
const [pendingInputs, setPendingInputs] = useState<Map<string, InputPrompt>>(new Map());
|
|
91
|
+
const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
|
|
51
92
|
|
|
52
|
-
// Register push subscription for the active host
|
|
53
93
|
usePushSubscription();
|
|
54
94
|
|
|
55
|
-
// Reset scroll when switching hosts
|
|
56
95
|
useEffect(() => {
|
|
57
96
|
window.scrollTo(0, 0);
|
|
58
97
|
}, [activeHostId]);
|
|
59
98
|
|
|
99
|
+
// host.info bootstrap: agents/version/platform + any prompts that were already
|
|
100
|
+
// pending when this PWA connected. Runs once per (host, connection).
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!connected) return;
|
|
103
|
+
request<{
|
|
104
|
+
agents?: AgentInfo[];
|
|
105
|
+
version?: string | null;
|
|
106
|
+
host_platform?: string;
|
|
107
|
+
capability_tokens?: Record<string, string | null>;
|
|
108
|
+
pending_prompts?: PendingPrompt[];
|
|
109
|
+
}>("host.info")
|
|
110
|
+
.then((result) => {
|
|
111
|
+
setAgents(result.agents ?? []);
|
|
112
|
+
setHostPlatform(result.host_platform);
|
|
113
|
+
setCapabilityTokens(result.capability_tokens ?? {});
|
|
114
|
+
setAgentLabels(result.agents ?? []);
|
|
115
|
+
const version = result.version ?? null;
|
|
116
|
+
setDaemonVersion(version);
|
|
117
|
+
setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
118
|
+
|
|
119
|
+
// Seed modal state from already-pending prompts.
|
|
120
|
+
const confirms = new Map<string, ConfirmPrompt>();
|
|
121
|
+
const perms = new Map<string, PermissionPrompt>();
|
|
122
|
+
const inputs = new Map<string, InputPrompt>();
|
|
123
|
+
const inputVals = new Map<string, string[]>();
|
|
124
|
+
for (const p of result.pending_prompts ?? []) {
|
|
125
|
+
if (p.type === "confirmation") {
|
|
126
|
+
confirms.set(p.key, {
|
|
127
|
+
description: p.meta?.description ?? "",
|
|
128
|
+
sessionName: p.meta?.session_name,
|
|
129
|
+
});
|
|
130
|
+
} else if (p.type === "permission") {
|
|
131
|
+
perms.set(p.key, {
|
|
132
|
+
permissions: (p.params as RequiredPermission[]) ?? [],
|
|
133
|
+
sessionName: p.meta?.session_name,
|
|
134
|
+
});
|
|
135
|
+
} else if (p.type === "input") {
|
|
136
|
+
const questions = (p.params as string[]) ?? p.meta?.input_questions ?? [];
|
|
137
|
+
inputs.set(p.key, {
|
|
138
|
+
questions,
|
|
139
|
+
description: p.meta?.description,
|
|
140
|
+
sessionName: p.meta?.session_name,
|
|
141
|
+
});
|
|
142
|
+
inputVals.set(p.key, new Array(questions.length).fill(""));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
setPendingConfirms(confirms);
|
|
146
|
+
setPendingPermissions(perms);
|
|
147
|
+
setPendingInputs(inputs);
|
|
148
|
+
setInputValues(inputVals);
|
|
149
|
+
})
|
|
150
|
+
.catch(() => { /* silent — update-required prompt guards the broken case */ });
|
|
151
|
+
}, [connected, activeHostId, request]);
|
|
152
|
+
|
|
153
|
+
// Always-on event subscription for modal lifecycle. Independent of which tab
|
|
154
|
+
// is active. Task-card status updates happen inside TaskListView while mounted.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!connected || !activeHostId) return;
|
|
157
|
+
const unsubscribe = subscribeEvents(activeHostId, (msg) => {
|
|
158
|
+
const tokens = msg.subject.split(".");
|
|
159
|
+
if (tokens.length < 3) return;
|
|
160
|
+
const taskId = tokens.slice(2).join(".");
|
|
161
|
+
|
|
162
|
+
let parsed: Record<string, unknown> = {};
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(new TextDecoder().decode(msg.data)) as Record<string, unknown>;
|
|
165
|
+
} catch { return; }
|
|
166
|
+
const eventType = parsed.event_type as string | undefined;
|
|
167
|
+
const sessionId = parsed.session_id as string | undefined;
|
|
168
|
+
|
|
169
|
+
if (eventType === "input-request" && sessionId) {
|
|
170
|
+
const questions = parsed.input_questions as string[] | undefined;
|
|
171
|
+
const sessionName = parsed.session_name as string | undefined;
|
|
172
|
+
const description = parsed.description as string | undefined;
|
|
173
|
+
if (questions?.length) {
|
|
174
|
+
setPendingInputs((prev) => {
|
|
175
|
+
if (prev.has(sessionId)) return prev;
|
|
176
|
+
const next = new Map(prev);
|
|
177
|
+
next.set(sessionId, { questions, description, sessionName });
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
setInputValues((prev) => {
|
|
181
|
+
if (prev.has(sessionId)) return prev;
|
|
182
|
+
const next = new Map(prev);
|
|
183
|
+
next.set(sessionId, new Array(questions.length).fill(""));
|
|
184
|
+
return next;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (eventType === "input-resolved" && sessionId) {
|
|
191
|
+
setPendingInputs((prev) => {
|
|
192
|
+
if (!prev.has(sessionId)) return prev;
|
|
193
|
+
const next = new Map(prev);
|
|
194
|
+
next.delete(sessionId);
|
|
195
|
+
return next;
|
|
196
|
+
});
|
|
197
|
+
setInputValues((prev) => {
|
|
198
|
+
const next = new Map(prev);
|
|
199
|
+
next.delete(sessionId);
|
|
200
|
+
return next;
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (eventType === "confirm-request" && sessionId) {
|
|
206
|
+
const description = parsed.description as string | undefined;
|
|
207
|
+
const sessionName = parsed.session_name as string | undefined;
|
|
208
|
+
if (description) {
|
|
209
|
+
setPendingConfirms((prev) => {
|
|
210
|
+
if (prev.has(sessionId)) return prev;
|
|
211
|
+
const next = new Map(prev);
|
|
212
|
+
next.set(sessionId, { description, sessionName });
|
|
213
|
+
return next;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (eventType === "confirm-resolved" && sessionId) {
|
|
220
|
+
setPendingConfirms((prev) => {
|
|
221
|
+
if (!prev.has(sessionId)) return prev;
|
|
222
|
+
const next = new Map(prev);
|
|
223
|
+
next.delete(sessionId);
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (eventType === "permission-request") {
|
|
230
|
+
const permissions = parsed.required_permissions as RequiredPermission[] | undefined;
|
|
231
|
+
const sessionName = parsed.session_name as string | undefined;
|
|
232
|
+
if (permissions?.length) {
|
|
233
|
+
setPendingPermissions((prev) => {
|
|
234
|
+
if (prev.has(taskId)) return prev;
|
|
235
|
+
const next = new Map(prev);
|
|
236
|
+
next.set(taskId, { permissions, sessionName });
|
|
237
|
+
return next;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (eventType === "permission-resolved") {
|
|
244
|
+
setPendingPermissions((prev) => {
|
|
245
|
+
if (!prev.has(taskId)) return prev;
|
|
246
|
+
const next = new Map(prev);
|
|
247
|
+
next.delete(taskId);
|
|
248
|
+
return next;
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return unsubscribe;
|
|
254
|
+
}, [connected, activeHostId, subscribeEvents]);
|
|
255
|
+
|
|
256
|
+
async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
|
|
257
|
+
try {
|
|
258
|
+
await request("task.user_input", { id: sessionId, value: [response] });
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error("[Dashboard] Failed to respond to confirmation:", err);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function respondToPermission(taskId: string, response: "granted" | "granted_all" | "aborted") {
|
|
265
|
+
try {
|
|
266
|
+
await request("task.user_input", { id: taskId, value: [response] });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error("[Dashboard] Failed to respond to permission request:", err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function respondToInput(sessionId: string, values: string[]) {
|
|
273
|
+
try {
|
|
274
|
+
await request("task.user_input", { id: sessionId, value: values });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error("[Dashboard] Failed to respond to input request:", err);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
60
280
|
function handleViewRun(taskId: string, runId?: string) {
|
|
281
|
+
if (!confirmLeaveDraft()) return;
|
|
61
282
|
if (runId) {
|
|
62
283
|
navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
|
|
63
284
|
} else {
|
|
@@ -78,7 +299,6 @@ export default function Dashboard() {
|
|
|
78
299
|
} catch {
|
|
79
300
|
// Expected: connection drops during daemon restart
|
|
80
301
|
}
|
|
81
|
-
// Daemon will restart; reload after it has time to come back up.
|
|
82
302
|
setTimeout(() => window.location.reload(), 10000);
|
|
83
303
|
}
|
|
84
304
|
|
|
@@ -116,7 +336,7 @@ export default function Dashboard() {
|
|
|
116
336
|
<div className="revoked-actions">
|
|
117
337
|
<button
|
|
118
338
|
className="btn btn-primary"
|
|
119
|
-
onClick={() => navigate("/pair")}
|
|
339
|
+
onClick={() => { if (confirmLeaveDraft()) navigate("/pair"); }}
|
|
120
340
|
>
|
|
121
341
|
Re-pair Device
|
|
122
342
|
</button>
|
|
@@ -132,18 +352,17 @@ export default function Dashboard() {
|
|
|
132
352
|
</div>
|
|
133
353
|
) : showTaskContent ? (
|
|
134
354
|
<>
|
|
135
|
-
|
|
355
|
+
{isTasksTab && !isRunDetail && (
|
|
136
356
|
<TaskListView
|
|
137
357
|
connected={connected}
|
|
138
358
|
hostId={activeHostId}
|
|
139
359
|
request={request}
|
|
140
360
|
subscribeEvents={subscribeEvents}
|
|
361
|
+
agents={agents}
|
|
362
|
+
hostPlatform={hostPlatform}
|
|
141
363
|
onViewRun={handleViewRun}
|
|
142
|
-
onUpdateRequired={setUpdateRequired}
|
|
143
|
-
onVersion={setDaemonVersion}
|
|
144
|
-
onCapabilityTokens={setCapabilityTokens}
|
|
145
364
|
/>
|
|
146
|
-
|
|
365
|
+
)}
|
|
147
366
|
{isRunDetail ? (
|
|
148
367
|
<RunDetailView
|
|
149
368
|
connected={connected}
|
|
@@ -153,14 +372,15 @@ export default function Dashboard() {
|
|
|
153
372
|
taskId={params.taskId!}
|
|
154
373
|
runId={decodeURIComponent(effectiveRunId!)}
|
|
155
374
|
/>
|
|
156
|
-
) :
|
|
375
|
+
) : !isTasksTab ? (
|
|
157
376
|
<RunsView
|
|
158
377
|
connected={connected}
|
|
159
378
|
hostId={activeHostId}
|
|
160
379
|
request={request}
|
|
161
380
|
subscribeEvents={subscribeEvents}
|
|
381
|
+
agents={agents}
|
|
162
382
|
filterTaskId={runsFilterTaskId}
|
|
163
|
-
onClearFilter={() => navigate("/
|
|
383
|
+
onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
|
|
164
384
|
/>
|
|
165
385
|
) : null}
|
|
166
386
|
</>
|
|
@@ -221,6 +441,109 @@ export default function Dashboard() {
|
|
|
221
441
|
)}
|
|
222
442
|
|
|
223
443
|
</div>
|
|
444
|
+
|
|
445
|
+
{createPortal(<>
|
|
446
|
+
{[...pendingConfirms.entries()].map(([sessionId, { description, sessionName }]) => (
|
|
447
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
448
|
+
<div className="confirm-modal">
|
|
449
|
+
<h2 className="confirm-modal-title">Confirmation Required</h2>
|
|
450
|
+
{sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
|
|
451
|
+
<p className="confirm-modal-message">{description}</p>
|
|
452
|
+
<div className="confirm-modal-actions">
|
|
453
|
+
<button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
|
|
454
|
+
Confirm
|
|
455
|
+
</button>
|
|
456
|
+
<button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
|
|
457
|
+
Abort
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
))}
|
|
463
|
+
|
|
464
|
+
{[...pendingPermissions.entries()].map(([taskId, { permissions, sessionName }]) => (
|
|
465
|
+
<div key={taskId} className="confirm-modal-overlay">
|
|
466
|
+
<div className="confirm-modal permission-modal">
|
|
467
|
+
<h2 className="confirm-modal-title">Permission Required</h2>
|
|
468
|
+
<p className="confirm-modal-message">
|
|
469
|
+
<strong>{sessionName || taskId}</strong>
|
|
470
|
+
</p>
|
|
471
|
+
<div className="permission-list">
|
|
472
|
+
{permissions.map((p, i) => (
|
|
473
|
+
<div key={i} className="permission-item">
|
|
474
|
+
<span className="permission-name">{p.name}</span>
|
|
475
|
+
{p.description && <span className="permission-desc">{p.description}</span>}
|
|
476
|
+
</div>
|
|
477
|
+
))}
|
|
478
|
+
</div>
|
|
479
|
+
<div className="permission-actions">
|
|
480
|
+
<button className="btn btn-primary" onClick={() => respondToPermission(taskId, "granted")}>
|
|
481
|
+
Allow Once
|
|
482
|
+
</button>
|
|
483
|
+
<button className="btn btn-secondary" onClick={() => respondToPermission(taskId, "granted_all")}>
|
|
484
|
+
Allow Always
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
<button
|
|
488
|
+
className="permission-abort-link"
|
|
489
|
+
onClick={() => respondToPermission(taskId, "aborted")}
|
|
490
|
+
>
|
|
491
|
+
Deny & Abort Task
|
|
492
|
+
</button>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
))}
|
|
496
|
+
|
|
497
|
+
{[...pendingInputs.entries()].map(([sessionId, { questions, description, sessionName }]) => {
|
|
498
|
+
const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
|
|
499
|
+
return (
|
|
500
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
501
|
+
<div className="confirm-modal input-modal">
|
|
502
|
+
<h2 className="confirm-modal-title">Input Required</h2>
|
|
503
|
+
{sessionName && <p className="confirm-modal-subtitle">{sessionName}</p>}
|
|
504
|
+
{description && <p className="confirm-modal-message">{description}</p>}
|
|
505
|
+
<div className="input-list">
|
|
506
|
+
{questions.map((desc: string, i: number) => (
|
|
507
|
+
<div key={i} className="input-item">
|
|
508
|
+
<label className="input-label">{desc}</label>
|
|
509
|
+
<input
|
|
510
|
+
type="text"
|
|
511
|
+
className="input-field"
|
|
512
|
+
value={values[i] ?? ""}
|
|
513
|
+
onChange={(e) => {
|
|
514
|
+
setInputValues((prev) => {
|
|
515
|
+
const next = new Map(prev);
|
|
516
|
+
const arr = [...(next.get(sessionId) ?? [])];
|
|
517
|
+
arr[i] = e.target.value;
|
|
518
|
+
next.set(sessionId, arr);
|
|
519
|
+
return next;
|
|
520
|
+
});
|
|
521
|
+
}}
|
|
522
|
+
autoFocus={i === 0}
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
))}
|
|
526
|
+
</div>
|
|
527
|
+
<div className="input-actions">
|
|
528
|
+
<button
|
|
529
|
+
className="btn btn-primary"
|
|
530
|
+
disabled={values.some((v) => !v.trim())}
|
|
531
|
+
onClick={() => respondToInput(sessionId, values)}
|
|
532
|
+
>
|
|
533
|
+
Submit
|
|
534
|
+
</button>
|
|
535
|
+
</div>
|
|
536
|
+
<button
|
|
537
|
+
className="permission-abort-link"
|
|
538
|
+
onClick={() => respondToInput(sessionId, ["aborted"])}
|
|
539
|
+
>
|
|
540
|
+
Cancel
|
|
541
|
+
</button>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
})}
|
|
546
|
+
</>, document.body)}
|
|
224
547
|
</div>
|
|
225
548
|
);
|
|
226
549
|
}
|
|
@@ -2,6 +2,7 @@ export interface AgentInfo {
|
|
|
2
2
|
key: string;
|
|
3
3
|
label: string;
|
|
4
4
|
supportsPermissions?: boolean;
|
|
5
|
+
supportsYolo?: boolean;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
|
|
@@ -41,12 +42,6 @@ export interface TaskStatus {
|
|
|
41
42
|
running_state: TaskRunningState;
|
|
42
43
|
/** UTC time in milliseconds since epoch */
|
|
43
44
|
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
45
|
}
|
|
51
46
|
|
|
52
47
|
export interface HistoryEntry {
|
package/palmier-server/spec.md
CHANGED
|
@@ -116,7 +116,8 @@ 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
122
|
| `task.create` | `user_prompt`, `agent`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if triggers present. |
|
|
122
123
|
| `task.update` | `id`, `user_prompt?`, `agent?`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. |
|
|
@@ -275,19 +276,30 @@ 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 `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload.
|
|
279
|
+
3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload. The PWA uses the response for bootstrap metadata (agents, host platform, version, capability tokens) that both the Sessions tab's composer and the Tasks tab depend on.
|
|
279
280
|
|
|
280
|
-
4. If the host responds
|
|
281
|
+
4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. If the host responds to `task.list`, it returns `{ tasks: [...] }` — an array of **flat task objects** (frontmatter fields spread to the top level) used by the Tasks tab. If the request fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
|
|
281
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
285
|
6. PWA discovers pending confirmations from the `task.list` RPC response — tasks with a pending confirmation, permission, or input request are shown as interactive modals. The PWA responds by calling the `task.user_input` RPC on the host, which resolves the in-memory pending request held by the serve daemon. The `run` process (blocked on an HTTP call to the serve daemon) receives the response and proceeds or exits accordingly.
|
|
285
286
|
|
|
286
|
-
### 4.2
|
|
287
|
+
### 4.2 UI Layout: Sessions & Tasks Tabs
|
|
287
288
|
|
|
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.
|
|
289
290
|
|
|
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). The "Describe your new task..." card opens the full task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when triggers are configured).
|
|
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 (`description`, `agent_name`, `task_name`) needed to render the modal cold, since the task list is no longer available at bootstrap.
|
|
297
|
+
|
|
298
|
+
### 4.3 Task Creation & Update
|
|
299
|
+
|
|
300
|
+
1. User clicks the "Describe your new task..." placeholder in the Tasks tab, which opens the task form directly.
|
|
301
|
+
|
|
302
|
+
2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Save" (no schedule) or "Schedule" (with triggers).
|
|
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
|
|
|
@@ -299,16 +311,17 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
299
311
|
|
|
300
312
|
7. **OS Integration:** Host translates triggers into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
|
|
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
|
|
package/src/agents/agent.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface AgentTool {
|
|
|
44
44
|
* If false, the permissions section is omitted from agent instructions. */
|
|
45
45
|
supportsPermissions: boolean;
|
|
46
46
|
|
|
47
|
+
/** Whether this agent supports yolo mode (auto-approve all tools). */
|
|
48
|
+
supportsYolo: boolean;
|
|
49
|
+
|
|
47
50
|
/** Detect whether the agent CLI is available and perform any agent-specific
|
|
48
51
|
* initialization. Returns true if the agent was detected and initialized successfully. */
|
|
49
52
|
init(): Promise<boolean>;
|
|
@@ -93,6 +96,7 @@ export interface DetectedAgent {
|
|
|
93
96
|
key: string;
|
|
94
97
|
label: string;
|
|
95
98
|
supportsPermissions: boolean;
|
|
99
|
+
supportsYolo: boolean;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
export async function detectAgents(): Promise<DetectedAgent[]> {
|
|
@@ -100,7 +104,7 @@ export async function detectAgents(): Promise<DetectedAgent[]> {
|
|
|
100
104
|
for (const [key, agent] of Object.entries(agentRegistry)) {
|
|
101
105
|
const label = agentLabels[key] ?? key;
|
|
102
106
|
const ok = await agent.init();
|
|
103
|
-
if (ok) detected.push({ key, label, supportsPermissions: agent.supportsPermissions });
|
|
107
|
+
if (ok) detected.push({ key, label, supportsPermissions: agent.supportsPermissions, supportsYolo: agent.supportsYolo });
|
|
104
108
|
}
|
|
105
109
|
return detected;
|
|
106
110
|
}
|
package/src/agents/aider.ts
CHANGED
package/src/agents/claude.ts
CHANGED
package/src/agents/cline.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class Cline implements AgentTool {
|
|
8
8
|
supportsPermissions = false;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "cline ", args: ["--yolo", "-p", prompt] };
|
|
11
12
|
}
|
package/src/agents/codex.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class CodexAgent implements AgentTool {
|
|
8
8
|
supportsPermissions = true;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "codex", args: ["exec", "--skip-git-repo-check", prompt] };
|
|
11
12
|
}
|
package/src/agents/copilot.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class CopilotAgent implements AgentTool {
|
|
8
8
|
supportsPermissions = false;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "copilot", args: ["-p", prompt] };
|
|
11
12
|
}
|
package/src/agents/cursor.ts
CHANGED
package/src/agents/deepagents.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class DeepAgents implements AgentTool {
|
|
8
8
|
supportsPermissions = false;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "deepagents", args: ["--non-interactive", prompt] };
|
|
11
12
|
}
|
package/src/agents/droid.ts
CHANGED
package/src/agents/gemini.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class GeminiAgent implements AgentTool {
|
|
8
8
|
supportsPermissions = true;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "gemini", args: ["--prompt", prompt] };
|
|
11
12
|
}
|
package/src/agents/goose.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class GooseAgent implements AgentTool {
|
|
8
8
|
supportsPermissions = false;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "goose", args: ["run", "--text", prompt] };
|
|
11
12
|
}
|
package/src/agents/hermes.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class Hermes implements AgentTool {
|
|
8
8
|
supportsPermissions = false;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "hermes", args: ["chat", "-q", prompt] };
|
|
11
12
|
}
|
package/src/agents/kimi.ts
CHANGED
package/src/agents/kiro.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
|
|
|
6
6
|
|
|
7
7
|
export class Kiro implements AgentTool {
|
|
8
8
|
supportsPermissions = false;
|
|
9
|
+
supportsYolo = true;
|
|
9
10
|
getPromptCommandLine(prompt: string): CommandLine {
|
|
10
11
|
return { command: "kiro-cli", args: ["--no-interactive", prompt] };
|
|
11
12
|
}
|