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,240 +1,287 @@
1
- import { randomUUID } from "crypto";
2
- import { execSync } from "child_process";
3
- import * as fs from "fs";
4
- import { StringCodec } from "nats";
5
- import { loadConfig } from "../config.js";
6
- import { connectNats } from "../nats-client.js";
7
- import { listTasks, parseTaskFile, writeTaskFile, getTaskDir } from "../task.js";
8
- import { installTaskTimer, removeTaskTimer, getTaskStatus } from "../systemd.js";
9
- import type { ParsedTask, RpcMessage } from "../types.js";
10
-
11
- /**
12
- * Start the persistent NATS RPC handler.
13
- */
14
- export async function serveCommand(): Promise<void> {
15
- const config = loadConfig();
16
- const nc = await connectNats(config);
17
- const sc = StringCodec();
18
-
19
- const subject = `user.${config.userId}.agent.${config.agentId}.rpc.>`;
20
- console.log(`Subscribing to: ${subject}`);
21
-
22
- const sub = nc.subscribe(subject);
23
-
24
- // Graceful shutdown
25
- const shutdown = async () => {
26
- console.log("Shutting down...");
27
- sub.unsubscribe();
28
- await nc.drain();
29
- process.exit(0);
30
- };
31
-
32
- process.on("SIGINT", shutdown);
33
- process.on("SIGTERM", shutdown);
34
-
35
- // On startup, clean up orphaned pending-hooks keys for this agent
36
- try {
37
- const js = nc.jetstream();
38
- const kv = await js.views.kv("pending-hooks");
39
- const keys = await kv.keys();
40
- for await (const key of keys) {
41
- if (key.startsWith(`${config.agentId}.`)) {
42
- console.log(`Cleaning up orphaned hook key: ${key}`);
43
- await kv.delete(key);
44
- }
45
- }
46
- } catch (err) {
47
- console.error(`Warning: could not clean up pending-hooks KV: ${err}`);
48
- }
49
-
50
- console.log("Agent serving. Waiting for RPC messages...");
51
-
52
- for await (const msg of sub) {
53
- // Derive RPC method from subject: ...rpc.<method parts>
54
- const subjectTokens = msg.subject.split(".");
55
- const rpcIdx = subjectTokens.indexOf("rpc");
56
- const method = rpcIdx >= 0 ? subjectTokens.slice(rpcIdx + 1).join(".") : "";
57
-
58
- // Parse params from message body (the PWA sends params directly, no wrapper)
59
- let params: Record<string, unknown> = {};
60
- if (msg.data && msg.data.length > 0) {
61
- const raw = sc.decode(msg.data).trim();
62
- if (raw.length > 0) {
63
- try {
64
- params = JSON.parse(raw);
65
- } catch {
66
- console.error(`Failed to parse RPC params for ${method}`);
67
- if (msg.reply) {
68
- msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
69
- }
70
- continue;
71
- }
72
- }
73
- }
74
-
75
- console.log(`RPC: ${method}`);
76
-
77
- let response: unknown;
78
- try {
79
- response = await handleRpc({ method, params });
80
- } catch (err) {
81
- console.error(`RPC error (${method}):`, err);
82
- response = { error: String(err) };
83
- }
84
-
85
- if (msg.reply) {
86
- msg.respond(sc.encode(JSON.stringify(response)));
87
- }
88
- }
89
-
90
- function flattenTask(task: ParsedTask, status?: unknown) {
91
- return { ...task.frontmatter, body: task.body, ...(status != null ? { status } : {}) };
92
- }
93
-
94
- async function handleRpc(request: RpcMessage): Promise<unknown> {
95
- switch (request.method) {
96
- case "task.list": {
97
- const tasks = listTasks(config.projectRoot);
98
- return {
99
- tasks: tasks.map((task) =>
100
- flattenTask(task, getTaskStatus(task.frontmatter.id))
101
- ),
102
- };
103
- }
104
-
105
- case "task.create": {
106
- const params = request.params as {
107
- name: string;
108
- user_prompt: string;
109
- triggers?: Array<{ type: "cron" | "once"; value: string }>;
110
- requires_confirmation?: boolean;
111
- suppress_permissions?: boolean;
112
- enabled?: boolean;
113
- body?: string;
114
- };
115
-
116
- const id = randomUUID();
117
- const taskDir = getTaskDir(config.projectRoot, id);
118
- const task = {
119
- frontmatter: {
120
- id,
121
- name: params.name,
122
- user_prompt: params.user_prompt,
123
- triggers: params.triggers ?? [],
124
- requires_confirmation: params.requires_confirmation ?? true,
125
- suppress_permissions: params.suppress_permissions ?? false,
126
- enabled: params.enabled ?? true,
127
- },
128
- body: params.body || "",
129
- };
130
-
131
- writeTaskFile(taskDir, task);
132
- installTaskTimer(config, task);
133
-
134
- return flattenTask(task);
135
- }
136
-
137
- case "task.update": {
138
- const params = request.params as {
139
- id: string;
140
- name?: string;
141
- user_prompt?: string;
142
- triggers?: Array<{ type: "cron" | "once"; value: string }>;
143
- requires_confirmation?: boolean;
144
- suppress_permissions?: boolean;
145
- enabled?: boolean;
146
- body?: string;
147
- };
148
-
149
- const taskDir = getTaskDir(config.projectRoot, params.id);
150
- const existing = parseTaskFile(taskDir);
151
-
152
- // Merge updates
153
- if (params.name !== undefined) existing.frontmatter.name = params.name;
154
- if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
155
- if (params.triggers !== undefined) existing.frontmatter.triggers = params.triggers;
156
- if (params.requires_confirmation !== undefined)
157
- existing.frontmatter.requires_confirmation = params.requires_confirmation;
158
- if (params.suppress_permissions !== undefined)
159
- existing.frontmatter.suppress_permissions = params.suppress_permissions;
160
- if (params.enabled !== undefined) existing.frontmatter.enabled = params.enabled;
161
- if (params.body !== undefined) existing.body = params.body;
162
-
163
- writeTaskFile(taskDir, existing);
164
-
165
- // Reinstall timer with updated config
166
- removeTaskTimer(params.id);
167
- installTaskTimer(config, existing);
168
-
169
- return flattenTask(existing, getTaskStatus(params.id));
170
- }
171
-
172
- case "task.delete": {
173
- const params = request.params as { id: string };
174
- const taskDir = getTaskDir(config.projectRoot, params.id);
175
-
176
- removeTaskTimer(params.id);
177
-
178
- // Remove task directory
179
- if (fs.existsSync(taskDir)) {
180
- fs.rmSync(taskDir, { recursive: true, force: true });
181
- }
182
-
183
- return { ok: true, task_id: params.id };
184
- }
185
-
186
- case "task.generate": {
187
- const params = request.params as { prompt: string };
188
-
189
- try {
190
- const output = execSync(`claude -p "${params.prompt.replace(/"/g, '\\"')}"`, {
191
- encoding: "utf-8",
192
- cwd: config.projectRoot,
193
- timeout: 120_000,
194
- });
195
- return { ok: true, body: output };
196
- } catch (err: unknown) {
197
- const error = err as { stdout?: string; stderr?: string };
198
- return { error: "claude command failed", stdout: error.stdout, stderr: error.stderr };
199
- }
200
- }
201
-
202
- case "task.run": {
203
- const params = request.params as { id: string };
204
- const serviceName = `palmier-task-${params.id}.service`;
205
-
206
- try {
207
- execSync(`systemctl --user start ${serviceName}`, { stdio: "inherit" });
208
- return { ok: true, task_id: params.id };
209
- } catch (err) {
210
- return { error: `Failed to start task service: ${err}` };
211
- }
212
- }
213
-
214
- case "task.status": {
215
- const params = request.params as { id: string };
216
- const status = getTaskStatus(params.id);
217
- return { task_id: params.id, status };
218
- }
219
-
220
- case "task.logs": {
221
- const params = request.params as { id: string };
222
- const serviceName = `palmier-task-${params.id}.service`;
223
-
224
- try {
225
- const logs = execSync(
226
- `journalctl --user -u ${serviceName} -n 100 --no-pager`,
227
- { encoding: "utf-8" }
228
- );
229
- return { task_id: params.id, logs };
230
- } catch (err: unknown) {
231
- const error = err as { stdout?: string; stderr?: string };
232
- return { task_id: params.id, logs: error.stdout || "", error: error.stderr };
233
- }
234
- }
235
-
236
- default:
237
- return { error: `Unknown method: ${request.method}` };
238
- }
239
- }
240
- }
1
+ import { randomUUID } from "crypto";
2
+ import { execSync, exec } from "child_process";
3
+ import { promisify } from "util";
4
+
5
+ const execAsync = promisify(exec);
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { StringCodec } from "nats";
10
+ import { loadConfig } from "../config.js";
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ function shellEscape(arg: string): string {
15
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
16
+ }
17
+ const TASK_GENERATION_PROMPT = fs.readFileSync(
18
+ path.join(__dirname, "task-generation.md"),
19
+ "utf-8",
20
+ );
21
+ import { connectNats } from "../nats-client.js";
22
+ import { listTasks, parseTaskFile, writeTaskFile, getTaskDir } from "../task.js";
23
+ import { installTaskTimer, removeTaskTimer } from "../systemd.js";
24
+ import type { ParsedTask, RpcMessage } from "../types.js";
25
+
26
+ /**
27
+ * Start the persistent NATS RPC handler.
28
+ */
29
+ export async function serveCommand(): Promise<void> {
30
+ const config = loadConfig();
31
+ const nc = await connectNats(config);
32
+ const sc = StringCodec();
33
+
34
+ const subject = `user.${config.userId}.agent.${config.agentId}.rpc.>`;
35
+ console.log(`Subscribing to: ${subject}`);
36
+
37
+ const sub = nc.subscribe(subject);
38
+
39
+ // Graceful shutdown
40
+ const shutdown = async () => {
41
+ console.log("Shutting down...");
42
+ sub.unsubscribe();
43
+ await nc.drain();
44
+ process.exit(0);
45
+ };
46
+
47
+ process.on("SIGINT", shutdown);
48
+ process.on("SIGTERM", shutdown);
49
+
50
+ // On startup, clean up orphaned pending-confirmation keys for this agent
51
+ try {
52
+ const js = nc.jetstream();
53
+ const kv = await js.views.kv("pending-confirmation");
54
+ const keys = await kv.keys();
55
+ for await (const key of keys) {
56
+ if (key.startsWith(`${config.agentId}.`)) {
57
+ console.log(`Cleaning up orphaned hook key: ${key}`);
58
+ await kv.delete(key);
59
+ }
60
+ }
61
+ } catch (err) {
62
+ console.error(`Warning: could not clean up pending-confirmation KV: ${err}`);
63
+ }
64
+
65
+ console.log("Agent serving. Waiting for RPC messages...");
66
+
67
+ for await (const msg of sub) {
68
+ // Derive RPC method from subject: ...rpc.<method parts>
69
+ const subjectTokens = msg.subject.split(".");
70
+ const rpcIdx = subjectTokens.indexOf("rpc");
71
+ const method = rpcIdx >= 0 ? subjectTokens.slice(rpcIdx + 1).join(".") : "";
72
+
73
+ // Parse params from message body (the PWA sends params directly, no wrapper)
74
+ let params: Record<string, unknown> = {};
75
+ if (msg.data && msg.data.length > 0) {
76
+ const raw = sc.decode(msg.data).trim();
77
+ if (raw.length > 0) {
78
+ try {
79
+ params = JSON.parse(raw);
80
+ } catch {
81
+ console.error(`Failed to parse RPC params for ${method}`);
82
+ if (msg.reply) {
83
+ msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
84
+ }
85
+ continue;
86
+ }
87
+ }
88
+ }
89
+
90
+ console.log(`RPC: ${method}`);
91
+
92
+ let response: unknown;
93
+ try {
94
+ response = await handleRpc({ method, params });
95
+ } catch (err) {
96
+ console.error(`RPC error (${method}):`, err);
97
+ response = { error: String(err) };
98
+ }
99
+
100
+ console.log(`RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
101
+ if (msg.reply) {
102
+ msg.respond(sc.encode(JSON.stringify(response)));
103
+ }
104
+ }
105
+
106
+ function flattenTask(task: ParsedTask) {
107
+ return { ...task.frontmatter, body: task.body };
108
+ }
109
+
110
+ async function handleRpc(request: RpcMessage): Promise<unknown> {
111
+ switch (request.method) {
112
+ case "task.list": {
113
+ const tasks = listTasks(config.projectRoot);
114
+ return {
115
+ tasks: tasks.map((task) => flattenTask(task)),
116
+ };
117
+ }
118
+
119
+ case "task.create": {
120
+ const params = request.params as {
121
+ user_prompt: string;
122
+ command_line?: string;
123
+ triggers?: Array<{ type: "cron" | "once"; value: string }>;
124
+ triggers_enabled?: boolean;
125
+ requires_confirmation?: boolean;
126
+ body?: string;
127
+ };
128
+
129
+ const id = randomUUID();
130
+ const taskDir = getTaskDir(config.projectRoot, id);
131
+ const task = {
132
+ frontmatter: {
133
+ id,
134
+ user_prompt: params.user_prompt,
135
+ command_line: params.command_line ?? "claude -p --dangerously-skip-permissions",
136
+ triggers: params.triggers ?? [],
137
+ triggers_enabled: params.triggers_enabled ?? true,
138
+ requires_confirmation: params.requires_confirmation ?? true,
139
+ },
140
+ body: params.body || "",
141
+ };
142
+
143
+ writeTaskFile(taskDir, task);
144
+ if (task.frontmatter.triggers_enabled) {
145
+ installTaskTimer(config, task);
146
+ }
147
+
148
+ return flattenTask(task);
149
+ }
150
+
151
+ case "task.update": {
152
+ const params = request.params as {
153
+ id: string;
154
+ user_prompt?: string;
155
+ command_line?: string;
156
+ triggers?: Array<{ type: "cron" | "once"; value: string }>;
157
+ triggers_enabled?: boolean;
158
+ requires_confirmation?: boolean;
159
+ body?: string;
160
+ };
161
+
162
+ const taskDir = getTaskDir(config.projectRoot, params.id);
163
+ const existing = parseTaskFile(taskDir);
164
+
165
+ // Merge updates
166
+ if (params.user_prompt !== undefined) existing.frontmatter.user_prompt = params.user_prompt;
167
+ if (params.command_line !== undefined) existing.frontmatter.command_line = params.command_line;
168
+ if (params.triggers !== undefined) existing.frontmatter.triggers = params.triggers;
169
+ if (params.triggers_enabled !== undefined) existing.frontmatter.triggers_enabled = params.triggers_enabled;
170
+ if (params.requires_confirmation !== undefined)
171
+ existing.frontmatter.requires_confirmation = params.requires_confirmation;
172
+ if (params.body !== undefined) existing.body = params.body;
173
+
174
+ writeTaskFile(taskDir, existing);
175
+
176
+ // Reinstall or remove timers based on triggers_enabled
177
+ removeTaskTimer(params.id);
178
+ if (existing.frontmatter.triggers_enabled) {
179
+ installTaskTimer(config, existing);
180
+ }
181
+
182
+ return flattenTask(existing);
183
+ }
184
+
185
+ case "task.delete": {
186
+ const params = request.params as { id: string };
187
+ const taskDir = getTaskDir(config.projectRoot, params.id);
188
+
189
+ removeTaskTimer(params.id);
190
+
191
+ // Remove task directory
192
+ if (fs.existsSync(taskDir)) {
193
+ fs.rmSync(taskDir, { recursive: true, force: true });
194
+ }
195
+
196
+ return { ok: true, task_id: params.id };
197
+ }
198
+
199
+ case "task.generate": {
200
+ const params = request.params as { prompt: string; command_line?: string };
201
+ const commandLine = params.command_line || "claude -p --dangerously-skip-permissions";
202
+ const fullPrompt = TASK_GENERATION_PROMPT + params.prompt;
203
+
204
+ try {
205
+ const output = execSync(`${commandLine} ${shellEscape(fullPrompt)}`, {
206
+ encoding: "utf-8",
207
+ cwd: config.projectRoot,
208
+ timeout: 120_000,
209
+ });
210
+ return { ok: true, body: output };
211
+ } catch (err: unknown) {
212
+ const error = err as { stdout?: string; stderr?: string };
213
+ return { error: "generation command failed", stdout: error.stdout, stderr: error.stderr };
214
+ }
215
+ }
216
+
217
+ case "task.run": {
218
+ const params = request.params as { id: string };
219
+ const serviceName = `palmier-task-${params.id}.service`;
220
+
221
+ try {
222
+ await execAsync(`systemctl --user start --no-block ${serviceName}`);
223
+ return { ok: true, task_id: params.id };
224
+ } catch (err: unknown) {
225
+ const e = err as { stderr?: string; message?: string };
226
+ console.error(`task.run failed for ${params.id}: ${e.stderr || e.message}`);
227
+ return { error: `Failed to start task: ${e.stderr || e.message}` };
228
+ }
229
+ }
230
+
231
+ case "task.abort": {
232
+ const params = request.params as { id: string };
233
+ const serviceName = `palmier-task-${params.id}.service`;
234
+
235
+ try {
236
+ await execAsync(`systemctl --user stop ${serviceName}`);
237
+ return { ok: true, task_id: params.id };
238
+ } catch (err: unknown) {
239
+ const e = err as { stderr?: string; message?: string };
240
+ console.error(`task.abort failed for ${params.id}: ${e.stderr || e.message}`);
241
+ return { error: `Failed to abort task: ${e.stderr || e.message}` };
242
+ }
243
+ }
244
+
245
+ case "task.logs": {
246
+ const params = request.params as { id: string };
247
+ const serviceName = `palmier-task-${params.id}.service`;
248
+
249
+ try {
250
+ const logs = execSync(
251
+ `journalctl --user -u ${serviceName} -n 100 --no-pager`,
252
+ { encoding: "utf-8" }
253
+ );
254
+ return { task_id: params.id, logs };
255
+ } catch (err: unknown) {
256
+ const error = err as { stdout?: string; stderr?: string };
257
+ return { task_id: params.id, logs: error.stdout || "", error: error.stderr };
258
+ }
259
+ }
260
+
261
+ case "task.result": {
262
+ const params = request.params as { id: string };
263
+ const resultPath = path.join(config.projectRoot, "tasks", params.id, "RESULT.md");
264
+
265
+ try {
266
+ const raw = fs.readFileSync(resultPath, "utf-8");
267
+ // Parse optional frontmatter
268
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
269
+ if (fmMatch) {
270
+ const meta: Record<string, number> = {};
271
+ for (const line of fmMatch[1].split("\n")) {
272
+ const [key, val] = line.split(": ");
273
+ if (key && val) meta[key.trim()] = Number(val.trim());
274
+ }
275
+ return { task_id: params.id, content: fmMatch[2], start_time: meta.start_time, end_time: meta.end_time };
276
+ }
277
+ return { task_id: params.id, content: raw };
278
+ } catch {
279
+ return { task_id: params.id, error: "No result file found" };
280
+ }
281
+ }
282
+
283
+ default:
284
+ return { error: `Unknown method: ${request.method}` };
285
+ }
286
+ }
287
+ }
@@ -0,0 +1,28 @@
1
+ You are a planning agent for a personal computer AI agent. Given a task description, produce a detailed Markdown execution plan that the agent can later follow step by step. **Do not execute any part of the task yourself.**
2
+
3
+ The plan must include the following sections:
4
+
5
+ ### 1. Goal
6
+ What the task accomplishes and the expected end state.
7
+
8
+ ### 2. Prerequisites
9
+ What must be true before starting:
10
+ - Required software and versions
11
+ - Files or data that must be present
12
+ - Permissions or access needed
13
+ - Environment state (e.g., running services, network access)
14
+
15
+ ### 3. Plan
16
+ A numbered sequence of concrete, actionable steps to complete the task.
17
+ Use sub-steps for complex actions. Include conditional branches where behavior may vary (e.g., "If file exists, do A; otherwise, do B"). Each step should be specific enough that the agent can execute it without ambiguity.
18
+
19
+ ### 4. Edge Cases & Risks
20
+ Anything that could go wrong and how to handle it:
21
+ - Common failure modes
22
+ - Platform-specific differences
23
+ - Race conditions or timing issues
24
+ - Data loss risks and mitigation
25
+
26
+ Format the entire document as Markdown with proper headings, code blocks for commands, and tables where appropriate.
27
+
28
+ **Task description:**
package/src/index.ts CHANGED
@@ -7,7 +7,6 @@ import { dirname, join } from "path";
7
7
  import { Command } from "commander";
8
8
  import { initCommand } from "./commands/init.js";
9
9
  import { runCommand } from "./commands/run.js";
10
- import { hookCommand } from "./commands/hook.js";
11
10
  import { serveCommand } from "./commands/serve.js";
12
11
 
13
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -35,13 +34,6 @@ program
35
34
  await runCommand(taskId);
36
35
  });
37
36
 
38
- program
39
- .command("hook")
40
- .description("Handle a Claude Code hook event (reads from stdin)")
41
- .action(async () => {
42
- await hookCommand();
43
- });
44
-
45
37
  program
46
38
  .command("serve", { isDefault: true })
47
39
  .description("Start the persistent NATS RPC handler")
@@ -1,15 +1,15 @@
1
- import { connect, type NatsConnection } from "nats";
2
- import type { AgentConfig } from "./types.js";
3
-
4
- /**
5
- * Connect to NATS using the agent config's TCP URL and token auth.
6
- */
7
- export async function connectNats(config: AgentConfig): Promise<NatsConnection> {
8
- const nc = await connect({
9
- servers: config.natsUrl,
10
- token: config.natsToken,
11
- });
12
-
13
- console.log(`Connected to NATS at ${config.natsUrl}`);
14
- return nc;
15
- }
1
+ import { connect, type NatsConnection } from "nats";
2
+ import type { AgentConfig } from "./types.js";
3
+
4
+ /**
5
+ * Connect to NATS using the agent config's TCP URL and token auth.
6
+ */
7
+ export async function connectNats(config: AgentConfig): Promise<NatsConnection> {
8
+ const nc = await connect({
9
+ servers: config.natsUrl,
10
+ token: config.natsToken,
11
+ });
12
+
13
+ console.log(`Connected to NATS at ${config.natsUrl}`);
14
+ return nc;
15
+ }