palmier 0.4.5 → 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.
Files changed (57) hide show
  1. package/README.md +29 -31
  2. package/dist/agents/agent-instructions.md +4 -11
  3. package/dist/agents/claude.js +3 -3
  4. package/dist/agents/codex.js +2 -2
  5. package/dist/agents/copilot.js +3 -3
  6. package/dist/agents/gemini.js +3 -3
  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/run.js +19 -43
  14. package/dist/commands/serve.d.ts +1 -1
  15. package/dist/commands/serve.js +9 -2
  16. package/dist/events.d.ts +2 -2
  17. package/dist/events.js +15 -16
  18. package/dist/index.js +0 -25
  19. package/dist/pending-requests.d.ts +27 -0
  20. package/dist/pending-requests.js +39 -0
  21. package/dist/rpc-handler.js +15 -8
  22. package/dist/transports/http-transport.d.ts +4 -2
  23. package/dist/transports/http-transport.js +226 -77
  24. package/dist/types.d.ts +7 -16
  25. package/package.json +1 -1
  26. package/src/agents/agent-instructions.md +4 -11
  27. package/src/agents/claude.ts +3 -3
  28. package/src/agents/codex.ts +2 -2
  29. package/src/agents/copilot.ts +3 -3
  30. package/src/agents/gemini.ts +3 -3
  31. package/src/agents/openclaw.ts +2 -2
  32. package/src/agents/shared-prompt.ts +12 -6
  33. package/src/commands/init.ts +34 -3
  34. package/src/commands/pair.ts +11 -14
  35. package/src/commands/run.ts +17 -57
  36. package/src/commands/serve.ts +11 -2
  37. package/src/events.ts +14 -15
  38. package/src/index.ts +0 -26
  39. package/src/pending-requests.ts +55 -0
  40. package/src/rpc-handler.ts +15 -9
  41. package/src/transports/http-transport.ts +235 -135
  42. package/src/types.ts +10 -16
  43. package/dist/commands/lan.d.ts +0 -8
  44. package/dist/commands/lan.js +0 -44
  45. package/dist/commands/notify.d.ts +0 -9
  46. package/dist/commands/notify.js +0 -43
  47. package/dist/commands/request-input.d.ts +0 -10
  48. package/dist/commands/request-input.js +0 -49
  49. package/dist/lan-lock.d.ts +0 -7
  50. package/dist/lan-lock.js +0 -18
  51. package/dist/user-input.d.ts +0 -15
  52. package/dist/user-input.js +0 -50
  53. package/src/commands/lan.ts +0 -48
  54. package/src/commands/notify.ts +0 -44
  55. package/src/commands/request-input.ts +0 -51
  56. package/src/lan-lock.ts +0 -16
  57. package/src/user-input.ts +0 -67
