palmier 0.4.5 → 0.4.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 +29 -31
- package/dist/agents/agent-instructions.md +9 -9
- package/dist/agents/claude.js +3 -3
- package/dist/agents/codex.js +3 -3
- package/dist/agents/copilot.js +3 -6
- package/dist/agents/gemini.js +4 -5
- package/dist/agents/openclaw.js +2 -2
- package/dist/agents/shared-prompt.d.ts +2 -4
- package/dist/agents/shared-prompt.js +9 -4
- package/dist/commands/init.js +31 -2
- package/dist/commands/pair.d.ts +1 -1
- package/dist/commands/pair.js +12 -15
- package/dist/commands/plan-generation.md +12 -15
- package/dist/commands/run.js +23 -44
- package/dist/commands/serve.d.ts +1 -1
- package/dist/commands/serve.js +9 -2
- package/dist/events.d.ts +2 -2
- package/dist/events.js +15 -16
- package/dist/index.js +0 -25
- package/dist/pending-requests.d.ts +27 -0
- package/dist/pending-requests.js +39 -0
- package/dist/rpc-handler.js +18 -10
- package/dist/task.d.ts +1 -1
- package/dist/task.js +3 -2
- package/dist/transports/http-transport.d.ts +4 -2
- package/dist/transports/http-transport.js +218 -77
- package/dist/types.d.ts +7 -16
- package/package.json +1 -1
- package/src/agents/agent-instructions.md +9 -9
- package/src/agents/claude.ts +3 -3
- package/src/agents/codex.ts +3 -3
- package/src/agents/copilot.ts +3 -6
- package/src/agents/gemini.ts +5 -5
- package/src/agents/openclaw.ts +2 -2
- package/src/agents/shared-prompt.ts +12 -6
- package/src/commands/init.ts +34 -3
- package/src/commands/pair.ts +11 -14
- package/src/commands/plan-generation.md +12 -15
- package/src/commands/run.ts +21 -58
- package/src/commands/serve.ts +11 -2
- package/src/events.ts +14 -15
- package/src/index.ts +0 -26
- package/src/pending-requests.ts +55 -0
- package/src/rpc-handler.ts +18 -11
- package/src/task.ts +3 -1
- package/src/transports/http-transport.ts +232 -133
- package/src/types.ts +10 -16
- package/dist/commands/lan.d.ts +0 -8
- package/dist/commands/lan.js +0 -44
- package/dist/commands/notify.d.ts +0 -9
- package/dist/commands/notify.js +0 -43
- package/dist/commands/request-input.d.ts +0 -10
- package/dist/commands/request-input.js +0 -49
- package/dist/lan-lock.d.ts +0 -7
- package/dist/lan-lock.js +0 -18
- package/dist/user-input.d.ts +0 -15
- package/dist/user-input.js +0 -50
- package/src/commands/lan.ts +0 -48
- package/src/commands/notify.ts +0 -44
- package/src/commands/request-input.ts +0 -51
- package/src/lan-lock.ts +0 -16
- package/src/user-input.ts +0 -67
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
2
|
import * as os from "os";
|
|
3
|
+
import { StringCodec } from "nats";
|
|
3
4
|
import { validateSession, addSession } from "../session-store.js";
|
|
5
|
+
import { registerPending } from "../pending-requests.js";
|
|
6
|
+
import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
|
|
4
7
|
const PWA_ORIGIN = "https://app.palmier.me";
|
|
8
|
+
const assetCache = new Map();
|
|
9
|
+
/** Paths currently being fetched (dedup concurrent requests). */
|
|
10
|
+
const assetInflight = new Map();
|
|
5
11
|
const CONTENT_TYPES = {
|
|
6
12
|
".html": "text/html; charset=utf-8",
|
|
7
13
|
".js": "application/javascript",
|
|
@@ -14,6 +20,8 @@ const CONTENT_TYPES = {
|
|
|
14
20
|
".svg": "image/svg+xml",
|
|
15
21
|
};
|
|
16
22
|
function guessContentType(urlPath) {
|
|
23
|
+
if (urlPath === "/")
|
|
24
|
+
return "text/html; charset=utf-8";
|
|
17
25
|
const ext = urlPath.match(/\.[^.]+$/)?.[0] ?? "";
|
|
18
26
|
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
19
27
|
}
|
|
@@ -24,55 +32,39 @@ async function fetchBuffer(url) {
|
|
|
24
32
|
return Buffer.from(await res.arrayBuffer());
|
|
25
33
|
}
|
|
26
34
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
35
|
+
* Fetch a PWA asset on-the-fly, caching in memory.
|
|
36
|
+
* Returns null if the asset cannot be fetched.
|
|
29
37
|
*/
|
|
30
|
-
async function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const refRegex = /(?:src|href)="([^"]+)"/g;
|
|
40
|
-
const htmlRefs = new Set();
|
|
41
|
-
let match;
|
|
42
|
-
while ((match = refRegex.exec(htmlStr)) !== null) {
|
|
43
|
-
const ref = match[1];
|
|
44
|
-
if (ref.startsWith("/") && !ref.startsWith("//") && !SKIP.has(ref)) {
|
|
45
|
-
htmlRefs.add(ref);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
// 3. Fetch all HTML-referenced assets
|
|
49
|
-
for (const ref of htmlRefs) {
|
|
38
|
+
async function getAsset(urlPath) {
|
|
39
|
+
const cached = assetCache.get(urlPath);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
// Dedup concurrent requests for the same path
|
|
43
|
+
const inflight = assetInflight.get(urlPath);
|
|
44
|
+
if (inflight)
|
|
45
|
+
return inflight;
|
|
46
|
+
const promise = (async () => {
|
|
50
47
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const urlRegex = /url\(["']?([^"')]+)["']?\)/g;
|
|
57
|
-
let cssMatch;
|
|
58
|
-
while ((cssMatch = urlRegex.exec(cssStr)) !== null) {
|
|
59
|
-
let fontRef = cssMatch[1];
|
|
60
|
-
if (fontRef.startsWith("data:"))
|
|
61
|
-
continue;
|
|
62
|
-
// Resolve relative URLs against the CSS file's directory
|
|
63
|
-
if (!fontRef.startsWith("/")) {
|
|
64
|
-
const cssDir = ref.substring(0, ref.lastIndexOf("/") + 1);
|
|
65
|
-
fontRef = cssDir + fontRef;
|
|
66
|
-
}
|
|
67
|
-
htmlRefs.add(fontRef);
|
|
68
|
-
}
|
|
48
|
+
let data = await fetchBuffer(`${PWA_ORIGIN}${urlPath}`);
|
|
49
|
+
// Inject LAN mode marker into index HTML so the PWA can detect it's served by palmier
|
|
50
|
+
if (urlPath === "/") {
|
|
51
|
+
const html = data.toString("utf-8").replace("</head>", "<script>window.__PALMIER_SERVE__=true</script></head>");
|
|
52
|
+
data = Buffer.from(html, "utf-8");
|
|
69
53
|
}
|
|
54
|
+
const asset = { data, contentType: guessContentType(urlPath) };
|
|
55
|
+
assetCache.set(urlPath, asset);
|
|
56
|
+
return asset;
|
|
70
57
|
}
|
|
71
58
|
catch (err) {
|
|
72
|
-
console.warn(`[pwa] Failed to fetch ${
|
|
59
|
+
console.warn(`[pwa] Failed to fetch ${urlPath}: ${err}`);
|
|
60
|
+
return null;
|
|
73
61
|
}
|
|
74
|
-
|
|
75
|
-
|
|
62
|
+
finally {
|
|
63
|
+
assetInflight.delete(urlPath);
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
assetInflight.set(urlPath, promise);
|
|
67
|
+
return promise;
|
|
76
68
|
}
|
|
77
69
|
const pendingPairs = new Map();
|
|
78
70
|
export function detectLanIp() {
|
|
@@ -87,22 +79,18 @@ export function detectLanIp() {
|
|
|
87
79
|
return "127.0.0.1";
|
|
88
80
|
}
|
|
89
81
|
/**
|
|
90
|
-
* Start the HTTP transport:
|
|
82
|
+
* Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
|
|
83
|
+
* localhost-only agent endpoints (notify, request-input, confirmation, permission).
|
|
91
84
|
*/
|
|
92
|
-
export async function startHttpTransport(config, handleRpc, port, pairingCode, onReady) {
|
|
93
|
-
// Download PWA assets into memory before starting the server
|
|
94
|
-
console.log("[http] Downloading PWA assets...");
|
|
95
|
-
const pwaAssets = await downloadPwaAssets();
|
|
96
|
-
console.log(`[http] Cached ${pwaAssets.size} PWA assets in memory.`);
|
|
85
|
+
export async function startHttpTransport(config, handleRpc, port, nc, pairingCode, onReady) {
|
|
97
86
|
const sseClients = new Set();
|
|
98
|
-
|
|
87
|
+
const lanEnabled = config.lanEnabled ?? false;
|
|
88
|
+
const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
89
|
+
// If a pairing code is provided, pre-register it
|
|
99
90
|
if (pairingCode) {
|
|
100
|
-
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
91
|
+
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
101
92
|
const timer = setTimeout(() => { pendingPairs.delete(pairingCode); }, EXPIRY_MS);
|
|
102
|
-
pendingPairs.set(pairingCode, {
|
|
103
|
-
resolve: () => { },
|
|
104
|
-
timer,
|
|
105
|
-
});
|
|
93
|
+
pendingPairs.set(pairingCode, { resolve: () => { }, timer });
|
|
106
94
|
}
|
|
107
95
|
function broadcastSseEvent(data) {
|
|
108
96
|
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
@@ -114,8 +102,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
114
102
|
const auth = req.headers.authorization;
|
|
115
103
|
if (!auth || !auth.startsWith("Bearer "))
|
|
116
104
|
return false;
|
|
117
|
-
|
|
118
|
-
return validateSession(token);
|
|
105
|
+
return validateSession(auth.slice(7));
|
|
119
106
|
}
|
|
120
107
|
function extractSessionToken(req) {
|
|
121
108
|
const auth = req.headers.authorization;
|
|
@@ -139,11 +126,22 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
139
126
|
const addr = req.socket.remoteAddress;
|
|
140
127
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
141
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Publish an event via NATS and SSE.
|
|
131
|
+
*/
|
|
132
|
+
async function publishEvent(taskId, payload) {
|
|
133
|
+
const sc = StringCodec();
|
|
134
|
+
const subject = `host-event.${config.hostId}.${taskId}`;
|
|
135
|
+
if (nc) {
|
|
136
|
+
nc.publish(subject, sc.encode(JSON.stringify(payload)));
|
|
137
|
+
}
|
|
138
|
+
broadcastSseEvent({ task_id: taskId, ...payload });
|
|
139
|
+
}
|
|
142
140
|
const server = http.createServer(async (req, res) => {
|
|
143
141
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
144
142
|
const pathname = url.pathname;
|
|
145
|
-
//
|
|
146
|
-
if (req.method === "POST" && pathname === "/
|
|
143
|
+
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
144
|
+
if (req.method === "POST" && pathname === "/event") {
|
|
147
145
|
if (!isLocalhost(req)) {
|
|
148
146
|
sendJson(res, 403, { error: "localhost only" });
|
|
149
147
|
return;
|
|
@@ -159,9 +157,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
159
157
|
}
|
|
160
158
|
return;
|
|
161
159
|
}
|
|
162
|
-
|
|
163
|
-
// The pair CLI posts here and blocks until paired or expired.
|
|
164
|
-
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
160
|
+
if (req.method === "POST" && pathname === "/pair-register") {
|
|
165
161
|
if (!isLocalhost(req)) {
|
|
166
162
|
sendJson(res, 403, { error: "localhost only" });
|
|
167
163
|
return;
|
|
@@ -183,7 +179,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
183
179
|
resolve({ paired: false });
|
|
184
180
|
}, expiryMs ?? 5 * 60 * 1000);
|
|
185
181
|
pendingPairs.set(code, { resolve, timer });
|
|
186
|
-
// Clean up if the CLI disconnects early
|
|
187
182
|
req.on("close", () => {
|
|
188
183
|
if (pendingPairs.has(code)) {
|
|
189
184
|
clearTimeout(timer);
|
|
@@ -198,7 +193,148 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
198
193
|
}
|
|
199
194
|
return;
|
|
200
195
|
}
|
|
201
|
-
//
|
|
196
|
+
// ── POST /notify — send push notification via NATS ─────────────────
|
|
197
|
+
if (req.method === "POST" && pathname === "/notify") {
|
|
198
|
+
if (!isLocalhost(req)) {
|
|
199
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!nc) {
|
|
203
|
+
sendJson(res, 503, { error: "NATS not connected — push notifications require server mode" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const body = await readBody(req);
|
|
208
|
+
const { title, body: notifBody } = JSON.parse(body);
|
|
209
|
+
if (!title || !notifBody) {
|
|
210
|
+
sendJson(res, 400, { error: "title and body are required" });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const sc = StringCodec();
|
|
214
|
+
const payload = { hostId: config.hostId, title, body: notifBody };
|
|
215
|
+
const subject = `host.${config.hostId}.push.send`;
|
|
216
|
+
const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
217
|
+
const result = JSON.parse(sc.decode(reply.data));
|
|
218
|
+
if (result.ok) {
|
|
219
|
+
sendJson(res, 200, { ok: true });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
sendJson(res, 502, { error: result.error ?? "Push notification failed" });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
sendJson(res, 500, { error: `Failed to send notification: ${err}` });
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// ── POST /request-input — held connection until user responds ────────
|
|
231
|
+
if (req.method === "POST" && pathname === "/request-input") {
|
|
232
|
+
if (!isLocalhost(req)) {
|
|
233
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const body = await readBody(req);
|
|
238
|
+
const { taskId, runId, descriptions } = JSON.parse(body);
|
|
239
|
+
if (!taskId || !descriptions?.length) {
|
|
240
|
+
sendJson(res, 400, { error: "taskId and descriptions are required" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
244
|
+
const task = parseTaskFile(taskDir);
|
|
245
|
+
await publishEvent(taskId, {
|
|
246
|
+
event_type: "input-request",
|
|
247
|
+
host_id: config.hostId,
|
|
248
|
+
input_descriptions: descriptions,
|
|
249
|
+
name: task.frontmatter.name,
|
|
250
|
+
});
|
|
251
|
+
const response = await registerPending(taskId, "input", descriptions);
|
|
252
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
253
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
254
|
+
if (runId) {
|
|
255
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
|
|
256
|
+
}
|
|
257
|
+
sendJson(res, 200, { aborted: true });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
|
|
261
|
+
if (runId) {
|
|
262
|
+
const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
|
|
263
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
|
|
264
|
+
}
|
|
265
|
+
sendJson(res, 200, { values: response });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
sendJson(res, 500, { error: String(err) });
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// ── POST /request-confirmation — held connection ────────────────────
|
|
274
|
+
if (req.method === "POST" && pathname === "/request-confirmation") {
|
|
275
|
+
if (!isLocalhost(req)) {
|
|
276
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const body = await readBody(req);
|
|
281
|
+
const { taskId } = JSON.parse(body);
|
|
282
|
+
if (!taskId) {
|
|
283
|
+
sendJson(res, 400, { error: "taskId is required" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await publishEvent(taskId, {
|
|
287
|
+
event_type: "confirm-request",
|
|
288
|
+
host_id: config.hostId,
|
|
289
|
+
});
|
|
290
|
+
const response = await registerPending(taskId, "confirmation");
|
|
291
|
+
const confirmed = response[0] === "confirmed";
|
|
292
|
+
await publishEvent(taskId, {
|
|
293
|
+
event_type: "confirm-resolved",
|
|
294
|
+
host_id: config.hostId,
|
|
295
|
+
status: confirmed ? "confirmed" : "aborted",
|
|
296
|
+
});
|
|
297
|
+
sendJson(res, 200, { confirmed });
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
sendJson(res, 500, { error: String(err) });
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// ── POST /request-permission — held connection ──────────────────────
|
|
305
|
+
if (req.method === "POST" && pathname === "/request-permission") {
|
|
306
|
+
if (!isLocalhost(req)) {
|
|
307
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const body = await readBody(req);
|
|
312
|
+
const { taskId, taskName, permissions } = JSON.parse(body);
|
|
313
|
+
if (!taskId || !permissions?.length) {
|
|
314
|
+
sendJson(res, 400, { error: "taskId and permissions are required" });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await publishEvent(taskId, {
|
|
318
|
+
event_type: "permission-request",
|
|
319
|
+
host_id: config.hostId,
|
|
320
|
+
required_permissions: permissions,
|
|
321
|
+
name: taskName,
|
|
322
|
+
});
|
|
323
|
+
const response = await registerPending(taskId, "permission", permissions);
|
|
324
|
+
const status = response[0];
|
|
325
|
+
await publishEvent(taskId, {
|
|
326
|
+
event_type: "permission-resolved",
|
|
327
|
+
host_id: config.hostId,
|
|
328
|
+
status,
|
|
329
|
+
});
|
|
330
|
+
sendJson(res, 200, { response: status });
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
sendJson(res, 500, { error: String(err) });
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// ── Public pair endpoint — no auth, PWA posts OTP code here ────────
|
|
202
338
|
if (req.method === "POST" && pathname === "/pair") {
|
|
203
339
|
try {
|
|
204
340
|
const body = await readBody(req);
|
|
@@ -212,7 +348,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
212
348
|
sendJson(res, 401, { error: "Invalid code" });
|
|
213
349
|
return;
|
|
214
350
|
}
|
|
215
|
-
// Create session and build response
|
|
216
351
|
const session = addSession(label);
|
|
217
352
|
const ip = detectLanIp();
|
|
218
353
|
const response = {
|
|
@@ -220,7 +355,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
220
355
|
sessionToken: session.token,
|
|
221
356
|
directUrl: `http://${ip}:${port}`,
|
|
222
357
|
};
|
|
223
|
-
// Resolve the long-poll and clean up
|
|
224
358
|
clearTimeout(pending.timer);
|
|
225
359
|
pendingPairs.delete(code);
|
|
226
360
|
pending.resolve({ paired: true });
|
|
@@ -231,22 +365,31 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
231
365
|
}
|
|
232
366
|
return;
|
|
233
367
|
}
|
|
234
|
-
//
|
|
368
|
+
// ── PWA assets (on-the-fly, cached) ────────────────────────────────
|
|
369
|
+
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
370
|
+
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
235
371
|
const isApiRoute = pathname === "/events" || pathname.startsWith("/rpc/");
|
|
236
372
|
if (!isApiRoute) {
|
|
237
|
-
|
|
238
|
-
|
|
373
|
+
if (SKIP.has(pathname)) {
|
|
374
|
+
sendJson(res, 404, { error: "Not found" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Try exact path, then fall back to index.html (SPA routing)
|
|
378
|
+
let asset = await getAsset(pathname);
|
|
379
|
+
if (!asset && pathname !== "/") {
|
|
380
|
+
asset = await getAsset("/");
|
|
381
|
+
}
|
|
239
382
|
if (asset) {
|
|
240
383
|
res.writeHead(200, { "Content-Type": asset.contentType });
|
|
241
384
|
res.end(asset.data);
|
|
242
385
|
}
|
|
243
386
|
else {
|
|
244
|
-
sendJson(res,
|
|
387
|
+
sendJson(res, 502, { error: "Failed to fetch PWA assets" });
|
|
245
388
|
}
|
|
246
389
|
return;
|
|
247
390
|
}
|
|
248
|
-
// API endpoints require auth
|
|
249
|
-
if (!checkAuth(req)) {
|
|
391
|
+
// ── API endpoints require auth (localhost is trusted) ───────────────
|
|
392
|
+
if (!isLocalhost(req) && !checkAuth(req)) {
|
|
250
393
|
sendJson(res, 401, { error: "Unauthorized" });
|
|
251
394
|
return;
|
|
252
395
|
}
|
|
@@ -258,7 +401,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
258
401
|
Connection: "keep-alive",
|
|
259
402
|
});
|
|
260
403
|
res.write(":ok\n\n");
|
|
261
|
-
// Send heartbeat every 5 seconds
|
|
262
404
|
const heartbeat = setInterval(() => {
|
|
263
405
|
res.write("data: {\"heartbeat\":true}\n\n");
|
|
264
406
|
}, 5000);
|
|
@@ -290,7 +432,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
290
432
|
const sessionToken = extractSessionToken(req);
|
|
291
433
|
console.log(`[http] RPC: ${method}`);
|
|
292
434
|
try {
|
|
293
|
-
const response = await handleRpc({ method, params, sessionToken });
|
|
435
|
+
const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
|
|
294
436
|
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
295
437
|
sendJson(res, 200, response);
|
|
296
438
|
}
|
|
@@ -303,10 +445,9 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
303
445
|
sendJson(res, 404, { error: "Not found" });
|
|
304
446
|
});
|
|
305
447
|
return new Promise((resolve, reject) => {
|
|
306
|
-
server.listen(port, () => {
|
|
307
|
-
console.log(`[http] Listening on
|
|
448
|
+
server.listen(port, bindAddress, () => {
|
|
449
|
+
console.log(`[http] Listening on ${bindAddress}:${port}`);
|
|
308
450
|
onReady?.();
|
|
309
|
-
// Graceful shutdown
|
|
310
451
|
const shutdown = () => {
|
|
311
452
|
console.log("[http] Shutting down...");
|
|
312
453
|
for (const client of sseClients) {
|
package/dist/types.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface HostConfig {
|
|
|
8
8
|
key: string;
|
|
9
9
|
label: string;
|
|
10
10
|
}>;
|
|
11
|
+
httpPort?: number;
|
|
12
|
+
lanEnabled?: boolean;
|
|
11
13
|
}
|
|
12
14
|
export interface TaskFrontmatter {
|
|
13
15
|
id: string;
|
|
@@ -29,8 +31,6 @@ export interface ParsedTask {
|
|
|
29
31
|
body: string;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
* State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
|
|
33
|
-
*
|
|
34
34
|
* - `started`: task is actively running
|
|
35
35
|
* - `finished`: agent completed successfully
|
|
36
36
|
* - `aborted`: user declined confirmation, permission, or input
|
|
@@ -38,26 +38,15 @@ export interface ParsedTask {
|
|
|
38
38
|
*/
|
|
39
39
|
export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
|
|
40
40
|
/**
|
|
41
|
-
* Persisted to `status.json` in the task directory.
|
|
42
|
-
* and
|
|
43
|
-
*
|
|
44
|
-
* Interactive request flow: the run process sets a `pending_*` field and waits
|
|
45
|
-
* for `user_input` to be populated by an RPC call (task.user_input). Only one
|
|
46
|
-
* `pending_*` field is set at a time.
|
|
41
|
+
* Persisted to `status.json` in the task directory. Used for crash detection
|
|
42
|
+
* (checkStaleTasks) and abort signalling. Interactive request flows (confirmation,
|
|
43
|
+
* permission, input) are handled via held HTTP connections on the serve daemon.
|
|
47
44
|
*/
|
|
48
45
|
export interface TaskStatus {
|
|
49
46
|
running_state: TaskRunningState;
|
|
50
47
|
time_stamp: number;
|
|
51
48
|
/** PID of the palmier run process (used on Windows to kill the process tree). */
|
|
52
49
|
pid?: number;
|
|
53
|
-
/** Set when the task has `requires_confirmation` and is awaiting user approval. */
|
|
54
|
-
pending_confirmation?: boolean;
|
|
55
|
-
/** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
|
|
56
|
-
pending_permission?: RequiredPermission[];
|
|
57
|
-
/** Set when the agent requests user input. Contains descriptions of each requested value. */
|
|
58
|
-
pending_input?: string[];
|
|
59
|
-
/** Written by the RPC handler to deliver the user's response to the waiting run process. */
|
|
60
|
-
user_input?: string[];
|
|
61
50
|
}
|
|
62
51
|
export interface HistoryEntry {
|
|
63
52
|
task_id: string;
|
|
@@ -78,5 +67,7 @@ export interface RpcMessage {
|
|
|
78
67
|
method: string;
|
|
79
68
|
params: Record<string, unknown>;
|
|
80
69
|
sessionToken?: string;
|
|
70
|
+
/** Trusted localhost request — skip session validation. */
|
|
71
|
+
localhost?: boolean;
|
|
81
72
|
}
|
|
82
73
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -20,19 +20,19 @@ If the task fails because a tool was denied or you lack the required permissions
|
|
|
20
20
|
[PALMIER_PERMISSION] Bash(npm test) | Run the test suite via npm
|
|
21
21
|
[PALMIER_PERMISSION] Write | Write generated output files
|
|
22
22
|
|
|
23
|
-
##
|
|
23
|
+
## HTTP Endpoints
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
The following HTTP endpoints are available at http://localhost:{{PORT}} during task execution. Use curl to call them.
|
|
26
26
|
|
|
27
|
-
**Requesting user input** —
|
|
27
|
+
**Requesting user input** — When you need information from the user (credentials, questions, preferences, clarifications, etc.), do not guess, fail, or prompt via stdout. Instead, POST to `/request-input` with:
|
|
28
|
+
```json
|
|
29
|
+
{"taskId":"{{TASK_ID}}","descriptions":["question 1","question 2"]}
|
|
28
30
|
```
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
The command blocks until the user responds and prints each value on its own line. If the user aborts, the command exits with a non-zero status.
|
|
31
|
+
The request blocks until the user responds. Response: `{"values":["answer1","answer2"]}` on success, or `{"aborted":true}` if the user declines.
|
|
32
32
|
|
|
33
|
-
**Sending push notifications** —
|
|
34
|
-
```
|
|
35
|
-
|
|
33
|
+
**Sending push notifications** — To notify the user, POST to `/notify` with:
|
|
34
|
+
```json
|
|
35
|
+
{"title":"...","body":"..."}
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
---
|
package/src/agents/claude.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
import { SHELL } from "../platform/index.js";
|
|
6
6
|
|
|
7
7
|
export class ClaudeAgent implements AgentTool {
|
|
@@ -13,8 +13,8 @@ export class ClaudeAgent implements AgentTool {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
-
const prompt =
|
|
17
|
-
const args = ["--permission-mode", "acceptEdits", "-p"];
|
|
16
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
17
|
+
const args = ["--permission-mode", "acceptEdits", "-p", "--allowedTools", "WebFetch"];
|
|
18
18
|
|
|
19
19
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
20
20
|
for (const p of allPerms) {
|
package/src/agents/codex.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
import { SHELL } from "../platform/index.js";
|
|
6
6
|
|
|
7
7
|
export class CodexAgent implements AgentTool {
|
|
@@ -13,7 +13,7 @@ export class CodexAgent implements AgentTool {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
-
const prompt =
|
|
16
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
17
17
|
// Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
|
|
18
18
|
const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
|
|
19
19
|
|
|
@@ -22,9 +22,9 @@ export class CodexAgent implements AgentTool {
|
|
|
22
22
|
args.push("--config");
|
|
23
23
|
args.push(`apps.${p.name}.default_tools_approval_mode="approve"`);
|
|
24
24
|
}
|
|
25
|
+
if (followupPrompt) {args.push("resume", "--last");} // continue mode for followups
|
|
25
26
|
args.push("-"); // read prompt from stdin
|
|
26
27
|
|
|
27
|
-
if (followupPrompt) {args.push("resume", "--last");} // continue mode for followups
|
|
28
28
|
return { command: "codex", args, stdin: prompt };
|
|
29
29
|
}
|
|
30
30
|
|
package/src/agents/copilot.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
import { SHELL } from "../platform/index.js";
|
|
6
6
|
|
|
7
7
|
export class CopilotAgent implements AgentTool {
|
|
@@ -13,14 +13,11 @@ export class CopilotAgent implements AgentTool {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
-
const prompt =
|
|
16
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
17
17
|
const args = ["-p", prompt];
|
|
18
18
|
|
|
19
19
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
20
|
-
|
|
21
|
-
args.push(`--allow-tool='${allPerms.map((p) => p.name).join(",")}'`);;
|
|
22
|
-
}
|
|
23
|
-
|
|
20
|
+
args.push(`--allow-tool=${["web_fetch", ...allPerms.map((p) => p.name)].join(",")}`);
|
|
24
21
|
if (followupPrompt) { args.push("--continue"); }
|
|
25
22
|
return { command: "copilot", args};
|
|
26
23
|
}
|
package/src/agents/gemini.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
import { SHELL } from "../platform/index.js";
|
|
6
6
|
|
|
7
7
|
export class GeminiAgent implements AgentTool {
|
|
@@ -13,19 +13,19 @@ export class GeminiAgent implements AgentTool {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const args = ["--prompt", "-"];
|
|
16
|
+
const fullPrompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
17
|
+
const args = ["--allowed-tools", "web_fetch"];
|
|
19
18
|
|
|
20
19
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
21
20
|
if (allPerms.length > 0) {
|
|
22
|
-
args.push("--allowed-tools");
|
|
23
21
|
for (const p of allPerms) {
|
|
24
22
|
args.push(p.name);
|
|
25
23
|
}
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
if (followupPrompt) {args.push("--resume");} // continue mode for followups
|
|
27
|
+
args.push("--prompt", "-"); // read prompt from stdin
|
|
28
|
+
|
|
29
29
|
return { command: "gemini", args, stdin: fullPrompt };
|
|
30
30
|
}
|
|
31
31
|
|
package/src/agents/openclaw.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import type { AgentTool, CommandLine } from "./agent.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentInstructions } from "./shared-prompt.js";
|
|
5
5
|
|
|
6
6
|
export class OpenClawAgent implements AgentTool {
|
|
7
7
|
getPlanGenerationCommandLine(prompt: string): CommandLine {
|
|
@@ -12,7 +12,7 @@ export class OpenClawAgent implements AgentTool {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
15
|
-
const prompt =
|
|
15
|
+
const prompt = followupPrompt ?? (getAgentInstructions(task.frontmatter.id) + "\n\n" + (task.body || task.frontmatter.user_prompt));
|
|
16
16
|
// OpenClaw does not support stdin as prompt.
|
|
17
17
|
const args = ["agent", "--local", "--session-id", task.frontmatter.id, "--message", prompt];
|
|
18
18
|
|