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/README.md +64 -0
- package/dist/commands/hook.d.ts +7 -0
- package/dist/commands/hook.js +181 -0
- package/dist/commands/init.d.ts +8 -0
- package/dist/commands/init.js +111 -0
- package/dist/commands/run.d.ts +5 -0
- package/dist/commands/run.js +163 -0
- package/dist/commands/serve.d.ts +5 -0
- package/dist/commands/serve.js +180 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +31 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +42 -0
- package/dist/nats-client.d.ts +7 -0
- package/dist/nats-client.js +13 -0
- package/dist/systemd.d.ts +24 -0
- package/dist/systemd.js +205 -0
- package/dist/task.d.ts +19 -0
- package/dist/task.js +74 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +2 -0
- package/package.json +35 -0
- package/src/commands/hook.ts +240 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/run.ts +197 -0
- package/src/commands/serve.ts +224 -0
- package/src/config.ts +40 -0
- package/src/index.ts +49 -0
- package/src/nats-client.ts +15 -0
- package/src/systemd.ts +232 -0
- package/src/task.ts +91 -0
- package/src/types.ts +63 -0
- package/tsconfig.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Palmier Agent
|
|
2
|
+
|
|
3
|
+
A Node.js CLI that runs on your machine as a persistent agent. It manages tasks, communicates with the Palmier platform via NATS, and executes Claude Code autonomously.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **Node.js 20+**
|
|
8
|
+
- **Claude Code CLI** installed and authenticated
|
|
9
|
+
- **Linux with systemd** (the agent installs as a systemd user service)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g palmier-agent
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## CLI Commands
|
|
18
|
+
|
|
19
|
+
| Command | Description |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `palmier init --token <token>` | Provision the agent with a token from the dashboard |
|
|
22
|
+
| `palmier serve` | Run the persistent NATS RPC handler (default command) |
|
|
23
|
+
| `palmier run <task-id>` | Execute a specific task |
|
|
24
|
+
| `palmier hook` | Handle Claude Code hook events (invoked by Claude Code, not manually) |
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
|
|
28
|
+
1. Install the agent: `npm install -g palmier-agent`
|
|
29
|
+
2. Register on the Palmier PWA and click **Add Agent**.
|
|
30
|
+
3. Copy the provisioning token.
|
|
31
|
+
4. Run `palmier init --token <token>` in your project directory.
|
|
32
|
+
|
|
33
|
+
The `init` command:
|
|
34
|
+
- Saves agent configuration to `~/.config/palmier/agent.json`
|
|
35
|
+
- Installs a systemd user service for the agent
|
|
36
|
+
- Configures Claude Code hooks for remote interaction
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
- The agent runs as a **systemd user service**, staying alive in the background.
|
|
41
|
+
- Incoming tasks from the platform are stored as `TASK.md` files in a local `tasks/` directory.
|
|
42
|
+
- Task execution spawns **Claude Code in a PTY**, giving the AI full CLI access within the project.
|
|
43
|
+
- **Hooks** intercept Claude Code permission, confirmation, and input prompts, resolving them remotely via NATS KV so tasks can run unattended.
|
|
44
|
+
|
|
45
|
+
## Project Structure
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/
|
|
49
|
+
index.ts # CLI entrypoint (commander setup)
|
|
50
|
+
config.ts # Agent configuration (read/write ~/.config/palmier)
|
|
51
|
+
nats-client.ts # NATS connection and messaging
|
|
52
|
+
systemd.ts # systemd service installation
|
|
53
|
+
task.ts # Task file management
|
|
54
|
+
types.ts # Shared type definitions
|
|
55
|
+
commands/
|
|
56
|
+
init.ts # Provisioning logic
|
|
57
|
+
serve.ts # Persistent NATS RPC handler
|
|
58
|
+
run.ts # Single task execution
|
|
59
|
+
hook.ts # Claude Code hook handler
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Related
|
|
63
|
+
|
|
64
|
+
See the [palmier](../palmier) repo for the server, API, and PWA.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handle a Claude Code hook invocation.
|
|
3
|
+
* Called by Claude Code as a subprocess. Reads hook event from stdin,
|
|
4
|
+
* dispatches by hook_name, and outputs response to stdout.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hookCommand(): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=hook.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Handle a Claude Code hook invocation.
|
|
7
|
+
* Called by Claude Code as a subprocess. Reads hook event from stdin,
|
|
8
|
+
* dispatches by hook_name, and outputs response to stdout.
|
|
9
|
+
*/
|
|
10
|
+
export async function hookCommand() {
|
|
11
|
+
const rawInput = await readStdin();
|
|
12
|
+
let event;
|
|
13
|
+
try {
|
|
14
|
+
event = JSON.parse(rawInput);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.error("Failed to parse hook event from stdin");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const taskId = process.env.PALMIER_TASK_ID;
|
|
21
|
+
if (!taskId) {
|
|
22
|
+
// Not running in a palmier task context, exit silently
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const config = loadConfig();
|
|
26
|
+
const nc = await connectNats(config);
|
|
27
|
+
const sc = StringCodec();
|
|
28
|
+
try {
|
|
29
|
+
const js = nc.jetstream();
|
|
30
|
+
const kv = await js.views.kv("pending-hooks");
|
|
31
|
+
switch (event.hook_name) {
|
|
32
|
+
case "PermissionRequest":
|
|
33
|
+
await handlePermissionRequest(config, nc, kv, sc, event, taskId);
|
|
34
|
+
break;
|
|
35
|
+
case "Notification":
|
|
36
|
+
await handleNotification(config, nc, kv, sc, event, taskId);
|
|
37
|
+
break;
|
|
38
|
+
case "Stop":
|
|
39
|
+
await handleStop(config, nc, sc, taskId);
|
|
40
|
+
break;
|
|
41
|
+
default:
|
|
42
|
+
// Unknown hook, exit silently
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await nc.drain();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function handlePermissionRequest(config, nc, kv, sc, event, taskId) {
|
|
51
|
+
const hookId = uuidv4();
|
|
52
|
+
const kvKey = `${config.agentId}.${taskId}.${hookId}`;
|
|
53
|
+
// Start watching BEFORE writing
|
|
54
|
+
const watch = await kv.watch({ key: kvKey });
|
|
55
|
+
// Write hook payload to KV
|
|
56
|
+
const payload = {
|
|
57
|
+
type: "permission",
|
|
58
|
+
task_id: taskId,
|
|
59
|
+
hook_id: hookId,
|
|
60
|
+
agent_id: config.agentId,
|
|
61
|
+
user_id: config.userId,
|
|
62
|
+
details: {
|
|
63
|
+
tool: event.tool_name,
|
|
64
|
+
input: event.tool_input,
|
|
65
|
+
},
|
|
66
|
+
status: "pending",
|
|
67
|
+
};
|
|
68
|
+
await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
|
|
69
|
+
// Publish push notification
|
|
70
|
+
nc.publish(`user.${config.userId}.push.request.permission`, sc.encode(JSON.stringify({
|
|
71
|
+
type: "permission",
|
|
72
|
+
task_id: taskId,
|
|
73
|
+
hook_id: hookId,
|
|
74
|
+
agent_id: config.agentId,
|
|
75
|
+
tool: event.tool_name,
|
|
76
|
+
input: event.tool_input,
|
|
77
|
+
})));
|
|
78
|
+
// Wait for status change
|
|
79
|
+
for await (const entry of watch) {
|
|
80
|
+
if (entry.operation === "DEL" || entry.operation === "PURGE") {
|
|
81
|
+
// Key deleted, deny by default
|
|
82
|
+
process.stdout.write(JSON.stringify({ behavior: "deny" }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const updated = JSON.parse(sc.decode(entry.value));
|
|
87
|
+
if (updated.status === "confirmed" || updated.status === "allowed") {
|
|
88
|
+
process.stdout.write(JSON.stringify({ behavior: "allow" }));
|
|
89
|
+
await kv.delete(kvKey);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
else if (updated.status === "denied" || updated.status === "aborted") {
|
|
93
|
+
process.stdout.write(JSON.stringify({ behavior: "deny" }));
|
|
94
|
+
await kv.delete(kvKey);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Still pending, keep watching
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Couldn't parse, keep watching
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function handleNotification(config, nc, kv, sc, event, taskId) {
|
|
105
|
+
const message = event.message || "";
|
|
106
|
+
// Check if notification requires user input
|
|
107
|
+
// Look for patterns suggesting input is needed
|
|
108
|
+
const inputPatterns = [
|
|
109
|
+
/\bwait(ing)?\s+(for|on)\s+(user\s+)?input\b/i,
|
|
110
|
+
/\bplease\s+(provide|enter|type|input)\b/i,
|
|
111
|
+
/\buser\s+input\s+(required|needed)\b/i,
|
|
112
|
+
/\bask(ing)?\s+(the\s+)?user\b/i,
|
|
113
|
+
/\brequires?\s+(user\s+)?input\b/i,
|
|
114
|
+
/\bprompt(ing)?\s+(the\s+)?user\b/i,
|
|
115
|
+
];
|
|
116
|
+
const needsInput = inputPatterns.some((pattern) => pattern.test(message));
|
|
117
|
+
if (!needsInput) {
|
|
118
|
+
// No input needed, exit silently
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const hookId = uuidv4();
|
|
122
|
+
const kvKey = `${config.agentId}.${taskId}.${hookId}`;
|
|
123
|
+
// Start watching BEFORE writing
|
|
124
|
+
const watch = await kv.watch({ key: kvKey });
|
|
125
|
+
// Write hook payload to KV
|
|
126
|
+
const payload = {
|
|
127
|
+
type: "input",
|
|
128
|
+
task_id: taskId,
|
|
129
|
+
hook_id: hookId,
|
|
130
|
+
agent_id: config.agentId,
|
|
131
|
+
user_id: config.userId,
|
|
132
|
+
details: {
|
|
133
|
+
message: event.message,
|
|
134
|
+
},
|
|
135
|
+
status: "pending",
|
|
136
|
+
};
|
|
137
|
+
await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
|
|
138
|
+
// Publish push notification
|
|
139
|
+
nc.publish(`user.${config.userId}.push.notify.input_needed`, sc.encode(JSON.stringify({
|
|
140
|
+
type: "input",
|
|
141
|
+
task_id: taskId,
|
|
142
|
+
hook_id: hookId,
|
|
143
|
+
agent_id: config.agentId,
|
|
144
|
+
message: event.message,
|
|
145
|
+
})));
|
|
146
|
+
// Wait for status change - the status field will contain the user's input text
|
|
147
|
+
for await (const entry of watch) {
|
|
148
|
+
if (entry.operation === "DEL" || entry.operation === "PURGE") {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const updated = JSON.parse(sc.decode(entry.value));
|
|
153
|
+
if (updated.status !== "pending") {
|
|
154
|
+
// The status field contains the user's input text
|
|
155
|
+
process.stdout.write(updated.status);
|
|
156
|
+
await kv.delete(kvKey);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Still pending, keep watching
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Couldn't parse, keep watching
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function handleStop(config, nc, sc, taskId) {
|
|
167
|
+
// Publish completion notification
|
|
168
|
+
nc.publish(`user.${config.userId}.push.notify.complete`, sc.encode(JSON.stringify({
|
|
169
|
+
type: "complete",
|
|
170
|
+
task_id: taskId,
|
|
171
|
+
agent_id: config.agentId,
|
|
172
|
+
})));
|
|
173
|
+
}
|
|
174
|
+
async function readStdin() {
|
|
175
|
+
const chunks = [];
|
|
176
|
+
for await (const chunk of process.stdin) {
|
|
177
|
+
chunks.push(chunk);
|
|
178
|
+
}
|
|
179
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=hook.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface InitOptions {
|
|
2
|
+
token: string;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Provision this agent by exchanging a base64 provisioning token for permanent credentials.
|
|
6
|
+
*/
|
|
7
|
+
export declare function initCommand(options: InitOptions): Promise<void>;
|
|
8
|
+
//# sourceMappingURL=init.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Provision this agent by exchanging a base64 provisioning token for permanent credentials.
|
|
8
|
+
*/
|
|
9
|
+
export async function initCommand(options) {
|
|
10
|
+
// 1. Decode base64 provisioning token
|
|
11
|
+
let decoded;
|
|
12
|
+
try {
|
|
13
|
+
const jsonStr = Buffer.from(options.token, "base64").toString("utf-8");
|
|
14
|
+
decoded = JSON.parse(jsonStr);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.error("Failed to decode provisioning token. Ensure it is a valid base64-encoded JSON string.");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
if (!decoded.server || !decoded.token) {
|
|
21
|
+
console.error("Invalid provisioning token: missing 'server' or 'token' field.");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
console.log(`Claiming agent at ${decoded.server}...`);
|
|
25
|
+
// 2. POST to server to claim agent
|
|
26
|
+
let claimResponse;
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`${decoded.server}/api/agents/claim`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ provisioning_token: decoded.token }),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
const body = await res.text();
|
|
35
|
+
console.error(`Failed to claim agent: ${res.status} ${res.statusText}\n${body}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
claimResponse = (await res.json());
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(`Failed to reach server: ${err}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
// 3. Save config
|
|
45
|
+
const config = {
|
|
46
|
+
agentId: claimResponse.agent_id,
|
|
47
|
+
userId: claimResponse.user_id,
|
|
48
|
+
natsUrl: claimResponse.nats_url,
|
|
49
|
+
natsWsUrl: claimResponse.nats_ws_url,
|
|
50
|
+
natsToken: claimResponse.nats_token,
|
|
51
|
+
projectRoot: process.cwd(),
|
|
52
|
+
};
|
|
53
|
+
saveConfig(config);
|
|
54
|
+
console.log(`Agent provisioned. ID: ${config.agentId}`);
|
|
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 hooksConfig = {
|
|
60
|
+
hooks: {
|
|
61
|
+
PermissionRequest: [{ type: "command", command: "palmier hook" }],
|
|
62
|
+
Notification: [{ type: "command", command: "palmier hook" }],
|
|
63
|
+
Stop: [{ type: "command", command: "palmier hook" }],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
fs.writeFileSync(path.join(claudeSettingsDir, "settings.json"), JSON.stringify(hooksConfig, null, 2), "utf-8");
|
|
67
|
+
console.log("Claude Code hooks config written to .claude/settings.json");
|
|
68
|
+
// 5. Install systemd user service for palmier serve
|
|
69
|
+
const unitDir = path.join(homedir(), ".config", "systemd", "user");
|
|
70
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
71
|
+
const palmierBin = process.argv[1] || "palmier";
|
|
72
|
+
const serviceContent = `[Unit]
|
|
73
|
+
Description=Palmier Agent
|
|
74
|
+
After=network-online.target
|
|
75
|
+
Wants=network-online.target
|
|
76
|
+
|
|
77
|
+
[Service]
|
|
78
|
+
Type=simple
|
|
79
|
+
ExecStart=${palmierBin} serve
|
|
80
|
+
WorkingDirectory=${config.projectRoot}
|
|
81
|
+
Restart=on-failure
|
|
82
|
+
RestartSec=5
|
|
83
|
+
Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
|
|
84
|
+
|
|
85
|
+
[Install]
|
|
86
|
+
WantedBy=default.target
|
|
87
|
+
`;
|
|
88
|
+
const servicePath = path.join(unitDir, "palmier-agent.service");
|
|
89
|
+
fs.writeFileSync(servicePath, serviceContent, "utf-8");
|
|
90
|
+
console.log("Systemd service installed at:", servicePath);
|
|
91
|
+
// 6. Enable and start the service
|
|
92
|
+
try {
|
|
93
|
+
execSync("systemctl --user daemon-reload", { stdio: "inherit" });
|
|
94
|
+
execSync("systemctl --user enable --now palmier-agent.service", { stdio: "inherit" });
|
|
95
|
+
console.log("Palmier agent service enabled and started.");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`Warning: failed to enable systemd service: ${err}`);
|
|
99
|
+
console.error("You may need to start it manually: systemctl --user enable --now palmier-agent.service");
|
|
100
|
+
}
|
|
101
|
+
// 7. Enable lingering so service runs without active login session
|
|
102
|
+
try {
|
|
103
|
+
execSync(`loginctl enable-linger ${process.env.USER || ""}`, { stdio: "inherit" });
|
|
104
|
+
console.log("Login lingering enabled.");
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
console.error(`Warning: failed to enable linger: ${err}`);
|
|
108
|
+
}
|
|
109
|
+
console.log("\nAgent initialization complete!");
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -0,0 +1,163 @@
|
|
|
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 { StringCodec } from "nats";
|
|
6
|
+
/**
|
|
7
|
+
* Execute a task by ID.
|
|
8
|
+
*/
|
|
9
|
+
export async function runCommand(taskId) {
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
12
|
+
const task = parseTaskFile(taskDir);
|
|
13
|
+
console.log(`Running task: ${task.frontmatter.name || taskId}`);
|
|
14
|
+
let nc;
|
|
15
|
+
let kv;
|
|
16
|
+
// Track KV keys we create so we can clean them up
|
|
17
|
+
const kvKeysToClean = [];
|
|
18
|
+
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
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (nc && !nc.isClosed()) {
|
|
30
|
+
await nc.drain();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
// Handle signals
|
|
34
|
+
const onSignal = async () => {
|
|
35
|
+
console.log("Received signal, cleaning up...");
|
|
36
|
+
await cleanup();
|
|
37
|
+
process.exit(1);
|
|
38
|
+
};
|
|
39
|
+
process.on("SIGINT", onSignal);
|
|
40
|
+
process.on("SIGTERM", onSignal);
|
|
41
|
+
try {
|
|
42
|
+
// If requires_confirmation, ask user via NATS KV
|
|
43
|
+
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);
|
|
48
|
+
if (!confirmed) {
|
|
49
|
+
console.log("Task aborted by user.");
|
|
50
|
+
await cleanup();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log("Task confirmed by user.");
|
|
54
|
+
}
|
|
55
|
+
// Spawn Claude CLI via node-pty
|
|
56
|
+
await spawnClaude(config, task, taskId);
|
|
57
|
+
console.log(`Task ${taskId} completed.`);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error(`Task ${taskId} failed:`, err);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await cleanup();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
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
|
|
75
|
+
const payload = {
|
|
76
|
+
type: "confirm",
|
|
77
|
+
task_id: task.frontmatter.id,
|
|
78
|
+
hook_id: hookId,
|
|
79
|
+
agent_id: config.agentId,
|
|
80
|
+
user_id: config.userId,
|
|
81
|
+
details: {
|
|
82
|
+
task_name: task.frontmatter.name,
|
|
83
|
+
prompt: task.frontmatter.user_prompt,
|
|
84
|
+
},
|
|
85
|
+
status: "pending",
|
|
86
|
+
};
|
|
87
|
+
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
|
|
97
|
+
for await (const entry of watch) {
|
|
98
|
+
if (entry.operation === "DEL" || entry.operation === "PURGE") {
|
|
99
|
+
// Key was deleted, treat as aborted
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const updated = JSON.parse(sc.decode(entry.value));
|
|
104
|
+
if (updated.status === "confirmed") {
|
|
105
|
+
await kv.delete(kvKey);
|
|
106
|
+
kvKeysToClean.pop();
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
else if (updated.status === "aborted") {
|
|
110
|
+
await kv.delete(kvKey);
|
|
111
|
+
kvKeysToClean.pop();
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// Still pending, keep watching
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Couldn't parse, keep watching
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
async function spawnClaude(config, task, taskId) {
|
|
123
|
+
// Dynamic import of node-pty (native module)
|
|
124
|
+
const { spawn } = await import("node-pty");
|
|
125
|
+
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,
|
|
138
|
+
cwd: config.projectRoot,
|
|
139
|
+
env: {
|
|
140
|
+
...process.env,
|
|
141
|
+
PALMIER_TASK_ID: taskId,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
ptyProcess.onData((data) => {
|
|
145
|
+
process.stdout.write(data);
|
|
146
|
+
});
|
|
147
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
148
|
+
if (exitCode === 0) {
|
|
149
|
+
resolve();
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
reject(new Error(`Claude exited with code ${exitCode}`));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// Forward signals to pty child
|
|
156
|
+
const killChild = () => {
|
|
157
|
+
ptyProcess.kill();
|
|
158
|
+
};
|
|
159
|
+
process.on("SIGINT", killChild);
|
|
160
|
+
process.on("SIGTERM", killChild);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=run.js.map
|