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
package/dist/events.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { StringCodec } from "nats";
2
- import { getLanPort } from "./lan-lock.js";
2
+ import { loadConfig } from "./config.js";
3
3
  const sc = StringCodec();
4
4
  /**
5
- * Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
5
+ * Broadcast an event to connected clients via NATS and HTTP SSE.
6
6
  *
7
7
  * - NATS: publishes to `host-event.{hostId}.{taskId}`
8
- * - HTTP: POSTs to the LAN server's `/internal/event` endpoint (auto-detected via lockfile)
8
+ * - HTTP: POSTs to the serve daemon's `/event` endpoint
9
9
  */
10
10
  export async function publishHostEvent(nc, hostId, taskId, payload) {
11
11
  const subject = `host-event.${hostId}.${taskId}`;
@@ -13,19 +13,18 @@ export async function publishHostEvent(nc, hostId, taskId, payload) {
13
13
  nc.publish(subject, sc.encode(JSON.stringify(payload)));
14
14
  console.log(`[nats] ${subject} →`, payload);
15
15
  }
16
- const lanPort = getLanPort();
17
- if (lanPort) {
18
- try {
19
- await fetch(`http://localhost:${lanPort}/internal/event`, {
20
- method: "POST",
21
- headers: { "Content-Type": "application/json" },
22
- body: JSON.stringify({ task_id: taskId, ...payload }),
23
- });
24
- console.log(`[http] host-event: ${taskId} →`, payload);
25
- }
26
- catch {
27
- // LAN server may have shut down — ignore
28
- }
16
+ const config = loadConfig();
17
+ const port = config.httpPort ?? 7400;
18
+ try {
19
+ await fetch(`http://localhost:${port}/event`, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ task_id: taskId, ...payload }),
23
+ });
24
+ console.log(`[http] host-event: ${taskId} →`, payload);
25
+ }
26
+ catch {
27
+ // Serve HTTP may not be ready yet — ignore
29
28
  }
30
29
  }
31
30
  //# sourceMappingURL=events.js.map
package/dist/index.js CHANGED
@@ -8,10 +8,7 @@ import { initCommand } from "./commands/init.js";
8
8
  import { infoCommand } from "./commands/info.js";
9
9
  import { runCommand } from "./commands/run.js";
10
10
  import { serveCommand } from "./commands/serve.js";
11
- import { notifyCommand } from "./commands/notify.js";
12
- import { requestInputCommand } from "./commands/request-input.js";
13
11
  import { pairCommand } from "./commands/pair.js";
14
- import { lanCommand } from "./commands/lan.js";
15
12
  import { restartCommand } from "./commands/restart.js";
16
13
  import { sessionsListCommand, sessionsRevokeCommand, sessionsRevokeAllCommand } from "./commands/sessions.js";
17
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -51,34 +48,12 @@ program
51
48
  .action(async () => {
52
49
  await restartCommand();
53
50
  });
54
- program
55
- .command("notify")
56
- .description("Send a push notification to the user")
57
- .requiredOption("--title <title>", "Notification title")
58
- .requiredOption("--body <body>", "Notification body text")
59
- .action(async (opts) => {
60
- await notifyCommand(opts);
61
- });
62
- program
63
- .command("request-input")
64
- .description("Request input from the user (requires PALMIER_TASK_ID env var)")
65
- .requiredOption("--description <desc...>", "Input descriptions to show the user")
66
- .action(async (opts) => {
67
- await requestInputCommand(opts);
68
- });
69
51
  program
70
52
  .command("pair")
71
53
  .description("Generate a pairing code for connecting a PWA client")
72
54
  .action(async () => {
73
55
  await pairCommand();
74
56
  });
75
- program
76
- .command("lan")
77
- .description("Start an on-demand LAN server for direct HTTP connections")
78
- .option("-p, --port <port>", "Port to listen on", "7400")
79
- .action(async (opts) => {
80
- await lanCommand({ port: parseInt(opts.port, 10) });
81
- });
82
57
  const sessionsCmd = program
