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,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 GeminiAgent implements AgentTool {
@@ -12,10 +12,10 @@ export class GeminiAgent implements AgentTool {
12
12
  };
13
13
  }
14
14
 
15
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
- const prompt = retryPrompt ?? (task.body || task.frontmatter.user_prompt);
17
- const fullPrompt = AGENT_INSTRUCTIONS + "\n\n" + prompt;
18
- const args = ["--prompt", "-"];
15
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
16
+ const prompt = followupPrompt ?? (task.body || task.frontmatter.user_prompt);
17
+ const fullPrompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + prompt;
18
+ const args = ["--prompt", "--allowed-tools", "web_fetch", "-"];
19
19
 
20
20
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
21
21
  if (allPerms.length > 0) {
@@ -25,7 +25,7 @@ export class GeminiAgent implements AgentTool {
25
25
  }
26
26
  }
27
27
 
28
- if (retryPrompt) {args.push("--resume");} // continue mode for retries
28
+ if (followupPrompt) {args.push("--resume");} // continue mode for followups
29
29
  return { command: "gemini", args, stdin: fullPrompt };
30
30
  }
31
31
 
@@ -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
 
6
6
  export class OpenClawAgent implements AgentTool {
7
7
  getPlanGenerationCommandLine(prompt: string): CommandLine {
@@ -11,8 +11,8 @@ export class OpenClawAgent implements AgentTool {
11
11
  };
12
12
  }
13
13
 
14
- getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
15
- const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
14
+ getTaskRunCommandLine(task: ParsedTask, followupPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
15
+ const prompt = getAgentInstructions(task.frontmatter.id) + "\n\n" + (followupPrompt ?? (task.body || task.frontmatter.user_prompt));
16
16
  // OpenClaw does not support stdin as prompt.
17
17
  const args = ["agent", "--local", "--session-id", task.frontmatter.id, "--message", prompt];
18
18
 
@@ -1,19 +1,25 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { fileURLToPath } from "url";
4
+ import { loadConfig } from "../config.js";
4
5
 
5
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
7
 
7
- /**
8
- * Instructions prepended or injected as system prompt for every task invocation.
9
- * Instructs the agent to output structured markers so palmier can determine
10
- * the task outcome, report files, and permission/input requests.
11
- */
12
- export const AGENT_INSTRUCTIONS = fs.readFileSync(
8
+ const AGENT_INSTRUCTIONS_TEMPLATE = fs.readFileSync(
13
9
  path.join(__dirname, "agent-instructions.md"),
14
10
  "utf-8",
15
11
  );
16
12
 
13
+ /**
14
+ * Agent instructions with the serve daemon's HTTP port and task ID baked in.
15
+ */
16
+ export function getAgentInstructions(taskId: string): string {
17
+ const port = loadConfig().httpPort ?? 7400;
18
+ return AGENT_INSTRUCTIONS_TEMPLATE
19
+ .replace(/\{\{PORT\}\}/g, String(port))
20
+ .replace(/\{\{TASK_ID\}\}/g, taskId);
21
+ }
22
+
17
23
  export const TASK_SUCCESS_MARKER = "[PALMIER_TASK_SUCCESS]";
18
24
  export const TASK_FAILURE_MARKER = "[PALMIER_TASK_FAILURE]";
19
25
  export const TASK_REPORT_PREFIX = "[PALMIER_REPORT]";
@@ -3,6 +3,7 @@ import { loadConfig, saveConfig } from "../config.js";
3
3
  import { detectAgents } from "../agents/agent.js";
4
4
  import { getPlatform } from "../platform/index.js";
5
5
  import { pairCommand } from "./pair.js";
6
+ import { detectLanIp } from "../transports/http-transport.js";
6
7
  import type { HostConfig } from "../types.js";
7
8
 
8
9
  type AskFn = (q: string) => Promise<string>;
@@ -39,6 +40,36 @@ export async function initCommand(): Promise<void> {
39
40
 
40
41
  console.log(` Found: ${green(agents.map((a) => a.label).join(", "))}\n`);
41
42
 
43
+ // LAN mode
44
+ const lanAnswer = await ask("Enable LAN access (direct HTTP from local network)? (y/N): ");
45
+ const lanEnabled = lanAnswer.trim().toLowerCase() === "y";
46
+
47
+ let httpPort = 7400;
48
+ const portLabel = lanEnabled ? "HTTP port for local and LAN access" : "HTTP port for local access";
49
+ const portAnswer = await ask(`${portLabel} (default ${httpPort}): `);
50
+ const parsed = parseInt(portAnswer.trim(), 10);
51
+ if (parsed > 0 && parsed < 65536) httpPort = parsed;
52
+
53
+ // Display summary and ask for confirmation before making any changes
54
+ console.log(`\n${bold("Setup summary:")}\n`);
55
+ console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
56
+ console.log(` All tasks and execution data will be stored here.\n`);
57
+ console.log(` ${dim("Local access:")} ${cyan(`http://localhost:${httpPort}`)}`);
58
+ console.log(` Always available — no internet required.\n`);
59
+ if (lanEnabled) {
60
+ const ip = detectLanIp();
61
+ console.log(` ${dim("LAN access:")} ${cyan(`http://${ip}:${httpPort}`)}`);
62
+ console.log(` Accessible from other devices on your local network. Pairing required.\n`);
63
+ }
64
+ console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
65
+
66
+ const confirm = await ask("Proceed? (Y/n): ");
67
+ if (confirm.trim().toLowerCase() === "n") {
68
+ console.log("\nSetup cancelled.");
69
+ rl.close();
70
+ return;
71
+ }
72
+
42
73
  // Register with server
43
74
  let existingHostId: string | undefined;
44
75
  try { existingHostId = loadConfig().hostId; } catch { /* first init */ }
@@ -63,7 +94,7 @@ export async function initCommand(): Promise<void> {
63
94
  }
64
95
  }
65
96
 
66
- // Build config
97
+ // Build and save config
67
98
  const config: HostConfig = {
68
99
  hostId: registerResponse.hostId,
69
100
  projectRoot: process.cwd(),
@@ -71,11 +102,11 @@ export async function initCommand(): Promise<void> {
71
102
  natsWsUrl: registerResponse.natsWsUrl,
72
103
  natsToken: registerResponse.natsToken,
73
104
  agents,
105
+ httpPort,
106
+ lanEnabled,
74
107
  };
75
108
 
76
109
  saveConfig(config);
77
-
78
- console.log(`\n${green("Host provisioned")} ID: ${cyan(config.hostId)}`);
79
110
  console.log(`Config saved to ${dim("~/.config/palmier/host.json")}`);
80
111
 
81
112
  getPlatform().installDaemon(config);
@@ -3,7 +3,6 @@ import { StringCodec } from "nats";
3
3
  import { loadConfig } from "../config.js";
4
4
  import { connectNats } from "../nats-client.js";
5
5
  import { addSession } from "../session-store.js";
6
- import { getLanPort } from "../lan-lock.js";
7
6
  import type { HostConfig } from "../types.js";
8
7
 
9
8
  const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
@@ -26,9 +25,9 @@ function buildPairResponse(config: HostConfig, label?: string) {
26
25
  }
27
26
 
28
27
  /**
29
- * POST to the running LAN server and long-poll until paired or expired.
28
+ * POST to the running serve daemon and long-poll until paired or expired.
30
29
  */
31
- function lanPairRegister(port: number, code: string): Promise<boolean> {
30
+ function httpPairRegister(port: number, code: string): Promise<boolean> {
32
31
  const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
33
32
 
34
33
  return new Promise((resolve) => {
@@ -36,7 +35,7 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
36
35
  {
37
36
  hostname: "127.0.0.1",
38
37
  port,
39
- path: "/internal/pair-register",
38
+ path: "/pair-register",
40
39
  method: "POST",
41
40
  headers: { "Content-Type": "application/json" },
42
41
  timeout: PAIRING_EXPIRY_MS + 5000,
@@ -63,11 +62,12 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
63
62
 
64
63
  /**
65
64
  * Generate an OTP code and wait for a PWA client to pair.
66
- * Listens on NATS always, and also on the LAN server if `palmier lan` is running.
65
+ * Listens on NATS (server mode) and HTTP (via serve daemon) in parallel.
67
66
  */
68
67
  export async function pairCommand(): Promise<void> {
69
68
  const config = loadConfig();
70
69
  const code = generatePairingCode();
70
+ const httpPort = config.httpPort ?? 7400;
71
71
 
72
72
  let paired = false;
73
73
 
@@ -86,7 +86,7 @@ export async function pairCommand(): Promise<void> {
86
86
  console.log("");
87
87
  console.log("Code expires in 5 minutes.");
88
88
 
89
- // NATS pairing (always active)
89
+ // NATS pairing (server mode)
90
90
  const nc = await connectNats(config);
91
91
  const sc = StringCodec();
92
92
  const subject = `pair.${code}`;
@@ -116,14 +116,11 @@ export async function pairCommand(): Promise<void> {
116
116
  }
117
117
  })();
118
118
 
119
- // LAN pairing — if `palmier lan` is running, also register with it
120
- const lanPort = getLanPort();
121
- if (lanPort) {
122
- (async () => {
123
- const result = await lanPairRegister(lanPort, code);
124
- if (result) onPaired();
125
- })();
126
- }
119
+ // HTTP pairing — register with serve daemon's /pair-register endpoint
120
+ (async () => {
121
+ const result = await httpPairRegister(httpPort, code);
122
+ if (result) onPaired();
123
+ })();
127
124
 
128
125
  // Wait for pairing or timeout
129
126
  const start = Date.now();
@@ -10,7 +10,6 @@ import { getPlatform } from "../platform/index.js";
10
10
  import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
11
11
  import type { AgentTool } from "../agents/agent.js";
12
12
  import { publishHostEvent } from "../events.js";
13
- import { waitForUserInput } from "../user-input.js";
14
13
  import type { HostConfig, ParsedTask, TaskRunningState, RequiredPermission } from "../types.js";
15
14
  import type { NatsConnection } from "nats";
16
15
 
@@ -36,23 +35,23 @@ interface InvocationResult {
36
35
  }
37
36
 
38
37
  /**
39
- * Invoke the agent CLI with a retry loop for permissions and user input.
38
+ * Invoke the agent CLI with a continuation loop for permissions and user input.
40
39
  *
41
40
  * Both standard and command-triggered execution use this.
42
41
  * The `invokeTask` is the ParsedTask whose prompt is passed to the agent
43
42
  * (for command-triggered mode this is the per-line augmented task).
44
43
  */
45
- async function invokeAgentWithRetry(
44
+ async function invokeAgentWithContinuation(
46
45
  ctx: InvocationContext,
47
46
  invokeTask: ParsedTask,
48
47
  ): Promise<InvocationResult> {
49
- let retryPrompt: string | undefined;
48
+ let followupPrompt: string | undefined;
50
49
  // eslint-disable-next-line no-constant-condition
51
50
  while (true) {
52
- const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, retryPrompt, ctx.transientPermissions);
51
+ const { command, args, stdin } = ctx.agent.getTaskRunCommandLine(invokeTask, followupPrompt, ctx.transientPermissions);
53
52
  const result = await spawnCommand(command, args, {
54
53
  cwd: getRunDir(ctx.taskDir, ctx.runId),
55
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
54
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7400) },
56
55
  echoStdout: true,
57
56
  resolveOnFailure: true,
58
57
  stdin,
@@ -70,10 +69,9 @@ async function invokeAgentWithRetry(
70
69
  attachments: reportFiles.length > 0 ? reportFiles : undefined,
71
70
  });
72
71
 
73
- // Permission retry
74
- if (outcome === "failed" && requiredPermissions.length > 0) {
75
- const response = await requestPermission(ctx.nc, ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
76
- await publishPermissionResolved(ctx.nc, ctx.config, ctx.taskId, response);
72
+ // Permission handling — agent requested permissions
73
+ if (requiredPermissions.length > 0) {
74
+ const response = await requestPermission(ctx.config, ctx.task, ctx.taskDir, requiredPermissions);
77
75
 
78
76
  if (response === "aborted") {
79
77
  await appendAndNotify(ctx, {
@@ -106,11 +104,14 @@ async function invokeAgentWithRetry(
106
104
  ctx.transientPermissions = [...ctx.transientPermissions, ...newPerms];
107
105
  }
108
106
 
109
- retryPrompt = "Permissions granted, please continue.";
110
- continue;
107
+ // If the agent actually failed, retry with the new permissions
108
+ if (outcome === "failed") {
109
+ followupPrompt = "Permissions granted, please continue.";
110
+ continue;
111
+ }
111
112
  }
112
113
 
113
- // Normal completion (success or non-retryable failure)
114
+ // Normal completion (success or terminal failure)
114
115
  return { outcome };
115
116
  }
116
117
  }
@@ -191,9 +192,7 @@ export async function runCommand(taskId: string): Promise<void> {
191
192
 
192
193
  // If requires_confirmation, notify clients and wait
193
194
  if (task.frontmatter.requires_confirmation) {
194
- const confirmed = await requestConfirmation(nc, config, task, taskDir);
195
- const resolvedStatus = confirmed ? "confirmed" : "aborted";
196
- await publishConfirmResolved(nc, config, taskId, resolvedStatus);
195
+ const confirmed = await requestConfirmation(config, task, taskDir);
197
196
  if (!confirmed) {
198
197
  console.log("Task aborted by user.");
199
198
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: "aborted" });
@@ -229,7 +228,7 @@ export async function runCommand(taskId: string): Promise<void> {
229
228
  content: task.body || task.frontmatter.user_prompt,
230
229
  });
231
230
 
232
- const result = await invokeAgentWithRetry(ctx, task);
231
+ const result = await invokeAgentWithContinuation(ctx, task);
233
232
  const outcome = resolveOutcome(taskDir, result.outcome);
234
233
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
235
234
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
@@ -272,7 +271,7 @@ async function runCommandTriggeredMode(
272
271
 
273
272
  const child = spawnStreamingCommand(commandStr, {
274
273
  cwd: getRunDir(ctx.taskDir, ctx.runId),
275
- env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId) },
274
+ env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7400) },
276
275
  });
277
276
 
278
277
  let linesProcessed = 0;
@@ -316,7 +315,7 @@ async function runCommandTriggeredMode(
316
315
  body: "",
317
316
  };
318
317
 
319
- const result = await invokeAgentWithRetry(ctx, perLineTask);
318
+ const result = await invokeAgentWithContinuation(ctx, perLineTask);
320
319
  if (result.outcome === "finished") {
321
320
  invocationsSucceeded++;
322
321
  } else {
@@ -405,41 +404,21 @@ async function publishTaskEvent(
405
404
  await publishHostEvent(nc, config.hostId, taskId, payload);
406
405
  }
407
406
 
408
- /**
409
- * Notify clients that a confirmation request has been resolved.
410
- */
411
- async function publishConfirmResolved(
412
- nc: NatsConnection | undefined,
413
- config: HostConfig,
414
- taskId: string,
415
- status: "confirmed" | "aborted",
416
- ): Promise<void> {
417
- await publishHostEvent(nc, config.hostId, taskId, {
418
- event_type: "confirm-resolved",
419
- host_id: config.hostId,
420
- status,
421
- });
422
- }
423
407
 
424
408
  async function requestPermission(
425
- nc: NatsConnection | undefined,
426
409
  config: HostConfig,
427
410
  task: ParsedTask,
428
411
  taskDir: string,
429
412
  requiredPermissions: RequiredPermission[],
430
413
  ): Promise<"granted" | "granted_all" | "aborted"> {
431
- const currentStatus = readTaskStatus(taskDir)!;
432
- writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
433
-
434
- await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
435
- event_type: "permission-request",
436
- host_id: config.hostId,
437
- required_permissions: requiredPermissions,
438
- name: task.frontmatter.name,
414
+ const port = config.httpPort ?? 7400;
415
+ const params = new URLSearchParams({
416
+ taskId: task.frontmatter.id,
417
+ taskName: task.frontmatter.name,
418
+ permissions: JSON.stringify(requiredPermissions),
439
419
  });
440
-
441
- const userInput = await waitForUserInput(taskDir);
442
- const response = userInput[0] as "granted" | "granted_all" | "aborted";
420
+ const res = await fetch(`http://localhost:${port}/request-permission?${params}`);
421
+ const { response } = await res.json() as { response: "granted" | "granted_all" | "aborted" };
443
422
  writeTaskStatus(taskDir, {
444
423
  running_state: response === "aborted" ? "aborted" : "started",
445
424
  time_stamp: Date.now(),
@@ -447,35 +426,19 @@ async function requestPermission(
447
426
  return response;
448
427
  }
449
428
 
450
- async function publishPermissionResolved(
451
- nc: NatsConnection | undefined,
452
- config: HostConfig,
453
- taskId: string,
454
- status: "granted" | "granted_all" | "aborted",
455
- ): Promise<void> {
456
- await publishHostEvent(nc, config.hostId, taskId, {
457
- event_type: "permission-resolved",
458
- host_id: config.hostId,
459
- status,
460
- });
461
- }
462
429
 
463
430
  async function requestConfirmation(
464
- nc: NatsConnection | undefined,
465
431
  config: HostConfig,
466
432
  task: ParsedTask,
467
433
  taskDir: string,
468
434
  ): Promise<boolean> {
469
- const currentStatus = readTaskStatus(taskDir)!;
470
- writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
471
-
472
- await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
473
- event_type: "confirm-request",
474
- host_id: config.hostId,
435
+ const port = config.httpPort ?? 7400;
436
+ const params = new URLSearchParams({
437
+ taskId: task.frontmatter.id,
438
+ taskName: task.frontmatter.name,
475
439
  });
476
-
477
- const userInput = await waitForUserInput(taskDir);
478
- const confirmed = userInput[0] === "confirmed";
440
+ const res = await fetch(`http://localhost:${port}/request-confirmation?${params}`);
441
+ const { confirmed } = await res.json() as { confirmed: boolean };
479
442
  writeTaskStatus(taskDir, {
480
443
  running_state: confirmed ? "started" : "aborted",
481
444
  time_stamp: Date.now(),
@@ -4,6 +4,7 @@ import { loadConfig } from "../config.js";
4
4
  import { connectNats } from "../nats-client.js";
5
5
  import { createRpcHandler } from "../rpc-handler.js";
6
6
  import { startNatsTransport } from "../transports/nats-transport.js";
7
+ import { startHttpTransport } from "../transports/http-transport.js";
7
8
  import { getTaskDir, readTaskStatus, writeTaskStatus, parseTaskFile, appendRunMessage } from "../task.js";
8
9
  import { publishHostEvent } from "../events.js";
9
10
  import { getPlatform } from "../platform/index.js";
@@ -78,7 +79,7 @@ async function checkStaleTasks(
78
79
  }
79
80
 
80
81
  /**
81
- * Start the persistent RPC handler (NATS only).
82
+ * Start the persistent RPC handler (NATS + HTTP).
82
83
  */
83
84
  export async function serveCommand(): Promise<void> {
84
85
  const config = loadConfig();
@@ -107,5 +108,13 @@ export async function serveCommand(): Promise<void> {
107
108
  }, POLL_INTERVAL_MS);
108
109
 
109
110
  const handleRpc = createRpcHandler(config, nc);
110
- await startNatsTransport(config, handleRpc, nc);
111
+ const httpPort = config.httpPort ?? 7400;
112
+
113
+ // Start NATS transport (loops forever, fire-and-forget)
114
+ if (nc) {
115
+ startNatsTransport(config, handleRpc, nc);
116
+ }
117
+
118
+ // Start HTTP transport (loops forever)
119
+ await startHttpTransport(config, handleRpc, httpPort, nc);
111
120
  }
package/src/events.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { StringCodec, type NatsConnection } from "nats";
2
- import { getLanPort } from "./lan-lock.js";
2
+ import { loadConfig } from "./config.js";
3
3
 
4
4
  const sc = StringCodec();
5
5
 
6
6
  /**
7
- * Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
7
+ * Broadcast an event to connected clients via NATS and HTTP SSE.
8
8
  *
9
9
  * - NATS: publishes to `host-event.{hostId}.{taskId}`
10
- * - HTTP: POSTs to the LAN server's `/internal/event` endpoint (auto-detected via lockfile)
10
+ * - HTTP: POSTs to the serve daemon's `/event` endpoint
11
11
  */
12
12
  export async function publishHostEvent(
13
13
  nc: NatsConnection | undefined,
@@ -22,17 +22,16 @@ export async function publishHostEvent(
22
22
  console.log(`[nats] ${subject} →`, payload);
23
23
  }
24
24
 
25
- const lanPort = getLanPort();
26
- if (lanPort) {
27
- try {
28
- await fetch(`http://localhost:${lanPort}/internal/event`, {
29
- method: "POST",
30
- headers: { "Content-Type": "application/json" },
31
- body: JSON.stringify({ task_id: taskId, ...payload }),
32
- });
33
- console.log(`[http] host-event: ${taskId} →`, payload);
34
- } catch {
35
- // LAN server may have shut down — ignore
36
- }
25
+ const config = loadConfig();
26
+ const port = config.httpPort ?? 7400;
27
+ try {
28
+ await fetch(`http://localhost:${port}/event`, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify({ task_id: taskId, ...payload }),
32
+ });
33
+ console.log(`[http] host-event: ${taskId} →`, payload);
34
+ } catch {
35
+ // Serve HTTP may not be ready yet — ignore
37
36
  }
38
37
  }
package/src/index.ts CHANGED
@@ -9,11 +9,8 @@ import { initCommand } from "./commands/init.js";
9
9
  import { infoCommand } from "./commands/info.js";
10
10
  import { runCommand } from "./commands/run.js";
11
11
  import { serveCommand } from "./commands/serve.js";
12
- import { notifyCommand } from "./commands/notify.js";
13
- import { requestInputCommand } from "./commands/request-input.js";
14
12
 
15
13
  import { pairCommand } from "./commands/pair.js";
16
- import { lanCommand } from "./commands/lan.js";
17
14
  import { restartCommand } from "./commands/restart.js";
18
15
  import { sessionsListCommand, sessionsRevokeCommand, sessionsRevokeAllCommand } from "./commands/sessions.js";
19
16
 
@@ -62,22 +59,6 @@ program
62
59
  await restartCommand();
63
60
  });
64
61
 
65
- program
66
- .command("notify")
67
- .description("Send a push notification to the user")
68
- .requiredOption("--title <title>", "Notification title")
69
- .requiredOption("--body <body>", "Notification body text")
70
- .action(async (opts: { title: string; body: string }) => {
71
- await notifyCommand(opts);
72
- });
73
-
74
- program
75
- .command("request-input")
76
- .description("Request input from the user (requires PALMIER_TASK_ID env var)")
77
- .requiredOption("--description <desc...>", "Input descriptions to show the user")
78
- .action(async (opts: { description: string[] }) => {
79
- await requestInputCommand(opts);
80
- });
81
62
 
82
63
  program
83
64
  .command("pair")
@@ -86,13 +67,6 @@ program
86
67
  await pairCommand();
87
68
  });
88
69
 
89
- program
90
- .command("lan")
91
- .description("Start an on-demand LAN server for direct HTTP connections")
92
- .option("-p, --port <port>", "Port to listen on", "7400")
93
- .action(async (opts: { port: string }) => {
94
- await lanCommand({ port: parseInt(opts.port, 10) });
95
- });
96
70
 
97
71
  const sessionsCmd = program
98
72
  .command("sessions")
@@ -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": {