palmier 0.1.9 → 0.2.0

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/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ ## Getting Started
4
+
5
+ Always read `spec.md` and `README.md` first before starting any task. These files contain the project specification and setup instructions that should inform all work.
package/README.md CHANGED
@@ -21,7 +21,6 @@ npm install -g palmier
21
21
  | `palmier init --token <token>` | Provision the agent with a token from the dashboard |
22
22
  | `palmier serve` | Run the persistent NATS RPC handler (default command) |
23
23
  | `palmier run <task-id>` | Execute a specific task |
24
- | `palmier hook` | Handle Claude Code hook events (invoked by Claude Code, not manually) |
25
24
 
26
25
  ## Setup
27
26
 
@@ -33,7 +32,6 @@ npm install -g palmier
33
32
  The `init` command:
34
33
  - Saves agent configuration to `~/.config/palmier/agent.json`
35
34
  - Installs a systemd user service for the agent
36
- - Configures Claude Code hooks for remote interaction
37
35
 
38
36
  ### Verifying the Service
39
37
 
@@ -66,9 +64,12 @@ cat ~/.config/palmier/agent.json
66
64
  - The persistent process (`palmier serve`) is a NATS RPC handler. It derives the RPC method from the NATS subject (e.g., `...rpc.task.create` → `task.create`) and treats the message body as request parameters.
67
65
  - **Task IDs** are generated by the agent as UUIDs.
68
66
  - All RPC responses (`task.list`, `task.create`, `task.update`) return **flat task objects** — frontmatter fields at the top level, not nested under a `frontmatter` key.
67
+ - Tasks have no separate name field — the `user_prompt` is the primary identifier and display label.
68
+ - Plan generation is optional — if no plan body is present, the agent uses only `user_prompt` as the prompt.
69
+ - **Triggers can be enabled/disabled** via the `triggers_enabled` frontmatter field (default `true`). When disabled, systemd timers are removed; when re-enabled, they are reinstalled. Tasks can still be run manually regardless.
69
70
  - Incoming tasks are stored as `TASK.md` files in a local `tasks/` directory.
70
- - Task execution spawns **Claude Code in a PTY**, giving the AI full CLI access within the project.
71
- - **Hooks** intercept Claude Code permission, confirmation, and input prompts, resolving them remotely via NATS KV so tasks can run unattended.
71
+ - Task execution spawns **Claude Code as a background process** with `-p --dangerously-skip-permissions`, running non-interactively. Task lifecycle events (`start`, `finish`, `abort`, `fail`) are tracked via a NATS JetStream KV bucket (`task-event`), keyed by `<agent_id>.<task_id>`.
72
+ - **Task confirmation** tasks with `requires_confirmation: true` write a pending entry to the `pending-confirmation` KV bucket before execution. The Web Server and PWA watch this bucket to show confirmation prompts. The task waits until the user confirms or aborts via the PWA or push notification.
72
73
 
73
74
  ## Project Structure
74
75
 
@@ -84,9 +85,54 @@ src/
84
85
  init.ts # Provisioning logic
85
86
  serve.ts # Persistent NATS RPC handler
86
87
  run.ts # Single task execution