83
58
  .command("sessions")
84
59
  .description("Manage paired client sessions");
@@ -0,0 +1,27 @@
1
+ import type { RequiredPermission } from "./types.js";
2
+ export interface PendingRequest {
3
+ type: "confirmation" | "permission" | "input";
4
+ resolve: (value: string[]) => void;
5
+ /** Permission list (for 'permission') or input descriptions (for 'input'). */
6
+ params?: RequiredPermission[] | string[];
7
+ }
8
+ /**
9
+ * Register a pending request for a task. Returns a Promise that resolves
10
+ * when `resolvePending` is called with the user's response.
11
+ * Only one pending request per task at a time.
12
+ */
13
+ export declare function registerPending(taskId: string, type: PendingRequest["type"], params?: PendingRequest["params"]): Promise<string[]>;
14
+ /**
15
+ * Resolve a pending request with the user's response.
16
+ * Returns true if a pending request was found and resolved.
17
+ */
18
+ export declare function resolvePending(taskId: string, value: string[]): boolean;
19
+ /**
20
+ * Get the current pending request for a task (if any).
21
+ */
22
+ export declare function getPending(taskId: string): PendingRequest | undefined;
23
+ /**
24
+ * Remove a pending request without resolving it.
25
+ */
26
+ export declare function removePending(taskId: string): void;
27
+ //# sourceMappingURL=pending-requests.d.ts.map
@@ -0,0 +1,39 @@
1
+ const pending = new Map();
2
+ /**
3
+ * Register a pending request for a task. Returns a Promise that resolves
4
+ * when `resolvePending` is called with the user's response.
5
+ * Only one pending request per task at a time.
6
+ */
7
+ export function registerPending(taskId, type, params) {
8
+ if (pending.has(taskId)) {
9
+ return Promise.reject(new Error(`Task ${taskId} already has a pending request`));
10
+ }
11
+ return new Promise((resolve) => {
12
+ pending.set(taskId, { type, resolve, params });
13
+ });
14
+ }
15
+ /**
16
+ * Resolve a pending request with the user's response.
17
+ * Returns true if a pending request was found and resolved.
18
+ */
19
+ export function resolvePending(taskId, value) {
20
+ const entry = pending.get(taskId);
21
+ if (!entry)
22
+ return false;
23
+ pending.delete(taskId);
24
+ entry.resolve(value);
25
+ return true;
26
+ }
27
+ /**
28
+ * Get the current pending request for a task (if any).
29
+ */
30
+ export function getPending(taskId) {
31
+ return pending.get(taskId);
32
+ }
33
+ /**
34
+ * Remove a pending request without resolving it.
35
+ */
36
+ export function removePending(taskId) {
37
+ pending.delete(taskId);
38
+ }
39
+ //# sourceMappingURL=pending-requests.js.map
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
5
5
  import { spawn } from "child_process";
6
6
  import { parse as parseYaml } from "yaml";
7
7
  import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
8
+ import { resolvePending, getPending } from "./pending-requests.js";
8
9
  import { getPlatform } from "./platform/index.js";
9
10
  import { spawnCommand } from "./spawn-command.js";
10
11
  import crossSpawn from "cross-spawn";
@@ -131,8 +132,8 @@ export function createRpcHandler(config, nc) {
131
132
  };
132
133
  }
