palmier 0.4.4 → 0.4.6
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 +32 -33
- package/dist/agents/agent-instructions.md +4 -11
- package/dist/agents/agent.d.ts +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claude.js +6 -6
- package/dist/agents/codex.d.ts +1 -1
- package/dist/agents/codex.js +5 -5
- package/dist/agents/copilot.d.ts +1 -1
- package/dist/agents/copilot.js +5 -5
- package/dist/agents/gemini.d.ts +1 -1
- package/dist/agents/gemini.js +7 -7
- package/dist/agents/openclaw.d.ts +1 -1
- package/dist/agents/openclaw.js +3 -3
- 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/run.js +33 -54
- 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 +15 -8
- package/dist/transports/http-transport.d.ts +4 -2
- package/dist/transports/http-transport.js +226 -77
- package/dist/types.d.ts +7 -16
- package/package.json +1 -1
- package/src/agents/agent-instructions.md +4 -11
- package/src/agents/agent.ts +2 -2
- package/src/agents/claude.ts +5 -5
- package/src/agents/codex.ts +4 -4
- package/src/agents/copilot.ts +5 -5
- package/src/agents/gemini.ts +6 -6
- package/src/agents/openclaw.ts +3 -3
- 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/run.ts +31 -68
- 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 +15 -9
- package/src/transports/http-transport.ts +235 -135
- package/src/types.ts +10 -16
- package/test/agent-output-parsing.test.ts +1 -14
- 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,156 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
198
193
|
}
|
|
199
194
|
return;
|
|
200
195
|
}
|
|
201
|
-
//
|
|
196
|
+
// ── GET /notify — send push notification via NATS ──────────────────
|
|
197
|
+
if (req.method === "GET" && 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 title = url.searchParams.get("title");
|
|
208
|
+
const notifBody = url.searchParams.get("body");
|
|
209
|
+
if (!title || !notifBody) {
|
|
210
|
+
sendJson(res, 400, { error: "title and body query params 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
|
+
// ── GET /request-input — held connection until user responds ────────
|
|
231
|
+
if (req.method === "GET" && pathname === "/request-input") {
|
|
232
|
+
if (!isLocalhost(req)) {
|
|
233
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const taskId = url.searchParams.get("taskId");
|
|
238
|
+
const runId = url.searchParams.get("runId");
|
|
239
|
+
const descriptions = url.searchParams.getAll("descriptions");
|
|
240
|
+
if (!taskId || !descriptions.length) {
|
|
241
|
+
sendJson(res, 400, { error: "taskId and descriptions query params are required" });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
245
|
+
const task = parseTaskFile(taskDir);
|
|
246
|
+
await publishEvent(taskId, {
|
|
247
|
+
event_type: "input-request",
|
|
248
|
+
host_id: config.hostId,
|
|
249
|
+
input_descriptions: descriptions,
|
|
250
|
+
name: task.frontmatter.name,
|
|
251
|
+
});
|
|
252
|
+
const response = await registerPending(taskId, "input", descriptions);
|
|
253
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
254
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
255
|
+
if (runId) {
|
|
256
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
|
|
257
|
+
}
|
|
258
|
+
sendJson(res, 200, { aborted: true });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
|
|
262
|
+
if (runId) {
|
|
263
|
+
const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
|
|
264
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
|
|
265
|
+
}
|
|
266
|
+
sendJson(res, 200, { values: response });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
sendJson(res, 500, { error: String(err) });
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// ── GET /request-confirmation — held connection ─────────────────────
|
|
275
|
+
if (req.method === "GET" && pathname === "/request-confirmation") {
|
|
276
|
+
if (!isLocalhost(req)) {
|
|
277
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const taskId = url.searchParams.get("taskId");
|
|
282
|
+
if (!taskId) {
|
|
283
|
+
sendJson(res, 400, { error: "taskId query param 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
|
+
// ── GET /request-permission — held connection ───────────────────────
|
|
305
|
+
if (req.method === "GET" && pathname === "/request-permission") {
|
|
306
|
+
if (!isLocalhost(req)) {
|
|
307
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const taskId = url.searchParams.get("taskId");
|
|
312
|
+
const taskName = url.searchParams.get("taskName");
|
|
313
|
+
const permissionsRaw = url.searchParams.get("permissions");
|
|
314
|
+
let permissions = [];
|
|
315
|
+
if (permissionsRaw) {
|
|
316
|
+
try {
|
|
317
|
+
permissions = JSON.parse(permissionsRaw);
|
|
318
|
+
}
|
|
319
|
+
catch { /* ignore */ }
|
|
320
|
+
}
|
|
321
|
+
if (!taskId || !permissions.length) {
|
|
322
|
+
sendJson(res, 400, { error: "taskId and permissions query params are required" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await publishEvent(taskId, {
|
|
326
|
+
event_type: "permission-request",
|
|
327
|
+
host_id: config.hostId,
|
|
328
|
+
required_permissions: permissions,
|
|
329
|
+
name: taskName,
|
|
330
|
+
});
|
|
331
|
+
const response = await registerPending(taskId, "permission", permissions);
|
|
332
|
+
const status = response[0];
|
|
333
|
+
await publishEvent(taskId, {
|
|
334
|
+
event_type: "permission-resolved",
|
|
335
|
+
host_id: config.hostId,
|
|
336
|
+
status,
|
|
337
|
+
});
|
|
338
|
+
sendJson(res, 200, { response: status });
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
sendJson(res, 500, { error: String(err) });
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// ── Public pair endpoint — no auth, PWA posts OTP code here ────────
|
|
202
346
|
if (req.method === "POST" && pathname === "/pair") {
|
|
203
347
|
try {
|
|
204
348
|
const body = await readBody(req);
|
|
@@ -212,7 +356,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
212
356
|
sendJson(res, 401, { error: "Invalid code" });
|
|
213
357
|
return;
|
|
214
358
|
}
|
|
215
|
-
// Create session and build response
|
|
216
359
|
const session = addSession(label);
|
|
217
360
|
const ip = detectLanIp();
|
|
218
361
|
const response = {
|
|
@@ -220,7 +363,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
220
363
|
sessionToken: session.token,
|
|
221
364
|
directUrl: `http://${ip}:${port}`,
|
|
222
365
|
};
|
|
223
|
-
// Resolve the long-poll and clean up
|
|
224
366
|
clearTimeout(pending.timer);
|
|
225
367
|
pendingPairs.delete(code);
|
|
226
368
|
pending.resolve({ paired: true });
|
|
@@ -231,22 +373,31 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
231
373
|
}
|
|
232
374
|
return;
|
|
233
375
|
}
|
|
234
|
-
//
|
|
376
|
+
// ── PWA assets (on-the-fly, cached) ────────────────────────────────
|
|
377
|
+
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
378
|
+
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
235
379
|
const isApiRoute = pathname === "/events" || pathname.startsWith("/rpc/");
|
|
236
380
|
if (!isApiRoute) {
|
|
237
|
-
|
|
238
|
-
|
|
381
|
+
if (SKIP.has(pathname)) {
|
|
382
|
+
sendJson(res, 404, { error: "Not found" });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Try exact path, then fall back to index.html (SPA routing)
|
|
386
|
+
let asset = await getAsset(pathname);
|
|
387
|
+
if (!asset && pathname !== "/") {
|
|
388
|
+
asset = await getAsset("/");
|
|
389
|
+
}
|
|
239
390
|
if (asset) {
|
|
240
391
|
res.writeHead(200, { "Content-Type": asset.contentType });
|
|
241
392
|
res.end(asset.data);
|
|
242
393
|
}
|
|
243
394
|
else {
|
|
244
|
-
sendJson(res,
|
|
395
|
+
sendJson(res, 502, { error: "Failed to fetch PWA assets" });
|
|
245
396
|
}
|
|
246
397
|
return;
|
|
247
398
|
}
|
|
248
|
-
// API endpoints require auth
|
|
249
|
-
if (!checkAuth(req)) {
|
|
399
|
+
// ── API endpoints require auth (localhost is trusted) ───────────────
|
|
400
|
+
if (!isLocalhost(req) && !checkAuth(req)) {
|
|
250
401
|
sendJson(res, 401, { error: "Unauthorized" });
|
|
251
402
|
return;
|
|
252
403
|
}
|
|
@@ -258,7 +409,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
258
409
|
Connection: "keep-alive",
|
|
259
410
|
});
|
|
260
411
|
res.write(":ok\n\n");
|
|
261
|
-
// Send heartbeat every 5 seconds
|
|
262
412
|
const heartbeat = setInterval(() => {
|
|
263
413
|
res.write("data: {\"heartbeat\":true}\n\n");
|
|
264
414
|
}, 5000);
|
|
@@ -290,7 +440,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
290
440
|
const sessionToken = extractSessionToken(req);
|
|
291
441
|
console.log(`[http] RPC: ${method}`);
|
|
292
442
|
try {
|
|
293
|
-
const response = await handleRpc({ method, params, sessionToken });
|
|
443
|
+
const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
|
|
294
444
|
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
295
445
|
sendJson(res, 200, response);
|
|
296
446
|
}
|
|
@@ -303,10 +453,9 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
303
453
|
sendJson(res, 404, { error: "Not found" });
|
|
304
454
|
});
|
|
305
455
|
return new Promise((resolve, reject) => {
|
|
306
|
-
server.listen(port, () => {
|
|
307
|
-
console.log(`[http] Listening on
|
|
456
|
+
server.listen(port, bindAddress, () => {
|
|
457
|
+
console.log(`[http] Listening on ${bindAddress}:${port}`);
|
|
308
458
|
onReady?.();
|
|
309
|
-
// Graceful shutdown
|
|
310
459
|
const shutdown = () => {
|
|
311
460
|
console.log("[http] Shutting down...");
|
|
312
461
|
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,20 +20,13 @@ 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.
|
|
26
26
|
|
|
27
|
-
**Requesting user input** — If you need any information you do not have (credentials, configuration values, preferences, clarifications, etc.) or the task explicitly asks you to get input from the user, do NOT fail the task. Instead, request
|
|
28
|
-
```
|
|
29
|
-
palmier request-input --description "What is the database connection string?" --description "What is the API key?"
|
|
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.
|
|
27
|
+
**Requesting user input** — If you need any information you do not have (credentials, configuration values, preferences, clarifications, etc.) or the task explicitly asks you to get input from the user, do NOT fail the task. Instead, GET `/request-input?taskId={{TASK_ID}}&descriptions=question+1&descriptions=question+2`. The request blocks until the user responds. The response is `{"values":["answer1","answer2"]}` on success, or `{"aborted":true}` if the user chooses to abort.
|
|
32
28
|
|
|
33
|
-
**Sending push notifications** —
|
|
34
|
-
```
|
|
35
|
-
palmier notify --title "Task Complete" --body "The deployment finished successfully."
|
|
36
|
-
```
|
|
29
|
+
**Sending push notifications** — GET `/notify?title=...&body=...` to send a push notification to the user's devices.
|
|
37
30
|
|
|
38
31
|
---
|
|
39
32
|
|
package/src/agents/agent.ts
CHANGED
|
@@ -20,10 +20,10 @@ export interface AgentTool {
|
|
|
20
20
|
/** Return the command and args used to generate a plan from a prompt. */
|
|
21
21
|
getPlanGenerationCommandLine(prompt: string): CommandLine;
|
|
22
22
|
|
|
23
|
-
/** Return the command and args used to run a task. If
|
|
23
|
+
/** Return the command and args used to run a task. If followupPrompt is provided, use it instead of the task's prompt,
|
|
24
24
|
* and treat it as a continuation of the original run (reuse the same session, etc). extraPermissions are transient
|
|
25
25
|
* permissions granted for this run only (not persisted in frontmatter). */
|
|
26
|
-
getTaskRunCommandLine(task: ParsedTask,
|
|
26
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
|
|
27
27
|
|
|
28
28
|
/** Detect whether the agent CLI is available and perform any agent-specific
|
|
29
29
|
* initialization. Returns true if the agent was detected and initialized successfully. */
|
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 {
|
|
@@ -12,16 +12,16 @@ export class ClaudeAgent implements AgentTool {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
getTaskRunCommandLine(task: ParsedTask,
|
|
16
|
-
const prompt =
|
|
17
|
-
const args = ["--permission-mode", "acceptEdits", "-p"];
|
|
15
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
+
const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (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) {
|
|
21
21
|
args.push("--allowedTools", p.name);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
if (
|
|
24
|
+
if (followupPrompt) {args.push("-c");} // continue mode for followups
|
|
25
25
|
return { command: "claude", args, stdin: prompt };
|
|
26
26
|
}
|
|
27
27
|
|
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 {
|
|
@@ -12,8 +12,8 @@ export class CodexAgent implements AgentTool {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
getTaskRunCommandLine(task: ParsedTask,
|
|
16
|
-
const prompt =
|
|
15
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
+
const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (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
|
|
|
@@ -24,7 +24,7 @@ export class CodexAgent implements AgentTool {
|
|
|
24
24
|
}
|
|
25
25
|
args.push("-"); // read prompt from stdin
|
|
26
26
|
|
|
27
|
-
if (
|
|
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 {
|
|
@@ -12,16 +12,16 @@ export class CopilotAgent implements AgentTool {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
getTaskRunCommandLine(task: ParsedTask,
|
|
16
|
-
const prompt =
|
|
17
|
-
const args = ["-p", prompt];
|
|
15
|
+
getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
|
|
16
|
+
const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
|
|
17
|
+
const args = ["-p", prompt, "--allowed-tools", "web_fetch"];
|
|
18
18
|
|
|
19
19
|
const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
|
|
20
20
|
if (allPerms.length > 0) {
|
|
21
21
|
args.push(`--allow-tool='${allPerms.map((p) => p.name).join(",")}'`);;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
if (
|
|
24
|
+
if (followupPrompt) { args.push("--continue"); }
|
|
25
25
|
return { command: "copilot", args};
|
|
26
26
|
}
|
|
27
27
|
|