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.
Files changed (62) hide show
  1. package/README.md +29 -31
  2. package/dist/agents/agent-instructions.md +9 -9
  3. package/dist/agents/claude.js +3 -3
  4. package/dist/agents/codex.js +3 -3
  5. package/dist/agents/copilot.js +3 -6
  6. package/dist/agents/gemini.js +4 -5
  7. package/dist/agents/openclaw.js +2 -2
  8. package/dist/agents/shared-prompt.d.ts +2 -4
  9. package/dist/agents/shared-prompt.js +9 -4
  10. package/dist/commands/init.js +31 -2
  11. package/dist/commands/pair.d.ts +1 -1
  12. package/dist/commands/pair.js +12 -15
  13. package/dist/commands/plan-generation.md +12 -15
  14. package/dist/commands/run.js +23 -44
  15. package/dist/commands/serve.d.ts +1 -1
  16. package/dist/commands/serve.js +9 -2
  17. package/dist/events.d.ts +2 -2
  18. package/dist/events.js +15 -16
  19. package/dist/index.js +0 -25
  20. package/dist/pending-requests.d.ts +27 -0
  21. package/dist/pending-requests.js +39 -0
  22. package/dist/rpc-handler.js +18 -10
  23. package/dist/task.d.ts +1 -1
  24. package/dist/task.js +3 -2
  25. package/dist/transports/http-transport.d.ts +4 -2
  26. package/dist/transports/http-transport.js +218 -77
  27. package/dist/types.d.ts +7 -16
  28. package/package.json +1 -1
  29. package/src/agents/agent-instructions.md +9 -9
  30. package/src/agents/claude.ts +3 -3
  31. package/src/agents/codex.ts +3 -3
  32. package/src/agents/copilot.ts +3 -6
  33. package/src/agents/gemini.ts +5 -5
  34. package/src/agents/openclaw.ts +2 -2
  35. package/src/agents/shared-prompt.ts +12 -6
  36. package/src/commands/init.ts +34 -3
  37. package/src/commands/pair.ts +11 -14
  38. package/src/commands/plan-generation.md +12 -15
  39. package/src/commands/run.ts +21 -58
  40. package/src/commands/serve.ts +11 -2
  41. package/src/events.ts +14 -15
  42. package/src/index.ts +0 -26
  43. package/src/pending-requests.ts +55 -0
  44. package/src/rpc-handler.ts +18 -11
  45. package/src/task.ts +3 -1
  46. package/src/transports/http-transport.ts +232 -133
  47. package/src/types.ts +10 -16
  48. package/dist/commands/lan.d.ts +0 -8
  49. package/dist/commands/lan.js +0 -44
  50. package/dist/commands/notify.d.ts +0 -9
  51. package/dist/commands/notify.js +0 -43
  52. package/dist/commands/request-input.d.ts +0 -10
  53. package/dist/commands/request-input.js +0 -49
  54. package/dist/lan-lock.d.ts +0 -7
  55. package/dist/lan-lock.js +0 -18
  56. package/dist/user-input.d.ts +0 -15
  57. package/dist/user-input.js +0 -50
  58. package/src/commands/lan.ts +0 -48
  59. package/src/commands/notify.ts +0 -44
  60. package/src/commands/request-input.ts +0 -51
  61. package/src/lan-lock.ts +0 -16
  62. 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 type { HostConfig, RpcMessage } from "../types.js";
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
- // ── In-memory PWA asset cache ──────────────────────────────────────────
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
- * Download the PWA from palmier.me into memory.
41
- * Parses index.html for asset references, then fetches each one.
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 downloadPwaAssets(): Promise<Map<string, CachedAsset>> {
44
- const assets = new Map<string, CachedAsset>();
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
- // 3. Fetch all HTML-referenced assets
66
- for (const ref of htmlRefs) {
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
- const data = await fetchBuffer(`${PWA_ORIGIN}${ref}`);
69
- assets.set(ref, { data, contentType: guessContentType(ref) });
70
-
71
- // 4. Parse CSS for font url() references
72
- if (ref.endsWith(".css")) {
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 ${ref}: ${err}`);
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
- return assets;
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: Express-like server with RPC, SSE, and health endpoints.
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 (from `palmier lan`), pre-register it
118
+ // If a pairing code is provided, pre-register it
134
119
  if (pairingCode) {
135
- const EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours — stays valid while lan server runs
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
- const token = auth.slice(7);
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
- // Internal event endpoint — localhost only, no auth
188
- if (req.method === "POST" && pathname === "/internal/event") {
189
- if (!isLocalhost(req)) {
190
- sendJson(res, 403, { error: "localhost only" });
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
- // Internal pair-register endpoint localhost only, long-poll
205
- // The pair CLI posts here and blocks until paired or expired.
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
- code: string;
215
- expiryMs: number;
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
- sendJson(res, 400, { error: "Invalid JSON" });
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
- // Public pair endpointno auth required, PWA posts OTP code here
253
- if (req.method === "POST" && pathname === "/pair") {
248
+ // ── POST /request-inputheld 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 { code, label } = JSON.parse(body) as {
257
- code: string;
258
- label?: string;
254
+ const { taskId, runId, descriptions } = JSON.parse(body) as {
255
+ taskId: string; runId?: string; descriptions: string[];
259
256
  };
260
-
261
- if (!code) {
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 pending = pendingPairs.get(code);
267
- if (!pending) {
268
- sendJson(res, 401, { error: "Invalid code" });
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
- // Create session and build response
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
- // Serve cached PWA assets for non-API routes (no auth required)
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
- // SPA fallback: serve index.html for unrecognized paths
297
- const asset = pwaAssets.get(pathname) ?? (pathname !== "/" ? pwaAssets.get("/") : undefined);
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, 404, { error: "Not found" });
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
- if (!checkAuth(req)) {
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 port ${port}`);
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. Updated by the run process
47
- * and read by the RPC handler + PWA to track live task state.
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
  }
@@ -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
@@ -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
@@ -1,9 +0,0 @@
1
- /**
2
- * Send a push notification to the user via NATS.
3
- * Usage: palmier notify --title "Title" --body "Body text"
4
- */
5
- export declare function notifyCommand(opts: {
6
- title: string;
7
- body: string;
8
- }): Promise<void>;
9
- //# sourceMappingURL=notify.d.ts.map