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,23 +1,29 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
2
|
import * as os from "os";
|
|
3
|
+
import { StringCodec, type NatsConnection } from "nats";
|
|
3
4
|
import { validateSession, addSession } from "../session-store.js";
|
|
4
|
-
import
|
|
5
|
+
import { registerPending } from "../pending-requests.js";
|
|
6
|
+
import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
|
|
7
|
+
import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
|
|
5
8
|
|
|
6
9
|
const PWA_ORIGIN = "https://app.palmier.me";
|
|
7
10
|
|
|
8
|
-
// ──
|
|
11
|
+
// ── On-the-fly PWA asset cache ──────────────────────────────────────────
|
|
9
12
|
|
|
10
13
|
interface CachedAsset {
|
|
11
14
|
data: Buffer;
|
|
12
15
|
contentType: string;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
const assetCache = new Map<string, CachedAsset>();
|
|
19
|
+
/** Paths currently being fetched (dedup concurrent requests). */
|
|
20
|
+
const assetInflight = new Map<string, Promise<CachedAsset | null>>();
|
|
21
|
+
|
|
15
22
|
const CONTENT_TYPES: Record<string, string> = {
|
|
16
23
|
".html": "text/html; charset=utf-8",
|
|
17
24
|
".js": "application/javascript",
|
|
18
25
|
".css": "text/css",
|
|
19
26
|
".json": "application/json",
|
|
20
|
-
|
|
21
27
|
".png": "image/png",
|
|
22
28
|
".ico": "image/x-icon",
|
|
23
29
|
".woff2": "font/woff2",
|
|
@@ -26,6 +32,7 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
26
32
|
};
|
|
27
33
|
|
|
28
34
|
function guessContentType(urlPath: string): string {
|
|
35
|
+
if (urlPath === "/") return "text/html; charset=utf-8";
|
|
29
36
|
const ext = urlPath.match(/\.[^.]+$/)?.[0] ?? "";
|
|
30
37
|
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
31
38
|
}
|
|
@@ -37,59 +44,38 @@ async function fetchBuffer(url: string): Promise<Buffer> {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
47
|
+
* Fetch a PWA asset on-the-fly, caching in memory.
|
|
48
|
+
* Returns null if the asset cannot be fetched.
|
|
42
49
|
*/
|
|
43
|
-
async function
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
// 1. Fetch index.html
|
|
47
|
-
const html = await fetchBuffer(`${PWA_ORIGIN}/`);
|
|
48
|
-
assets.set("/", { data: html, contentType: "text/html; charset=utf-8" });
|
|
49
|
-
|
|
50
|
-
const htmlStr = html.toString("utf-8");
|
|
51
|
-
|
|
52
|
-
// 2. Extract references from HTML (src="..." and href="...")
|
|
53
|
-
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
54
|
-
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
55
|
-
const refRegex = /(?:src|href)="([^"]+)"/g;
|
|
56
|
-
const htmlRefs = new Set<string>();
|
|
57
|
-
let match;
|
|
58
|
-
while ((match = refRegex.exec(htmlStr)) !== null) {
|
|
59
|
-
const ref = match[1];
|
|
60
|
-
if (ref.startsWith("/") && !ref.startsWith("//") && !SKIP.has(ref)) {
|
|
61
|
-
htmlRefs.add(ref);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
50
|
+
async function getAsset(urlPath: string): Promise<CachedAsset | null> {
|
|
51
|
+
const cached = assetCache.get(urlPath);
|
|
52
|
+
if (cached) return cached;
|
|
64
53
|
|
|
65
|
-
//
|
|
66
|
-
|
|
54
|
+
// Dedup concurrent requests for the same path
|
|
55
|
+
const inflight = assetInflight.get(urlPath);
|
|
56
|
+
if (inflight) return inflight;
|
|
57
|
+
|
|
58
|
+
const promise = (async () => {
|
|
67
59
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const cssStr = data.toString("utf-8");
|
|
74
|
-
const urlRegex = /url\(["']?([^"')]+)["']?\)/g;
|
|
75
|
-
let cssMatch;
|
|
76
|
-
while ((cssMatch = urlRegex.exec(cssStr)) !== null) {
|
|
77
|
-
let fontRef = cssMatch[1];
|
|
78
|
-
if (fontRef.startsWith("data:")) continue;
|
|
79
|
-
// Resolve relative URLs against the CSS file's directory
|
|
80
|
-
if (!fontRef.startsWith("/")) {
|
|
81
|
-
const cssDir = ref.substring(0, ref.lastIndexOf("/") + 1);
|
|
82
|
-
fontRef = cssDir + fontRef;
|
|
83
|
-
}
|
|
84
|
-
htmlRefs.add(fontRef);
|
|
85
|
-
}
|
|
60
|
+
let data = await fetchBuffer(`${PWA_ORIGIN}${urlPath}`);
|
|
61
|
+
// Inject LAN mode marker into index HTML so the PWA can detect it's served by palmier
|
|
62
|
+
if (urlPath === "/") {
|
|
63
|
+
const html = data.toString("utf-8").replace("</head>", "<script>window.__PALMIER_SERVE__=true</script></head>");
|
|
64
|
+
data = Buffer.from(html, "utf-8");
|
|
86
65
|
}
|
|
66
|
+
const asset: CachedAsset = { data, contentType: guessContentType(urlPath) };
|
|
67
|
+
assetCache.set(urlPath, asset);
|
|
68
|
+
return asset;
|
|
87
69
|
} catch (err) {
|
|
88
|
-
console.warn(`[pwa] Failed to fetch ${
|
|
70
|
+
console.warn(`[pwa] Failed to fetch ${urlPath}: ${err}`);
|
|
71
|
+
return null;
|
|
72
|
+
} finally {
|
|
73
|
+
assetInflight.delete(urlPath);
|
|
89
74
|
}
|
|
90
|
-
}
|
|
75
|
+
})();
|
|
91
76
|
|
|
92
|
-
|
|
77
|
+
assetInflight.set(urlPath, promise);
|
|
78
|
+
return promise;
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
type SseClient = http.ServerResponse;
|
|
@@ -114,30 +100,26 @@ export function detectLanIp(): string {
|
|
|
114
100
|
}
|
|
115
101
|
|
|
116
102
|
/**
|
|
117
|
-
* Start the HTTP transport:
|
|
103
|
+
* Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
|
|
104
|
+
* localhost-only agent endpoints (notify, request-input, confirmation, permission).
|
|
118
105
|
*/
|
|
119
106
|
export async function startHttpTransport(
|
|
120
107
|
config: HostConfig,
|
|
121
108
|
handleRpc: (req: RpcMessage) => Promise<unknown>,
|
|
122
109
|
port: number,
|
|
110
|
+
nc: NatsConnection | undefined,
|
|
123
111
|
pairingCode?: string,
|
|
124
112
|
onReady?: () => void,
|
|
125
113
|
): Promise<void> {
|
|
126
|
-
// Download PWA assets into memory before starting the server
|
|
127
|
-
console.log("[http] Downloading PWA assets...");
|
|
128
|
-
const pwaAssets = await downloadPwaAssets();
|
|
129
|
-
console.log(`[http] Cached ${pwaAssets.size} PWA assets in memory.`);
|
|
130
|
-
|
|
131
114
|
const sseClients = new Set<SseClient>();
|
|
115
|
+
const lanEnabled = config.lanEnabled ?? false;
|
|
116
|
+
const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
132
117
|
|
|
133
|
-
// If a pairing code is provided
|
|
118
|
+
// If a pairing code is provided, pre-register it
|
|
134
119
|
if (pairingCode) {
|
|
135
|
-
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
120
|
+
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
136
121
|
const timer = setTimeout(() => { pendingPairs.delete(pairingCode); }, EXPIRY_MS);
|
|
137
|
-
pendingPairs.set(pairingCode, {
|
|
138
|
-
resolve: () => {},
|
|
139
|
-
timer,
|
|
140
|
-
});
|
|
122
|
+
pendingPairs.set(pairingCode, { resolve: () => {}, timer });
|
|
141
123
|
}
|
|
142
124
|
|
|
143
125
|
function broadcastSseEvent(data: unknown) {
|
|
@@ -147,12 +129,10 @@ export async function startHttpTransport(
|
|
|
147
129
|
}
|
|
148
130
|
}
|
|
149
131
|
|
|
150
|
-
|
|
151
132
|
function checkAuth(req: http.IncomingMessage): boolean {
|
|
152
133
|
const auth = req.headers.authorization;
|
|
153
134
|
if (!auth || !auth.startsWith("Bearer ")) return false;
|
|
154
|
-
|
|
155
|
-
return validateSession(token);
|
|
135
|
+
return validateSession(auth.slice(7));
|
|
156
136
|
}
|
|
157
137
|
|
|
158
138
|
function extractSessionToken(req: http.IncomingMessage): string | undefined {
|
|
@@ -180,50 +160,42 @@ export async function startHttpTransport(
|
|
|
180
160
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
181
161
|
}
|
|
182
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Publish an event via NATS and SSE.
|
|
165
|
+
*/
|
|
166
|
+
async function publishEvent(taskId: string, payload: Record<string, unknown>): Promise<void> {
|
|
167
|
+
const sc = StringCodec();
|
|
168
|
+
const subject = `host-event.${config.hostId}.${taskId}`;
|
|
169
|
+
if (nc) {
|
|
170
|
+
nc.publish(subject, sc.encode(JSON.stringify(payload)));
|
|
171
|
+
}
|
|
172
|
+
broadcastSseEvent({ task_id: taskId, ...payload });
|
|
173
|
+
}
|
|
174
|
+
|
|
183
175
|
const server = http.createServer(async (req, res) => {
|
|
184
176
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
185
177
|
const pathname = url.pathname;
|
|
186
178
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
179
|
+
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
180
|
+
|
|
181
|
+
if (req.method === "POST" && pathname === "/event") {
|
|
182
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
193
183
|
try {
|
|
194
184
|
const body = await readBody(req);
|
|
195
185
|
const event = JSON.parse(body);
|
|
196
186
|
broadcastSseEvent(event);
|
|
197
187
|
sendJson(res, 200, { ok: true });
|
|
198
|
-
} catch {
|
|
199
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
200
|
-
}
|
|
188
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); }
|
|
201
189
|
return;
|
|
202
190
|
}
|
|
203
191
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
207
|
-
if (!isLocalhost(req)) {
|
|
208
|
-
sendJson(res, 403, { error: "localhost only" });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
192
|
+
if (req.method === "POST" && pathname === "/pair-register") {
|
|
193
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
211
194
|
try {
|
|
212
195
|
const body = await readBody(req);
|
|
213
|
-
const { code, expiryMs } = JSON.parse(body) as {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
if (!code) {
|
|
219
|
-
sendJson(res, 400, { error: "Missing code" });
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (pendingPairs.has(code)) {
|
|
224
|
-
sendJson(res, 409, { error: "Code already registered" });
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
196
|
+
const { code, expiryMs } = JSON.parse(body) as { code: string; expiryMs: number };
|
|
197
|
+
if (!code) { sendJson(res, 400, { error: "Missing code" }); return; }
|
|
198
|
+
if (pendingPairs.has(code)) { sendJson(res, 409, { error: "Code already registered" }); return; }
|
|
227
199
|
|
|
228
200
|
const result = await new Promise<{ paired: boolean }>((resolve) => {
|
|
229
201
|
const timer = setTimeout(() => {
|
|
@@ -232,8 +204,6 @@ export async function startHttpTransport(
|
|
|
232
204
|
}, expiryMs ?? 5 * 60 * 1000);
|
|
233
205
|
|
|
234
206
|
pendingPairs.set(code, { resolve, timer });
|
|
235
|
-
|
|
236
|
-
// Clean up if the CLI disconnects early
|
|
237
207
|
req.on("close", () => {
|
|
238
208
|
if (pendingPairs.has(code)) {
|
|
239
209
|
clearTimeout(timer);
|
|
@@ -243,33 +213,162 @@ export async function startHttpTransport(
|
|
|
243
213
|
});
|
|
244
214
|
|
|
245
215
|
sendJson(res, 200, result);
|
|
246
|
-
} catch {
|
|
247
|
-
|
|
216
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); }
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── POST /notify — send push notification via NATS ─────────────────
|
|
221
|
+
|
|
222
|
+
if (req.method === "POST" && pathname === "/notify") {
|
|
223
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
224
|
+
if (!nc) { sendJson(res, 503, { error: "NATS not connected — push notifications require server mode" }); return; }
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const body = await readBody(req);
|
|
228
|
+
const { title, body: notifBody } = JSON.parse(body) as { title: string; body: string };
|
|
229
|
+
if (!title || !notifBody) { sendJson(res, 400, { error: "title and body are required" }); return; }
|
|
230
|
+
|
|
231
|
+
const sc = StringCodec();
|
|
232
|
+
const payload = { hostId: config.hostId, title, body: notifBody };
|
|
233
|
+
const subject = `host.${config.hostId}.push.send`;
|
|
234
|
+
const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
235
|
+
const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
|
|
236
|
+
|
|
237
|
+
if (result.ok) {
|
|
238
|
+
sendJson(res, 200, { ok: true });
|
|
239
|
+
} else {
|
|
240
|
+
sendJson(res, 502, { error: result.error ?? "Push notification failed" });
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
sendJson(res, 500, { error: `Failed to send notification: ${err}` });
|
|
248
244
|
}
|
|
249
245
|
return;
|
|
250
246
|
}
|
|
251
247
|
|
|
252
|
-
//
|
|
253
|
-
|
|
248
|
+
// ── POST /request-input — held connection until user responds ────────
|
|
249
|
+
|
|
250
|
+
if (req.method === "POST" && pathname === "/request-input") {
|
|
251
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
254
252
|
try {
|
|
255
253
|
const body = await readBody(req);
|
|
256
|
-
const {
|
|
257
|
-
|
|
258
|
-
label?: string;
|
|
254
|
+
const { taskId, runId, descriptions } = JSON.parse(body) as {
|
|
255
|
+
taskId: string; runId?: string; descriptions: string[];
|
|
259
256
|
};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
sendJson(res, 400, { error: "Missing code" });
|
|
257
|
+
if (!taskId || !descriptions?.length) {
|
|
258
|
+
sendJson(res, 400, { error: "taskId and descriptions are required" });
|
|
263
259
|
return;
|
|
264
260
|
}
|
|
265
261
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
262
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
263
|
+
const task = parseTaskFile(taskDir);
|
|
264
|
+
|
|
265
|
+
await publishEvent(taskId, {
|
|
266
|
+
event_type: "input-request",
|
|
267
|
+
host_id: config.hostId,
|
|
268
|
+
input_descriptions: descriptions,
|
|
269
|
+
name: task.frontmatter.name,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const response = await registerPending(taskId, "input", descriptions);
|
|
273
|
+
|
|
274
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
275
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
276
|
+
if (runId) {
|
|
277
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
|
|
278
|
+
}
|
|
279
|
+
sendJson(res, 200, { aborted: true });
|
|
280
|
+
} else {
|
|
281
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
|
|
282
|
+
if (runId) {
|
|
283
|
+
const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
|
|
284
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
|
|
285
|
+
}
|
|
286
|
+
sendJson(res, 200, { values: response });
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
sendJson(res, 500, { error: String(err) });
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── POST /request-confirmation — held connection ────────────────────
|
|
295
|
+
|
|
296
|
+
if (req.method === "POST" && pathname === "/request-confirmation") {
|
|
297
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
298
|
+
try {
|
|
299
|
+
const body = await readBody(req);
|
|
300
|
+
const { taskId } = JSON.parse(body) as { taskId: string };
|
|
301
|
+
if (!taskId) { sendJson(res, 400, { error: "taskId is required" }); return; }
|
|
302
|
+
|
|
303
|
+
await publishEvent(taskId, {
|
|
304
|
+
event_type: "confirm-request",
|
|
305
|
+
host_id: config.hostId,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const response = await registerPending(taskId, "confirmation");
|
|
309
|
+
const confirmed = response[0] === "confirmed";
|
|
310
|
+
|
|
311
|
+
await publishEvent(taskId, {
|
|
312
|
+
event_type: "confirm-resolved",
|
|
313
|
+
host_id: config.hostId,
|
|
314
|
+
status: confirmed ? "confirmed" : "aborted",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
sendJson(res, 200, { confirmed });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
sendJson(res, 500, { error: String(err) });
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── POST /request-permission — held connection ──────────────────────
|
|
325
|
+
|
|
326
|
+
if (req.method === "POST" && pathname === "/request-permission") {
|
|
327
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
328
|
+
try {
|
|
329
|
+
const body = await readBody(req);
|
|
330
|
+
const { taskId, taskName, permissions } = JSON.parse(body) as {
|
|
331
|
+
taskId: string; taskName?: string; permissions: RequiredPermission[];
|
|
332
|
+
};
|
|
333
|
+
if (!taskId || !permissions?.length) {
|
|
334
|
+
sendJson(res, 400, { error: "taskId and permissions are required" });
|
|
269
335
|
return;
|
|
270
336
|
}
|
|
271
337
|
|
|
272
|
-
|
|
338
|
+
await publishEvent(taskId, {
|
|
339
|
+
event_type: "permission-request",
|
|
340
|
+
host_id: config.hostId,
|
|
341
|
+
required_permissions: permissions,
|
|
342
|
+
name: taskName,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const response = await registerPending(taskId, "permission", permissions);
|
|
346
|
+
const status = response[0] as "granted" | "granted_all" | "aborted";
|
|
347
|
+
|
|
348
|
+
await publishEvent(taskId, {
|
|
349
|
+
event_type: "permission-resolved",
|
|
350
|
+
host_id: config.hostId,
|
|
351
|
+
status,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
sendJson(res, 200, { response: status });
|
|
355
|
+
} catch (err) {
|
|
356
|
+
sendJson(res, 500, { error: String(err) });
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Public pair endpoint — no auth, PWA posts OTP code here ────────
|
|
362
|
+
|
|
363
|
+
if (req.method === "POST" && pathname === "/pair") {
|
|
364
|
+
try {
|
|
365
|
+
const body = await readBody(req);
|
|
366
|
+
const { code, label } = JSON.parse(body) as { code: string; label?: string };
|
|
367
|
+
if (!code) { sendJson(res, 400, { error: "Missing code" }); return; }
|
|
368
|
+
|
|
369
|
+
const pending = pendingPairs.get(code);
|
|
370
|
+
if (!pending) { sendJson(res, 401, { error: "Invalid code" }); return; }
|
|
371
|
+
|
|
273
372
|
const session = addSession(label);
|
|
274
373
|
const ip = detectLanIp();
|
|
275
374
|
const response: Record<string, unknown> = {
|
|
@@ -278,34 +377,42 @@ export async function startHttpTransport(
|
|
|
278
377
|
directUrl: `http://${ip}:${port}`,
|
|
279
378
|
};
|
|
280
379
|
|
|
281
|
-
// Resolve the long-poll and clean up
|
|
282
380
|
clearTimeout(pending.timer);
|
|
283
381
|
pendingPairs.delete(code);
|
|
284
382
|
pending.resolve({ paired: true });
|
|
285
383
|
|
|
286
384
|
sendJson(res, 200, response);
|
|
287
|
-
} catch {
|
|
288
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
289
|
-
}
|
|
385
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); }
|
|
290
386
|
return;
|
|
291
387
|
}
|
|
292
388
|
|
|
293
|
-
//
|
|
389
|
+
// ── PWA assets (on-the-fly, cached) ────────────────────────────────
|
|
390
|
+
|
|
391
|
+
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
392
|
+
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
393
|
+
|
|
294
394
|
const isApiRoute = pathname === "/events" || pathname.startsWith("/rpc/");
|
|
295
395
|
if (!isApiRoute) {
|
|
296
|
-
|
|
297
|
-
|
|
396
|
+
if (SKIP.has(pathname)) { sendJson(res, 404, { error: "Not found" }); return; }
|
|
397
|
+
|
|
398
|
+
// Try exact path, then fall back to index.html (SPA routing)
|
|
399
|
+
let asset = await getAsset(pathname);
|
|
400
|
+
if (!asset && pathname !== "/") {
|
|
401
|
+
asset = await getAsset("/");
|
|
402
|
+
}
|
|
403
|
+
|
|
298
404
|
if (asset) {
|
|
299
405
|
res.writeHead(200, { "Content-Type": asset.contentType });
|
|
300
406
|
res.end(asset.data);
|
|
301
407
|
} else {
|
|
302
|
-
sendJson(res,
|
|
408
|
+
sendJson(res, 502, { error: "Failed to fetch PWA assets" });
|
|
303
409
|
}
|
|
304
410
|
return;
|
|
305
411
|
}
|
|
306
412
|
|
|
307
|
-
// API endpoints require auth
|
|
308
|
-
|
|
413
|
+
// ── API endpoints require auth (localhost is trusted) ───────────────
|
|
414
|
+
|
|
415
|
+
if (!isLocalhost(req) && !checkAuth(req)) {
|
|
309
416
|
sendJson(res, 401, { error: "Unauthorized" });
|
|
310
417
|
return;
|
|
311
418
|
}
|
|
@@ -319,7 +426,6 @@ export async function startHttpTransport(
|
|
|
319
426
|
});
|
|
320
427
|
res.write(":ok\n\n");
|
|
321
428
|
|
|
322
|
-
// Send heartbeat every 5 seconds
|
|
323
429
|
const heartbeat = setInterval(() => {
|
|
324
430
|
res.write("data: {\"heartbeat\":true}\n\n");
|
|
325
431
|
}, 5000);
|
|
@@ -335,10 +441,7 @@ export async function startHttpTransport(
|
|
|
335
441
|
// RPC endpoint: POST /rpc/<method>
|
|
336
442
|
if (req.method === "POST" && pathname.startsWith("/rpc/")) {
|
|
337
443
|
const method = pathname.slice("/rpc/".length);
|
|
338
|
-
if (!method) {
|
|
339
|
-
sendJson(res, 400, { error: "Missing RPC method" });
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
444
|
+
if (!method) { sendJson(res, 400, { error: "Missing RPC method" }); return; }
|
|
342
445
|
|
|
343
446
|
let params: Record<string, unknown> = {};
|
|
344
447
|
try {
|
|
@@ -346,16 +449,13 @@ export async function startHttpTransport(
|
|
|
346
449
|
if (body.trim().length > 0) {
|
|
347
450
|
params = JSON.parse(body);
|
|
348
451
|
}
|
|
349
|
-
} catch {
|
|
350
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
452
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); return; }
|
|
353
453
|
|
|
354
454
|
const sessionToken = extractSessionToken(req);
|
|
355
455
|
console.log(`[http] RPC: ${method}`);
|
|
356
456
|
|
|
357
457
|
try {
|
|
358
|
-
const response = await handleRpc({ method, params, sessionToken });
|
|
458
|
+
const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
|
|
359
459
|
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
360
460
|
sendJson(res, 200, response);
|
|
361
461
|
} catch (err) {
|
|
@@ -369,11 +469,10 @@ export async function startHttpTransport(
|
|
|
369
469
|
});
|
|
370
470
|
|
|
371
471
|
return new Promise<void>((resolve, reject) => {
|
|
372
|
-
server.listen(port, () => {
|
|
373
|
-
console.log(`[http] Listening on
|
|
472
|
+
server.listen(port, bindAddress, () => {
|
|
473
|
+
console.log(`[http] Listening on ${bindAddress}:${port}`);
|
|
374
474
|
onReady?.();
|
|
375
475
|
|
|
376
|
-
// Graceful shutdown
|
|
377
476
|
const shutdown = () => {
|
|
378
477
|
console.log("[http] Shutting down...");
|
|
379
478
|
for (const client of sseClients) {
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,11 @@ export interface HostConfig {
|
|
|
8
8
|
|
|
9
9
|
// Detected agent CLIs
|
|
10
10
|
agents?: Array<{ key: string; label: string }>;
|
|
11
|
+
|
|
12
|
+
// HTTP server port (default 7400)
|
|
13
|
+
httpPort?: number;
|
|
14
|
+
// Whether to accept non-localhost HTTP connections
|
|
15
|
+
lanEnabled?: boolean;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export interface TaskFrontmatter {
|
|
@@ -33,8 +38,6 @@ export interface ParsedTask {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
|
-
* State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
|
|
37
|
-
*
|
|
38
41
|
* - `started`: task is actively running
|
|
39
42
|
* - `finished`: agent completed successfully
|
|
40
43
|
* - `aborted`: user declined confirmation, permission, or input
|
|
@@ -43,26 +46,15 @@ export interface ParsedTask {
|
|
|
43
46
|
export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
* Persisted to `status.json` in the task directory.
|
|
47
|
-
* and
|
|
48
|
-
*
|
|
49
|
-
* Interactive request flow: the run process sets a `pending_*` field and waits
|
|
50
|
-
* for `user_input` to be populated by an RPC call (task.user_input). Only one
|
|
51
|
-
* `pending_*` field is set at a time.
|
|
49
|
+
* Persisted to `status.json` in the task directory. Used for crash detection
|
|
50
|
+
* (checkStaleTasks) and abort signalling. Interactive request flows (confirmation,
|
|
51
|
+
* permission, input) are handled via held HTTP connections on the serve daemon.
|
|
52
52
|
*/
|
|
53
53
|
export interface TaskStatus {
|
|
54
54
|
running_state: TaskRunningState;
|
|
55
55
|
time_stamp: number;
|
|
56
56
|
/** PID of the palmier run process (used on Windows to kill the process tree). */
|
|
57
57
|
pid?: number;
|
|
58
|
-
/** Set when the task has `requires_confirmation` and is awaiting user approval. */
|
|
59
|
-
pending_confirmation?: boolean;
|
|
60
|
-
/** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
|
|
61
|
-
pending_permission?: RequiredPermission[];
|
|
62
|
-
/** Set when the agent requests user input. Contains descriptions of each requested value. */
|
|
63
|
-
pending_input?: string[];
|
|
64
|
-
/** Written by the RPC handler to deliver the user's response to the waiting run process. */
|
|
65
|
-
user_input?: string[];
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
export interface HistoryEntry {
|
|
@@ -87,4 +79,6 @@ export interface RpcMessage {
|
|
|
87
79
|
method: string;
|
|
88
80
|
params: Record<string, unknown>;
|
|
89
81
|
sessionToken?: string;
|
|
82
|
+
/** Trusted localhost request — skip session validation. */
|
|
83
|
+
localhost?: boolean;
|
|
90
84
|
}
|
package/dist/commands/lan.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Start an on-demand LAN server for direct HTTP connections.
|
|
3
|
-
* Generates a pairing code and displays it — no separate `palmier pair` needed.
|
|
4
|
-
*/
|
|
5
|
-
export declare function lanCommand(opts: {
|
|
6
|
-
port: number;
|
|
7
|
-
}): Promise<void>;
|
|
8
|
-
//# sourceMappingURL=lan.d.ts.map
|
package/dist/commands/lan.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import { loadConfig, CONFIG_DIR } from "../config.js";
|
|
3
|
-
import { createRpcHandler } from "../rpc-handler.js";
|
|
4
|
-
import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
|
|
5
|
-
import { generatePairingCode } from "./pair.js";
|
|
6
|
-
import { LAN_LOCKFILE } from "../lan-lock.js";
|
|
7
|
-
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
8
|
-
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
9
|
-
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
10
|
-
function writeLockfile(port) {
|
|
11
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
12
|
-
fs.writeFileSync(LAN_LOCKFILE, JSON.stringify({ port, pid: process.pid }), "utf-8");
|
|
13
|
-
}
|
|
14
|
-
function removeLockfile() {
|
|
15
|
-
try {
|
|
16
|
-
fs.unlinkSync(LAN_LOCKFILE);
|
|
17
|
-
}
|
|
18
|
-
catch { /* ignore */ }
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Start an on-demand LAN server for direct HTTP connections.
|
|
22
|
-
* Generates a pairing code and displays it — no separate `palmier pair` needed.
|
|
23
|
-
*/
|
|
24
|
-
export async function lanCommand(opts) {
|
|
25
|
-
const config = loadConfig();
|
|
26
|
-
const port = opts.port;
|
|
27
|
-
const ip = detectLanIp();
|
|
28
|
-
const code = generatePairingCode();
|
|
29
|
-
const handleRpc = createRpcHandler(config);
|
|
30
|
-
// Write lockfile so other palmier processes can discover us
|
|
31
|
-
writeLockfile(port);
|
|
32
|
-
// Clean up on exit
|
|
33
|
-
process.on("SIGINT", () => { removeLockfile(); process.exit(0); });
|
|
34
|
-
process.on("SIGTERM", () => { removeLockfile(); process.exit(0); });
|
|
35
|
-
process.on("exit", removeLockfile);
|
|
36
|
-
// Start the HTTP transport with the pre-generated pairing code
|
|
37
|
-
await startHttpTransport(config, handleRpc, port, code, () => {
|
|
38
|
-
console.log(`\n${bold("Palmier LAN Server")}\n`);
|
|
39
|
-
console.log(` ${cyan("Open the app at:")} ${bold(`http://${ip}:${port}`)}\n`);
|
|
40
|
-
console.log(` ${cyan("Pairing code:")} ${bold(code)}\n`);
|
|
41
|
-
console.log(` ${dim("Press Ctrl+C to stop.")}\n`);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
//# sourceMappingURL=lan.js.map
|