@@ -0,0 +1,55 @@
1
+ import type { RequiredPermission } from "./types.js";
2
+
3
+ export interface PendingRequest {
4
+ type: "confirmation" | "permission" | "input";
5
+ resolve: (value: string[]) => void;
6
+ /** Permission list (for 'permission') or input descriptions (for 'input'). */
7
+ params?: RequiredPermission[] | string[];
8
+ }
9
+
10
+ const pending = new Map<string, PendingRequest>();
11
+
12
+ /**
13
+ * Register a pending request for a task. Returns a Promise that resolves
14
+ * when `resolvePending` is called with the user's response.
15
+ * Only one pending request per task at a time.
16
+ */
17
+ export function registerPending(
18
+ taskId: string,
19
+ type: PendingRequest["type"],
20
+ params?: PendingRequest["params"],
21
+ ): Promise<string[]> {
22
+ if (pending.has(taskId)) {
23
+ return Promise.reject(new Error(`Task ${taskId} already has a pending request`));
24
+ }
25
+
26
+ return new Promise<string[]>((resolve) => {
27
+ pending.set(taskId, { type, resolve, params });
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Resolve a pending request with the user's response.
33
+ * Returns true if a pending request was found and resolved.
34
+ */
35
+ export function resolvePending(taskId: string, value: string[]): boolean {
36
+ const entry = pending.get(taskId);
37
+ if (!entry) return false;
38
+ pending.delete(taskId);
39
+ entry.resolve(value);
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * Get the current pending request for a task (if any).
45
+ */
46
+ export function getPending(taskId: string): PendingRequest | undefined {
47
+ return pending.get(taskId);
48
+ }
49
+
50
+ /**
51
+ * Remove a pending request without resolving it.
52
+ */
53
+ export function removePending(taskId: string): void {
54
+ pending.delete(taskId);
55
+ }
@@ -6,6 +6,7 @@ import { spawn, type ChildProcess } from "child_process";
6
6
  import { parse as parseYaml } from "yaml";
7
7
  import { type NatsConnection } from "nats";
8
8
  import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
9
+ import { resolvePending, getPending } from "./pending-requests.js";
9
10
  import { getPlatform } from "./platform/index.js";
10
11
  import { spawnCommand } from "./spawn-command.js";
11
12
  import crossSpawn from "cross-spawn";
@@ -157,8 +158,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
157
158
  }
158
159
 
159
160
  async function handleRpc(request: RpcMessage): Promise<unknown> {
160
- // Session token validation: always require a valid session token
161
- if (!request.sessionToken || !validateSession(request.sessionToken)) {
161
+ // Session token validation: skip for trusted localhost requests
162
+ if (!request.localhost && (!request.sessionToken || !validateSession(request.sessionToken))) {
162
163
  return { error: "Unauthorized" };
163
164
  }
164
165
 
@@ -521,7 +522,14 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
521
522
  if (!status) {
522
523
  return { task_id: params.id, error: "No status found" };
523
524
  }
524
- return { task_id: params.id, ...status };
525
+ const pending = getPending(params.id);
526
+ return {
527
+ task_id: params.id,
528
+ ...status,
529
+ ...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
530
+ ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
531
+ ...(pending?.type === "input" ? { pending_input: pending.params } : {}),
532
+ };
525
533
  }
526
534
 
527
535
  case "task.result": {
@@ -570,17 +578,15 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
570
578
 
571
579
  case "task.user_input": {
572
580
  const params = request.params as { id: string; value: string[] };
573
- const taskDir = getTaskDir(config.projectRoot, params.id);
574
581
 
575
- const currentStatus = readTaskStatus(taskDir);
576
- if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length && !currentStatus?.pending_input?.length) {
582
+ const pending = getPending(params.id);
583
+ if (!pending) {
577
584
  return { ok: false, error: "not pending" };
578
585
  }
579
586
 
580
- writeTaskStatus(taskDir, { ...currentStatus, user_input: params.value });
581
-
587
+ const resolved = resolvePending(params.id, params.value);
582
588
  console.log(`[task.user_input] ${params.id} → ${params.value}`);
583
- return { ok: true };
589
+ return { ok: resolved };
584
590
  }
585
591
 
586
592
  case "taskrun.list": {
@@ -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,163 @@ export async function startHttpTransport(
243
213
  });
244
214
 
245
215
  sendJson(res, 200, result);
246
- } catch {
247
- sendJson(res, 400, { error: "Invalid JSON" });
248
- }
216
+ } catch { sendJson(res, 400, { error: "Invalid JSON" }); }
249
217
  return;
250
218
  }
251
219
 
252
- // Public pair endpointno auth required, PWA posts OTP code here
253
- if (req.method === "POST" && pathname === "/pair") {
220
+ // ── GET /notifysend push notification via NATS ──────────────────
221
+
222
+ if (req.method === "GET" && 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
+
254
226
  try {
255
- const body = await readBody(req);
256
- const { code, label } = JSON.parse(body) as {
257
- code: string;
258
- label?: string;
259
- };
227
+ const title = url.searchParams.get("title");
228
+ const notifBody = url.searchParams.get("body");
229
+ if (!title || !notifBody) { sendJson(res, 400, { error: "title and body query params 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}` });
244
+ }
245
+ return;
246
+ }
260
247
 
261
- if (!code) {
262
- sendJson(res, 400, { error: "Missing code" });
248
+ // ── GET /request-input — held connection until user responds ────────
249
+
250
+ if (req.method === "GET" && pathname === "/request-input") {
251
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
252
+ try {
253
+ const taskId = url.searchParams.get("taskId");
254
+ const runId = url.searchParams.get("runId");
255
+ const descriptions = url.searchParams.getAll("descriptions");
256
+ if (!taskId || !descriptions.length) {
257
+ sendJson(res, 400, { error: "taskId and descriptions query params are required" });
263
258
  return;
264
259
  }
265
260
 
266
- const pending = pendingPairs.get(code);
267
- if (!pending) {
268
- sendJson(res, 401, { error: "Invalid code" });
261
+ const taskDir = getTaskDir(config.projectRoot, taskId);
262
+ const task = parseTaskFile(taskDir);
263
+
264
+ await publishEvent(taskId, {
265
+ event_type: "input-request",
266
+ host_id: config.hostId,
267
+ input_descriptions: descriptions,
268
+ name: task.frontmatter.name,
269
+ });
270
+
271
+ const response = await registerPending(taskId, "input", descriptions);
272
+
273
+ if (response.length === 1 && response[0] === "aborted") {
274
+ await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
275
+ if (runId) {
276
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
277
+ }
278
+ sendJson(res, 200, { aborted: true });
279
+ } else {
280
+ await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
281
+ if (runId) {
282
+ const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
283
+ appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
284
+ }
285
+ sendJson(res, 200, { values: response });
286
+ }
287
+ } catch (err) {
288
+ sendJson(res, 500, { error: String(err) });
289
+ }
290
+ return;
291
+ }
292
+
293
+ // ── GET /request-confirmation — held connection ─────────────────────
294
+
295
+ if (req.method === "GET" && pathname === "/request-confirmation") {
296
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
297
+ try {
298
+ const taskId = url.searchParams.get("taskId");
299
+ if (!taskId) { sendJson(res, 400, { error: "taskId query param is required" }); return; }
300
+
301
+ await publishEvent(taskId, {
302
+ event_type: "confirm-request",
303
+ host_id: config.hostId,
304
+ });
305
+
306
+ const response = await registerPending(taskId, "confirmation");
307
+ const confirmed = response[0] === "confirmed";
308
+
309
+ await publishEvent(taskId, {
310
+ event_type: "confirm-resolved",
311
+ host_id: config.hostId,
312
+ status: confirmed ? "confirmed" : "aborted",
313
+ });
314
+
315
+ sendJson(res, 200, { confirmed });
316
+ } catch (err) {
317
+ sendJson(res, 500, { error: String(err) });
318
+ }
319
+ return;
320
+ }
321
+
322
+ // ── GET /request-permission — held connection ───────────────────────
323
+
324
+ if (req.method === "GET" && pathname === "/request-permission") {
325
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
326
+ try {
327
+ const taskId = url.searchParams.get("taskId");
328
+ const taskName = url.searchParams.get("taskName");
329
+ const permissionsRaw = url.searchParams.get("permissions");
330
+ let permissions: RequiredPermission[] = [];
331
+ if (permissionsRaw) {
332
+ try { permissions = JSON.parse(permissionsRaw) as RequiredPermission[]; } catch { /* ignore */ }
333
+ }
334
+ if (!taskId || !permissions.length) {
335
+ sendJson(res, 400, { error: "taskId and permissions query params are required" });
269
336
  return;
270
337
  }
271
338
 
272
- // Create session and build response
339
+ await publishEvent(taskId, {
340
+ event_type: "permission-request",
341
+ host_id: config.hostId,
342
+ required_permissions: permissions,
343
+ name: taskName,
344
+ });
345
+
346
+ const response = await registerPending(taskId, "permission", permissions);
347
+ const status = response[0] as "granted" | "granted_all" | "aborted";
348
+
349
+ await publishEvent(taskId, {
350
+ event_type: "permission-resolved",
351
+ host_id: config.hostId,
352
+ status,
353
+ });
354
+
355
+ sendJson(res, 200, { response: status });
356
+ } catch (err) {
357
+ sendJson(res, 500, { error: String(err) });
358
+ }
359
+ return;
360
+ }
361
+
362
+ // ── Public pair endpoint — no auth, PWA posts OTP code here ────────
363
+
364
+ if (req.method === "POST" && pathname === "/pair") {
365
+ try {
366
+ const body = await readBody(req);
367
+ const { code, label } = JSON.parse(body) as { code: string; label?: string };
368
+ if (!code) { sendJson(res, 400, { error: "Missing code" }); return; }
369
+
370
+ const pending = pendingPairs.get(code);
371
+ if (!pending) { sendJson(res, 401, { error: "Invalid code" }); return; }
372
+
273
373
  const session = addSession(label);
274
374
  const ip = detectLanIp();
275
375
  const response: Record<string, unknown> = {
@@ -278,34 +378,42 @@ export async function startHttpTransport(
278
378
  directUrl: `http://${ip}:${port}`,
279
379
  };
280
380
 
281
- // Resolve the long-poll and clean up
282
381
  clearTimeout(pending.timer);
283
382
  pendingPairs.delete(code);
284
383
  pending.resolve({ paired: true });
285
384
 
286
385
  sendJson(res, 200, response);
287
- } catch {
288
- sendJson(res, 400, { error: "Invalid JSON" });
289
- }
386
+ } catch { sendJson(res, 400, { error: "Invalid JSON" }); }
290
387
  return;
291
388
  }
292
389
 
293
- // Serve cached PWA assets for non-API routes (no auth required)
390
+ // ── PWA assets (on-the-fly, cached) ────────────────────────────────
391
+
392
+ // Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
393
+ const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
394
+
294
395
  const isApiRoute = pathname === "/events" || pathname.startsWith("/rpc/");
295
396
  if (!isApiRoute) {
296
- // SPA fallback: serve index.html for unrecognized paths
297
- const asset = pwaAssets.get(pathname) ?? (pathname !== "/" ? pwaAssets.get("/") : undefined);
397
+ if (SKIP.has(pathname)) { sendJson(res, 404, { error: "Not found" }); return; }
398
+
399
+ // Try exact path, then fall back to index.html (SPA routing)
400
+ let asset = await getAsset(pathname);
401
+ if (!asset && pathname !== "/") {
402
+ asset = await getAsset("/");
403
+ }
404
+
298
405
  if (asset) {
299
406
  res.writeHead(200, { "Content-Type": asset.contentType });
300
407
  res.end(asset.data);
301
408
  } else {
302
- sendJson(res, 404, { error: "Not found" });
409
+ sendJson(res, 502, { error: "Failed to fetch PWA assets" });
303
410
  }
304
411
  return;
305
412
  }
306
413
 
307
- // API endpoints require auth
308
- if (!checkAuth(req)) {
414
+ // ── API endpoints require auth (localhost is trusted) ───────────────
415
+
416
+ if (!isLocalhost(req) && !checkAuth(req)) {
309
417
  sendJson(res, 401, { error: "Unauthorized" });
310
418
  return;
311
419
  }
@@ -319,7 +427,6 @@ export async function startHttpTransport(
319
427
  });
320
428
  res.write(":ok\n\n");
321
429
 
322
- // Send heartbeat every 5 seconds
323
430
  const heartbeat = setInterval(() => {
324
431
  res.write("data: {\"heartbeat\":true}\n\n");
325
432
  }, 5000);
@@ -335,10 +442,7 @@ export async function startHttpTransport(
335
442
  // RPC endpoint: POST /rpc/<method>
336
443
  if (req.method === "POST" && pathname.startsWith("/rpc/")) {
337
444
  const method = pathname.slice("/rpc/".length);
338
- if (!method) {
339
- sendJson(res, 400, { error: "Missing RPC method" });
340
- return;
341
- }
445
+ if (!method) { sendJson(res, 400, { error: "Missing RPC method" }); return; }
342
446
 
343
447
  let params: Record<string, unknown> = {};
344
448
  try {
@@ -346,16 +450,13 @@ export async function startHttpTransport(
346
450
  if (body.trim().length > 0) {
347
451
  params = JSON.parse(body);
348
452
  }
349
- } catch {
350
- sendJson(res, 400, { error: "Invalid JSON" });
351
- return;
352
- }
453
+ } catch { sendJson(res, 400, { error: "Invalid JSON" }); return; }
353
454
 
354
455
  const sessionToken = extractSessionToken(req);
355
456
  console.log(`[http] RPC: ${method}`);
356
457
 
357
458
  try {
358
- const response = await handleRpc({ method, params, sessionToken });
459
+ const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
359
460
  console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
360
461
  sendJson(res, 200, response);
361
462
  } catch (err) {
@@ -369,11 +470,10 @@ export async function startHttpTransport(
369
470
  });
370
471
 
371
472
  return new Promise<void>((resolve, reject) => {
372
- server.listen(port, () => {
373
- console.log(`[http] Listening on port ${port}`);
473
+ server.listen(port, bindAddress, () => {
474
+ console.log(`[http] Listening on ${bindAddress}:${port}`);
374
475
  onReady?.();
375
476
 
376
- // Graceful shutdown
377
477
  const shutdown = () => {
378
478
  console.log("[http] Shutting down...");
379
479
  for (const client of sseClients) {