palmier 0.6.5 → 0.6.7
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-instructions.md +28 -6
- package/dist/agents/agent.js +6 -3
- package/dist/agents/hermes.d.ts +9 -0
- package/dist/agents/hermes.js +35 -0
- package/dist/commands/plan-generation.md +1 -0
- package/dist/commands/run.js +3 -3
- package/dist/location-device.d.ts +8 -0
- package/dist/location-device.js +32 -0
- package/dist/mcp-handler.d.ts +8 -0
- package/dist/mcp-handler.js +110 -0
- package/dist/mcp-tools.d.ts +22 -0
- package/dist/mcp-tools.js +152 -0
- package/dist/pwa/assets/{index-DhvJN8ie.css → index-DAI3J-jU.css} +1 -1
- package/dist/pwa/assets/index-RrJvjqz9.js +118 -0
- package/dist/pwa/assets/web-DQteXlI7.js +1 -0
- package/dist/pwa/assets/web-EzNEHXEh.js +1 -0
- package/dist/pwa/index.html +3 -3
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +23 -15
- package/dist/transports/http-transport.js +61 -129
- package/package.json +1 -1
- package/palmier-server/README.md +6 -1
- package/palmier-server/package.json +7 -1
- package/palmier-server/pnpm-lock.yaml +1025 -1
- package/palmier-server/pwa/index.html +1 -1
- package/palmier-server/pwa/package.json +3 -0
- package/palmier-server/pwa/src/App.css +55 -0
- package/palmier-server/pwa/src/api.ts +8 -2
- package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
- package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
- package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
- package/palmier-server/pwa/src/service-worker.ts +7 -7
- package/palmier-server/server/.env.example +4 -0
- package/palmier-server/server/package.json +1 -0
- package/palmier-server/server/src/db.ts +10 -0
- package/palmier-server/server/src/fcm.ts +74 -0
- package/palmier-server/server/src/index.ts +101 -21
- package/palmier-server/server/src/notify.ts +34 -0
- package/palmier-server/server/src/push.ts +1 -1
- package/palmier-server/server/src/routes/fcm.ts +64 -0
- package/palmier-server/server/src/routes/push.ts +6 -5
- package/palmier-server/spec.md +4 -2
- package/src/agents/agent-instructions.md +28 -6
- package/src/agents/agent.ts +6 -3
- package/src/agents/hermes.ts +38 -0
- package/src/commands/plan-generation.md +1 -0
- package/src/commands/run.ts +3 -3
- package/src/location-device.ts +35 -0
- package/src/mcp-handler.ts +133 -0
- package/src/mcp-tools.ts +182 -0
- package/src/rpc-handler.ts +24 -15
- package/src/transports/http-transport.ts +58 -128
- package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
|
@@ -33,14 +33,15 @@ self.addEventListener("push", (event) => {
|
|
|
33
33
|
// Silent dismiss: close matching notification without showing a new one
|
|
34
34
|
if (type === "confirm-dismiss" || type === "permission-dismiss" || type === "input-dismiss") {
|
|
35
35
|
const data = payload.data ?? payload;
|
|
36
|
-
const taskId = (data as Record<string, unknown>).task_id;
|
|
37
36
|
const dataHostId = (data as Record<string, unknown>).host_id;
|
|
37
|
+
const requestId = (data as Record<string, unknown>).session_id;
|
|
38
|
+
const taskId = (data as Record<string, unknown>).task_id; // permission-dismiss still uses task_id
|
|
38
39
|
event.waitUntil(
|
|
39
40
|
self.registration.getNotifications().then((notifications) => {
|
|
40
41
|
for (const n of notifications) {
|
|
41
|
-
if (n.data?.
|
|
42
|
-
|
|
43
|
-
}
|
|
42
|
+
if (n.data?.host_id !== dataHostId) continue;
|
|
43
|
+
if (requestId && n.data?.session_id === requestId) { n.close(); continue; }
|
|
44
|
+
if (taskId && n.data?.task_id === taskId) { n.close(); } // permission only
|
|
44
45
|
}
|
|
45
46
|
})
|
|
46
47
|
);
|
|
@@ -85,7 +86,7 @@ self.addEventListener("notificationclick", (event) => {
|
|
|
85
86
|
const data = notification.data ?? {};
|
|
86
87
|
const action = event.action;
|
|
87
88
|
|
|
88
|
-
if (action && data.type === "confirm" && data.
|
|
89
|
+
if (action && data.type === "confirm" && data.session_id && data.host_id) {
|
|
89
90
|
const response = action === "confirm" ? "confirmed" : "aborted";
|
|
90
91
|
|
|
91
92
|
event.waitUntil(
|
|
@@ -93,8 +94,7 @@ self.addEventListener("notificationclick", (event) => {
|
|
|
93
94
|
method: "POST",
|
|
94
95
|
headers: { "Content-Type": "application/json" },
|
|
95
96
|
body: JSON.stringify({
|
|
96
|
-
|
|
97
|
-
task_id: data.task_id,
|
|
97
|
+
session_id: data.session_id,
|
|
98
98
|
host_id: data.host_id,
|
|
99
99
|
response,
|
|
100
100
|
}),
|
|
@@ -14,3 +14,7 @@ NATS_TOKEN=
|
|
|
14
14
|
VAPID_PUBLIC_KEY=
|
|
15
15
|
VAPID_PRIVATE_KEY=
|
|
16
16
|
VAPID_MAILTO=mailto:admin@example.com
|
|
17
|
+
|
|
18
|
+
# Firebase Admin SDK (for FCM push to Android devices)
|
|
19
|
+
# Download from: Firebase Console → Project Settings → Service accounts → Generate new private key
|
|
20
|
+
GOOGLE_APPLICATION_CREDENTIALS=./palmier-firebase-adminsdk.json
|
|
@@ -27,6 +27,16 @@ export async function initDb(): Promise<void> {
|
|
|
27
27
|
UNIQUE(host_id, endpoint)
|
|
28
28
|
);
|
|
29
29
|
`);
|
|
30
|
+
await client.query(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS fcm_tokens (
|
|
32
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
33
|
+
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
|
34
|
+
fcm_token TEXT NOT NULL,
|
|
35
|
+
device_label VARCHAR(255),
|
|
36
|
+
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
37
|
+
UNIQUE(host_id, fcm_token)
|
|
38
|
+
);
|
|
39
|
+
`);
|
|
30
40
|
console.log("Database tables initialized.");
|
|
31
41
|
} finally {
|
|
32
42
|
client.release();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import admin from "firebase-admin";
|
|
2
|
+
import { pool } from "./db.js";
|
|
3
|
+
|
|
4
|
+
let initialized = false;
|
|
5
|
+
|
|
6
|
+
function ensureInitialized(): boolean {
|
|
7
|
+
if (initialized) return true;
|
|
8
|
+
|
|
9
|
+
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
10
|
+
console.warn("GOOGLE_APPLICATION_CREDENTIALS not set. FCM will not work.");
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
admin.initializeApp({
|
|
15
|
+
credential: admin.credential.applicationDefault(),
|
|
16
|
+
});
|
|
17
|
+
initialized = true;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function sendFcmToClients(
|
|
22
|
+
hostId: string,
|
|
23
|
+
data: Record<string, string>
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
if (!ensureInitialized()) {
|
|
26
|
+
console.warn("[FCM] Not initialized, skipping message for host", hostId);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = await pool.query(
|
|
31
|
+
"SELECT fcm_token FROM fcm_tokens WHERE host_id = $1",
|
|
32
|
+
[hostId]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (result.rows.length === 0) {
|
|
36
|
+
console.warn(`[FCM] No FCM tokens registered for host ${hostId}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sendPromises = result.rows.map(async (row) => {
|
|
41
|
+
try {
|
|
42
|
+
await admin.messaging().send({
|
|
43
|
+
token: row.fcm_token,
|
|
44
|
+
data,
|
|
45
|
+
});
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
if (
|
|
48
|
+
err.code === "messaging/registration-token-not-registered" ||
|
|
49
|
+
err.code === "messaging/invalid-registration-token"
|
|
50
|
+
) {
|
|
51
|
+
await pool.query(
|
|
52
|
+
"DELETE FROM fcm_tokens WHERE host_id = $1 AND fcm_token = $2",
|
|
53
|
+
[hostId, row.fcm_token]
|
|
54
|
+
);
|
|
55
|
+
console.log(`[FCM] Removed stale token for host ${hostId}`);
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`[FCM] Failed to send to token:`, err.message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await Promise.allSettled(sendPromises);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function sendFcmToDevice(
|
|
66
|
+
fcmToken: string,
|
|
67
|
+
data: Record<string, string>
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (!ensureInitialized()) {
|
|
70
|
+
throw new Error("FCM not initialized");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await admin.messaging().send({ token: fcmToken, data });
|
|
74
|
+
}
|
|
@@ -5,12 +5,13 @@ import express from "express";
|
|
|
5
5
|
import helmet from "helmet";
|
|
6
6
|
import { pool, initDb } from "./db.js";
|
|
7
7
|
import { connectNats, getNatsConnection } from "./nats.js";
|
|
8
|
-
import { sendPushToHost } from "./push.js";
|
|
9
|
-
|
|
10
8
|
import { StringCodec } from "nats";
|
|
11
9
|
|
|
12
10
|
import hostsRoutes from "./routes/hosts.js";
|
|
13
11
|
import pushRoutes from "./routes/push.js";
|
|
12
|
+
import fcmRoutes from "./routes/fcm.js";
|
|
13
|
+
import { notifyClients } from "./notify.js";
|
|
14
|
+
import { sendFcmToClients, sendFcmToDevice } from "./fcm.js";
|
|
14
15
|
|
|
15
16
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
16
17
|
|
|
@@ -42,28 +43,34 @@ async function main(): Promise<void> {
|
|
|
42
43
|
running_state?: string;
|
|
43
44
|
name?: string;
|
|
44
45
|
run_id?: string;
|
|
46
|
+
session_id?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
agent_name?: string;
|
|
45
49
|
required_permissions?: Array<{ name: string; description: string }>;
|
|
46
|
-
|
|
50
|
+
input_questions?: string[];
|
|
47
51
|
result_file?: string;
|
|
48
52
|
};
|
|
49
53
|
|
|
50
54
|
if (data.event_type === "confirm-request") {
|
|
51
|
-
await
|
|
55
|
+
await notifyClients(hostId, {
|
|
52
56
|
type: "confirm",
|
|
53
|
-
|
|
57
|
+
title: "Confirmation Required",
|
|
58
|
+
body: data.description || "A task requires confirmation to run.",
|
|
54
59
|
host_id: hostId,
|
|
60
|
+
session_id: data.session_id,
|
|
61
|
+
agent_name: data.agent_name,
|
|
55
62
|
});
|
|
56
63
|
} else if (data.event_type === "confirm-resolved") {
|
|
57
|
-
await
|
|
64
|
+
await notifyClients(hostId, {
|
|
58
65
|
type: "confirm-dismiss",
|
|
59
|
-
task_id: taskId,
|
|
60
66
|
host_id: hostId,
|
|
67
|
+
session_id: data.session_id,
|
|
61
68
|
});
|
|
62
69
|
} else if (data.event_type === "permission-request") {
|
|
63
70
|
const taskLabel = data.name
|
|
64
71
|
? data.name.length > 60 ? data.name.slice(0, 60) + "…" : data.name
|
|
65
72
|
: "A task";
|
|
66
|
-
await
|
|
73
|
+
await notifyClients(hostId, {
|
|
67
74
|
type: "permission",
|
|
68
75
|
title: "Permission Required",
|
|
69
76
|
body: `${taskLabel} needs additional permissions to continue.`,
|
|
@@ -71,27 +78,25 @@ async function main(): Promise<void> {
|
|
|
71
78
|
host_id: hostId,
|
|
72
79
|
});
|
|
73
80
|
} else if (data.event_type === "permission-resolved") {
|
|
74
|
-
await
|
|
81
|
+
await notifyClients(hostId, {
|
|
75
82
|
type: "permission-dismiss",
|
|
76
83
|
task_id: taskId,
|
|
77
84
|
host_id: hostId,
|
|
78
85
|
});
|
|
79
86
|
} else if (data.event_type === "input-request") {
|
|
80
|
-
|
|
81
|
-
? data.name.length > 60 ? data.name.slice(0, 60) + "…" : data.name
|
|
82
|
-
: "A task";
|
|
83
|
-
await sendPushToHost(hostId, {
|
|
87
|
+
await notifyClients(hostId, {
|
|
84
88
|
type: "input",
|
|
85
89
|
title: "Input Required",
|
|
86
|
-
body:
|
|
87
|
-
task_id: taskId,
|
|
90
|
+
body: "A task needs your input to continue.",
|
|
88
91
|
host_id: hostId,
|
|
92
|
+
session_id: data.session_id,
|
|
93
|
+
agent_name: data.agent_name,
|
|
89
94
|
});
|
|
90
95
|
} else if (data.event_type === "input-resolved") {
|
|
91
|
-
await
|
|
96
|
+
await notifyClients(hostId, {
|
|
92
97
|
type: "input-dismiss",
|
|
93
|
-
task_id: taskId,
|
|
94
98
|
host_id: hostId,
|
|
99
|
+
session_id: data.session_id,
|
|
95
100
|
});
|
|
96
101
|
} else if (data.event_type === "report-generated" || (data.event_type === "running-state" && data.running_state === "failed")) {
|
|
97
102
|
const label = data.name;
|
|
@@ -100,7 +105,7 @@ async function main(): Promise<void> {
|
|
|
100
105
|
: "Task";
|
|
101
106
|
const isFailure = data.running_state === "failed";
|
|
102
107
|
const body = isFailure ? `${taskLabel} — failed` : `${taskLabel} — report ready`;
|
|
103
|
-
await
|
|
108
|
+
await notifyClients(hostId, {
|
|
104
109
|
type: isFailure ? "fail" : "complete",
|
|
105
110
|
title: "Palmier",
|
|
106
111
|
body,
|
|
@@ -132,7 +137,8 @@ async function main(): Promise<void> {
|
|
|
132
137
|
hostId: string;
|
|
133
138
|
title: string;
|
|
134
139
|
body: string;
|
|
135
|
-
|
|
140
|
+
session_id?: string;
|
|
141
|
+
agent_name?: string;
|
|
136
142
|
};
|
|
137
143
|
|
|
138
144
|
// Validate hostId in subject matches payload
|
|
@@ -144,12 +150,17 @@ async function main(): Promise<void> {
|
|
|
144
150
|
continue;
|
|
145
151
|
}
|
|
146
152
|
|
|
153
|
+
// If no agent_name, session_id is a taskId — include as task_id for deep-linking
|
|
154
|
+
const isTask = data.session_id && !data.agent_name;
|
|
155
|
+
|
|
147
156
|
console.log(`[Push] Sending notification for host ${data.hostId}`);
|
|
148
|
-
await
|
|
157
|
+
await notifyClients(data.hostId, {
|
|
149
158
|
type: "notification",
|
|
159
|
+
host_id: data.hostId,
|
|
150
160
|
title: data.title,
|
|
151
161
|
body: data.body,
|
|
152
|
-
...(
|
|
162
|
+
...(isTask ? { task_id: data.session_id } : {}),
|
|
163
|
+
agent_name: data.agent_name,
|
|
153
164
|
});
|
|
154
165
|
|
|
155
166
|
if (msg.reply) {
|
|
@@ -167,19 +178,88 @@ async function main(): Promise<void> {
|
|
|
167
178
|
}
|
|
168
179
|
})();
|
|
169
180
|
|
|
181
|
+
// Subscribe to FCM geolocation requests from hosts
|
|
182
|
+
(async () => {
|
|
183
|
+
try {
|
|
184
|
+
const conn = await getNatsConnection();
|
|
185
|
+
const sub = conn.subscribe("host.*.fcm.geolocation");
|
|
186
|
+
console.log("Listening for FCM geolocation requests");
|
|
187
|
+
|
|
188
|
+
for await (const msg of sub) {
|
|
189
|
+
try {
|
|
190
|
+
const data = JSON.parse(sc.decode(msg.data)) as {
|
|
191
|
+
hostId: string;
|
|
192
|
+
requestId: string;
|
|
193
|
+
fcmToken?: string;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const subjectHostId = msg.subject.split(".")[1];
|
|
197
|
+
if (data.hostId !== subjectHostId) {
|
|
198
|
+
if (msg.reply) {
|
|
199
|
+
msg.respond(sc.encode(JSON.stringify({ error: "hostId mismatch" })));
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fcmPayload = {
|
|
205
|
+
type: "geolocation-request",
|
|
206
|
+
requestId: data.requestId,
|
|
207
|
+
hostId: data.hostId,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
console.log(`[FCM] Sending geolocation request for host ${data.hostId}`);
|
|
211
|
+
if (data.fcmToken) {
|
|
212
|
+
await sendFcmToDevice(data.fcmToken, fcmPayload);
|
|
213
|
+
} else {
|
|
214
|
+
await sendFcmToClients(data.hostId, fcmPayload);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (msg.reply) {
|
|
218
|
+
msg.respond(sc.encode(JSON.stringify({ ok: true })));
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error("[FCM] Error handling geolocation request:", err);
|
|
222
|
+
if (msg.reply) {
|
|
223
|
+
msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error("Failed to subscribe to FCM geolocation requests:", err);
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
|
|
170
232
|
// Create Express app
|
|
171
233
|
const app = express();
|
|
172
234
|
|
|
173
235
|
app.use(
|
|
174
236
|
helmet({
|
|
175
237
|
contentSecurityPolicy: false,
|
|
238
|
+
crossOriginResourcePolicy: false,
|
|
176
239
|
})
|
|
177
240
|
);
|
|
241
|
+
|
|
242
|
+
// CORS for Capacitor Android app (requests from capacitor://localhost)
|
|
243
|
+
app.use((req, res, next) => {
|
|
244
|
+
const origin = req.headers.origin;
|
|
245
|
+
if (origin && (origin.startsWith("capacitor://") || origin === "https://localhost" || origin.startsWith("http://localhost"))) {
|
|
246
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
247
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
248
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
249
|
+
}
|
|
250
|
+
if (req.method === "OPTIONS") {
|
|
251
|
+
res.sendStatus(204);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
next();
|
|
255
|
+
});
|
|
256
|
+
|
|
178
257
|
app.use(express.json());
|
|
179
258
|
|
|
180
259
|
// Mount routes
|
|
181
260
|
app.use("/api/hosts", hostsRoutes);
|
|
182
261
|
app.use("/api/push", pushRoutes);
|
|
262
|
+
app.use("/api/fcm", fcmRoutes);
|
|
183
263
|
|
|
184
264
|
// Public NATS config endpoint (used by PWA for pairing)
|
|
185
265
|
app.get("/api/config", (_req, res) => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { sendPushToClients } from "./push.js";
|
|
2
|
+
import { sendFcmToClients } from "./fcm.js";
|
|
3
|
+
|
|
4
|
+
export interface NotificationPayload {
|
|
5
|
+
type: string;
|
|
6
|
+
host_id: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
body?: string;
|
|
9
|
+
task_id?: string;
|
|
10
|
+
session_id?: string;
|
|
11
|
+
run_id?: string;
|
|
12
|
+
result_file?: string;
|
|
13
|
+
agent_name?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stringifyPayload(payload: NotificationPayload): Record<string, string> {
|
|
17
|
+
const result: Record<string, string> = {};
|
|
18
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
19
|
+
if (value !== undefined && value !== null) {
|
|
20
|
+
result[key] = String(value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function notifyClients(
|
|
27
|
+
hostId: string,
|
|
28
|
+
payload: NotificationPayload
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
await Promise.allSettled([
|
|
31
|
+
sendPushToClients(hostId, payload),
|
|
32
|
+
sendFcmToClients(hostId, stringifyPayload(payload)),
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import type { Router as RouterType } from "express";
|
|
3
|
+
import { pool } from "../db.js";
|
|
4
|
+
import { getNatsConnection } from "../nats.js";
|
|
5
|
+
import { StringCodec } from "nats";
|
|
6
|
+
|
|
7
|
+
const router: RouterType = Router();
|
|
8
|
+
|
|
9
|
+
// POST /api/fcm/register - Register or refresh an FCM token for a host
|
|
10
|
+
router.post("/register", async (req: Request, res: Response) => {
|
|
11
|
+
try {
|
|
12
|
+
const { hostId, fcmToken, deviceLabel } = req.body;
|
|
13
|
+
|
|
14
|
+
if (!hostId || !fcmToken) {
|
|
15
|
+
res.status(400).json({ error: "hostId and fcmToken are required" });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await pool.query(
|
|
20
|
+
`INSERT INTO fcm_tokens (host_id, fcm_token, device_label, updated_at)
|
|
21
|
+
VALUES ($1, $2, $3, NOW())
|
|
22
|
+
ON CONFLICT (host_id, fcm_token)
|
|
23
|
+
DO UPDATE SET device_label = EXCLUDED.device_label, updated_at = NOW()`,
|
|
24
|
+
[hostId, fcmToken, deviceLabel || null]
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
res.status(201).json({ message: "FCM token registered" });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error("FCM register error:", err);
|
|
30
|
+
res.status(500).json({ error: "Internal server error" });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// POST /api/fcm/geolocation-response - Receive location from Android device, forward via NATS
|
|
35
|
+
router.post("/geolocation-response", async (req: Request, res: Response) => {
|
|
36
|
+
try {
|
|
37
|
+
const { requestId, hostId, latitude, longitude, accuracy, timestamp, error } = req.body;
|
|
38
|
+
|
|
39
|
+
if (!requestId || !hostId) {
|
|
40
|
+
res.status(400).json({ error: "requestId and hostId are required" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const conn = await getNatsConnection();
|
|
45
|
+
const sc = StringCodec();
|
|
46
|
+
const subject = `host.${hostId}.geolocation.${requestId}`;
|
|
47
|
+
|
|
48
|
+
if (error) {
|
|
49
|
+
conn.publish(subject, sc.encode(JSON.stringify({ error })));
|
|
50
|
+
} else {
|
|
51
|
+
conn.publish(
|
|
52
|
+
subject,
|
|
53
|
+
sc.encode(JSON.stringify({ latitude, longitude, accuracy, timestamp }))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
res.json({ message: "Location forwarded" });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error("FCM geolocation-response error:", err);
|
|
60
|
+
res.status(500).json({ error: "Internal server error" });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export default router;
|
|
@@ -66,14 +66,15 @@ router.get("/vapid-key", (_req, res: Response) => {
|
|
|
66
66
|
res.json({ publicKey });
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
// POST /api/push/respond - Respond to a pending
|
|
69
|
+
// POST /api/push/respond - Respond to a pending request via NATS request-reply
|
|
70
70
|
router.post("/respond", async (req: Request, res: Response) => {
|
|
71
71
|
try {
|
|
72
|
-
const { task_id, host_id, response } = req.body;
|
|
72
|
+
const { task_id, session_id, host_id, response } = req.body;
|
|
73
|
+
const id = session_id || task_id;
|
|
73
74
|
|
|
74
|
-
if (!
|
|
75
|
+
if (!id || !host_id || !response) {
|
|
75
76
|
res.status(400).json({
|
|
76
|
-
error: "
|
|
77
|
+
error: "host_id, response, and session_id (or task_id for permissions) are required",
|
|
77
78
|
});
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
@@ -81,7 +82,7 @@ router.post("/respond", async (req: Request, res: Response) => {
|
|
|
81
82
|
const conn = await getNatsConnection();
|
|
82
83
|
const sc = StringCodec();
|
|
83
84
|
const subject = `host.${host_id}.rpc.task.user_input`;
|
|
84
|
-
const payload = sc.encode(JSON.stringify({ id
|
|
85
|
+
const payload = sc.encode(JSON.stringify({ id, value: [response] }));
|
|
85
86
|
|
|
86
87
|
const reply = await conn.request(subject, payload, { timeout: 5000 });
|
|
87
88
|
const result = JSON.parse(sc.decode(reply.data));
|
package/palmier-server/spec.md
CHANGED
|
@@ -12,9 +12,11 @@ The host supports **Linux** (systemd) and **Windows** (Task Scheduler for both d
|
|
|
12
12
|
|
|
13
13
|
### 1.2 Components
|
|
14
14
|
|
|
15
|
-
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Localhost-only HTTP endpoints (`/notify`, `/request-input`, `/request-confirmation`, `/request-permission`) are used by agents and the `palmier run` process for interactive flows via held HTTP connections. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPlanGenerationCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
|
|
15
|
+
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts a local HTTP server (bound to `127.0.0.1` by default, or `0.0.0.0` if LAN mode is enabled) alongside the NATS transport. Localhost-only HTTP endpoints (`/notify`, `/request-input`, `/request-confirmation`, `/request-permission`, `/device-geolocation`) are used by agents and the `palmier run` process for interactive flows via held HTTP connections. `/request-input` and `/notify` are task-independent (no `taskId` required) — input requests use an internal `requestId` for routing. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPlanGenerationCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
|
|
16
16
|
|
|
17
|
-
* **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Co-located with the NATS server on the same machine.
|
|
17
|
+
* **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Co-located with the NATS server on the same machine.
|
|
18
|
+
|
|
19
|
+
* **Android App (Capacitor):** Native Android wrapper for the PWA. Provides FCM push messaging for receiving data messages in the background and `FusedLocationProviderClient` for GPS access. When a geolocation request arrives via FCM, a foreground service briefly starts to fetch the GPS fix and POST the result back to the Web Server. See the `palmier-android` repo.
|
|
18
20
|
|
|
19
21
|
* **PWA (React):** The user-facing frontend, primarily targeting mobile devices. Connects to the NATS server via **WebSockets** at `nats.palmier.me` (DNS only, not Cloudflare proxied, to avoid interference with persistent connections). No user accounts — paired hosts are stored in localStorage.
|
|
20
22
|
|
|
@@ -18,18 +18,40 @@ If the task fails because a tool was denied or you lack the required permissions
|
|
|
18
18
|
|
|
19
19
|
## HTTP Endpoints
|
|
20
20
|
|
|
21
|
-
The following HTTP endpoints are available at http://localhost:{{PORT}} during task execution. Use curl to call them.
|
|
21
|
+
The following HTTP endpoints are available at http://localhost:{{PORT}} during task execution. Use curl to call them. All endpoints require `taskId` in the request body.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
**`POST /request-input`** — Request input from the user. The request blocks until the user responds.
|
|
24
24
|
```json
|
|
25
|
-
{"taskId":"{{TASK_ID}}","
|
|
25
|
+
{"taskId": "{{TASK_ID}}", "description": "optional context", "questions": ["question 1", "question 2"]}
|
|
26
26
|
```
|
|
27
|
-
|
|
27
|
+
- `taskId` (required, string): The current task ID.
|
|
28
|
+
- `questions` (required, string array): Questions to present to the user.
|
|
29
|
+
- `description` (optional, string): Context or heading for the input request.
|
|
30
|
+
- Response: `{"values": ["answer1", "answer2"]}` on success, or `{"aborted": true}` if the user declines.
|
|
31
|
+
- When you need information from the user (credentials, answers to questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout, even in a non-interactive environment. Use this endpoint instead.
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
**`POST /request-confirmation`** — Request confirmation from the user. The request blocks until the user confirms or aborts.
|
|
30
34
|
```json
|
|
31
|
-
{"taskId":"{{TASK_ID}}","
|
|
35
|
+
{"taskId": "{{TASK_ID}}", "description": "What the user is confirming"}
|
|
32
36
|
```
|
|
37
|
+
- `taskId` (required, string): The current task ID.
|
|
38
|
+
- `description` (required, string): What the user is confirming.
|
|
39
|
+
- Response: `{"confirmed": true}` or `{"confirmed": false}`.
|
|
40
|
+
|
|
41
|
+
**`POST /device-geolocation`** — Get the GPS location of the user's mobile device. Blocks until the device responds (up to 30 seconds).
|
|
42
|
+
```json
|
|
43
|
+
{"taskId": "{{TASK_ID}}"}
|
|
44
|
+
```
|
|
45
|
+
- `taskId` (required, string): The current task ID.
|
|
46
|
+
- Response: `{"latitude": ..., "longitude": ..., "accuracy": ..., "timestamp": ...}` on success, or `{"error": "..."}` on failure.
|
|
47
|
+
|
|
48
|
+
**`POST /notify`** — Send a push notification to the user's device.
|
|
49
|
+
```json
|
|
50
|
+
{"taskId": "{{TASK_ID}}", "title": "...", "body": "..."}
|
|
51
|
+
```
|
|
52
|
+
- `taskId` (required, string): The current task ID.
|
|
53
|
+
- `title` (required, string): Notification title.
|
|
54
|
+
- `body` (required, string): Notification body.
|
|
33
55
|
|
|
34
56
|
---
|
|
35
57
|
|
package/src/agents/agent.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { Cursor } from "./cursor.js";
|
|
|
15
15
|
import { Kiro } from "./kiro.js";
|
|
16
16
|
import { Cline } from "./cline.js";
|
|
17
17
|
import { Qoder } from "./qoder.js";
|
|
18
|
+
import { Hermes } from "./hermes.js";
|
|
18
19
|
|
|
19
20
|
export interface CommandLine {
|
|
20
21
|
command: string;
|
|
@@ -65,6 +66,7 @@ const agentRegistry: Record<string, AgentTool> = {
|
|
|
65
66
|
kiro: new Kiro(),
|
|
66
67
|
cline: new Cline(),
|
|
67
68
|
qoder: new Qoder(),
|
|
69
|
+
hermes: new Hermes(),
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
const agentLabels: Record<string, string> = {
|
|
@@ -81,9 +83,10 @@ const agentLabels: Record<string, string> = {
|
|
|
81
83
|
deepagents: "Deep Agents CLI",
|
|
82
84
|
aider: "Aider",
|
|
83
85
|
cursor: "Cursor CLI",
|
|
84
|
-
kiro: "Kiro",
|
|
85
|
-
cline: "Cline",
|
|
86
|
-
qoder: "Qoder",
|
|
86
|
+
kiro: "Kiro CLI",
|
|
87
|
+
cline: "Cline CLI",
|
|
88
|
+
qoder: "Qoder CLI",
|
|
89
|
+
hermes: "Hermes Agent",
|
|
87
90
|
};
|
|
88
91
|
|
|
89
92
|
export interface DetectedAgent {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
|
+
import { SHELL } from "../platform/index.js";
|
|
6
|
+
|
|
7
|
+
export class Hermes implements AgentTool {
|
|
8
|
+
supportsPermissions = false;
|
|
9
|
+
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
|
10
|
+
return {
|
|
11
|
+
command: "hermes",
|
|
12
|
+
args: ["chat", "-q", prompt],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[] | "yolo"): CommandLine {
|
|
17
|
+
const yolo = extraPermissions === "yolo";
|
|
18
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id, yolo || !this.supportsPermissions) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
19
|
+
const args = ["chat"];
|
|
20
|
+
|
|
21
|
+
if (yolo) {
|
|
22
|
+
args.push("--trust-all-tools");
|
|
23
|
+
}
|
|
24
|
+
if (followupPrompt) {args.push("--continue");} // continue mode for followups
|
|
25
|
+
args.push("-q", prompt);
|
|
26
|
+
|
|
27
|
+
return { command: "hermes", args};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async init(): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
execSync("hermes --version", { stdio: "ignore", shell: SHELL });
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -16,6 +16,7 @@ task_name: <concise label, 3-6 words>
|
|
|
16
16
|
- If the task produces formatted output (report, email, summary, etc.), specify the structure, sections, and tone.
|
|
17
17
|
- When a step requires user input, simply state what information is needed from the user. Do **not** specify how to obtain it — the agent has its own tool for requesting user input.
|
|
18
18
|
- Preserve relative time expressions (e.g., "today", "yesterday", "last week") exactly as written — do **not** resolve them to specific dates. The plan may be executed on a different day than it was generated.
|
|
19
|
+
- If the task involves opening a web browser or application, include a final step to close it before finishing.
|
|
19
20
|
|
|
20
21
|
## Task Description
|
|
21
22
|
|