palmier 0.3.1 → 0.3.2

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.
package/README.md CHANGED
@@ -132,6 +132,15 @@ palmier restart
132
132
  - **Real-time updates** — task status changes (started, finished, failed) are pushed to connected PWA clients via NATS pub/sub (server mode) and/or SSE (LAN mode).
133
133
  - **MCP server** (`palmier mcpserver`) exposes platform tools (e.g., `send-push-notification`) to AI agents like Claude Code over stdio.
134
134
 
135
+ ## NATS Subjects
136
+
137
+ | Subject | Direction | Description |
138
+ |---|---|---|
139
+ | `host.<hostId>.rpc.<method>` | Client → Host | RPC request/reply (e.g., `task.list`, `task.create`) |
140
+ | `host-event.<hostId>.<taskId>` | Host → Client | Real-time task events (`running-state`, `confirm-request`, `permission-request`, `input-request`) |
141
+ | `host.<hostId>.push.send` | Host → Server | Request server to deliver a push notification |
142
+ | `pair.<code>` | Client → Host | OTP pairing request/reply |
143
+
135
144
  ## Project Structure
136
145
 
137
146
  ```
@@ -144,16 +153,19 @@ src/
144
153
  spawn-command.ts # Shared helper for spawning CLI tools
145
154
  task.ts # Task file management
146
155
  types.ts # Shared type definitions
156
+ pairing.ts # OTP code generation and expiry constant
157
+ lan-lock.ts # LAN lockfile path and port reader
158
+ events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
147
159
  agents/
148
160
  agent.ts # AgentTool interface, registry, and agent detection
149
161
  claude.ts # Claude Code agent implementation
150
162
  gemini.ts # Gemini CLI agent implementation
151
163
  codex.ts # Codex CLI agent implementation
152
164
  openclaw.ts # OpenClaw agent implementation
153
- events.ts # Event broadcasting (NATS pub/sub or HTTP SSE)
154
165
  commands/
155
166
  init.ts # Interactive setup wizard (auto-pair)
156
167
  pair.ts # OTP code generation and pairing handler
168
+ lan.ts # On-demand LAN server
157
169
  sessions.ts # Session token management CLI (list, revoke, revoke-all)
158
170
  info.ts # Print host connection info
159
171
  agents.ts # Re-detect installed agent CLIs
@@ -1,8 +1,6 @@
1
1
  import { execSync } from "child_process";
2
2
  import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
3
- // execSync's shell option takes a string (shell path), not boolean.
4
- // On Windows we need a shell so .cmd shims resolve correctly.
5
- const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
3
+ import { SHELL } from "../platform/index.js";
6
4
  export class ClaudeAgent {
7
5
  getPlanGenerationCommandLine(prompt) {
8
6
  return {
@@ -1,10 +1,8 @@
1
1
  import { execSync } from "child_process";
2
2
  import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
3
- // On Windows we need a shell so .cmd shims resolve correctly.
4
- const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
3
+ import { SHELL } from "../platform/index.js";
5
4
  export class CodexAgent {
6
5
  getPlanGenerationCommandLine(prompt) {
7
- // TODO: fill in
8
6
  return {
9
7
  command: "codex",
10
8
  args: ["exec", "--skip-git-repo-check", prompt],
@@ -12,8 +10,7 @@ export class CodexAgent {
12
10
  }
13
11
  getTaskRunCommandLine(task, retryPrompt, extraPermissions) {
14
12
  const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
15
- // TODO: Update sandbox to workspace-write once https://github.com/openai/codex/issues/12572
16
- // is fixed.
13
+ // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
17
14
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
18
15
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
19
16
  for (const p of allPerms) {
@@ -1,10 +1,8 @@
1
1
  import { execSync } from "child_process";
2
2
  import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
3
- // On Windows we need a shell so .cmd shims resolve correctly.
4
- const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
3
+ import { SHELL } from "../platform/index.js";
5
4
  export class GeminiAgent {
6
5
  getPlanGenerationCommandLine(prompt) {
7
- // TODO: fill in
8
6
  return {
9
7
  command: "gemini",
10
8
  args: ["--approval-mode", "auto_edit", "--prompt", prompt],
@@ -58,7 +58,6 @@ export async function initCommand() {
58
58
  const config = {
59
59
  hostId: registerResponse.hostId,
60
60
  projectRoot: process.cwd(),
61
- nats: true,
62
61
  natsUrl: registerResponse.natsUrl,
63
62
  natsWsUrl: registerResponse.natsWsUrl,
64
63
  natsToken: registerResponse.natsToken,
@@ -1,19 +1,12 @@
1
1
  import * as fs from "fs";
2
- import * as path from "path";
3
2
  import { loadConfig, CONFIG_DIR } from "../config.js";
4
3
  import { createRpcHandler } from "../rpc-handler.js";
5
4
  import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
6
- const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
5
+ import { generatePairingCode } from "../pairing.js";
6
+ import { LAN_LOCKFILE } from "../lan-lock.js";
7
7
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
8
8
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
9
9
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
10
- const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
11
- const CODE_LENGTH = 6;
12
- function generateCode() {
13
- const bytes = new Uint8Array(CODE_LENGTH);
14
- crypto.getRandomValues(bytes);
15
- return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
16
- }
17
10
  function writeLockfile(port) {
18
11
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
12
  fs.writeFileSync(LAN_LOCKFILE, JSON.stringify({ port, pid: process.pid }), "utf-8");
@@ -32,7 +25,7 @@ export async function lanCommand(opts) {
32
25
  const config = loadConfig();
33
26
  const port = opts.port;
34
27
  const ip = detectLanIp();
35
- const code = generateCode();
28
+ const code = generatePairingCode();
36
29
  const handleRpc = createRpcHandler(config);
37
30
  // Write lockfile so other palmier processes can discover us
38
31
  writeLockfile(port);
@@ -1,19 +1,10 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
1
  import * as http from "node:http";
4
2
  import { StringCodec } from "nats";
5
- import { loadConfig, CONFIG_DIR } from "../config.js";
3
+ import { loadConfig } from "../config.js";
6
4
  import { connectNats } from "../nats-client.js";
7
5
  import { addSession } from "../session-store.js";
8
- const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
9
- const CODE_LENGTH = 6;
10
- const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
11
- const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
12
- function generateCode() {
13
- const bytes = new Uint8Array(CODE_LENGTH);
14
- crypto.getRandomValues(bytes);
15
- return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
16
- }
6
+ import { generatePairingCode, PAIRING_EXPIRY_MS } from "../pairing.js";
7
+ import { getLanPort } from "../lan-lock.js";
17
8
  function buildPairResponse(config, label) {
18
9
  const session = addSession(label);
19
10
  return {
@@ -25,7 +16,7 @@ function buildPairResponse(config, label) {
25
16
  * POST to the running LAN server and long-poll until paired or expired.
26
17
  */
27
18
  function lanPairRegister(port, code) {
28
- const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
19
+ const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
29
20
  return new Promise((resolve) => {
30
21
  const req = http.request({
31
22
  hostname: "127.0.0.1",
@@ -33,7 +24,7 @@ function lanPairRegister(port, code) {
33
24
  path: "/internal/pair-register",
34
25
  method: "POST",
35
26
  headers: { "Content-Type": "application/json" },
36
- timeout: EXPIRY_MS + 5000,
27
+ timeout: PAIRING_EXPIRY_MS + 5000,
37
28
  }, (res) => {
38
29
  const chunks = [];
39
30
  res.on("data", (chunk) => chunks.push(chunk));
@@ -52,25 +43,13 @@ function lanPairRegister(port, code) {
52
43
  req.end(body);
53
44
  });
54
45
  }
55
- /**
56
- * Read the LAN lockfile to check if `palmier lan` is running.
57
- */
58
- function getLanPort() {
59
- try {
60
- const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
61
- return JSON.parse(raw).port;
62
- }
63
- catch {
64
- return null;
65
- }
66
- }
67
46
  /**
68
47
  * Generate an OTP code and wait for a PWA client to pair.
69
48
  * Listens on NATS always, and also on the LAN server if `palmier lan` is running.
70
49
  */
71
50
  export async function pairCommand() {
72
51
  const config = loadConfig();
73
- const code = generateCode();
52
+ const code = generatePairingCode();
74
53
  let paired = false;
75
54
  function onPaired() {
76
55
  paired = true;
@@ -125,7 +104,7 @@ export async function pairCommand() {
125
104
  const start = Date.now();
126
105
  await new Promise((resolve) => {
127
106
  const interval = setInterval(() => {
128
- if (paired || Date.now() - start >= EXPIRY_MS) {
107
+ if (paired || Date.now() - start >= PAIRING_EXPIRY_MS) {
129
108
  clearInterval(interval);
130
109
  resolve();
131
110
  }
@@ -194,16 +194,13 @@ async function runCommandTriggeredMode(ctx) {
194
194
  cwd: ctx.taskDir,
195
195
  env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
196
196
  });
197
- // Stats
198
197
  let linesProcessed = 0;
199
198
  let invocationsSucceeded = 0;
200
199
  let invocationsFailed = 0;
201
- // Bounded queue for incoming lines
202
200
  const lineQueue = [];
203
201
  let processing = false;
204
202
  let commandExited = false;
205
203
  let resolveWhenDone;
206
- // Rolling log of per-line agent outputs
207
204
  const logPath = path.join(ctx.taskDir, "command-output.log");
208
205
  function appendLog(line, agentOutput, outcome) {
209
206
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
@@ -259,11 +256,10 @@ async function runCommandTriggeredMode(ctx) {
259
256
  }
260
257
  }
261
258
  }
262
- // Read stdout line by line
263
259
  const rl = readline.createInterface({ input: child.stdout });
264
260
  rl.on("line", (line) => {
265
261
  if (!line.trim())
266
- return; // skip empty lines
262
+ return;
267
263
  if (lineQueue.length >= MAX_QUEUE_SIZE) {
268
264
  console.warn(`[command-triggered] Queue full, dropping oldest line.`);
269
265
  lineQueue.shift();
@@ -274,7 +270,6 @@ async function runCommandTriggeredMode(ctx) {
274
270
  invocationsFailed++;
275
271
  });
276
272
  });
277
- // Log stderr
278
273
  child.stderr?.on("data", (d) => process.stderr.write(d));
279
274
  // Wait for command to exit
280
275
  const exitCode = await new Promise((resolve) => {
@@ -305,9 +300,7 @@ async function runCommandTriggeredMode(ctx) {
305
300
  `Agent invocations succeeded: ${invocationsSucceeded}`,
306
301
  `Agent invocations failed: ${invocationsFailed}`,
307
302
  ].join("\n");
308
- // Command-triggered tasks run until the command exits — any exit is a normal finish.
309
- const outcome = "finished";
310
- return { outcome, endTime, output: summary };
303
+ return { outcome: "finished", endTime, output: summary };
311
304
  }
312
305
  async function publishTaskEvent(nc, config, taskDir, taskId, eventType, taskName) {
313
306
  writeTaskStatus(taskDir, {
@@ -329,32 +322,39 @@ async function publishConfirmResolved(nc, config, taskId, status) {
329
322
  status,
330
323
  });
331
324
  }
332
- async function requestPermission(nc, config, task, taskDir, requiredPermissions) {
333
- const taskId = task.frontmatter.id;
325
+ /**
326
+ * Watch status.json until user_input is populated by an RPC call, then resolve.
327
+ * All interactive request flows (confirmation, permission, user input) share this.
328
+ */
329
+ function waitForUserInput(taskDir) {
334
330
  const statusPath = path.join(taskDir, "status.json");
335
- const currentStatus = readTaskStatus(taskDir);
336
- writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
337
- await publishHostEvent(nc, config.hostId, taskId, {
338
- event_type: "permission-request",
339
- host_id: config.hostId,
340
- required_permissions: requiredPermissions,
341
- name: task.frontmatter.name,
342
- });
343
331
  return new Promise((resolve) => {
344
332
  const watcher = fs.watch(statusPath, () => {
345
333
  const status = readTaskStatus(taskDir);
346
334
  if (!status || !status.user_input?.length)
347
335
  return;
348
336
  watcher.close();
349
- const response = status.user_input[0];
350
- writeTaskStatus(taskDir, {
351
- running_state: response === "aborted" ? "aborted" : "started",
352
- time_stamp: Date.now(),
353
- });
354
- resolve(response);
337
+ resolve(status.user_input);
355
338
  });
356
339
  });
357
340
  }
341
+ async function requestPermission(nc, config, task, taskDir, requiredPermissions) {
342
+ const currentStatus = readTaskStatus(taskDir);
343
+ writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
344
+ await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
345
+ event_type: "permission-request",
346
+ host_id: config.hostId,
347
+ required_permissions: requiredPermissions,
348
+ name: task.frontmatter.name,
349
+ });
350
+ const userInput = await waitForUserInput(taskDir);
351
+ const response = userInput[0];
352
+ writeTaskStatus(taskDir, {
353
+ running_state: response === "aborted" ? "aborted" : "started",
354
+ time_stamp: Date.now(),
355
+ });
356
+ return response;
357
+ }
358
358
  async function publishPermissionResolved(nc, config, taskId, status) {
359
359
  await publishHostEvent(nc, config.hostId, taskId, {
360
360
  event_type: "permission-resolved",
@@ -363,33 +363,21 @@ async function publishPermissionResolved(nc, config, taskId, status) {
363
363
  });
364
364
  }
365
365
  async function requestUserInput(nc, config, task, taskDir, inputDescriptions) {
366
- const taskId = task.frontmatter.id;
367
- const statusPath = path.join(taskDir, "status.json");
368
366
  const currentStatus = readTaskStatus(taskDir);
369
367
  writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
370
- await publishHostEvent(nc, config.hostId, taskId, {
368
+ await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
371
369
  event_type: "input-request",
372
370
  host_id: config.hostId,
373
371
  input_descriptions: inputDescriptions,
374
372
  name: task.frontmatter.name,
375
373
  });
376
- return new Promise((resolve) => {
377
- const watcher = fs.watch(statusPath, () => {
378
- const status = readTaskStatus(taskDir);
379
- if (!status || !status.user_input?.length)
380
- return;
381
- watcher.close();
382
- const response = status.user_input;
383
- if (response.length === 1 && response[0] === "aborted") {
384
- writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
385
- resolve("aborted");
386
- }
387
- else {
388
- writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
389
- resolve(response);
390
- }
391
- });
392
- });
374
+ const userInput = await waitForUserInput(taskDir);
375
+ if (userInput.length === 1 && userInput[0] === "aborted") {
376
+ writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
377
+ return "aborted";
378
+ }
379
+ writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
380
+ return userInput;
393
381
  }
394
382
  async function publishInputResolved(nc, config, taskId, status) {
395
383
  await publishHostEvent(nc, config.hostId, taskId, {
@@ -399,32 +387,19 @@ async function publishInputResolved(nc, config, taskId, status) {
399
387
  });
400
388
  }
401
389
  async function requestConfirmation(nc, config, task, taskDir) {
402
- const taskId = task.frontmatter.id;
403
- const statusPath = path.join(taskDir, "status.json");
404
- // Flag that we're awaiting user confirmation
405
390
  const currentStatus = readTaskStatus(taskDir);
406
391
  writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
407
- // Publish confirmation request via NATS and/or HTTP SSE
408
- await publishHostEvent(nc, config.hostId, taskId, {
392
+ await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
409
393
  event_type: "confirm-request",
410
394
  host_id: config.hostId,
411
395
  });
412
- // Wait for task.user_input RPC to set user_input in status.json
413
- return new Promise((resolve) => {
414
- const watcher = fs.watch(statusPath, () => {
415
- const status = readTaskStatus(taskDir);
416
- if (!status || !status.user_input?.length)
417
- return; // still pending
418
- watcher.close();
419
- const confirmed = status.user_input[0] === "confirmed";
420
- // Clear pending_confirmation/user_input and update running_state
421
- writeTaskStatus(taskDir, {
422
- running_state: confirmed ? "started" : "aborted",
423
- time_stamp: Date.now(),
424
- });
425
- resolve(confirmed);
426
- });
396
+ const userInput = await waitForUserInput(taskDir);
397
+ const confirmed = userInput[0] === "confirmed";
398
+ writeTaskStatus(taskDir, {
399
+ running_state: confirmed ? "started" : "aborted",
400
+ time_stamp: Date.now(),
427
401
  });
402
+ return confirmed;
428
403
  }
429
404
  /**
430
405
  * Extract report file names from agent output.
package/dist/config.js CHANGED
@@ -20,7 +20,6 @@ export function loadConfig() {
20
20
  if (!config.natsUrl || !config.natsToken) {
21
21
  throw new Error("Invalid host config: missing natsUrl or natsToken");
22
22
  }
23
- config.nats = true;
24
23
  return config;
25
24
  }
26
25
  /**
package/dist/events.js CHANGED
@@ -1,21 +1,6 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
1
  import { StringCodec } from "nats";
4
- import { CONFIG_DIR } from "./config.js";
2
+ import { getLanPort } from "./lan-lock.js";
5
3
  const sc = StringCodec();
6
- const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
7
- /**
8
- * Read the LAN lockfile to determine if `palmier lan` is running.
9
- */
10
- function getLanPort() {
11
- try {
12
- const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
13
- return JSON.parse(raw).port;
14
- }
15
- catch {
16
- return null;
17
- }
18
- }
19
4
  /**
20
5
  * Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
21
6
  *
@@ -0,0 +1,7 @@
1
+ export declare const LAN_LOCKFILE: string;
2
+ /**
3
+ * Read the LAN lockfile to determine if `palmier lan` is running.
4
+ * Returns the port number, or null if not running.
5
+ */
6
+ export declare function getLanPort(): number | null;
7
+ //# sourceMappingURL=lan-lock.d.ts.map
@@ -0,0 +1,18 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+ export const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
5
+ /**
6
+ * Read the LAN lockfile to determine if `palmier lan` is running.
7
+ * Returns the port number, or null if not running.
8
+ */
9
+ export function getLanPort() {
10
+ try {
11
+ const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
12
+ return JSON.parse(raw).port;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ //# sourceMappingURL=lan-lock.js.map
@@ -0,0 +1,3 @@
1
+ export declare const PAIRING_EXPIRY_MS: number;
2
+ export declare function generatePairingCode(): string;
3
+ //# sourceMappingURL=pairing.d.ts.map
@@ -0,0 +1,9 @@
1
+ const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
2
+ const CODE_LENGTH = 6;
3
+ export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
4
+ export function generatePairingCode() {
5
+ const bytes = new Uint8Array(CODE_LENGTH);
6
+ crypto.getRandomValues(bytes);
7
+ return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
8
+ }
9
+ //# sourceMappingURL=pairing.js.map
@@ -1,4 +1,9 @@
1
1
  import type { PlatformService } from "./platform.js";
2
+ /**
3
+ * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
4
+ * On Unix, undefined lets Node use the default shell.
5
+ */
6
+ export declare const SHELL: string | undefined;
2
7
  export declare function getPlatform(): PlatformService;
3
8
  export type { PlatformService } from "./platform.js";
4
9
  //# sourceMappingURL=index.d.ts.map
@@ -1,5 +1,10 @@
1
1
  import { LinuxPlatform } from "./linux.js";
2
2
  import { WindowsPlatform } from "./windows.js";
3
+ /**
4
+ * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
5
+ * On Unix, undefined lets Node use the default shell.
6
+ */
7
+ export const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
3
8
  let _instance;
4
9
  export function getPlatform() {
5
10
  if (!_instance) {
package/dist/types.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export interface HostConfig {
2
2
  hostId: string;
3
3
  projectRoot: string;
4
- nats?: boolean;
5
4
  natsUrl?: string;
6
5
  natsWsUrl?: string;
7
6
  natsToken?: string;
@@ -29,13 +28,33 @@ export interface ParsedTask {
29
28
  frontmatter: TaskFrontmatter;
30
29
  body: string;
31
30
  }
31
+ /**
32
+ * State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
33
+ *
34
+ * - `started`: task is actively running
35
+ * - `finished`: agent completed successfully
36
+ * - `aborted`: user declined confirmation, permission, or input
37
+ * - `failed`: agent exited with an error
38
+ */
32
39
  export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
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.
47
+ */
33
48
  export interface TaskStatus {
34
49
  running_state: TaskRunningState;
35
50
  time_stamp: number;
51
+ /** Set when the task has `requires_confirmation` and is awaiting user approval. */
36
52
  pending_confirmation?: boolean;
53
+ /** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
37
54
  pending_permission?: RequiredPermission[];
55
+ /** Set when the agent requests user input. Contains descriptions of each requested value. */
38
56
  pending_input?: string[];
57
+ /** Written by the RPC handler to deliver the user's response to the waiting run process. */
39
58
  user_input?: string[];
40
59
  }
41
60
  export interface HistoryEntry {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -2,10 +2,7 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
4
  import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
5
-
6
- // execSync's shell option takes a string (shell path), not boolean.
7
- // On Windows we need a shell so .cmd shims resolve correctly.
8
- const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
5
+ import { SHELL } from "../platform/index.js";
9
6
 
10
7
  export class ClaudeAgent implements AgentTool {
11
8
  getPlanGenerationCommandLine(prompt: string): CommandLine {
@@ -2,13 +2,10 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
4
  import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
5
-
6
- // On Windows we need a shell so .cmd shims resolve correctly.
7
- const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
5
+ import { SHELL } from "../platform/index.js";
8
6
 
9
7
  export class CodexAgent implements AgentTool {
10
8
  getPlanGenerationCommandLine(prompt: string): CommandLine {
11
- // TODO: fill in
12
9
  return {
13
10
  command: "codex",
14
11
  args: ["exec", "--skip-git-repo-check", prompt],
@@ -17,8 +14,7 @@ export class CodexAgent implements AgentTool {
17
14
 
18
15
  getTaskRunCommandLine(task: ParsedTask, retryPrompt?: string, extraPermissions?: RequiredPermission[]): CommandLine {
19
16
  const prompt = AGENT_INSTRUCTIONS + "\n\n" + (retryPrompt ?? (task.body || task.frontmatter.user_prompt));
20
- // TODO: Update sandbox to workspace-write once https://github.com/openai/codex/issues/12572
21
- // is fixed.
17
+ // Using danger-full-access until workspace-write is fixed: https://github.com/openai/codex/issues/12572
22
18
  const args = ["exec", "--full-auto", "--skip-git-repo-check", "--sandbox", "danger-full-access"];
23
19
 
24
20
  const allPerms = [...(task.frontmatter.permissions ?? []), ...(extraPermissions ?? [])];
@@ -2,13 +2,10 @@ import type { ParsedTask, RequiredPermission } from "../types.js";
2
2
  import { execSync } from "child_process";
3
3
  import type { AgentTool, CommandLine } from "./agent.js";
4
4
  import { AGENT_INSTRUCTIONS } from "./shared-prompt.js";
5
-
6
- // On Windows we need a shell so .cmd shims resolve correctly.
7
- const SHELL = process.platform === "win32" ? "cmd.exe" : undefined;
5
+ import { SHELL } from "../platform/index.js";
8
6
 
9
7
  export class GeminiAgent implements AgentTool {
10
8
  getPlanGenerationCommandLine(prompt: string): CommandLine {
11
- // TODO: fill in
12
9
  return {
13
10
  command: "gemini",
14
11
  args: ["--approval-mode", "auto_edit", "--prompt", prompt],
@@ -67,7 +67,6 @@ export async function initCommand(): Promise<void> {
67
67
  const config: HostConfig = {
68
68
  hostId: registerResponse.hostId,
69
69
  projectRoot: process.cwd(),
70
- nats: true,
71
70
  natsUrl: registerResponse.natsUrl,
72
71
  natsWsUrl: registerResponse.natsWsUrl,
73
72
  natsToken: registerResponse.natsToken,
@@ -1,24 +1,14 @@
1
1
  import * as fs from "fs";
2
- import * as path from "path";
3
2
  import { loadConfig, CONFIG_DIR } from "../config.js";
4
3
  import { createRpcHandler } from "../rpc-handler.js";
5
4
  import { startHttpTransport, detectLanIp } from "../transports/http-transport.js";
6
-
7
- const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
5
+ import { generatePairingCode } from "../pairing.js";
6
+ import { LAN_LOCKFILE } from "../lan-lock.js";
8
7
 
9
8
  const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
10
9
  const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
11
10
  const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
12
11
 
13
- const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
14
- const CODE_LENGTH = 6;
15
-
16
- function generateCode(): string {
17
- const bytes = new Uint8Array(CODE_LENGTH);
18
- crypto.getRandomValues(bytes);
19
- return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
20
- }
21
-
22
12
  function writeLockfile(port: number): void {
23
13
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
24
14
  fs.writeFileSync(LAN_LOCKFILE, JSON.stringify({ port, pid: process.pid }), "utf-8");
@@ -36,7 +26,7 @@ export async function lanCommand(opts: { port: number }): Promise<void> {
36
26
  const config = loadConfig();
37
27
  const port = opts.port;
38
28
  const ip = detectLanIp();
39
- const code = generateCode();
29
+ const code = generatePairingCode();
40
30
 
41
31
  const handleRpc = createRpcHandler(config);
42
32
 
@@ -1,23 +1,12 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
1
  import * as http from "node:http";
4
2
  import { StringCodec } from "nats";
5
- import { loadConfig, CONFIG_DIR } from "../config.js";
3
+ import { loadConfig } from "../config.js";
6
4
  import { connectNats } from "../nats-client.js";
7
5
  import { addSession } from "../session-store.js";
6
+ import { generatePairingCode, PAIRING_EXPIRY_MS } from "../pairing.js";
7
+ import { getLanPort } from "../lan-lock.js";
8
8
  import type { HostConfig } from "../types.js";
9
9
 
10
- const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
11
- const CODE_LENGTH = 6;
12
- const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
13
- const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
14
-
15
- function generateCode(): string {
16
- const bytes = new Uint8Array(CODE_LENGTH);
17
- crypto.getRandomValues(bytes);
18
- return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
19
- }
20
-
21
10
  function buildPairResponse(config: HostConfig, label?: string) {
22
11
  const session = addSession(label);
23
12
  return {
@@ -30,7 +19,7 @@ function buildPairResponse(config: HostConfig, label?: string) {
30
19
  * POST to the running LAN server and long-poll until paired or expired.
31
20
  */
32
21
  function lanPairRegister(port: number, code: string): Promise<boolean> {
33
- const body = JSON.stringify({ code, expiryMs: EXPIRY_MS });
22
+ const body = JSON.stringify({ code, expiryMs: PAIRING_EXPIRY_MS });
34
23
 
35
24
  return new Promise((resolve) => {
36
25
  const req = http.request(
@@ -40,7 +29,7 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
40
29
  path: "/internal/pair-register",
41
30
  method: "POST",
42
31
  headers: { "Content-Type": "application/json" },
43
- timeout: EXPIRY_MS + 5000,
32
+ timeout: PAIRING_EXPIRY_MS + 5000,
44
33
  },
45
34
  (res) => {
46
35
  const chunks: Buffer[] = [];
@@ -62,23 +51,13 @@ function lanPairRegister(port: number, code: string): Promise<boolean> {
62
51
  });
63
52
  }
64
53
 
65
- /**
66
- * Read the LAN lockfile to check if `palmier lan` is running.
67
- */
68
- function getLanPort(): number | null {
69
- try {
70
- const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
71
- return (JSON.parse(raw) as { port: number }).port;
72
- } catch { return null; }
73
- }
74
-
75
54
  /**
76
55
  * Generate an OTP code and wait for a PWA client to pair.
77
56
  * Listens on NATS always, and also on the LAN server if `palmier lan` is running.
78
57
  */
79
58
  export async function pairCommand(): Promise<void> {
80
59
  const config = loadConfig();
81
- const code = generateCode();
60
+ const code = generatePairingCode();
82
61
 
83
62
  let paired = false;
84
63
 
@@ -140,7 +119,7 @@ export async function pairCommand(): Promise<void> {
140
119
  const start = Date.now();
141
120
  await new Promise<void>((resolve) => {
142
121
  const interval = setInterval(() => {
143
- if (paired || Date.now() - start >= EXPIRY_MS) {
122
+ if (paired || Date.now() - start >= PAIRING_EXPIRY_MS) {
144
123
  clearInterval(interval);
145
124
  resolve();
146
125
  }
@@ -264,18 +264,15 @@ async function runCommandTriggeredMode(
264
264
  env: { ...ctx.guiEnv, PALMIER_TASK_ID: ctx.task.frontmatter.id },
265
265
  });
266
266
 
267
- // Stats
268
267
  let linesProcessed = 0;
269
268
  let invocationsSucceeded = 0;
270
269
  let invocationsFailed = 0;
271
270
 
272
- // Bounded queue for incoming lines
273
271
  const lineQueue: string[] = [];
274
272
  let processing = false;
275
273
  let commandExited = false;
276
274
  let resolveWhenDone: (() => void) | undefined;
277
275
 
278
- // Rolling log of per-line agent outputs
279
276
  const logPath = path.join(ctx.taskDir, "command-output.log");
280
277
  function appendLog(line: string, agentOutput: string, outcome: string) {
281
278
  const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
@@ -333,10 +330,9 @@ async function runCommandTriggeredMode(
333
330
  }
334
331
  }
335
332
 
336
- // Read stdout line by line
337
333
  const rl = readline.createInterface({ input: child.stdout! });
338
334
  rl.on("line", (line: string) => {
339
- if (!line.trim()) return; // skip empty lines
335
+ if (!line.trim()) return;
340
336
  if (lineQueue.length >= MAX_QUEUE_SIZE) {
341
337
  console.warn(`[command-triggered] Queue full, dropping oldest line.`);
342
338
  lineQueue.shift();
@@ -348,7 +344,6 @@ async function runCommandTriggeredMode(
348
344
  });
349
345
  });
350
346
 
351
- // Log stderr
352
347
  child.stderr?.on("data", (d: Buffer) => process.stderr.write(d));
353
348
 
354
349
  // Wait for command to exit
@@ -383,9 +378,7 @@ async function runCommandTriggeredMode(
383
378
  `Agent invocations failed: ${invocationsFailed}`,
384
379
  ].join("\n");
385
380
 
386
- // Command-triggered tasks run until the command exits — any exit is a normal finish.
387
- const outcome: TaskRunningState = "finished";
388
- return { outcome, endTime, output: summary };
381
+ return { outcome: "finished", endTime, output: summary };
389
382
  }
390
383
 
391
384
  async function publishTaskEvent(
@@ -422,6 +415,22 @@ async function publishConfirmResolved(
422
415
  });
423
416
  }
424
417
 
418
+ /**
419
+ * Watch status.json until user_input is populated by an RPC call, then resolve.
420
+ * All interactive request flows (confirmation, permission, user input) share this.
421
+ */
422
+ function waitForUserInput(taskDir: string): Promise<string[]> {
423
+ const statusPath = path.join(taskDir, "status.json");
424
+ return new Promise<string[]>((resolve) => {
425
+ const watcher = fs.watch(statusPath, () => {
426
+ const status = readTaskStatus(taskDir);
427
+ if (!status || !status.user_input?.length) return;
428
+ watcher.close();
429
+ resolve(status.user_input);
430
+ });
431
+ });
432
+ }
433
+
425
434
  async function requestPermission(
426
435
  nc: NatsConnection | undefined,
427
436
  config: HostConfig,
@@ -429,32 +438,23 @@ async function requestPermission(
429
438
  taskDir: string,
430
439
  requiredPermissions: RequiredPermission[],
431
440
  ): Promise<"granted" | "granted_all" | "aborted"> {
432
- const taskId = task.frontmatter.id;
433
- const statusPath = path.join(taskDir, "status.json");
434
-
435
441
  const currentStatus = readTaskStatus(taskDir)!;
436
442
  writeTaskStatus(taskDir, { ...currentStatus, pending_permission: requiredPermissions });
437
443
 
438
- await publishHostEvent(nc, config.hostId, taskId, {
444
+ await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
439
445
  event_type: "permission-request",
440
446
  host_id: config.hostId,
441
447
  required_permissions: requiredPermissions,
442
448
  name: task.frontmatter.name,
443
449
  });
444
450
 
445
- return new Promise<"granted" | "granted_all" | "aborted">((resolve) => {
446
- const watcher = fs.watch(statusPath, () => {
447
- const status = readTaskStatus(taskDir);
448
- if (!status || !status.user_input?.length) return;
449
- watcher.close();
450
- const response = status.user_input[0] as "granted" | "granted_all" | "aborted";
451
- writeTaskStatus(taskDir, {
452
- running_state: response === "aborted" ? "aborted" : "started",
453
- time_stamp: Date.now(),
454
- });
455
- resolve(response);
456
- });
451
+ const userInput = await waitForUserInput(taskDir);
452
+ const response = userInput[0] as "granted" | "granted_all" | "aborted";
453
+ writeTaskStatus(taskDir, {
454
+ running_state: response === "aborted" ? "aborted" : "started",
455
+ time_stamp: Date.now(),
457
456
  });
457
+ return response;
458
458
  }
459
459
 
460
460
  async function publishPermissionResolved(
@@ -477,34 +477,23 @@ async function requestUserInput(
477
477
  taskDir: string,
478
478
  inputDescriptions: string[],
479
479
  ): Promise<string[] | "aborted"> {
480
- const taskId = task.frontmatter.id;
481
- const statusPath = path.join(taskDir, "status.json");
482
-
483
480
  const currentStatus = readTaskStatus(taskDir)!;
484
481
  writeTaskStatus(taskDir, { ...currentStatus, pending_input: inputDescriptions });
485
482
 
486
- await publishHostEvent(nc, config.hostId, taskId, {
483
+ await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
487
484
  event_type: "input-request",
488
485
  host_id: config.hostId,
489
486
  input_descriptions: inputDescriptions,
490
487
  name: task.frontmatter.name,
491
488
  });
492
489
 
493
- return new Promise<string[] | "aborted">((resolve) => {
494
- const watcher = fs.watch(statusPath, () => {
495
- const status = readTaskStatus(taskDir);
496
- if (!status || !status.user_input?.length) return;
497
- watcher.close();
498
- const response = status.user_input;
499
- if (response.length === 1 && response[0] === "aborted") {
500
- writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
501
- resolve("aborted");
502
- } else {
503
- writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
504
- resolve(response);
505
- }
506
- });
507
- });
490
+ const userInput = await waitForUserInput(taskDir);
491
+ if (userInput.length === 1 && userInput[0] === "aborted") {
492
+ writeTaskStatus(taskDir, { running_state: "aborted", time_stamp: Date.now() });
493
+ return "aborted";
494
+ }
495
+ writeTaskStatus(taskDir, { running_state: "started", time_stamp: Date.now() });
496
+ return userInput;
508
497
  }
509
498
 
510
499
  async function publishInputResolved(
@@ -526,34 +515,21 @@ async function requestConfirmation(
526
515
  task: ParsedTask,
527
516
  taskDir: string,
528
517
  ): Promise<boolean> {
529
- const taskId = task.frontmatter.id;
530
- const statusPath = path.join(taskDir, "status.json");
531
-
532
- // Flag that we're awaiting user confirmation
533
518
  const currentStatus = readTaskStatus(taskDir)!;
534
519
  writeTaskStatus(taskDir, { ...currentStatus, pending_confirmation: true });
535
520
 
536
- // Publish confirmation request via NATS and/or HTTP SSE
537
- await publishHostEvent(nc, config.hostId, taskId, {
521
+ await publishHostEvent(nc, config.hostId, task.frontmatter.id, {
538
522
  event_type: "confirm-request",
539
523
  host_id: config.hostId,
540
524
  });
541
525
 
542
- // Wait for task.user_input RPC to set user_input in status.json
543
- return new Promise<boolean>((resolve) => {
544
- const watcher = fs.watch(statusPath, () => {
545
- const status = readTaskStatus(taskDir);
546
- if (!status || !status.user_input?.length) return; // still pending
547
- watcher.close();
548
- const confirmed = status.user_input[0] === "confirmed";
549
- // Clear pending_confirmation/user_input and update running_state
550
- writeTaskStatus(taskDir, {
551
- running_state: confirmed ? "started" : "aborted",
552
- time_stamp: Date.now(),
553
- });
554
- resolve(confirmed);
555
- });
526
+ const userInput = await waitForUserInput(taskDir);
527
+ const confirmed = userInput[0] === "confirmed";
528
+ writeTaskStatus(taskDir, {
529
+ running_state: confirmed ? "started" : "aborted",
530
+ time_stamp: Date.now(),
556
531
  });
532
+ return confirmed;
557
533
  }
558
534
 
559
535
  /**
package/src/config.ts CHANGED
@@ -29,7 +29,6 @@ export function loadConfig(): HostConfig {
29
29
  throw new Error("Invalid host config: missing natsUrl or natsToken");
30
30
  }
31
31
 
32
- config.nats = true;
33
32
  return config;
34
33
  }
35
34
 
package/src/events.ts CHANGED
@@ -1,20 +1,7 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
1
  import { StringCodec, type NatsConnection } from "nats";
4
- import { CONFIG_DIR } from "./config.js";
2
+ import { getLanPort } from "./lan-lock.js";
5
3
 
6
4
  const sc = StringCodec();
7
- const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
8
-
9
- /**
10
- * Read the LAN lockfile to determine if `palmier lan` is running.
11
- */
12
- function getLanPort(): number | null {
13
- try {
14
- const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
15
- return (JSON.parse(raw) as { port: number }).port;
16
- } catch { return null; }
17
- }
18
5
 
19
6
  /**
20
7
  * Broadcast an event to connected clients via NATS and HTTP SSE (if LAN server is running).
@@ -0,0 +1,16 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+
5
+ export const LAN_LOCKFILE = path.join(CONFIG_DIR, "lan.json");
6
+
7
+ /**
8
+ * Read the LAN lockfile to determine if `palmier lan` is running.
9
+ * Returns the port number, or null if not running.
10
+ */
11
+ export function getLanPort(): number | null {
12
+ try {
13
+ const raw = fs.readFileSync(LAN_LOCKFILE, "utf-8");
14
+ return (JSON.parse(raw) as { port: number }).port;
15
+ } catch { return null; }
16
+ }
package/src/pairing.ts ADDED
@@ -0,0 +1,10 @@
1
+ const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
2
+ const CODE_LENGTH = 6;
3
+
4
+ export const PAIRING_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
5
+
6
+ export function generatePairingCode(): string {
7
+ const bytes = new Uint8Array(CODE_LENGTH);
8
+ crypto.getRandomValues(bytes);
9
+ return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
10
+ }
@@ -2,6 +2,12 @@ import type { PlatformService } from "./platform.js";
2
2
  import { LinuxPlatform } from "./linux.js";
3
3
  import { WindowsPlatform } from "./windows.js";
4
4
 
5
+ /**
6
+ * On Windows, execSync needs an explicit shell so .cmd shims resolve correctly.
7
+ * On Unix, undefined lets Node use the default shell.
8
+ */
9
+ export const SHELL: string | undefined = process.platform === "win32" ? "cmd.exe" : undefined;
10
+
5
11
  let _instance: PlatformService | undefined;
6
12
 
7
13
  export function getPlatform(): PlatformService {
package/src/types.ts CHANGED
@@ -2,8 +2,6 @@ export interface HostConfig {
2
2
  hostId: string;
3
3
  projectRoot: string;
4
4
 
5
- // NATS (always enabled)
6
- nats?: boolean;
7
5
  natsUrl?: string;
8
6
  natsWsUrl?: string;
9
7
  natsToken?: string;
@@ -34,14 +32,34 @@ export interface ParsedTask {
34
32
  body: string;
35
33
  }
36
34
 
35
+ /**
36
+ * State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
37
+ *
38
+ * - `started`: task is actively running
39
+ * - `finished`: agent completed successfully
40
+ * - `aborted`: user declined confirmation, permission, or input
41
+ * - `failed`: agent exited with an error
42
+ */
37
43
  export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
38
44
 
45
+ /**
46
+ * Persisted to `status.json` in the task directory. Updated by the run process
47
+ * and read by the RPC handler + PWA to track live task state.
48
+ *
49
+ * Interactive request flow: the run process sets a `pending_*` field and waits
50
+ * for `user_input` to be populated by an RPC call (task.user_input). Only one
51
+ * `pending_*` field is set at a time.
52
+ */
39
53
  export interface TaskStatus {
40
54
  running_state: TaskRunningState;
41
55
  time_stamp: number;
56
+ /** Set when the task has `requires_confirmation` and is awaiting user approval. */
42
57
  pending_confirmation?: boolean;
58
+ /** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
43
59
  pending_permission?: RequiredPermission[];
60
+ /** Set when the agent requests user input. Contains descriptions of each requested value. */
44
61
  pending_input?: string[];
62
+ /** Written by the RPC handler to deliver the user's response to the waiting run process. */
45
63
  user_input?: string[];
46
64
  }
47
65