87
- hook.ts # Claude Code hook handler
88
88
  ```
89
89
 
90
+ ## Removing an Agent
91
+
92
+ To fully remove an agent from a machine:
93
+
94
+ 1. **Delete the agent from the PWA dashboard** (this removes it from the server database).
95
+
96
+ 2. **Stop and remove the systemd service:**
97
+
98
+ ```bash
99
+ systemctl --user stop palmier-agent.service
100
+ systemctl --user disable palmier-agent.service
101
+ rm ~/.config/systemd/user/palmier-agent.service
102
+ ```
103
+
104
+ 3. **Remove any task timers and services:**
105
+
106
+ ```bash
107
+ systemctl --user stop palmier-task-*.timer palmier-task-*.service 2>/dev/null
108
+ systemctl --user disable palmier-task-*.timer 2>/dev/null
109
+ rm -f ~/.config/systemd/user/palmier-task-*.timer ~/.config/systemd/user/palmier-task-*.service
110
+ ```
111
+
112
+ 4. **Reload systemd:**
113
+
114
+ ```bash
115
+ systemctl --user daemon-reload
116
+ ```
117
+
118
+ 5. **Remove the agent configuration:**
119
+
120
+ ```bash
121
+ rm -rf ~/.config/palmier
122
+ ```
123
+
124
+ 6. **Remove the tasks directory** from your project root:
125
+
126
+ ```bash
127
+ rm -rf tasks/
128
+ ```
129
+
130
+ 7. *(Optional)* **Disable login lingering** if no other user services need it:
131
+
132
+ ```bash
133
+ loginctl disable-linger
134
+ ```
135
+
90
136
  ## Related
91
137
 
92
138
  See the [palmier](../palmier) repo for the server, API, and PWA.
@@ -1,7 +1,12 @@
1
1
  import { v4 as uuidv4 } from "uuid";
2
2
  import { StringCodec } from "nats";
3
+ import { appendFileSync } from "fs";
3
4
  import { loadConfig } from "../config.js";
4
5
  import { connectNats } from "../nats-client.js";
6
+ function log(msg) {
7
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
8
+ appendFileSync("/tmp/palmier-hook.log", line);
9
+ }
5
10
  /**
6
11
  * Handle a Claude Code hook invocation.
7
12
  * Called by Claude Code as a subprocess. Reads hook event from stdin,
@@ -17,9 +22,10 @@ export async function hookCommand() {
17
22
  console.error("Failed to parse hook event from stdin");
18
23
  process.exit(1);
19
24
  }
25
+ log(`received: ${JSON.stringify(event).slice(0, 500)}`);
20
26
  const taskId = process.env.PALMIER_TASK_ID;
21
27
  if (!taskId) {
22
- // Not running in a palmier task context, exit silently
28
+ log("no PALMIER_TASK_ID, exiting");
23
29
  return;
24
30
  }
25
31
  const config = loadConfig();
@@ -28,7 +34,7 @@ export async function hookCommand() {
28
34
  try {
29
35
  const js = nc.jetstream();
30
36
  const kv = await js.views.kv("pending-hooks");
31
- switch (event.hook_name) {
37
+ switch (event.hook_event_name) {
32
38
  case "PermissionRequest":
33
39
  await handlePermissionRequest(config, nc, kv, sc, event, taskId);
34
40
  break;
@@ -47,6 +53,14 @@ export async function hookCommand() {
47
53
  await nc.drain();
48
54
  }
49
55
  }
56
+ function permissionResponse(behavior) {
57
+ return {
58
+ hookSpecificOutput: {
59
+ hookEventName: "PermissionRequest",
60
+ decision: { behavior },
61
+ },
62
+ };
63
+ }
50
64
  async function handlePermissionRequest(config, nc, kv, sc, event, taskId) {
51
65
  const hookId = uuidv4();
52
66
  const kvKey = `${config.agentId}.${taskId}.${hookId}`;
@@ -65,6 +79,7 @@ async function handlePermissionRequest(config, nc, kv, sc, event, taskId) {
65
79
  },
66
80
  status: "pending",
67
81
  };
82
+ log(`permission: putting KV key=${kvKey} payload=${JSON.stringify(payload)}`);
68
83
  await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
69
84
  // Publish push notification
70
85
  nc.publish(`user.${config.userId}.push.request.permission`, sc.encode(JSON.stringify({
@@ -77,20 +92,27 @@ async function handlePermissionRequest(config, nc, kv, sc, event, taskId) {
77
92
  })));
78
93
  // Wait for status change
79
94
  for await (const entry of watch) {
95
+ log(`permission: watch event op=${entry.operation} key=${entry.key}`);
80
96
  if (entry.operation === "DEL" || entry.operation === "PURGE") {
81
97
  // Key deleted, deny by default
82
- process.stdout.write(JSON.stringify({ behavior: "deny" }));
98
+ log(`permission: key deleted/purged, denying`);
99
+ process.stdout.write(JSON.stringify(permissionResponse("deny")));
83
100
  return;
84
101
  }
85
102
  try {
86
103
  const updated = JSON.parse(sc.decode(entry.value));
104
+ log(`permission: KV update status=${updated.status} payload=${JSON.stringify(updated)}`);
87
105
  if (updated.status === "confirmed" || updated.status === "allowed") {
88
- process.stdout.write(JSON.stringify({ behavior: "allow" }));
106
+ const out = JSON.stringify(permissionResponse("allow"));
107
+ log(`permission: allowing, stdout=${out}`);
108
+ process.stdout.write(out);
89
109
  await kv.delete(kvKey);
90
110
  return;
91
111
  }
92
112
  else if (updated.status === "denied" || updated.status === "aborted") {
93
- process.stdout.write(JSON.stringify({ behavior: "deny" }));
113
+ const out = JSON.stringify(permissionResponse("deny"));
114
+ log(`permission: denying, stdout=${out}`);
115
+ process.stdout.write(out);
94
116
  await kv.delete(kvKey);
95
117
  return;
96
118
  }
@@ -134,6 +156,7 @@ async function handleNotification(config, nc, kv, sc, event, taskId) {
134
156
  },
135
157
  status: "pending",
136
158
  };
159
+ log(`input: putting KV key=${kvKey} payload=${JSON.stringify(payload)}`);
137
160
  await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
138
161
  // Publish push notification
139
162
  nc.publish(`user.${config.userId}.push.notify.input_needed`, sc.encode(JSON.stringify({
@@ -145,13 +168,17 @@ async function handleNotification(config, nc, kv, sc, event, taskId) {
145
168
  })));
146
169
  // Wait for status change - the status field will contain the user's input text
147
170
  for await (const entry of watch) {
171
+ log(`input: watch event op=${entry.operation} key=${entry.key}`);
148
172
  if (entry.operation === "DEL" || entry.operation === "PURGE") {
173
+ log(`input: key deleted/purged`);
149
174
  return;
150
175
  }
151
176
  try {
152
177
  const updated = JSON.parse(sc.decode(entry.value));
178
+ log(`input: KV update status=${updated.status} payload=${JSON.stringify(updated)}`);
153
179
  if (updated.status !== "pending") {
154
180
  // The status field contains the user's input text
181
+ log(`input: resolved with user input`);
155
182
  process.stdout.write(updated.status);
156
183
  await kv.delete(kvKey);
157
184
  return;
@@ -53,38 +53,25 @@ export async function initCommand(options) {
53
53
  saveConfig(config);
54
54
  console.log(`Agent provisioned. ID: ${config.agentId}`);
55
55
  console.log("Config saved to ~/.config/palmier/agent.json");
56
- // 4. Write Claude Code hooks config
57
- const claudeSettingsDir = path.join(process.cwd(), ".claude");
58
- fs.mkdirSync(claudeSettingsDir, { recursive: true });
59
- const hookEntry = { hooks: [{ type: "command", command: "palmier hook" }] };
60
- const hooksConfig = {
61
- hooks: {
62
- PermissionRequest: [hookEntry],
63
- Notification: [hookEntry],
64
- Stop: [hookEntry],
65
- },
66
- };
67
- fs.writeFileSync(path.join(claudeSettingsDir, "settings.json"), JSON.stringify(hooksConfig, null, 2), "utf-8");
68
- console.log("Claude Code hooks config written to .claude/settings.json");
69
- // 5. Install systemd user service for palmier serve
56
+ // 4. Install systemd user service for palmier serve
70
57
  const unitDir = path.join(homedir(), ".config", "systemd", "user");
71
58
  fs.mkdirSync(unitDir, { recursive: true });
72
59
  const palmierBin = process.argv[1] || "palmier";
73
- const serviceContent = `[Unit]
74
- Description=Palmier Agent
75
- After=network-online.target
76
- Wants=network-online.target
77
-
78
- [Service]
79
- Type=simple
80
- ExecStart=${palmierBin} serve
81
- WorkingDirectory=${config.projectRoot}
82
- Restart=on-failure
83
- RestartSec=5
84
- Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
85
-
86
- [Install]
87
- WantedBy=default.target
60
+ const serviceContent = `[Unit]
61
+ Description=Palmier Agent
62
+ After=network-online.target
63
+ Wants=network-online.target
64
+
65
+ [Service]
66
+ Type=simple
67
+ ExecStart=${palmierBin} serve
68
+ WorkingDirectory=${config.projectRoot}
69
+ Restart=on-failure
70
+ RestartSec=5
71
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
72
+
73
+ [Install]
74
+ WantedBy=default.target
88
75
  `;
