palmier 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +32 -33
  2. package/dist/agents/agent-instructions.md +4 -11
  3. package/dist/agents/agent.d.ts +2 -2
  4. package/dist/agents/claude.d.ts +1 -1
  5. package/dist/agents/claude.js +6 -6
  6. package/dist/agents/codex.d.ts +1 -1
  7. package/dist/agents/codex.js +5 -5
  8. package/dist/agents/copilot.d.ts +1 -1
  9. package/dist/agents/copilot.js +5 -5
  10. package/dist/agents/gemini.d.ts +1 -1
  11. package/dist/agents/gemini.js +7 -7
  12. package/dist/agents/openclaw.d.ts +1 -1
  13. package/dist/agents/openclaw.js +3 -3
  14. package/dist/agents/shared-prompt.d.ts +2 -4
  15. package/dist/agents/shared-prompt.js +9 -4
  16. package/dist/commands/init.js +31 -2
  17. package/dist/commands/pair.d.ts +1 -1
  18. package/dist/commands/pair.js +12 -15
  19. package/dist/commands/run.js +33 -54
  20. package/dist/commands/serve.d.ts +1 -1
  21. package/dist/commands/serve.js +9 -2
  22. package/dist/events.d.ts +2 -2
  23. package/dist/events.js +15 -16
  24. package/dist/index.js +0 -25
  25. package/dist/pending-requests.d.ts +27 -0
  26. package/dist/pending-requests.js +39 -0
  27. package/dist/rpc-handler.js +15 -8
  28. package/dist/transports/http-transport.d.ts +4 -2
  29. package/dist/transports/http-transport.js +226 -77
  30. package/dist/types.d.ts +7 -16
  31. package/package.json +1 -1
  32. package/src/agents/agent-instructions.md +4 -11
  33. package/src/agents/agent.ts +2 -2
  34. package/src/agents/claude.ts +5 -5
  35. package/src/agents/codex.ts +4 -4
  36. package/src/agents/copilot.ts +5 -5
  37. package/src/agents/gemini.ts +6 -6
  38. package/src/agents/openclaw.ts +3 -3
  39. package/src/agents/shared-prompt.ts +12 -6
  40. package/src/commands/init.ts +34 -3
  41. package/src/commands/pair.ts +11 -14
  42. package/src/commands/run.ts +31 -68
  43. package/src/commands/serve.ts +11 -2
  44. package/src/events.ts +14 -15
  45. package/src/index.ts +0 -26
  46. package/src/pending-requests.ts +55 -0
  47. package/src/rpc-handler.ts +15 -9
  48. package/src/transports/http-transport.ts +235 -135
  49. package/src/types.ts +10 -16
  50. package/test/agent-output-parsing.test.ts +1 -14
  51. package/dist/commands/lan.d.ts +0 -8
  52. package/dist/commands/lan.js +0 -44
  53. package/dist/commands/notify.d.ts +0 -9
  54. package/dist/commands/notify.js +0 -43
  55. package/dist/commands/request-input.d.ts +0 -10
  56. package/dist/commands/request-input.js +0 -49
  57. package/dist/lan-lock.d.ts +0 -7
  58. package/dist/lan-lock.js +0 -18
  59. package/dist/user-input.d.ts +0 -15
  60. package/dist/user-input.js +0 -50
  61. package/src/commands/lan.ts +0 -48
  62. package/src/commands/notify.ts +0 -44
  63. package/src/commands/request-input.ts +0 -51
  64. package/src/lan-lock.ts +0 -16
  65. package/src/user-input.ts +0 -67
@@ -1,7 +1,13 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "os";
3
+ import { StringCodec } from "nats";
3
4
  import { validateSession, addSession } from "../session-store.js";
5
+ import { registerPending } from "../pending-requests.js";
6
+ import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
4
7
  const PWA_ORIGIN = "https://app.palmier.me";