133
134
  async function handleRpc(request) {
134
- // Session token validation: always require a valid session token
135
- if (!request.sessionToken || !validateSession(request.sessionToken)) {
135
+ // Session token validation: skip for trusted localhost requests
136
+ if (!request.localhost && (!request.sessionToken || !validateSession(request.sessionToken))) {
136
137
  return { error: "Unauthorized" };
137
138
  }
138
139
  switch (request.method) {
@@ -447,7 +448,14 @@ export function createRpcHandler(config, nc) {
447
448
  if (!status) {
448
449
  return { task_id: params.id, error: "No status found" };
449
450
  }
450
- return { task_id: params.id, ...status };
451
+ const pending = getPending(params.id);
452
+ return {
453
+ task_id: params.id,
454
+ ...status,
455
+ ...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
456
+ ...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
457
+ ...(pending?.type === "input" ? { pending_input: pending.params } : {}),
458
+ };
451
459
  }
452
460
  case "task.result": {
453
461
  const params = request.params;
@@ -494,14 +502,13 @@ export function createRpcHandler(config, nc) {
494
502
  }
495
503
  case "task.user_input": {
496
504
  const params = request.params;
497
- const taskDir = getTaskDir(config.projectRoot, params.id);
498
- const currentStatus = readTaskStatus(taskDir);
499
- if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length && !currentStatus?.pending_input?.length) {
505
+ const pending = getPending(params.id);
506
+ if (!pending) {
500
507
  return { ok: false, error: "not pending" };
501
508
  }
502
- writeTaskStatus(taskDir, { ...currentStatus, user_input: params.value });
509
+ const resolved = resolvePending(params.id, params.value);
503
510
  console.log(`[task.user_input] ${params.id} → ${params.value}`);
504
- return { ok: true };
511
+ return { ok: resolved };
505
512
  }
506
513
  case "taskrun.list": {
507
514
  const params = request.params;
@@ -1,7 +1,9 @@
1
+ import { type NatsConnection } from "nats";
1
2
  import type { HostConfig, RpcMessage } from "../types.js";
2
3
  export declare function detectLanIp(): string;
3
4
  /**
4
- * Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
5
+ * Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
6
+ * localhost-only agent endpoints (notify, request-input, confirmation, permission).
5
7
  */
6
- export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, port: number, pairingCode?: string, onReady?: () => void): Promise<void>;
8
+ export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, port: number, nc: NatsConnection | undefined, pairingCode?: string, onReady?: () => void): Promise<void>;
7
9
  //# sourceMappingURL=http-transport.d.ts.map
@@ -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
- * Download the PWA from palmier.me into memory.
28
- * Parses index.html for asset references, then fetches each one.
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 downloadPwaAssets() {
31
- const assets = new Map();
32
- // 1. Fetch index.html
33
- const html = await fetchBuffer(`${PWA_ORIGIN}/`);
34
- assets.set("/", { data: html, contentType: "text/html; charset=utf-8" });
35
- const htmlStr = html.toString("utf-8");
36
- // 2. Extract references from HTML (src="..." and href="...")
37
- // Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
38
- const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
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
- const data = await fetchBuffer(`${PWA_ORIGIN}${ref}`);
52
- assets.set(ref, { data, contentType: guessContentType(ref) });
53
- // 4. Parse CSS for font url() references
54
- if (ref.endsWith(".css")) {
55
- const cssStr = data.toString("utf-8");
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 ${ref}: ${err}`);
59
+ console.warn(`[pwa] Failed to fetch ${urlPath}: ${err}`);
60
+ return null;
73
61
  }
74
- }
75
- return assets;
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: Express-like server with RPC, SSE, and health endpoints.
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
- // If a pairing code is provided (from `palmier lan`), pre-register it
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; // 24 hours — stays valid while lan server runs
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
- const token = auth.slice(7);
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
- // Internal event endpoint — localhost only, no auth
146
- if (req.method === "POST" && pathname === "/internal/event") {
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
- // Internal pair-register endpoint localhost only, long-poll
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
- // Public pair endpointno auth required, PWA posts OTP code here
196
+ // ── GET /notifysend 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
- // Serve cached PWA assets for non-API routes (no auth required)
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
- // SPA fallback: serve index.html for unrecognized paths
238
- const asset = pwaAssets.get(pathname) ?? (pathname !== "/" ? pwaAssets.get("/") : undefined);
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, 404, { error: "Not found" });
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 port ${port}`);
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) {