palmier 0.1.8 → 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.
@@ -1,197 +1,205 @@
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
- }
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { spawn } from "child_process";
4
+ import { loadConfig } from "../config.js";
5
+ import { connectNats } from "../nats-client.js";
6
+ import { parseTaskFile, getTaskDir } from "../task.js";
7
+ import type { AgentConfig, ParsedTask, ConfirmPayload } from "../types.js";
8
+ import type { NatsConnection, KV } from "nats";
9
+ import { StringCodec } from "nats";
10
+
11
+ export type TaskEventType = "start" | "finish" | "abort" | "fail";
12
+
13
+ /**
14
+ * Execute a task by ID.
15
+ */
16
+ export async function runCommand(taskId: string): Promise<void> {
17
+ const config = loadConfig();
18
+ const taskDir = getTaskDir(config.projectRoot, taskId);
19
+ const task = parseTaskFile(taskDir);
20
+
21
+ console.log(`Running task: ${taskId}`);
22
+
23
+ let nc: NatsConnection | undefined;
24
+ let confirmKv: KV | undefined;
25
+ const confirmKey = `${config.agentId}.${taskId}`;
26
+
27
+ const cleanup = async () => {
28
+ if (confirmKv) {
29
+ try { await confirmKv.delete(confirmKey); } catch { /* may not exist */ }
30
+ }
31
+ if (nc && !nc.isClosed()) {
32
+ await nc.drain();
33
+ }
34
+ };
35
+
36
+ // Handle signals
37
+ const onSignal = async () => {
38
+ console.log("Received signal, cleaning up...");
39
+ if (eventKv) {
40
+ await writeTaskEvent(eventKv, `${config.agentId}.${taskId}`, "abort").catch(() => {});
41
+ }
42
+ await cleanup();
43
+ process.exit(1);
44
+ };
45
+ process.on("SIGINT", onSignal);
46
+ process.on("SIGTERM", onSignal);
47
+
48
+ let eventKv: KV | undefined;
49
+
50
+ try {
51
+ nc = await connectNats(config);
52
+ const js = nc.jetstream();
53
+
54
+ // Set up task-event KV and mark as started immediately
55
+ eventKv = await js.views.kv("task-event", { history: 1 });
56
+ const eventKey = `${config.agentId}.${taskId}`;
57
+ await writeTaskEvent(eventKv, eventKey, "start");
58
+
59
+ // If requires_confirmation, ask user via NATS KV
60
+ if (task.frontmatter.requires_confirmation) {
61
+ confirmKv = await js.views.kv("pending-confirmation");
62
+
63
+ const confirmed = await requestConfirmation(config, task, confirmKv);
64
+ if (!confirmed) {
65
+ console.log("Task aborted by user.");
66
+ await writeTaskEvent(eventKv, eventKey, "abort");
67
+ await cleanup();
68
+ return;
69
+ }
70
+ console.log("Task confirmed by user.");
71
+ }
72
+
73
+ // Spawn task process
74
+ const startTime = Date.now();
75
+ const output = await spawnTask(config, task);
76
+ const endTime = Date.now();
77
+
78
+ // Save result with frontmatter to task directory
79
+ const resultPath = path.join(taskDir, "RESULT.md");
80
+ const resultContent = `---\nstart_time: ${startTime}\nend_time: ${endTime}\n---\n${output}`;
81
+ fs.writeFileSync(resultPath, resultContent, "utf-8");
82
+
83
+ // Set event to finish on completion
84
+ await writeTaskEvent(eventKv, eventKey, "finish");
85
+
86
+ console.log(`Task ${taskId} completed.`);
87
+ } catch (err) {
88
+ console.error(`Task ${taskId} failed:`, err);
89
+ if (eventKv) {
90
+ await writeTaskEvent(eventKv, `${config.agentId}.${taskId}`, "fail").catch(() => {});
91
+ }
92
+ process.exitCode = 1;
93
+ } finally {
94
+ await cleanup();
95
+ }
96
+ }
97
+
98
+ const sc = StringCodec();
99
+
100
+ async function writeTaskEvent(kv: KV, key: string, eventType: TaskEventType): Promise<void> {
101
+ const event = { event_type: eventType, time_stamp: Date.now() };
102
+ console.log(`[task-event] ${key} → ${eventType}`);
103
+ await kv.put(key, sc.encode(JSON.stringify(event)));
104
+ }
105
+
106
+ async function requestConfirmation(
107
+ config: AgentConfig,
108
+ task: ParsedTask,
109
+ kv: KV,
110
+ ): Promise<boolean> {
111
+ const kvKey = `${config.agentId}.${task.frontmatter.id}`;
112
+
113
+ // Write confirmation payload to KV — the server watches this bucket and sends push notifications
114
+ const payload: ConfirmPayload = {
115
+ type: "confirm",
116
+ task_id: task.frontmatter.id,
117
+ agent_id: config.agentId,
118
+ user_id: config.userId,
119
+ details: {
120
+ prompt: task.frontmatter.user_prompt,
121
+ },
122
+ status: "pending",
123
+ };
124
+
125
+ await kv.put(kvKey, sc.encode(JSON.stringify(payload)));
126
+
127
+ // Watch AFTER writing — the initial history replay delivers the "pending" entry (skipped),
128
+ // then the iterator stays open for live updates (confirmed/aborted).
129
+ const watch = await kv.watch({ key: kvKey });
130
+
131
+ for await (const entry of watch) {
132
+ if (entry.operation === "DEL" || entry.operation === "PURGE") {
133
+ return false;
134
+ }
135
+
136
+ try {
137
+ const updated = JSON.parse(sc.decode(entry.value)) as ConfirmPayload;
138
+ if (updated.status === "confirmed") return true;
139
+ if (updated.status === "aborted") return false;
140
+ // Still pending, keep watching
141
+ } catch {
142
+ // Couldn't parse, keep watching
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ function shellEscape(arg: string): string {
150
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
151
+ }
152
+
153
+ async function spawnTask(
154
+ config: AgentConfig,
155
+ task: ParsedTask,
156
+ ): Promise<string> {
157
+ return new Promise<string>((resolve, reject) => {
158
+ const prompt = task.body
159
+ ? `${task.body}\n\n${task.frontmatter.user_prompt}`
160
+ : task.frontmatter.user_prompt;
161
+
162
+ const command = `${task.frontmatter.command_line} ${shellEscape(prompt)}`;
163
+
164
+ const child = spawn(command, {
165
+ cwd: config.projectRoot,
166
+ shell: true,
167
+ stdio: ["ignore", "pipe", "pipe"],
168
+ env: {
169
+ ...process.env,
170
+ PALMIER_TASK_ID: task.frontmatter.id,
171
+ },
172
+ });
173
+
174
+ const stdoutChunks: Buffer[] = [];
175
+
176
+ child.stdout?.on("data", (data: Buffer) => {
177
+ stdoutChunks.push(data);
178
+ process.stdout.write(data);
179
+ });
180
+
181
+ child.stderr?.on("data", (data: Buffer) => {
182
+ process.stderr.write(data);
183
+ });
184
+
185
+ let stopping = false;
186
+ const killChild = () => {
187
+ stopping = true;
188
+ child.kill("SIGTERM");
189
+ };
190
+ process.on("SIGINT", killChild);
191
+ process.on("SIGTERM", killChild);
192
+
193
+ child.on("close", (exitCode) => {
194
+ if (exitCode === 0 || stopping) {
195
+ resolve(Buffer.concat(stdoutChunks).toString("utf-8"));
196
+ } else {
197
+ reject(new Error(`Task process exited with code ${exitCode}`));
198
+ }
199
+ });
200
+
201
+ child.on("error", (err) => {
202
+ reject(err);
203
+ });
204
+ });
205
+ }