89
76
  const servicePath = path.join(unitDir, "palmier-agent.service");
90
77
  fs.writeFileSync(servicePath, serviceContent, "utf-8");
@@ -1,3 +1,4 @@
1
+ export type TaskEventType = "start" | "finish" | "abort" | "fail";
1
2
  /**
2
3
  * Execute a task by ID.
3
4
  */
@@ -1,4 +1,6 @@
1
- import { v4 as uuidv4 } from "uuid";
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { spawn } from "child_process";
2
4
  import { loadConfig } from "../config.js";
3
5
  import { connectNats } from "../nats-client.js";
4
6
  import { parseTaskFile, getTaskDir } from "../task.js";
@@ -10,21 +12,16 @@ export async function runCommand(taskId) {
10
12
  const config = loadConfig();
11
13
  const taskDir = getTaskDir(config.projectRoot, taskId);
12
14
  const task = parseTaskFile(taskDir);
13
- console.log(`Running task: ${task.frontmatter.name || taskId}`);
15
+ console.log(`Running task: ${taskId}`);
14
16
  let nc;
15
- let kv;
16
- // Track KV keys we create so we can clean them up
17
- const kvKeysToClean = [];
17
+ let confirmKv;
18
+ const confirmKey = `${config.agentId}.${taskId}`;
18
19
  const cleanup = async () => {
19
- if (kv) {
20
- for (const key of kvKeysToClean) {
21
- try {
22
- await kv.delete(key);
23
- }
24
- catch {
25
- // Ignore cleanup errors
26
- }
20
+ if (confirmKv) {
21
+ try {
22
+ await confirmKv.delete(confirmKey);
27
23
  }
24
+ catch { /* may not exist */ }
28
25
  }
29
26
  if (nc && !nc.isClosed()) {
30
27
  await nc.drain();
@@ -33,84 +30,90 @@ export async function runCommand(taskId) {
33
30
  // Handle signals
34
31
  const onSignal = async () => {
35
32
  console.log("Received signal, cleaning up...");
33
+ if (eventKv) {
34
+ await writeTaskEvent(eventKv, `${config.agentId}.${taskId}`, "abort").catch(() => { });
35
+ }
36
36
  await cleanup();
37
37
  process.exit(1);
38
38
  };
39
39
  process.on("SIGINT", onSignal);
40
40
  process.on("SIGTERM", onSignal);
41
+ let eventKv;
41
42
  try {
43
+ nc = await connectNats(config);
44
+ const js = nc.jetstream();
45
+ // Set up task-event KV and mark as started immediately
46
+ eventKv = await js.views.kv("task-event", { history: 1 });
47
+ const eventKey = `${config.agentId}.${taskId}`;
48
+ await writeTaskEvent(eventKv, eventKey, "start");
42
49
  // If requires_confirmation, ask user via NATS KV
43
50
  if (task.frontmatter.requires_confirmation) {
44
- nc = await connectNats(config);
45
- const js = nc.jetstream();
46
- kv = await js.views.kv("pending-hooks");
47
- const confirmed = await requestConfirmation(config, task, kv, nc, kvKeysToClean);
51
+ confirmKv = await js.views.kv("pending-confirmation");
52
+ const confirmed = await requestConfirmation(config, task, confirmKv);
48
53
  if (!confirmed) {
49
54
  console.log("Task aborted by user.");
55
+ await writeTaskEvent(eventKv, eventKey, "abort");
50
56
  await cleanup();
51
57
  return;
52
58
  }
53
59
  console.log("Task confirmed by user.");
54
60
  }
55
- // Spawn Claude CLI via node-pty
56
- await spawnClaude(config, task, taskId);
61
+ // Spawn task process
62
+ const startTime = Date.now();
63
+ const output = await spawnTask(config, task);
64
+ const endTime = Date.now();
65
+ // Save result with frontmatter to task directory
66
+ const resultPath = path.join(taskDir, "RESULT.md");
67
+ const resultContent = `---\nstart_time: ${startTime}\nend_time: ${endTime}\n---\n${output}`;
68
+ fs.writeFileSync(resultPath, resultContent, "utf-8");
69
+ // Set event to finish on completion
70
+ await writeTaskEvent(eventKv, eventKey, "finish");
57
71
  console.log(`Task ${taskId} completed.`);
58
72
  }
59
73
  catch (err) {
60
74
  console.error(`Task ${taskId} failed:`, err);
75
+ if (eventKv) {
76
+ await writeTaskEvent(eventKv, `${config.agentId}.${taskId}`, "fail").catch(() => { });
77
+ }
61
78
  process.exitCode = 1;
62
79
  }
63
80
  finally {
64
81
  await cleanup();
65
82
  }
66
83
  }
67
- async function requestConfirmation(config, task, kv, nc, kvKeysToClean) {
68
- const sc = StringCodec();
69
- const hookId = uuidv4();
70
- const kvKey = `${config.agentId}.${task.frontmatter.id}.${hookId}`;
71
- kvKeysToClean.push(kvKey);
72
- // Start watching BEFORE writing, to avoid race condition
73
- const watch = await kv.watch({ key: kvKey });
74
- // Write hook payload to KV
84
+ const sc = StringCodec();
85
+ async function writeTaskEvent(kv, key, eventType) {
86
+ const event = { event_type: eventType, time_stamp: Date.now() };
87
+ console.log(`[task-event] ${key} ${eventType}`);
88
+ await kv.put(key, sc.encode(JSON.stringify(event)));
89
+ }
90
+ async function requestConfirmation(config, task, kv) {
91
+ const kvKey = `${config.agentId}.${task.frontmatter.id}`;
92
+ // Write confirmation payload to KV — the server watches this bucket and sends push notifications
75
93
  const payload = {
76
94
  type: "confirm",
77
95
  task_id: task.frontmatter.id,
78
- hook_id: hookId,
79
96
  agent_id: config.agentId,
80
97
  user_id: config.userId,
81
98
  details: {
82
- task_name: task.frontmatter.name,
83
99
  prompt: task.frontmatter.user_prompt,
84
100
  },
85
101
  status: "pending",
86
102
  };
87
103
  await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
88
- // Publish push notification
89
- nc.publish(`user.${config.userId}.push.request.confirm`, sc.encode(JSON.stringify({
90
- type: "confirm",
91
- task_id: task.frontmatter.id,
92
- hook_id: hookId,
93
- agent_id: config.agentId,
94
- task_name: task.frontmatter.name,
95
- })));
96
- // Wait for status change
104
+ // Watch AFTER writing — the initial history replay delivers the "pending" entry (skipped),
105
+ // then the iterator stays open for live updates (confirmed/aborted).
106
+ const watch = await kv.watch({ key: kvKey });
97
107
  for await (const entry of watch) {
98
108
  if (entry.operation === "DEL" || entry.operation === "PURGE") {
99
- // Key was deleted, treat as aborted
100
109
  return false;
101
110
  }
102
111
  try {
103
112
  const updated = JSON.parse(sc.decode(entry.value));
104
- if (updated.status === "confirmed") {
105
- await kv.delete(kvKey);
106
- kvKeysToClean.pop();
113
+ if (updated.status === "confirmed")
107
114
  return true;
108
- }
109
- else if (updated.status === "aborted") {
110
- await kv.delete(kvKey);
111
- kvKeysToClean.pop();
115
+ if (updated.status === "aborted")
112
116
  return false;
113
- }
114
117
  // Still pending, keep watching
115
118
  }
116
119
  catch {
@@ -119,45 +122,50 @@ async function requestConfirmation(config, task, kv, nc, kvKeysToClean) {
119
122
  }
120
123
  return false;
121
124
  }
122
- async function spawnClaude(config, task, taskId) {
123
- // Dynamic import of node-pty (native module)
124
- const { spawn } = await import("node-pty");
125
+ function shellEscape(arg) {
126
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
127
+ }
128
+ async function spawnTask(config, task) {
125
129
  return new Promise((resolve, reject) => {
126
- const args = ["-p", task.frontmatter.user_prompt];
127
- if (task.frontmatter.suppress_permissions) {
128
- args.push("--dangerously-skip-permissions");
129
- }
130
- // If the task has a body (system prompt / additional instructions), pass via --system-prompt
131
- if (task.body) {
132
- args.push("--system-prompt", task.body);
133
- }
134
- const ptyProcess = spawn("claude", args, {
135
- name: "xterm-256color",
136
- cols: 120,
137
- rows: 40,
130
+ const prompt = task.body
131
+ ? `${task.body}\n\n${task.frontmatter.user_prompt}`
132
+ : task.frontmatter.user_prompt;
133
+ const command = `${task.frontmatter.command_line} ${shellEscape(prompt)}`;
134
+ const child = spawn(command, {
138
135
  cwd: config.projectRoot,
136
+ shell: true,
137
+ stdio: ["ignore", "pipe", "pipe"],
139
138
  env: {
140
139
  ...process.env,
141
- PALMIER_TASK_ID: taskId,
140
+ PALMIER_TASK_ID: task.frontmatter.id,
142
141
  },
143
142
  });
144
- ptyProcess.onData((data) => {
143
+ const stdoutChunks = [];
144
+ child.stdout?.on("data", (data) => {
145
+ stdoutChunks.push(data);
145
146
  process.stdout.write(data);
146
147
  });
147
- ptyProcess.onExit(({ exitCode }) => {
148
- if (exitCode === 0) {
149
- resolve();
150
- }
151
- else {
152
- reject(new Error(`Claude exited with code ${exitCode}`));
153
- }
148
+ child.stderr?.on("data", (data) => {
149
+ process.stderr.write(data);
154
150
  });
155
- // Forward signals to pty child
151
+ let stopping = false;
156
152
  const killChild = () => {
157
- ptyProcess.kill();
153
+ stopping = true;
154
+ child.kill("SIGTERM");
158
155
  };
159
156
  process.on("SIGINT", killChild);
160
157
  process.on("SIGTERM", killChild);
158
+ child.on("close", (exitCode) => {
159
+ if (exitCode === 0 || stopping) {
160
+ resolve(Buffer.concat(stdoutChunks).toString("utf-8"));
161
+ }
162
+ else {
163
+ reject(new Error(`Task process exited with code ${exitCode}`));
164
+ }
165
+ });
166
+ child.on("error", (err) => {
167
+ reject(err);
168
+ });
161
169
  });
162
170
  }
163
171
  //# sourceMappingURL=run.js.map