8
+ const assetCache = new Map();
9
+ /** Paths currently being fetched (dedup concurrent requests). */
10
+ const assetInflight = new Map();
5
11
  const CONTENT_TYPES = {
6
12
  ".html": "text/html; charset=utf-8",
7
13
  ".js": "application/javascript",
@@ -14,6 +20,8 @@ const CONTENT_TYPES = {
14
20
  ".svg": "image/svg+xml",
15
21
  };
16
22
  function guessContentType(urlPath) {
23
+ if (urlPath === "/")
24
+ return "text/html; charset=utf-8";
17
25
  const ext = urlPath.match(/\.[^.]+$/)?.[0] ?? "";
18
26
  return CONTENT_TYPES[ext] ?? "application/octet-stream";
19
27
  }
@@ -24,55 +32,39 @@ async function fetchBuffer(url) {
24
32
  return Buffer.from(await res.arrayBuffer());
25
33
  }
26
34
  /**
27
- * 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) {
package/dist/types.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface HostConfig {
8
8
  key: string;
9
9
  label: string;
10
10
  }>;
11
+ httpPort?: number;
12
+ lanEnabled?: boolean;
11
13
  }
12
14
  export interface TaskFrontmatter {
13
15
  id: string;
@@ -29,8 +31,6 @@ export interface ParsedTask {
29
31
  body: string;
30
32
  }
31
33
  /**
32
- * State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
33
- *
34
34
  * - `started`: task is actively running
35
35
  * - `finished`: agent completed successfully
36
36
  * - `aborted`: user declined confirmation, permission, or input
@@ -38,26 +38,15 @@ export interface ParsedTask {
38
38
  */
39
39
  export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
40
40
  /**
41
- * Persisted to `status.json` in the task directory. Updated by the run process
42
- * and read by the RPC handler + PWA to track live task state.
43
- *
44
- * Interactive request flow: the run process sets a `pending_*` field and waits
45
- * for `user_input` to be populated by an RPC call (task.user_input). Only one
46
- * `pending_*` field is set at a time.
41
+ * Persisted to `status.json` in the task directory. Used for crash detection
42
+ * (checkStaleTasks) and abort signalling. Interactive request flows (confirmation,
43
+ * permission, input) are handled via held HTTP connections on the serve daemon.
47
44
  */
48
45
  export interface TaskStatus {
49
46
  running_state: TaskRunningState;
50
47
  time_stamp: number;
51
48
  /** PID of the palmier run process (used on Windows to kill the process tree). */
52
49
  pid?: number;
53
- /** Set when the task has `requires_confirmation` and is awaiting user approval. */
54
- pending_confirmation?: boolean;
55
- /** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
56
- pending_permission?: RequiredPermission[];
57
- /** Set when the agent requests user input. Contains descriptions of each requested value. */
58
- pending_input?: string[];
59
- /** Written by the RPC handler to deliver the user's response to the waiting run process. */
60
- user_input?: string[];
61
50
  }
62
51
  export interface HistoryEntry {
63
52
  task_id: string;
@@ -78,5 +67,7 @@ export interface RpcMessage {
78
67
  method: string;
79
68
  params: Record<string, unknown>;
80
69
  sessionToken?: string;
70
+ /** Trusted localhost request — skip session validation. */
71
+ localhost?: boolean;
81
72
  }
82
73
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -20,20 +20,13 @@ If the task fails because a tool was denied or you lack the required permissions
20
20
  [PALMIER_PERMISSION] Bash(npm test) | Run the test suite via npm
21
21
  [PALMIER_PERMISSION] Write | Write generated output files
22
22
 
23
- ## CLI Commands
23
+ ## HTTP Endpoints
24
24
 
25
- You have access to the following palmier CLI commands:
25
+ The following HTTP endpoints are available at http://localhost:{{PORT}} during task execution.
26
26
 
27
- **Requesting user input** — If you need any information you do not have (credentials, configuration values, preferences, clarifications, etc.) or the task explicitly asks you to get input from the user, do NOT fail the task. Instead, request it:
28
- ```
29
- palmier request-input --description "What is the database connection string?" --description "What is the API key?"
30
- ```
31
- The command blocks until the user responds and prints each value on its own line. If the user aborts, the command exits with a non-zero status.
27
+ **Requesting user input** — If you need any information you do not have (credentials, configuration values, preferences, clarifications, etc.) or the task explicitly asks you to get input from the user, do NOT fail the task. Instead, GET `/request-input?taskId={{TASK_ID}}&descriptions=question+1&descriptions=question+2`. The request blocks until the user responds. The response is `{"values":["answer1","answer2"]}` on success, or `{"aborted":true}` if the user chooses to abort.
32
28
 
33
- **Sending push notifications** — If you need to send a push notification to the user:
34
- ```
35
- palmier notify --title "Task Complete" --body "The deployment finished successfully."
36
- ```
29
+ **Sending push notifications** — GET `/notify?title=...&body=...` to send a push notification to the user's devices.
37
30
 
38
31
  ---
39
32
 
@@ -20,10 +20,10 @@ export interface AgentTool {
20
20
  /** Return the command and args used to generate a plan from a prompt. */
21
21
  getPlanGenerationCommandLine(prompt: string): CommandLine;
22
22
 
23
- /** Return the command and args used to run a task. If retryPrompt is provided, use it instead of the task's prompt,
23
+ /** Return the command and args used to run a task. If followupPrompt is provided, use it instead of the task's prompt,
24
24
  * and treat it as a continuation of the original run (reuse the same session, etc). extraPermissions are transient
25
25
  * permissions granted for this run only (not persisted in frontmatter). */
26
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
26
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
27
27
 
28
28
  /** Detect whether the agent CLI is available and perform any agent-specific
29
29
  * initialization. Returns true if the agent was detected and initialized successfully. */
@@ -1,7 +1,7 @@
1
1
  import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
- import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
4
+ import { getAgentInstructions } from "./shared-prompt.js";
5
5
  import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class ClaudeAgent implements AgentTool {
@@ -12,16 +12,16 @@ export class ClaudeAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
17
- const args = ["--permission-mode", "acceptEdits", "-p"];
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
17
+ const args = ["--permission-mode", "acceptEdits", "-p", "--allowedTools", "WebFetch"];
18
18
 
19
19
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
20
20
  for (const p of allPerms) {
21
21
  args.push("--allowedTools", p.name);
22
22
  }
23
23
 
24
- if (retryPrompt) {args.push("-c");} // continue mode for retries
24
+ if (followupPrompt) {args.push("-c");} // continue mode for followups
25
25
  return { command: "claude", args, stdin: prompt };
26
26
  }
27
27
 
@@ -1,7 +1,7 @@
1
1
  import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
- import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
4
+ import { getAgentInstructions } from "./shared-prompt.js";
5
5
  import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class CodexAgent implements AgentTool {
@@ -12,8 +12,8 @@ export class CodexAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
17
17
  // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
18
18
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
19
19
 
@@ -24,7 +24,7 @@ export class CodexAgent implements AgentTool {
24
24
  }
25
25
  args.push("-"); // read prompt from stdin
26
26
 
27
- if (retryPrompt) {args.push("resume", "--last");} // continue mode for retries
27
+ if (followupPrompt) {args.push("resume", "--last");} // continue mode for followups
28
28
  return { command: "codex", args, stdin: prompt };
29
29
  }
30
30
 
@@ -1,7 +1,7 @@
1
1
  import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
- import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
4
+ import { getAgentInstructions } from "./shared-prompt.js";
5
5
  import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class CopilotAgent implements AgentTool {
@@ -12,16 +12,16 @@ export class CopilotAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
17
- const args = ["-p", prompt];
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
17
+ const args = ["-p", prompt, "--allowed-tools", "web_fetch"];
18
18
 
19
19
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
20
20
  if (allPerms.length > 0) {
21
21
  args.push(`--allow-tool='${allPerms.map((p) => p.name).join(",")}'`);;
22
22
  }
23
23
 
24
- if (retryPrompt) { args.push("--continue"); }
24
+ if (followupPrompt) { args.push("--continue"); }
25
25
  return { command: "copilot", args};
26
26
  }
27
27