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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { StringCodec } from "nats";
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
|
+
import { connectNats } from "../nats-client.js";
|
|
6
|
+
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir } from "../task.js";
|
|
7
|
+
import { installTaskTimer, removeTaskTimer, getTaskStatus } from "../systemd.js";
|
|
8
|
+
/**
|
|
9
|
+
* Start the persistent NATS RPC handler.
|
|
10
|
+
*/
|
|
11
|
+
export async function serveCommand() {
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
const nc = await connectNats(config);
|
|
14
|
+
const sc = StringCodec();
|
|
15
|
+
const subject = `user.${config.userId}.agent.${config.agentId}.rpc.*`;
|
|
16
|
+
console.log(`Subscribing to: ${subject}`);
|
|
17
|
+
const sub = nc.subscribe(subject);
|
|
18
|
+
// Graceful shutdown
|
|
19
|
+
const shutdown = async () => {
|
|
20
|
+
console.log("Shutting down...");
|
|
21
|
+
sub.unsubscribe();
|
|
22
|
+
await nc.drain();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
};
|
|
25
|
+
process.on("SIGINT", shutdown);
|
|
26
|
+
process.on("SIGTERM", shutdown);
|
|
27
|
+
// On startup, clean up orphaned pending-hooks keys for this agent
|
|
28
|
+
try {
|
|
29
|
+
const js = nc.jetstream();
|
|
30
|
+
const kv = await js.views.kv("pending-hooks");
|
|
31
|
+
const keys = await kv.keys();
|
|
32
|
+
for await (const key of keys) {
|
|
33
|
+
if (key.startsWith(`${config.agentId}.`)) {
|
|
34
|
+
console.log(`Cleaning up orphaned hook key: ${key}`);
|
|
35
|
+
await kv.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(`Warning: could not clean up pending-hooks KV: ${err}`);
|
|
41
|
+
}
|
|
42
|
+
console.log("Agent serving. Waiting for RPC messages...");
|
|
43
|
+
for await (const msg of sub) {
|
|
44
|
+
let request;
|
|
45
|
+
try {
|
|
46
|
+
request = JSON.parse(sc.decode(msg.data));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
console.error("Failed to parse RPC message");
|
|
50
|
+
if (msg.reply) {
|
|
51
|
+
msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
console.log(`RPC: ${request.method}`);
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await handleRpc(request);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(`RPC error (${request.method}):`, err);
|
|
62
|
+
response = { error: String(err) };
|
|
63
|
+
}
|
|
64
|
+
if (msg.reply) {
|
|
65
|
+
msg.respond(sc.encode(JSON.stringify(response)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function handleRpc(request) {
|
|
69
|
+
switch (request.method) {
|
|
70
|
+
case "task.list": {
|
|
71
|
+
const tasks = listTasks(config.projectRoot);
|
|
72
|
+
const tasksWithStatus = tasks.map((task) => ({
|
|
73
|
+
...task,
|
|
74
|
+
status: getTaskStatus(task.frontmatter.id),
|
|
75
|
+
}));
|
|
76
|
+
return { tasks: tasksWithStatus };
|
|
77
|
+
}
|
|
78
|
+
case "task.create": {
|
|
79
|
+
const params = request.params;
|
|
80
|
+
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
81
|
+
const task = {
|
|
82
|
+
frontmatter: {
|
|
83
|
+
id: params.id,
|
|
84
|
+
name: params.name,
|
|
85
|
+
user_prompt: params.user_prompt,
|
|
86
|
+
triggers: params.triggers || [],
|
|
87
|
+
requires_confirmation: params.requires_confirmation ?? true,
|
|
88
|
+
suppress_permissions: params.suppress_permissions ?? false,
|
|
89
|
+
enabled: params.enabled ?? true,
|
|
90
|
+
},
|
|
91
|
+
body: params.body || "",
|
|
92
|
+
};
|
|
93
|
+
writeTaskFile(taskDir, task);
|
|
94
|
+
installTaskTimer(config, task);
|
|
95
|
+
return { ok: true, task_id: params.id };
|
|
96
|
+
}
|
|
97
|
+
case "task.update": {
|
|
98
|
+
const params = request.params;
|
|
99
|
+
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
100
|
+
const existing = parseTaskFile(taskDir);
|
|
101
|
+
// Merge updates
|
|
102
|
+
if (params.name !== undefined)
|
|
103
|
+
existing.frontmatter.name = params.name;
|
|
104
|
+
if (params.user_prompt !== undefined)
|
|
105
|
+
existing.frontmatter.user_prompt = params.user_prompt;
|
|
106
|
+
if (params.triggers !== undefined)
|
|
107
|
+
existing.frontmatter.triggers = params.triggers;
|
|
108
|
+
if (params.requires_confirmation !== undefined)
|
|
109
|
+
existing.frontmatter.requires_confirmation = params.requires_confirmation;
|
|
110
|
+
if (params.suppress_permissions !== undefined)
|
|
111
|
+
existing.frontmatter.suppress_permissions = params.suppress_permissions;
|
|
112
|
+
if (params.enabled !== undefined)
|
|
113
|
+
existing.frontmatter.enabled = params.enabled;
|
|
114
|
+
if (params.body !== undefined)
|
|
115
|
+
existing.body = params.body;
|
|
116
|
+
writeTaskFile(taskDir, existing);
|
|
117
|
+
// Reinstall timer with updated config
|
|
118
|
+
removeTaskTimer(params.id);
|
|
119
|
+
installTaskTimer(config, existing);
|
|
120
|
+
return { ok: true, task_id: params.id };
|
|
121
|
+
}
|
|
122
|
+
case "task.delete": {
|
|
123
|
+
const params = request.params;
|
|
124
|
+
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
125
|
+
removeTaskTimer(params.id);
|
|
126
|
+
// Remove task directory
|
|
127
|
+
if (fs.existsSync(taskDir)) {
|
|
128
|
+
fs.rmSync(taskDir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, task_id: params.id };
|
|
131
|
+
}
|
|
132
|
+
case "task.generate": {
|
|
133
|
+
const params = request.params;
|
|
134
|
+
try {
|
|
135
|
+
const output = execSync(`claude -p "${params.prompt.replace(/"/g, '\\"')}"`, {
|
|
136
|
+
encoding: "utf-8",
|
|
137
|
+
cwd: config.projectRoot,
|
|
138
|
+
timeout: 120_000,
|
|
139
|
+
});
|
|
140
|
+
return { ok: true, output };
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const error = err;
|
|
144
|
+
return { error: "claude command failed", stdout: error.stdout, stderr: error.stderr };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
case "task.run": {
|
|
148
|
+
const params = request.params;
|
|
149
|
+
const serviceName = `palmier-task-${params.id}.service`;
|
|
150
|
+
try {
|
|
151
|
+
execSync(`systemctl --user start ${serviceName}`, { stdio: "inherit" });
|
|
152
|
+
return { ok: true, task_id: params.id };
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return { error: `Failed to start task service: ${err}` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
case "task.status": {
|
|
159
|
+
const params = request.params;
|
|
160
|
+
const status = getTaskStatus(params.id);
|
|
161
|
+
return { task_id: params.id, status };
|
|
162
|
+
}
|
|
163
|
+
case "task.logs": {
|
|
164
|
+
const params = request.params;
|
|
165
|
+
const serviceName = `palmier-task-${params.id}.service`;
|
|
166
|
+
try {
|
|
167
|
+
const logs = execSync(`journalctl --user -u ${serviceName} -n 100 --no-pager`, { encoding: "utf-8" });
|
|
168
|
+
return { task_id: params.id, logs };
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
const error = err;
|
|
172
|
+
return { task_id: params.id, logs: error.stdout || "", error: error.stderr };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
return { error: `Unknown method: ${request.method}` };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=serve.js.map
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentConfig } from "./types.js";
|
|
2
|
+
declare const CONFIG_DIR: string;
|
|
3
|
+
declare const CONFIG_FILE: string;
|
|
4
|
+
/**
|
|
5
|
+
* Load agent configuration from ~/.config/palmier/agent.json.
|
|
6
|
+
* Throws if the file is missing or invalid.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadConfig(): AgentConfig;
|
|
9
|
+
/**
|
|
10
|
+
* Persist agent configuration to ~/.config/palmier/agent.json.
|
|
11
|
+
* Creates parent directories if needed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function saveConfig(config: AgentConfig): void;
|
|
14
|
+
export { CONFIG_DIR, CONFIG_FILE };
|
|
15
|
+
//# sourceMappingURL=config.d.ts.map
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
const CONFIG_DIR = path.join(homedir(), ".config", "palmier");
|
|
5
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "agent.json");
|
|
6
|
+
/**
|
|
7
|
+
* Load agent configuration from ~/.config/palmier/agent.json.
|
|
8
|
+
* Throws if the file is missing or invalid.
|
|
9
|
+
*/
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
12
|
+
throw new Error("Agent not provisioned. Run `palmier init --token <token>` first.\n" +
|
|
13
|
+
`Expected config at: ${CONFIG_FILE}`);
|
|
14
|
+
}
|
|
15
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
16
|
+
const config = JSON.parse(raw);
|
|
17
|
+
if (!config.agentId || !config.natsUrl || !config.natsToken) {
|
|
18
|
+
throw new Error("Invalid agent config: missing required fields (agentId, natsUrl, natsToken)");
|
|
19
|
+
}
|
|
20
|
+
return config;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Persist agent configuration to ~/.config/palmier/agent.json.
|
|
24
|
+
* Creates parent directories if needed.
|
|
25
|
+
*/
|
|
26
|
+
export function saveConfig(config) {
|
|
27
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
28
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
29
|
+
}
|
|
30
|
+
export { CONFIG_DIR, CONFIG_FILE };
|
|
31
|
+
//# sourceMappingURL=config.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { initCommand } from "./commands/init.js";
|
|
5
|
+
import { runCommand } from "./commands/run.js";
|
|
6
|
+
import { hookCommand } from "./commands/hook.js";
|
|
7
|
+
import { serveCommand } from "./commands/serve.js";
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name("palmier")
|
|
11
|
+
.description("Palmier agent CLI")
|
|
12
|
+
.version("0.1.0");
|
|
13
|
+
program
|
|
14
|
+
.command("init")
|
|
15
|
+
.description("Provision this agent with a provisioning token")
|
|
16
|
+
.requiredOption("--token <token>", "Provisioning token from palmier dashboard")
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
await initCommand({ token: options.token });
|
|
19
|
+
});
|
|
20
|
+
program
|
|
21
|
+
.command("run <task-id>")
|
|
22
|
+
.description("Execute a task by ID")
|
|
23
|
+
.action(async (taskId) => {
|
|
24
|
+
await runCommand(taskId);
|
|
25
|
+
});
|
|
26
|
+
program
|
|
27
|
+
.command("hook")
|
|
28
|
+
.description("Handle a Claude Code hook event (reads from stdin)")
|
|
29
|
+
.action(async () => {
|
|
30
|
+
await hookCommand();
|
|
31
|
+
});
|
|
32
|
+
program
|
|
33
|
+
.command("serve", { isDefault: true })
|
|
34
|
+
.description("Start the persistent NATS RPC handler")
|
|
35
|
+
.action(async () => {
|
|
36
|
+
await serveCommand();
|
|
37
|
+
});
|
|
38
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
39
|
+
console.error(err);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type NatsConnection } from "nats";
|
|
2
|
+
import type { AgentConfig } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Connect to NATS using the agent config's TCP URL and token auth.
|
|
5
|
+
*/
|
|
6
|
+
export declare function connectNats(config: AgentConfig): Promise<NatsConnection>;
|
|
7
|
+
//# sourceMappingURL=nats-client.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { connect } from "nats";
|
|
2
|
+
/**
|
|
3
|
+
* Connect to NATS using the agent config's TCP URL and token auth.
|
|
4
|
+
*/
|
|
5
|
+
export async function connectNats(config) {
|
|
6
|
+
const nc = await connect({
|
|
7
|
+
servers: config.natsUrl,
|
|
8
|
+
token: config.natsToken,
|
|
9
|
+
});
|
|
10
|
+
console.log(`Connected to NATS at ${config.natsUrl}`);
|
|
11
|
+
return nc;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=nats-client.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentConfig } from "./types.js";
|
|
2
|
+
import type { ParsedTask, TaskStatus } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Convert a cron expression (5-field) to a systemd OnCalendar string.
|
|
5
|
+
* Handles basic cron patterns: minute hour day-of-month month day-of-week
|
|
6
|
+
*/
|
|
7
|
+
export declare function cronToOnCalendar(cron: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Install a systemd user timer + service for a task.
|
|
10
|
+
*/
|
|
11
|
+
export declare function installTaskTimer(config: AgentConfig, task: ParsedTask): void;
|
|
12
|
+
/**
|
|
13
|
+
* Remove a task's systemd timer and service files.
|
|
14
|
+
*/
|
|
15
|
+
export declare function removeTaskTimer(taskId: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Query systemd for task status information.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getTaskStatus(taskId: string): TaskStatus;
|
|
20
|
+
/**
|
|
21
|
+
* Run systemctl --user daemon-reload.
|
|
22
|
+
*/
|
|
23
|
+
export declare function daemonReload(): void;
|
|
24
|
+
//# sourceMappingURL=systemd.d.ts.map
|
package/dist/systemd.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
const UNIT_DIR = path.join(homedir(), ".config", "systemd", "user");
|
|
6
|
+
function getTimerName(taskId) {
|
|
7
|
+
return `palmier-task-${taskId}.timer`;
|
|
8
|
+
}
|
|
9
|
+
function getServiceName(taskId) {
|
|
10
|
+
return `palmier-task-${taskId}.service`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Convert a cron expression (5-field) to a systemd OnCalendar string.
|
|
14
|
+
* Handles basic cron patterns: minute hour day-of-month month day-of-week
|
|
15
|
+
*/
|
|
16
|
+
export function cronToOnCalendar(cron) {
|
|
17
|
+
const parts = cron.trim().split(/\s+/);
|
|
18
|
+
if (parts.length !== 5) {
|
|
19
|
+
throw new Error(`Invalid cron expression (expected 5 fields): ${cron}`);
|
|
20
|
+
}
|
|
21
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
22
|
+
// Map cron day-of-week names/numbers to systemd abbreviated names
|
|
23
|
+
const dowMap = {
|
|
24
|
+
"0": "Sun",
|
|
25
|
+
"1": "Mon",
|
|
26
|
+
"2": "Tue",
|
|
27
|
+
"3": "Wed",
|
|
28
|
+
"4": "Thu",
|
|
29
|
+
"5": "Fri",
|
|
30
|
+
"6": "Sat",
|
|
31
|
+
"7": "Sun",
|
|
32
|
+
};
|
|
33
|
+
// Convert day-of-week
|
|
34
|
+
let dow = dayOfWeek === "*" ? "*" : dayOfWeek;
|
|
35
|
+
if (dowMap[dow]) {
|
|
36
|
+
dow = dowMap[dow];
|
|
37
|
+
}
|
|
38
|
+
// Build OnCalendar string
|
|
39
|
+
// Format: DayOfWeek Year-Month-Day Hour:Minute:Second
|
|
40
|
+
const monthPart = month === "*" ? "*" : month.padStart(2, "0");
|
|
41
|
+
const dayPart = dayOfMonth === "*" ? "*" : dayOfMonth.padStart(2, "0");
|
|
42
|
+
const hourPart = hour === "*" ? "*" : hour.padStart(2, "0");
|
|
43
|
+
const minutePart = minute === "*" ? "*" : minute.padStart(2, "0");
|
|
44
|
+
if (dow === "*") {
|
|
45
|
+
return `*-${monthPart}-${dayPart} ${hourPart}:${minutePart}:00`;
|
|
46
|
+
}
|
|
47
|
+
return `${dow} *-${monthPart}-${dayPart} ${hourPart}:${minutePart}:00`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Install a systemd user timer + service for a task.
|
|
51
|
+
*/
|
|
52
|
+
export function installTaskTimer(config, task) {
|
|
53
|
+
fs.mkdirSync(UNIT_DIR, { recursive: true });
|
|
54
|
+
const taskId = task.frontmatter.id;
|
|
55
|
+
const serviceName = getServiceName(taskId);
|
|
56
|
+
const timerName = getTimerName(taskId);
|
|
57
|
+
// Determine the palmier binary path
|
|
58
|
+
const palmierBin = process.argv[1] || "palmier";
|
|
59
|
+
// Generate service unit
|
|
60
|
+
const serviceContent = `[Unit]
|
|
61
|
+
Description=Palmier Task: ${task.frontmatter.name || taskId}
|
|
62
|
+
|
|
63
|
+
[Service]
|
|
64
|
+
Type=oneshot
|
|
65
|
+
ExecStart=${palmierBin} run ${taskId}
|
|
66
|
+
WorkingDirectory=${config.projectRoot}
|
|
67
|
+
Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
|
|
68
|
+
`;
|
|
69
|
+
// Generate timer unit with OnCalendar entries for each cron trigger
|
|
70
|
+
const onCalendarLines = [];
|
|
71
|
+
for (const trigger of task.frontmatter.triggers || []) {
|
|
72
|
+
if (trigger.type === "cron") {
|
|
73
|
+
onCalendarLines.push(`OnCalendar=${cronToOnCalendar(trigger.value)}`);
|
|
74
|
+
}
|
|
75
|
+
else if (trigger.type === "once") {
|
|
76
|
+
// "once" triggers use OnActiveSec or a specific timestamp
|
|
77
|
+
onCalendarLines.push(`OnActiveSec=${trigger.value}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const timerContent = `[Unit]
|
|
81
|
+
Description=Timer for Palmier Task: ${task.frontmatter.name || taskId}
|
|
82
|
+
|
|
83
|
+
[Timer]
|
|
84
|
+
${onCalendarLines.join("\n")}
|
|
85
|
+
Persistent=true
|
|
86
|
+
|
|
87
|
+
[Install]
|
|
88
|
+
WantedBy=timers.target
|
|
89
|
+
`;
|
|
90
|
+
// Write unit files
|
|
91
|
+
fs.writeFileSync(path.join(UNIT_DIR, serviceName), serviceContent, "utf-8");
|
|
92
|
+
fs.writeFileSync(path.join(UNIT_DIR, timerName), timerContent, "utf-8");
|
|
93
|
+
// Reload and enable
|
|
94
|
+
daemonReload();
|
|
95
|
+
if (task.frontmatter.enabled) {
|
|
96
|
+
execSync(`systemctl --user enable --now ${timerName}`, { stdio: "inherit" });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
execSync(`systemctl --user enable ${timerName}`, { stdio: "inherit" });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Remove a task's systemd timer and service files.
|
|
104
|
+
*/
|
|
105
|
+
export function removeTaskTimer(taskId) {
|
|
106
|
+
const timerName = getTimerName(taskId);
|
|
107
|
+
const serviceName = getServiceName(taskId);
|
|
108
|
+
// Stop and disable
|
|
109
|
+
try {
|
|
110
|
+
execSync(`systemctl --user stop ${timerName}`, { stdio: "inherit" });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Timer might not be running
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
execSync(`systemctl --user disable ${timerName}`, { stdio: "inherit" });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Timer might not be enabled
|
|
120
|
+
}
|
|
121
|
+
// Remove unit files
|
|
122
|
+
const timerPath = path.join(UNIT_DIR, timerName);
|
|
123
|
+
const servicePath = path.join(UNIT_DIR, serviceName);
|
|
124
|
+
if (fs.existsSync(timerPath))
|
|
125
|
+
fs.unlinkSync(timerPath);
|
|
126
|
+
if (fs.existsSync(servicePath))
|
|
127
|
+
fs.unlinkSync(servicePath);
|
|
128
|
+
daemonReload();
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Query systemd for task status information.
|
|
132
|
+
*/
|
|
133
|
+
export function getTaskStatus(taskId) {
|
|
134
|
+
const timerName = getTimerName(taskId);
|
|
135
|
+
const serviceName = getServiceName(taskId);
|
|
136
|
+
let state = "inactive";
|
|
137
|
+
let lastRun;
|
|
138
|
+
let lastResult;
|
|
139
|
+
let nextRun;
|
|
140
|
+
// Check if timer is active
|
|
141
|
+
try {
|
|
142
|
+
const activeState = execSync(`systemctl --user is-active ${timerName}`, {
|
|
143
|
+
encoding: "utf-8",
|
|
144
|
+
}).trim();
|
|
145
|
+
if (activeState === "active") {
|
|
146
|
+
state = "active";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Not active
|
|
151
|
+
}
|
|
152
|
+
// Check if service has failed
|
|
153
|
+
try {
|
|
154
|
+
const serviceState = execSync(`systemctl --user is-failed ${serviceName}`, {
|
|
155
|
+
encoding: "utf-8",
|
|
156
|
+
}).trim();
|
|
157
|
+
if (serviceState === "failed") {
|
|
158
|
+
state = "failed";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Not failed
|
|
163
|
+
}
|
|
164
|
+
// Get last run time and result
|
|
165
|
+
try {
|
|
166
|
+
const props = execSync(`systemctl --user show ${serviceName} --property=ExecMainStartTimestamp,ExecMainStatus`, { encoding: "utf-8" });
|
|
167
|
+
for (const line of props.split("\n")) {
|
|
168
|
+
if (line.startsWith("ExecMainStartTimestamp=") && line.trim() !== "ExecMainStartTimestamp=") {
|
|
169
|
+
const val = line.split("=", 2)[1].trim();
|
|
170
|
+
if (val)
|
|
171
|
+
lastRun = val;
|
|
172
|
+
}
|
|
173
|
+
if (line.startsWith("ExecMainStatus=")) {
|
|
174
|
+
const val = parseInt(line.split("=", 2)[1].trim(), 10);
|
|
175
|
+
if (!isNaN(val))
|
|
176
|
+
lastResult = val;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Couldn't get properties
|
|
182
|
+
}
|
|
183
|
+
// Get next run time from timer
|
|
184
|
+
try {
|
|
185
|
+
const timerProps = execSync(`systemctl --user show ${timerName} --property=NextElapseUSecRealtime`, { encoding: "utf-8" });
|
|
186
|
+
for (const line of timerProps.split("\n")) {
|
|
187
|
+
if (line.startsWith("NextElapseUSecRealtime=")) {
|
|
188
|
+
const val = line.split("=", 2)[1].trim();
|
|
189
|
+
if (val)
|
|
190
|
+
nextRun = val;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Couldn't get next run
|
|
196
|
+
}
|
|
197
|
+
return { state, lastRun, lastResult, nextRun };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Run systemctl --user daemon-reload.
|
|
201
|
+
*/
|
|
202
|
+
export function daemonReload() {
|
|
203
|
+
execSync("systemctl --user daemon-reload", { stdio: "inherit" });
|
|
204
|
+
}
|
|
205
|
+
//# sourceMappingURL=systemd.js.map
|
package/dist/task.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ParsedTask } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse a TASK.md file from the given task directory.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseTaskFile(taskDir: string): ParsedTask;
|
|
6
|
+
/**
|
|
7
|
+
* Write a TASK.md file to the given task directory.
|
|
8
|
+
* Creates the directory if it doesn't exist.
|
|
9
|
+
*/
|
|
10
|
+
export declare function writeTaskFile(taskDir: string, task: ParsedTask): void;
|
|
11
|
+
/**
|
|
12
|
+
* List all tasks from projectRoot/tasks/{id}/TASK.md.
|
|
13
|
+
*/
|
|
14
|
+
export declare function listTasks(projectRoot: string): ParsedTask[];
|
|
15
|
+
/**
|
|
16
|
+
* Get the directory path for a task by its ID.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getTaskDir(projectRoot: string, taskId: string): string;
|
|
19
|
+
//# sourceMappingURL=task.d.ts.map
|
package/dist/task.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
|
+
/**
|
|
5
|
+
* Parse a TASK.md file from the given task directory.
|
|
6
|
+
*/
|
|
7
|
+
export function parseTaskFile(taskDir) {
|
|
8
|
+
const filePath = path.join(taskDir, "TASK.md");
|
|
9
|
+
if (!fs.existsSync(filePath)) {
|
|
10
|
+
throw new Error(`TASK.md not found at: ${filePath}`);
|
|
11
|
+
}
|
|
12
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
13
|
+
return parseTaskContent(content);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse TASK.md content string into frontmatter + body.
|
|
17
|
+
*/
|
|
18
|
+
function parseTaskContent(content) {
|
|
19
|
+
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
20
|
+
const match = content.match(fmRegex);
|
|
21
|
+
if (!match) {
|
|
22
|
+
throw new Error("TASK.md is missing valid YAML frontmatter delimiters (---)");
|
|
23
|
+
}
|
|
24
|
+
const frontmatter = parseYaml(match[1]);
|
|
25
|
+
const body = (match[2] || "").trim();
|
|
26
|
+
if (!frontmatter.id) {
|
|
27
|
+
throw new Error("TASK.md frontmatter must include at least: id");
|
|
28
|
+
}
|
|
29
|
+
return { frontmatter, body };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write a TASK.md file to the given task directory.
|
|
33
|
+
* Creates the directory if it doesn't exist.
|
|
34
|
+
*/
|
|
35
|
+
export function writeTaskFile(taskDir, task) {
|
|
36
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
37
|
+
const yamlStr = stringifyYaml(task.frontmatter).trim();
|
|
38
|
+
const content = `---\n${yamlStr}\n---\n${task.body}\n`;
|
|
39
|
+
const filePath = path.join(taskDir, "TASK.md");
|
|
40
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* List all tasks from projectRoot/tasks/{id}/TASK.md.
|
|
44
|
+
*/
|
|
45
|
+
export function listTasks(projectRoot) {
|
|
46
|
+
const tasksDir = path.join(projectRoot, "tasks");
|
|
47
|
+
if (!fs.existsSync(tasksDir)) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const entries = fs.readdirSync(tasksDir, { withFileTypes: true });
|
|
51
|
+
const tasks = [];
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory())
|
|
54
|
+
continue;
|
|
55
|
+
const taskDir = path.join(tasksDir, entry.name);
|
|
56
|
+
const taskFile = path.join(taskDir, "TASK.md");
|
|
57
|
+
if (!fs.existsSync(taskFile))
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
tasks.push(parseTaskFile(taskDir));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error(`Warning: failed to parse task in ${taskDir}: ${err}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return tasks;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get the directory path for a task by its ID.
|
|
70
|
+
*/
|
|
71
|
+
export function getTaskDir(projectRoot, taskId) {
|
|
72
|
+
return path.join(projectRoot, "tasks", taskId);
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=task.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface AgentConfig {
|
|
2
|
+
agentId: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
natsUrl: string;
|
|
5
|
+
natsWsUrl: string;
|
|
6
|
+
natsToken: string;
|
|
7
|
+
projectRoot: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TaskFrontmatter {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
user_prompt: string;
|
|
13
|
+
triggers: Trigger[];
|
|
14
|
+
requires_confirmation: boolean;
|
|
15
|
+
suppress_permissions: boolean;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface Trigger {
|
|
19
|
+
type: "cron" | "once";
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ParsedTask {
|
|
23
|
+
frontmatter: TaskFrontmatter;
|
|
24
|
+
body: string;
|
|
25
|
+
}
|
|
26
|
+
export interface TaskStatus {
|
|
27
|
+
state: "active" | "inactive" | "failed";
|
|
28
|
+
lastRun?: string;
|
|
29
|
+
lastResult?: number;
|
|
30
|
+
nextRun?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface TaskWithStatus extends ParsedTask {
|
|
33
|
+
status: TaskStatus;
|
|
34
|
+
}
|
|
35
|
+
export interface HookPayload {
|
|
36
|
+
type: "confirm" | "permission" | "input";
|
|
37
|
+
task_id: string;
|
|
38
|
+
hook_id: string;
|
|
39
|
+
agent_id: string;
|
|
40
|
+
user_id: string;
|
|
41
|
+
details: Record<string, unknown>;
|
|
42
|
+
status: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ClaudeHookEvent {
|
|
45
|
+
hook_name: string;
|
|
46
|
+
session_id: string;
|
|
47
|
+
tool_name?: string;
|
|
48
|
+
tool_input?: Record<string, unknown>;
|
|
49
|
+
message?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
export interface RpcMessage {
|
|
53
|
+
method: string;
|
|
54
|
+
params: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED