palmier 0.1.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/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "palmier",
3
+ "version": "0.1.0",
4
+ "description": "Palmier agent CLI - provisions, executes tasks, and serves NATS RPC",
5
+ "license": "ISC",
6
+ "author": "Hongxu Cai",
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "bin": {
11
+ "palmier": "dist/index.js"
12
+ },
13
+ "scripts": {
14
+ "dev": "tsx src/index.ts",
15
+ "build": "tsc",
16
+ "start": "node dist/index.js"
17
+ },
18
+ "dependencies": {
19
+ "commander": "^13.1.0",
20
+ "dotenv": "^16.4.7",
21
+ "nats": "^2.29.1",
22
+ "node-pty": "^1.0.0",
23
+ "uuid": "^11.1.0",
24
+ "yaml": "^2.7.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.13.0",
28
+ "@types/uuid": "^10.0.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript": "^5.7.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=20.0.0"
34
+ }
35
+ }
@@ -0,0 +1,240 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { StringCodec } from "nats";
3
+ import { loadConfig } from "../config.js";
4
+ import { connectNats } from "../nats-client.js";
5
+ import type { ClaudeHookEvent, HookPayload } from "../types.js";
6
+ import type { NatsConnection, KV } from "nats";
7
+
8
+ /**
9
+ * Handle a Claude Code hook invocation.
10
+ * Called by Claude Code as a subprocess. Reads hook event from stdin,
11
+ * dispatches by hook_name, and outputs response to stdout.
12
+ */
13
+ export async function hookCommand(): Promise<void> {
14
+ const rawInput = await readStdin();
15
+
16
+ let event: ClaudeHookEvent;
17
+ try {
18
+ event = JSON.parse(rawInput) as ClaudeHookEvent;
19
+ } catch {
20
+ console.error("Failed to parse hook event from stdin");
21
+ process.exit(1);
22
+ }
23
+
24
+ const taskId = process.env.PALMIER_TASK_ID;
25
+ if (!taskId) {
26
+ // Not running in a palmier task context, exit silently
27
+ return;
28
+ }
29
+
30
+ const config = loadConfig();
31
+ const nc = await connectNats(config);
32
+ const sc = StringCodec();
33
+
34
+ try {
35
+ const js = nc.jetstream();
36
+ const kv = await js.views.kv("pending-hooks");
37
+
38
+ switch (event.hook_name) {
39
+ case "PermissionRequest":
40
+ await handlePermissionRequest(config, nc, kv, sc, event, taskId);
41
+ break;
42
+
43
+ case "Notification":
44
+ await handleNotification(config, nc, kv, sc, event, taskId);
45
+ break;
46
+
47
+ case "Stop":
48
+ await handleStop(config, nc, sc, taskId);
49
+ break;
50
+
51
+ default:
52
+ // Unknown hook, exit silently
53
+ break;
54
+ }
55
+ } finally {
56
+ await nc.drain();
57
+ }
58
+ }
59
+
60
+ async function handlePermissionRequest(
61
+ config: ReturnType<typeof loadConfig>,
62
+ nc: NatsConnection,
63
+ kv: KV,
64
+ sc: ReturnType<typeof StringCodec>,
65
+ event: ClaudeHookEvent,
66
+ taskId: string
67
+ ): Promise<void> {
68
+ const hookId = uuidv4();
69
+ const kvKey = `${config.agentId}.${taskId}.${hookId}`;
70
+
71
+ // Start watching BEFORE writing
72
+ const watch = await kv.watch({ key: kvKey });
73
+
74
+ // Write hook payload to KV
75
+ const payload: HookPayload = {
76
+ type: "permission",
77
+ task_id: taskId,
78
+ hook_id: hookId,
79
+ agent_id: config.agentId,
80
+ user_id: config.userId,
81
+ details: {
82
+ tool: event.tool_name,
83
+ input: event.tool_input,
84
+ },
85
+ status: "pending",
86
+ };
87
+
88
+ await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
89
+
90
+ // Publish push notification
91
+ nc.publish(
92
+ `user.${config.userId}.push.request.permission`,
93
+ sc.encode(
94
+ JSON.stringify({
95
+ type: "permission",
96
+ task_id: taskId,
97
+ hook_id: hookId,
98
+ agent_id: config.agentId,
99
+ tool: event.tool_name,
100
+ input: event.tool_input,
101
+ })
102
+ )
103
+ );
104
+
105
+ // Wait for status change
106
+ for await (const entry of watch) {
107
+ if (entry.operation === "DEL" || entry.operation === "PURGE") {
108
+ // Key deleted, deny by default
109
+ process.stdout.write(JSON.stringify({ behavior: "deny" }));
110
+ return;
111
+ }
112
+
113
+ try {
114
+ const updated = JSON.parse(sc.decode(entry.value)) as HookPayload;
115
+ if (updated.status === "confirmed" || updated.status === "allowed") {
116
+ process.stdout.write(JSON.stringify({ behavior: "allow" }));
117
+ await kv.delete(kvKey);
118
+ return;
119
+ } else if (updated.status === "denied" || updated.status === "aborted") {
120
+ process.stdout.write(JSON.stringify({ behavior: "deny" }));
121
+ await kv.delete(kvKey);
122
+ return;
123
+ }
124
+ // Still pending, keep watching
125
+ } catch {
126
+ // Couldn't parse, keep watching
127
+ }
128
+ }
129
+ }
130
+
131
+ async function handleNotification(
132
+ config: ReturnType<typeof loadConfig>,
133
+ nc: NatsConnection,
134
+ kv: KV,
135
+ sc: ReturnType<typeof StringCodec>,
136
+ event: ClaudeHookEvent,
137
+ taskId: string
138
+ ): Promise<void> {
139
+ const message = event.message || "";
140
+
141
+ // Check if notification requires user input
142
+ // Look for patterns suggesting input is needed
143
+ const inputPatterns = [
144
+ /\bwait(ing)?\s+(for|on)\s+(user\s+)?input\b/i,
145
+ /\bplease\s+(provide|enter|type|input)\b/i,
146
+ /\buser\s+input\s+(required|needed)\b/i,
147
+ /\bask(ing)?\s+(the\s+)?user\b/i,
148
+ /\brequires?\s+(user\s+)?input\b/i,
149
+ /\bprompt(ing)?\s+(the\s+)?user\b/i,
150
+ ];
151
+
152
+ const needsInput = inputPatterns.some((pattern) => pattern.test(message));
153
+
154
+ if (!needsInput) {
155
+ // No input needed, exit silently
156
+ return;
157
+ }
158
+
159
+ const hookId = uuidv4();
160
+ const kvKey = `${config.agentId}.${taskId}.${hookId}`;
161
+
162
+ // Start watching BEFORE writing
163
+ const watch = await kv.watch({ key: kvKey });
164
+
165
+ // Write hook payload to KV
166
+ const payload: HookPayload = {
167
+ type: "input",
168
+ task_id: taskId,
169
+ hook_id: hookId,
170
+ agent_id: config.agentId,
171
+ user_id: config.userId,
172
+ details: {
173
+ message: event.message,
174
+ },
175
+ status: "pending",
176
+ };
177
+
178
+ await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
179
+
180
+ // Publish push notification
181
+ nc.publish(
182
+ `user.${config.userId}.push.notify.input_needed`,
183
+ sc.encode(
184
+ JSON.stringify({
185
+ type: "input",
186
+ task_id: taskId,
187
+ hook_id: hookId,
188
+ agent_id: config.agentId,
189
+ message: event.message,
190
+ })
191
+ )
192
+ );
193
+
194
+ // Wait for status change - the status field will contain the user's input text
195
+ for await (const entry of watch) {
196
+ if (entry.operation === "DEL" || entry.operation === "PURGE") {
197
+ return;
198
+ }
199
+
200
+ try {
201
+ const updated = JSON.parse(sc.decode(entry.value)) as HookPayload;
202
+ if (updated.status !== "pending") {
203
+ // The status field contains the user's input text
204
+ process.stdout.write(updated.status);
205
+ await kv.delete(kvKey);
206
+ return;
207
+ }
208
+ // Still pending, keep watching
209
+ } catch {
210
+ // Couldn't parse, keep watching
211
+ }
212
+ }
213
+ }
214
+
215
+ async function handleStop(
216
+ config: ReturnType<typeof loadConfig>,
217
+ nc: NatsConnection,
218
+ sc: ReturnType<typeof StringCodec>,
219
+ taskId: string
220
+ ): Promise<void> {
221
+ // Publish completion notification
222
+ nc.publish(
223
+ `user.${config.userId}.push.notify.complete`,
224
+ sc.encode(
225
+ JSON.stringify({
226
+ type: "complete",
227
+ task_id: taskId,
228
+ agent_id: config.agentId,
229
+ })
230
+ )
231
+ );
232
+ }
233
+
234
+ async function readStdin(): Promise<string> {
235
+ const chunks: Buffer[] = [];
236
+ for await (const chunk of process.stdin) {
237
+ chunks.push(chunk as Buffer);
238
+ }
239
+ return Buffer.concat(chunks).toString("utf-8");
240
+ }
@@ -0,0 +1,140 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execSync } from "child_process";
4
+ import { homedir } from "os";
5
+ import { saveConfig } from "../config.js";
6
+ import type { AgentConfig } from "../types.js";
7
+
8
+ export interface InitOptions {
9
+ token: string;
10
+ }
11
+
12
+ /**
13
+ * Provision this agent by exchanging a base64 provisioning token for permanent credentials.
14
+ */
15
+ export async function initCommand(options: InitOptions): Promise<void> {
16
+ // 1. Decode base64 provisioning token
17
+ let decoded: { server: string; token: string };
18
+ try {
19
+ const jsonStr = Buffer.from(options.token, "base64").toString("utf-8");
20
+ decoded = JSON.parse(jsonStr) as { server: string; token: string };
21
+ } catch {
22
+ console.error("Failed to decode provisioning token. Ensure it is a valid base64-encoded JSON string.");
23
+ process.exit(1);
24
+ }
25
+
26
+ if (!decoded.server || !decoded.token) {
27
+ console.error("Invalid provisioning token: missing 'server' or 'token' field.");
28
+ process.exit(1);
29
+ }
30
+
31
+ console.log(`Claiming agent at ${decoded.server}...`);
32
+
33
+ // 2. POST to server to claim agent
34
+ let claimResponse: {
35
+ agent_id: string;
36
+ user_id: string;
37
+ nats_url: string;
38
+ nats_ws_url: string;
39
+ nats_token: string;
40
+ };
41
+
42
+ try {
43
+ const res = await fetch(`${decoded.server}/api/agents/claim`, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ provisioning_token: decoded.token }),
47
+ });
48
+
49
+ if (!res.ok) {
50
+ const body = await res.text();
51
+ console.error(`Failed to claim agent: ${res.status} ${res.statusText}\n${body}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ claimResponse = (await res.json()) as typeof claimResponse;
56
+ } catch (err) {
57
+ console.error(`Failed to reach server: ${err}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ // 3. Save config
62
+ const config: AgentConfig = {
63
+ agentId: claimResponse.agent_id,
64
+ userId: claimResponse.user_id,
65
+ natsUrl: claimResponse.nats_url,
66
+ natsWsUrl: claimResponse.nats_ws_url,
67
+ natsToken: claimResponse.nats_token,
68
+ projectRoot: process.cwd(),
69
+ };
70
+
71
+ saveConfig(config);
72
+ console.log(`Agent provisioned. ID: ${config.agentId}`);
73
+ console.log("Config saved to ~/.config/palmier/agent.json");
74
+
75
+ // 4. Write Claude Code hooks config
76
+ const claudeSettingsDir = path.join(process.cwd(), ".claude");
77
+ fs.mkdirSync(claudeSettingsDir, { recursive: true });
78
+
79
+ const hooksConfig = {
80
+ hooks: {
81
+ PermissionRequest: [{ type: "command", command: "palmier hook" }],
82
+ Notification: [{ type: "command", command: "palmier hook" }],
83
+ Stop: [{ type: "command", command: "palmier hook" }],
84
+ },
85
+ };
86
+
87
+ fs.writeFileSync(
88
+ path.join(claudeSettingsDir, "settings.json"),
89
+ JSON.stringify(hooksConfig, null, 2),
90
+ "utf-8"
91
+ );
92
+ console.log("Claude Code hooks config written to .claude/settings.json");
93
+
94
+ // 5. Install systemd user service for palmier serve
95
+ const unitDir = path.join(homedir(), ".config", "systemd", "user");
96
+ fs.mkdirSync(unitDir, { recursive: true });
97
+
98
+ const palmierBin = process.argv[1] || "palmier";
99
+
100
+ const serviceContent = `[Unit]
101
+ Description=Palmier Agent
102
+ After=network-online.target
103
+ Wants=network-online.target
104
+
105
+ [Service]
106
+ Type=simple
107
+ ExecStart=${palmierBin} serve
108
+ WorkingDirectory=${config.projectRoot}
109
+ Restart=on-failure
110
+ RestartSec=5
111
+ Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
112
+
113
+ [Install]
114
+ WantedBy=default.target
115
+ `;
116
+
117
+ const servicePath = path.join(unitDir, "palmier-agent.service");
118
+ fs.writeFileSync(servicePath, serviceContent, "utf-8");
119
+ console.log("Systemd service installed at:", servicePath);
120
+
121
+ // 6. Enable and start the service
122
+ try {
123
+ execSync("systemctl --user daemon-reload", { stdio: "inherit" });
124
+ execSync("systemctl --user enable --now palmier-agent.service", { stdio: "inherit" });
125
+ console.log("Palmier agent service enabled and started.");
126
+ } catch (err) {
127
+ console.error(`Warning: failed to enable systemd service: ${err}`);
128
+ console.error("You may need to start it manually: systemctl --user enable --now palmier-agent.service");
129
+ }
130
+
131
+ // 7. Enable lingering so service runs without active login session
132
+ try {
133
+ execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
134
+ console.log("Login lingering enabled.");
135
+ } catch (err) {
136
+ console.error(`Warning: failed to enable linger: ${err}`);
137
+ }
138
+
139
+ console.log("\nAgent initialization complete!");
140
+ }
@@ -0,0 +1,197 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { loadConfig } from "../config.js";
3
+ import { connectNats } from "../nats-client.js";
4
+ import { parseTaskFile, getTaskDir } from "../task.js";
5
+ import type { AgentConfig, ParsedTask, HookPayload } from "../types.js";
6
+ import type { NatsConnection, KV } from "nats";
7
+ import { StringCodec } from "nats";
8
+
9
+ /**
10
+ * Execute a task by ID.
11
+ */
12
+ export async function runCommand(taskId: string): Promise<void> {
13
+ const config = loadConfig();
14
+ const taskDir = getTaskDir(config.projectRoot, taskId);
15
+ const task = parseTaskFile(taskDir);
16
+
17
+ console.log(`Running task: ${task.frontmatter.name || taskId}`);
18
+
19
+ let nc: NatsConnection | undefined;
20
+ let kv: KV | undefined;
21
+
22
+ // Track KV keys we create so we can clean them up
23
+ const kvKeysToClean: string[] = [];
24
+
25
+ const cleanup = async () => {
26
+ if (kv) {
27
+ for (const key of kvKeysToClean) {
28
+ try {
29
+ await kv.delete(key);
30
+ } catch {
31
+ // Ignore cleanup errors
32
+ }
33
+ }
34
+ }
35
+ if (nc && !nc.isClosed()) {
36
+ await nc.drain();
37
+ }
38
+ };
39
+
40
+ // Handle signals
41
+ const onSignal = async () => {
42
+ console.log("Received signal, cleaning up...");
43
+ await cleanup();
44
+ process.exit(1);
45
+ };
46
+ process.on("SIGINT", onSignal);
47
+ process.on("SIGTERM", onSignal);
48
+
49
+ try {
50
+ // If requires_confirmation, ask user via NATS KV
51
+ if (task.frontmatter.requires_confirmation) {
52
+ nc = await connectNats(config);
53
+ const js = nc.jetstream();
54
+ kv = await js.views.kv("pending-hooks");
55
+
56
+ const confirmed = await requestConfirmation(config, task, kv, nc, kvKeysToClean);
57
+ if (!confirmed) {
58
+ console.log("Task aborted by user.");
59
+ await cleanup();
60
+ return;
61
+ }
62
+ console.log("Task confirmed by user.");
63
+ }
64
+
65
+ // Spawn Claude CLI via node-pty
66
+ await spawnClaude(config, task, taskId);
67
+
68
+ console.log(`Task ${taskId} completed.`);
69
+ } catch (err) {
70
+ console.error(`Task ${taskId} failed:`, err);
71
+ process.exitCode = 1;
72
+ } finally {
73
+ await cleanup();
74
+ }
75
+ }
76
+
77
+ async function requestConfirmation(
78
+ config: AgentConfig,
79
+ task: ParsedTask,
80
+ kv: KV,
81
+ nc: NatsConnection,
82
+ kvKeysToClean: string[]
83
+ ): Promise<boolean> {
84
+ const sc = StringCodec();
85
+ const hookId = uuidv4();
86
+ const kvKey = `${config.agentId}.${task.frontmatter.id}.${hookId}`;
87
+ kvKeysToClean.push(kvKey);
88
+
89
+ // Start watching BEFORE writing, to avoid race condition
90
+ const watch = await kv.watch({ key: kvKey });
91
+
92
+ // Write hook payload to KV
93
+ const payload: HookPayload = {
94
+ type: "confirm",
95
+ task_id: task.frontmatter.id,
96
+ hook_id: hookId,
97
+ agent_id: config.agentId,
98
+ user_id: config.userId,
99
+ details: {
100
+ task_name: task.frontmatter.name,
101
+ prompt: task.frontmatter.user_prompt,
102
+ },
103
+ status: "pending",
104
+ };
105
+
106
+ await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
107
+
108
+ // Publish push notification
109
+ nc.publish(
110
+ `user.${config.userId}.push.request.confirm`,
111
+ sc.encode(JSON.stringify({
112
+ type: "confirm",
113
+ task_id: task.frontmatter.id,
114
+ hook_id: hookId,
115
+ agent_id: config.agentId,
116
+ task_name: task.frontmatter.name,
117
+ }))
118
+ );
119
+
120
+ // Wait for status change
121
+ for await (const entry of watch) {
122
+ if (entry.operation === "DEL" || entry.operation === "PURGE") {
123
+ // Key was deleted, treat as aborted
124
+ return false;
125
+ }
126
+
127
+ try {
128
+ const updated = JSON.parse(sc.decode(entry.value)) as HookPayload;
129
+ if (updated.status === "confirmed") {
130
+ await kv.delete(kvKey);
131
+ kvKeysToClean.pop();
132
+ return true;
133
+ } else if (updated.status === "aborted") {
134
+ await kv.delete(kvKey);
135
+ kvKeysToClean.pop();
136
+ return false;
137
+ }
138
+ // Still pending, keep watching
139
+ } catch {
140
+ // Couldn't parse, keep watching
141
+ }
142
+ }
143
+
144
+ return false;
145
+ }
146
+
147
+ async function spawnClaude(
148
+ config: AgentConfig,
149
+ task: ParsedTask,
150
+ taskId: string
151
+ ): Promise<void> {
152
+ // Dynamic import of node-pty (native module)
153
+ const { spawn } = await import("node-pty");
154
+
155
+ return new Promise<void>((resolve, reject) => {
156
+ const args = ["-p", task.frontmatter.user_prompt];
157
+
158
+ if (task.frontmatter.suppress_permissions) {
159
+ args.push("--dangerously-skip-permissions");
160
+ }
161
+
162
+ // If the task has a body (system prompt / additional instructions), pass via --system-prompt
163
+ if (task.body) {
164
+ args.push("--system-prompt", task.body);
165
+ }
166
+
167
+ const ptyProcess = spawn("claude", args, {
168
+ name: "xterm-256color",
169
+ cols: 120,
170
+ rows: 40,
171
+ cwd: config.projectRoot,
172
+ env: {
173
+ ...process.env,
174
+ PALMIER_TASK_ID: taskId,
175
+ } as Record<string, string>,
176
+ });
177
+
178
+ ptyProcess.onData((data: string) => {
179
+ process.stdout.write(data);
180
+ });
181
+
182
+ ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
183
+ if (exitCode === 0) {
184
+ resolve();
185
+ } else {
186
+ reject(new Error(`Claude exited with code ${exitCode}`));
187
+ }
188
+ });
189
+
190
+ // Forward signals to pty child
191
+ const killChild = () => {
192
+ ptyProcess.kill();
193
+ };
194
+ process.on("SIGINT", killChild);
195
+ process.on("SIGTERM", killChild);
196
+ });
197
+ }