palmier 0.7.6 → 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/agents/shared-prompt.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/pair.js +3 -3
- package/dist/commands/run.js +4 -4
- package/dist/commands/serve.js +1 -1
- package/dist/config.js +2 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/events.js +1 -1
- package/dist/mcp-tools.js +79 -7
- package/dist/nats-client.d.ts +1 -1
- package/dist/nats-client.js +6 -3
- 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-DnuoxUd4.js → web-BNr628AV.js} +1 -1
- package/dist/pwa/assets/{web-7raT3zOZ.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 +4 -1
- package/package.json +1 -1
- package/palmier-server/PRODUCTION.md +31 -28
- package/palmier-server/README.md +35 -5
- package/palmier-server/nats.conf +9 -5
- package/palmier-server/package.json +2 -1
- package/palmier-server/pnpm-lock.yaml +6 -0
- 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 +65 -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/contexts/HostConnectionContext.tsx +9 -5
- package/palmier-server/pwa/src/draftGuard.ts +24 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
- package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
- package/palmier-server/pwa/src/types.ts +1 -6
- package/palmier-server/server/package.json +3 -1
- package/palmier-server/server/src/index.ts +83 -2
- package/palmier-server/server/src/nats-jwt.ts +299 -0
- package/palmier-server/server/src/nats-setup.ts +48 -0
- package/palmier-server/server/src/nats.ts +12 -4
- package/palmier-server/server/src/routes/device.ts +24 -0
- package/palmier-server/server/src/routes/hosts.ts +13 -2
- package/palmier-server/spec.md +28 -14
- 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/agents/shared-prompt.ts +1 -1
- package/src/commands/init.ts +7 -5
- package/src/commands/pair.ts +3 -3
- package/src/commands/run.ts +4 -4
- package/src/commands/serve.ts +1 -1
- package/src/config.ts +2 -2
- package/src/device-capabilities.ts +1 -0
- package/src/events.ts +1 -1
- package/src/mcp-tools.ts +83 -7
- package/src/nats-client.ts +10 -3
- 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 +4 -3
- package/test/agent-instructions.test.ts +10 -10
- package/test/pairing.test.ts +2 -2
- package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
- package/dist/pwa/assets/index-uSwkmHBs.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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
|
-
import { connect, StringCodec } from "nats.ws";
|
|
3
|
+
import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
|
|
4
4
|
import { Capacitor } from "@capacitor/core";
|
|
5
5
|
import { Preferences } from "@capacitor/preferences";
|
|
6
6
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
@@ -54,12 +54,15 @@ export default function PairHost() {
|
|
|
54
54
|
// Server mode — pair via NATS
|
|
55
55
|
const configRes = await fetch(`${SERVER_URL}/api/config`);
|
|
56
56
|
if (!configRes.ok) throw new Error("Failed to fetch server config");
|
|
57
|
-
const config = await configRes.json() as { natsWsUrl: string;
|
|
57
|
+
const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
58
58
|
if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
|
|
59
59
|
|
|
60
60
|
const nc = await connect({
|
|
61
61
|
servers: config.natsWsUrl,
|
|
62
|
-
|
|
62
|
+
authenticator: jwtAuthenticator(
|
|
63
|
+
config.natsJwt,
|
|
64
|
+
new TextEncoder().encode(config.natsNkeySeed),
|
|
65
|
+
),
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
const sc = StringCodec();
|
|
@@ -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 {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "tsx watch src/index.ts",
|
|
7
7
|
"build": "tsc",
|
|
8
|
-
"start": "node dist/index.js"
|
|
8
|
+
"start": "node dist/index.js",
|
|
9
|
+
"nats-setup": "tsx src/nats-setup.ts"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"bcrypt": "^5.1.1",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"helmet": "^8.0.0",
|
|
17
18
|
"jsonwebtoken": "^9.0.2",
|
|
18
19
|
"nats": "^2.29.1",
|
|
20
|
+
"nkeys.js": "^1.1.0",
|
|
19
21
|
"pg": "^8.13.1",
|
|
20
22
|
"uuid": "^11.0.5",
|
|
21
23
|
"web-push": "^3.6.7"
|
|
@@ -13,6 +13,7 @@ import fcmRoutes from "./routes/fcm.js";
|
|
|
13
13
|
import deviceRoutes from "./routes/device.js";
|
|
14
14
|
import { notifyClients } from "./notify.js";
|
|
15
15
|
import { sendFcmToClients, sendFcmToDevice } from "./fcm.js";
|
|
16
|
+
import { createPairingCredentials, createPwaCredentials } from "./nats-jwt.js";
|
|
16
17
|
|
|
17
18
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
18
19
|
|
|
@@ -446,6 +447,61 @@ async function main(): Promise<void> {
|
|
|
446
447
|
}
|
|
447
448
|
})();
|
|
448
449
|
|
|
450
|
+
// Subscribe to email requests from hosts
|
|
451
|
+
(async () => {
|
|
452
|
+
try {
|
|
453
|
+
const conn = await getNatsConnection();
|
|
454
|
+
const sub = conn.subscribe("host.*.fcm.email");
|
|
455
|
+
console.log("Listening for FCM email requests");
|
|
456
|
+
|
|
457
|
+
for await (const msg of sub) {
|
|
458
|
+
try {
|
|
459
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
460
|
+
hostId: string;
|
|
461
|
+
requestId: string;
|
|
462
|
+
fcmToken?: string;
|
|
463
|
+
[key: string]: string | undefined;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
467
|
+
if (data.hostId !== subjectHostId) {
|
|
468
|
+
if (msg.reply) {
|
|
469
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const fcmPayload: Record<string, string> = {
|
|
475
|
+
type: "send-email",
|
|
476
|
+
requestId: data.requestId,
|
|
477
|
+
hostId: data.hostId,
|
|
478
|
+
};
|
|
479
|
+
for (const key of ["to", "subject", "body", "cc", "bcc"]) {
|
|
480
|
+
if (data[key]) fcmPayload[key] = data[key]!;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
console.log(`[FCM] Sending email request for host ${data.hostId}`);
|
|
484
|
+
if (data.fcmToken) {
|
|
485
|
+
await sendFcmToDevice(data.fcmToken, fcmPayload);
|
|
486
|
+
} else {
|
|
487
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (msg.reply) {
|
|
491
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
492
|
+
}
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error("[FCM] Error handling email request:", err);
|
|
495
|
+
if (msg.reply) {
|
|
496
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error("Failed to subscribe to FCM email requests:", err);
|
|
502
|
+
}
|
|
503
|
+
})();
|
|
504
|
+
|
|
449
505
|
// Subscribe to battery requests from hosts
|
|
450
506
|
(async () => {
|
|
451
507
|
try {
|
|
@@ -569,11 +625,36 @@ async function main(): Promise<void> {
|
|
|
569
625
|
app.use("/api/fcm", fcmRoutes);
|
|
570
626
|
app.use("/api/device", deviceRoutes);
|
|
571
627
|
|
|
572
|
-
// Public NATS config endpoint
|
|
628
|
+
// Public NATS config endpoint — returns pairing-only credentials.
|
|
629
|
+
// These can only publish to pair.* subjects (no RPC, no event subscriptions).
|
|
573
630
|
app.get("/api/config", (_req, res) => {
|
|
631
|
+
const accountSeed = process.env.NATS_ACCOUNT_SEED;
|
|
632
|
+
if (!accountSeed) {
|
|
633
|
+
res.status(500).json({ error: "Server NATS auth not configured" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const creds = createPairingCredentials(accountSeed);
|
|
637
|
+
res.json({
|
|
638
|
+
natsWsUrl: process.env.NATS_WS_URL || "",
|
|
639
|
+
natsJwt: creds.jwt,
|
|
640
|
+
natsNkeySeed: creds.nkeySeed,
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Host-scoped NATS credentials — returns JWT scoped to a single host's subjects.
|
|
645
|
+
// Called by the PWA after pairing to get credentials for RPC + event subscriptions.
|
|
646
|
+
app.get("/api/nats-credentials/:hostId", (req, res) => {
|
|
647
|
+
const accountSeed = process.env.NATS_ACCOUNT_SEED;
|
|
648
|
+
if (!accountSeed) {
|
|
649
|
+
res.status(500).json({ error: "Server NATS auth not configured" });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const { hostId } = req.params;
|
|
653
|
+
const creds = createPwaCredentials(accountSeed, hostId);
|
|
574
654
|
res.json({
|
|
575
655
|
natsWsUrl: process.env.NATS_WS_URL || "",
|
|
576
|
-
|
|
656
|
+
natsJwt: creds.jwt,
|
|
657
|
+
natsNkeySeed: creds.nkeySeed,
|
|
577
658
|
});
|
|
578
659
|
});
|
|
579
660
|
|