palmier 0.2.0 → 0.2.2
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/CLAUDE.md +5 -1
- package/README.md +135 -45
- package/dist/agents/agent.d.ts +26 -0
- package/dist/agents/agent.js +32 -0
- package/dist/agents/claude.d.ts +8 -0
- package/dist/agents/claude.js +35 -0
- package/dist/agents/codex.d.ts +8 -0
- package/dist/agents/codex.js +41 -0
- package/dist/agents/gemini.d.ts +8 -0
- package/dist/agents/gemini.js +39 -0
- package/dist/agents/openclaw.d.ts +8 -0
- package/dist/agents/openclaw.js +25 -0
- package/dist/agents/shared-prompt.d.ts +11 -0
- package/dist/agents/shared-prompt.js +26 -0
- package/dist/commands/agents.d.ts +2 -0
- package/dist/commands/agents.js +19 -0
- package/dist/commands/info.d.ts +5 -0
- package/dist/commands/info.js +40 -0
- package/dist/commands/init.d.ts +7 -2
- package/dist/commands/init.js +139 -49
- package/dist/commands/mcpserver.d.ts +2 -0
- package/dist/commands/mcpserver.js +75 -0
- package/dist/commands/pair.d.ts +6 -0
- package/dist/commands/pair.js +140 -0
- package/dist/commands/plan-generation.md +32 -0
- package/dist/commands/run.d.ts +0 -1
- package/dist/commands/run.js +258 -114
- package/dist/commands/serve.d.ts +1 -1
- package/dist/commands/serve.js +16 -228
- package/dist/commands/sessions.d.ts +4 -0
- package/dist/commands/sessions.js +30 -0
- package/dist/commands/task-generation.md +1 -1
- package/dist/config.d.ts +5 -5
- package/dist/config.js +24 -6
- package/dist/index.js +58 -5
- package/dist/nats-client.d.ts +3 -3
- package/dist/nats-client.js +2 -2
- package/dist/rpc-handler.d.ts +6 -0
- package/dist/rpc-handler.js +367 -0
- package/dist/session-store.d.ts +12 -0
- package/dist/session-store.js +57 -0
- package/dist/spawn-command.d.ts +26 -0
- package/dist/spawn-command.js +48 -0
- package/dist/systemd.d.ts +2 -2
- package/dist/task.d.ts +45 -2
- package/dist/task.js +155 -14
- package/dist/transports/http-transport.d.ts +6 -0
- package/dist/transports/http-transport.js +243 -0
- package/dist/transports/nats-transport.d.ts +6 -0
- package/dist/transports/nats-transport.js +69 -0
- package/dist/types.d.ts +30 -13
- package/package.json +4 -3
- package/src/agents/agent.ts +62 -0
- package/src/agents/claude.ts +39 -0
- package/src/agents/codex.ts +46 -0
- package/src/agents/gemini.ts +43 -0
- package/src/agents/openclaw.ts +29 -0
- package/src/agents/shared-prompt.ts +26 -0
- package/src/commands/agents.ts +20 -0
- package/src/commands/info.ts +44 -0
- package/src/commands/init.ts +229 -121
- package/src/commands/mcpserver.ts +92 -0
- package/src/commands/pair.ts +163 -0
- package/src/commands/plan-generation.md +32 -0
- package/src/commands/run.ts +323 -129
- package/src/commands/serve.ts +26 -287
- package/src/commands/sessions.ts +32 -0
- package/src/config.ts +30 -10
- package/src/index.ts +67 -6
- package/src/nats-client.ts +4 -4
- package/src/rpc-handler.ts +421 -0
- package/src/session-store.ts +68 -0
- package/src/spawn-command.ts +78 -0
- package/src/systemd.ts +2 -2
- package/src/task.ts +166 -16
- package/src/transports/http-transport.ts +290 -0
- package/src/transports/nats-transport.ts +82 -0
- package/src/types.ts +36 -13
- package/src/commands/task-generation.md +0 -28
package/dist/task.js
CHANGED
|
@@ -26,7 +26,8 @@ function parseTaskContent(content) {
|
|
|
26
26
|
if (!frontmatter.id) {
|
|
27
27
|
throw new Error("TASK.md frontmatter must include at least: id");
|
|
28
28
|
}
|
|
29
|
-
frontmatter.
|
|
29
|
+
frontmatter.name ??= frontmatter.user_prompt?.slice(0, 60) ?? "";
|
|
30
|
+
frontmatter.agent ??= "claude";
|
|
30
31
|
frontmatter.triggers_enabled ??= true;
|
|
31
32
|
return { frontmatter, body };
|
|
32
33
|
}
|
|
@@ -42,30 +43,65 @@ export function writeTaskFile(taskDir, task) {
|
|
|
42
43
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
+
* Append a task ID to the project-level tasks.jsonl file.
|
|
47
|
+
*/
|
|
48
|
+
export function appendTaskList(projectRoot, taskId) {
|
|
49
|
+
const listPath = path.join(projectRoot, "tasks.jsonl");
|
|
50
|
+
fs.appendFileSync(listPath, JSON.stringify({ task_id: taskId }) + "\n", "utf-8");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Remove a task ID from the project-level tasks.jsonl file.
|
|
54
|
+
* Returns true if the entry was found and removed.
|
|
55
|
+
*/
|
|
56
|
+
export function removeFromTaskList(projectRoot, taskId) {
|
|
57
|
+
const listPath = path.join(projectRoot, "tasks.jsonl");
|
|
58
|
+
if (!fs.existsSync(listPath))
|
|
59
|
+
return false;
|
|
60
|
+
const lines = fs.readFileSync(listPath, "utf-8").split("\n").filter(Boolean);
|
|
61
|
+
let found = false;
|
|
62
|
+
const remaining = [];
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
try {
|
|
65
|
+
const entry = JSON.parse(line);
|
|
66
|
+
if (entry.task_id === taskId) {
|
|
67
|
+
found = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* keep malformed lines */ }
|
|
72
|
+
remaining.push(line);
|
|
73
|
+
}
|
|
74
|
+
if (!found)
|
|
75
|
+
return false;
|
|
76
|
+
fs.writeFileSync(listPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* List all tasks referenced in tasks.jsonl.
|
|
46
81
|
*/
|
|
47
82
|
export function listTasks(projectRoot) {
|
|
48
|
-
const
|
|
49
|
-
if (!fs.existsSync(
|
|
83
|
+
const listPath = path.join(projectRoot, "tasks.jsonl");
|
|
84
|
+
if (!fs.existsSync(listPath))
|
|
50
85
|
return [];
|
|
51
|
-
|
|
52
|
-
const entries = fs.readdirSync(tasksDir, { withFileTypes: true });
|
|
86
|
+
const lines = fs.readFileSync(listPath, "utf-8").split("\n").filter(Boolean);
|
|
53
87
|
const tasks = [];
|
|
54
|
-
for (const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
let taskId;
|
|
90
|
+
try {
|
|
91
|
+
taskId = JSON.parse(line).task_id;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
60
94
|
continue;
|
|
95
|
+
}
|
|
96
|
+
const taskDir = getTaskDir(projectRoot, taskId);
|
|
61
97
|
try {
|
|
62
98
|
tasks.push(parseTaskFile(taskDir));
|
|
63
99
|
}
|
|
64
100
|
catch (err) {
|
|
65
|
-
console.error(`Warning: failed to parse task
|
|
101
|
+
console.error(`Warning: failed to parse task ${taskId}: ${err}`);
|
|
66
102
|
}
|
|
67
103
|
}
|
|
68
|
-
return tasks;
|
|
104
|
+
return tasks.reverse();
|
|
69
105
|
}
|
|
70
106
|
/**
|
|
71
107
|
* Get the directory path for a task by its ID.
|
|
@@ -73,4 +109,109 @@ export function listTasks(projectRoot) {
|
|
|
73
109
|
export function getTaskDir(projectRoot, taskId) {
|
|
74
110
|
return path.join(projectRoot, "tasks", taskId);
|
|
75
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Get the creation time (birthtime) of a TASK.md file in ms since epoch.
|
|
114
|
+
*/
|
|
115
|
+
export function getTaskCreatedAt(taskDir) {
|
|
116
|
+
const filePath = path.join(taskDir, "TASK.md");
|
|
117
|
+
try {
|
|
118
|
+
return fs.statSync(filePath).birthtimeMs;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Write task status to status.json in the task directory.
|
|
126
|
+
*/
|
|
127
|
+
export function writeTaskStatus(taskDir, status) {
|
|
128
|
+
const filePath = path.join(taskDir, "status.json");
|
|
129
|
+
fs.writeFileSync(filePath, JSON.stringify(status), "utf-8");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Read task status from status.json in the task directory.
|
|
133
|
+
* Returns undefined if the file doesn't exist.
|
|
134
|
+
*/
|
|
135
|
+
export function readTaskStatus(taskDir) {
|
|
136
|
+
const filePath = path.join(taskDir, "status.json");
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Append a history entry to the project-level history.jsonl file.
|
|
146
|
+
*/
|
|
147
|
+
export function appendHistory(projectRoot, entry) {
|
|
148
|
+
const historyPath = path.join(projectRoot, "history.jsonl");
|
|
149
|
+
fs.appendFileSync(historyPath, JSON.stringify(entry) + "\n", "utf-8");
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Delete a history entry and its associated result/task-snapshot files.
|
|
153
|
+
* Returns true if the entry was found and removed.
|
|
154
|
+
*/
|
|
155
|
+
export function deleteHistoryEntry(projectRoot, taskId, resultFile) {
|
|
156
|
+
const historyPath = path.join(projectRoot, "history.jsonl");
|
|
157
|
+
if (!fs.existsSync(historyPath))
|
|
158
|
+
return false;
|
|
159
|
+
const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(Boolean);
|
|
160
|
+
let found = false;
|
|
161
|
+
const remaining = [];
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
try {
|
|
164
|
+
const entry = JSON.parse(line);
|
|
165
|
+
if (entry.task_id === taskId && entry.result_file === resultFile) {
|
|
166
|
+
found = true;
|
|
167
|
+
continue; // skip this entry
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch { /* keep malformed lines */ }
|
|
171
|
+
remaining.push(line);
|
|
172
|
+
}
|
|
173
|
+
if (!found)
|
|
174
|
+
return false;
|
|
175
|
+
// Rewrite history.jsonl without the deleted entry
|
|
176
|
+
fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
|
|
177
|
+
// Delete the result file
|
|
178
|
+
const resultPath = path.join(projectRoot, "tasks", taskId, resultFile);
|
|
179
|
+
if (fs.existsSync(resultPath)) {
|
|
180
|
+
fs.unlinkSync(resultPath);
|
|
181
|
+
}
|
|
182
|
+
// Delete the corresponding task snapshot (TASK-<timestamp>.md)
|
|
183
|
+
const tsMatch = resultFile.match(/^RESULT-(\d+)\.md$/);
|
|
184
|
+
if (tsMatch) {
|
|
185
|
+
const snapshotFile = `TASK-${tsMatch[1]}.md`;
|
|
186
|
+
const snapshotPath = path.join(projectRoot, "tasks", taskId, snapshotFile);
|
|
187
|
+
if (fs.existsSync(snapshotPath)) {
|
|
188
|
+
fs.unlinkSync(snapshotPath);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Read history entries from history.jsonl with pagination.
|
|
195
|
+
* Returns entries sorted most-recent-first.
|
|
196
|
+
*/
|
|
197
|
+
export function readHistory(projectRoot, opts) {
|
|
198
|
+
const historyPath = path.join(projectRoot, "history.jsonl");
|
|
199
|
+
if (!fs.existsSync(historyPath))
|
|
200
|
+
return { entries: [], total: 0 };
|
|
201
|
+
const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(Boolean);
|
|
202
|
+
let all = [];
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
try {
|
|
205
|
+
all.push(JSON.parse(line));
|
|
206
|
+
}
|
|
207
|
+
catch { /* skip malformed */ }
|
|
208
|
+
}
|
|
209
|
+
all.reverse();
|
|
210
|
+
if (opts.task_id) {
|
|
211
|
+
all = all.filter((e) => e.task_id === opts.task_id);
|
|
212
|
+
}
|
|
213
|
+
const offset = opts.offset ?? 0;
|
|
214
|
+
const limit = opts.limit ?? 10;
|
|
215
|
+
return { entries: all.slice(offset, offset + limit), total: all.length };
|
|
216
|
+
}
|
|
76
217
|
//# sourceMappingURL=task.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostConfig, RpcMessage } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
|
|
4
|
+
*/
|
|
5
|
+
export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>): Promise<void>;
|
|
6
|
+
//# sourceMappingURL=http-transport.d.ts.map
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import { validateSession, hasSessions, addSession } from "../session-store.js";
|
|
4
|
+
const pendingPairs = new Map();
|
|
5
|
+
function detectLanIp() {
|
|
6
|
+
const interfaces = os.networkInterfaces();
|
|
7
|
+
for (const name of Object.keys(interfaces)) {
|
|
8
|
+
for (const iface of interfaces[name] ?? []) {
|
|
9
|
+
if (iface.family === "IPv4" && !iface.internal) {
|
|
10
|
+
return iface.address;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return "127.0.0.1";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
|
|
18
|
+
*/
|
|
19
|
+
export async function startHttpTransport(config, handleRpc) {
|
|
20
|
+
const port = config.directPort ?? 7400;
|
|
21
|
+
const sseClients = new Set();
|
|
22
|
+
function broadcastSseEvent(data) {
|
|
23
|
+
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
24
|
+
for (const client of sseClients) {
|
|
25
|
+
client.write(payload);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function setCorsHeaders(res) {
|
|
29
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
30
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
31
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
32
|
+
}
|
|
33
|
+
function checkAuth(req) {
|
|
34
|
+
const auth = req.headers.authorization;
|
|
35
|
+
if (!auth || !auth.startsWith("Bearer "))
|
|
36
|
+
return false;
|
|
37
|
+
const token = auth.slice(7);
|
|
38
|
+
// Accept the original directToken or any valid session token
|
|
39
|
+
if (token === config.directToken)
|
|
40
|
+
return true;
|
|
41
|
+
if (hasSessions() && validateSession(token))
|
|
42
|
+
return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function extractSessionToken(req) {
|
|
46
|
+
const auth = req.headers.authorization;
|
|
47
|
+
if (!auth || !auth.startsWith("Bearer "))
|
|
48
|
+
return undefined;
|
|
49
|
+
return auth.slice(7);
|
|
50
|
+
}
|
|
51
|
+
function sendJson(res, status, body) {
|
|
52
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
53
|
+
res.end(JSON.stringify(body));
|
|
54
|
+
}
|
|
55
|
+
function readBody(req) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
59
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
60
|
+
req.on("error", reject);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function isLocalhost(req) {
|
|
64
|
+
const addr = req.socket.remoteAddress;
|
|
65
|
+
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
66
|
+
}
|
|
67
|
+
const server = http.createServer(async (req, res) => {
|
|
68
|
+
setCorsHeaders(res);
|
|
69
|
+
// Handle CORS preflight
|
|
70
|
+
if (req.method === "OPTIONS") {
|
|
71
|
+
res.writeHead(204);
|
|
72
|
+
res.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
76
|
+
const pathname = url.pathname;
|
|
77
|
+
// Internal event endpoint — localhost only, no auth
|
|
78
|
+
if (req.method === "POST" && pathname === "/internal/event") {
|
|
79
|
+
if (!isLocalhost(req)) {
|
|
80
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const body = await readBody(req);
|
|
85
|
+
const event = JSON.parse(body);
|
|
86
|
+
broadcastSseEvent(event);
|
|
87
|
+
sendJson(res, 200, { ok: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Internal pair-register endpoint — localhost only, long-poll
|
|
95
|
+
// The pair CLI posts here and blocks until paired or expired.
|
|
96
|
+
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
97
|
+
if (!isLocalhost(req)) {
|
|
98
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const body = await readBody(req);
|
|
103
|
+
const { code, expiryMs } = JSON.parse(body);
|
|
104
|
+
if (!code) {
|
|
105
|
+
sendJson(res, 400, { error: "Missing code" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (pendingPairs.has(code)) {
|
|
109
|
+
sendJson(res, 409, { error: "Code already registered" });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const result = await new Promise((resolve) => {
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
pendingPairs.delete(code);
|
|
115
|
+
resolve({ paired: false });
|
|
116
|
+
}, expiryMs ?? 5 * 60 * 1000);
|
|
117
|
+
pendingPairs.set(code, { resolve, timer });
|
|
118
|
+
// Clean up if the CLI disconnects early
|
|
119
|
+
req.on("close", () => {
|
|
120
|
+
if (pendingPairs.has(code)) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
pendingPairs.delete(code);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
sendJson(res, 200, result);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Public pair endpoint — no auth required, PWA posts OTP code here
|
|
134
|
+
if (req.method === "POST" && pathname === "/pair") {
|
|
135
|
+
try {
|
|
136
|
+
const body = await readBody(req);
|
|
137
|
+
const { code, label } = JSON.parse(body);
|
|
138
|
+
if (!code) {
|
|
139
|
+
sendJson(res, 400, { error: "Missing code" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const pending = pendingPairs.get(code);
|
|
143
|
+
if (!pending) {
|
|
144
|
+
sendJson(res, 401, { error: "Invalid code" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Create session and build response
|
|
148
|
+
const session = addSession(label);
|
|
149
|
+
const ip = detectLanIp();
|
|
150
|
+
const response = {
|
|
151
|
+
hostId: config.hostId,
|
|
152
|
+
sessionToken: session.token,
|
|
153
|
+
directUrl: `http://${ip}:${port}`,
|
|
154
|
+
directToken: config.directToken,
|
|
155
|
+
};
|
|
156
|
+
// Resolve the long-poll and clean up
|
|
157
|
+
clearTimeout(pending.timer);
|
|
158
|
+
pendingPairs.delete(code);
|
|
159
|
+
pending.resolve({ paired: true });
|
|
160
|
+
sendJson(res, 200, response);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// All other endpoints require auth
|
|
168
|
+
if (!checkAuth(req)) {
|
|
169
|
+
sendJson(res, 401, { error: "Unauthorized" });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// SSE event stream
|
|
173
|
+
if (req.method === "GET" && pathname === "/events") {
|
|
174
|
+
res.writeHead(200, {
|
|
175
|
+
"Content-Type": "text/event-stream",
|
|
176
|
+
"Cache-Control": "no-cache",
|
|
177
|
+
Connection: "keep-alive",
|
|
178
|
+
"Access-Control-Allow-Origin": "*",
|
|
179
|
+
});
|
|
180
|
+
res.write(":ok\n\n");
|
|
181
|
+
// Send heartbeat every 5 seconds
|
|
182
|
+
const heartbeat = setInterval(() => {
|
|
183
|
+
res.write("data: {\"heartbeat\":true}\n\n");
|
|
184
|
+
}, 5000);
|
|
185
|
+
sseClients.add(res);
|
|
186
|
+
req.on("close", () => {
|
|
187
|
+
clearInterval(heartbeat);
|
|
188
|
+
sseClients.delete(res);
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// RPC endpoint: POST /rpc/<method>
|
|
193
|
+
if (req.method === "POST" && pathname.startsWith("/rpc/")) {
|
|
194
|
+
const method = pathname.slice("/rpc/".length);
|
|
195
|
+
if (!method) {
|
|
196
|
+
sendJson(res, 400, { error: "Missing RPC method" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
let params = {};
|
|
200
|
+
try {
|
|
201
|
+
const body = await readBody(req);
|
|
202
|
+
if (body.trim().length > 0) {
|
|
203
|
+
params = JSON.parse(body);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const sessionToken = extractSessionToken(req);
|
|
211
|
+
console.log(`[http] RPC: ${method}`);
|
|
212
|
+
try {
|
|
213
|
+
const response = await handleRpc({ method, params, sessionToken });
|
|
214
|
+
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
215
|
+
sendJson(res, 200, response);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
console.error(`[http] RPC error (${method}):`, err);
|
|
219
|
+
sendJson(res, 500, { error: String(err) });
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
sendJson(res, 404, { error: "Not found" });
|
|
224
|
+
});
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
server.listen(port, () => {
|
|
227
|
+
console.log(`[http] Listening on port ${port}`);
|
|
228
|
+
console.log(`[http] SSE clients can connect to /events`);
|
|
229
|
+
// Graceful shutdown
|
|
230
|
+
const shutdown = () => {
|
|
231
|
+
console.log("[http] Shutting down...");
|
|
232
|
+
for (const client of sseClients) {
|
|
233
|
+
client.end();
|
|
234
|
+
}
|
|
235
|
+
server.close(() => process.exit(0));
|
|
236
|
+
};
|
|
237
|
+
process.on("SIGINT", shutdown);
|
|
238
|
+
process.on("SIGTERM", shutdown);
|
|
239
|
+
});
|
|
240
|
+
server.on("error", reject);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=http-transport.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostConfig, RpcMessage } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Start the NATS transport: connect, subscribe to RPC subjects, dispatch to handler.
|
|
4
|
+
*/
|
|
5
|
+
export declare function startNatsTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>): Promise<void>;
|
|
6
|
+
//# sourceMappingURL=nats-transport.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { StringCodec } from "nats";
|
|
2
|
+
import { connectNats } from "../nats-client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Start the NATS transport: connect, subscribe to RPC subjects, dispatch to handler.
|
|
5
|
+
*/
|
|
6
|
+
export async function startNatsTransport(config, handleRpc) {
|
|
7
|
+
const nc = await connectNats(config);
|
|
8
|
+
const sc = StringCodec();
|
|
9
|
+
const subject = `host.${config.hostId}.rpc.>`;
|
|
10
|
+
console.log(`[nats] Subscribing to: ${subject}`);
|
|
11
|
+
const sub = nc.subscribe(subject);
|
|
12
|
+
// Graceful shutdown
|
|
13
|
+
const shutdown = async () => {
|
|
14
|
+
console.log("[nats] Shutting down...");
|
|
15
|
+
sub.unsubscribe();
|
|
16
|
+
await nc.drain();
|
|
17
|
+
process.exit(0);
|
|
18
|
+
};
|
|
19
|
+
process.on("SIGINT", shutdown);
|
|
20
|
+
process.on("SIGTERM", shutdown);
|
|
21
|
+
async function processMessage(msg) {
|
|
22
|
+
// Derive RPC method from subject: ...rpc.<method parts>
|
|
23
|
+
const subjectTokens = msg.subject.split(".");
|
|
24
|
+
const rpcIdx = subjectTokens.indexOf("rpc");
|
|
25
|
+
const method = rpcIdx >= 0 ? subjectTokens.slice(rpcIdx + 1).join(".") : "";
|
|
26
|
+
// Parse params from message body
|
|
27
|
+
let params = {};
|
|
28
|
+
if (msg.data && msg.data.length > 0) {
|
|
29
|
+
const raw = sc.decode(msg.data).trim();
|
|
30
|
+
if (raw.length > 0) {
|
|
31
|
+
try {
|
|
32
|
+
params = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
console.error(`[nats] Failed to parse RPC params for ${method}`);
|
|
36
|
+
if (msg.reply) {
|
|
37
|
+
msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Extract sessionToken from params (PWA includes it in the payload)
|
|
44
|
+
const sessionToken = typeof params.sessionToken === "string" ? params.sessionToken : undefined;
|
|
45
|
+
delete params.sessionToken;
|
|
46
|
+
console.log(`[nats] RPC: ${method}`);
|
|
47
|
+
let response;
|
|
48
|
+
try {
|
|
49
|
+
response = await handleRpc({ method, params, sessionToken });
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
console.error(`[nats] RPC error (${method}):`, err);
|
|
53
|
+
response = { error: String(err) };
|
|
54
|
+
}
|
|
55
|
+
console.log(`[nats] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
56
|
+
if (msg.reply) {
|
|
57
|
+
msg.respond(sc.encode(JSON.stringify(response)));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function consumeSubscription(subscription) {
|
|
61
|
+
for await (const msg of subscription) {
|
|
62
|
+
// Handle RPC without blocking the message loop so heartbeats keep flowing
|
|
63
|
+
processMessage(msg);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
console.log("[nats] Waiting for RPC messages...");
|
|
67
|
+
await consumeSubscription(sub);
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=nats-transport.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
export interface
|
|
2
|
-
|
|
3
|
-
userId: string;
|
|
4
|
-
natsUrl: string;
|
|
5
|
-
natsWsUrl: string;
|
|
6
|
-
natsToken: string;
|
|
1
|
+
export interface HostConfig {
|
|
2
|
+
hostId: string;
|
|
7
3
|
projectRoot: string;
|
|
4
|
+
mode: "nats" | "lan" | "auto";
|
|
5
|
+
natsUrl?: string;
|
|
6
|
+
natsWsUrl?: string;
|
|
7
|
+
natsToken?: string;
|
|
8
|
+
directPort?: number;
|
|
9
|
+
directToken?: string;
|
|
10
|
+
agents?: Array<{
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}>;
|
|
8
14
|
}
|
|
9
15
|
export interface TaskFrontmatter {
|
|
10
16
|
id: string;
|
|
17
|
+
name: string;
|
|
11
18
|
user_prompt: string;
|
|
12
|
-
|
|
19
|
+
agent: string;
|
|
13
20
|
triggers: Trigger[];
|
|
14
21
|
triggers_enabled: boolean;
|
|
15
22
|
requires_confirmation: boolean;
|
|
23
|
+
permissions?: RequiredPermission[];
|
|
16
24
|
}
|
|
17
25
|
export interface Trigger {
|
|
18
26
|
type: "cron" | "once";
|
|
@@ -22,16 +30,25 @@ export interface ParsedTask {
|
|
|
22
30
|
frontmatter: TaskFrontmatter;
|
|
23
31
|
body: string;
|
|
24
32
|
}
|
|
25
|
-
export
|
|
26
|
-
|
|
33
|
+
export type TaskRunningState = "start" | "finish" | "abort" | "fail";
|
|
34
|
+
export interface TaskStatus {
|
|
35
|
+
running_state: TaskRunningState;
|
|
36
|
+
time_stamp: number;
|
|
37
|
+
pending_confirmation?: boolean;
|
|
38
|
+
pending_permission?: RequiredPermission[];
|
|
39
|
+
user_input?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface HistoryEntry {
|
|
27
42
|
task_id: string;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
result_file: string;
|
|
44
|
+
}
|
|
45
|
+
export interface RequiredPermission {
|
|
46
|
+
name: string;
|
|
47
|
+
description: string;
|
|
32
48
|
}
|
|
33
49
|
export interface RpcMessage {
|
|
34
50
|
method: string;
|
|
35
51
|
params: Record<string, unknown>;
|
|
52
|
+
sessionToken?: string;
|
|
36
53
|
}
|
|
37
54
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "palmier",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Palmier
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "Hongxu Cai",
|
|
7
7
|
"type": "module",
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"dev": "tsx src/index.ts",
|
|
15
|
-
"build": "tsc && node -e \"require('fs').cpSync('src/commands/
|
|
15
|
+
"build": "tsc && node -e \"require('fs').cpSync('src/commands/plan-generation.md','dist/commands/plan-generation.md')\"",
|
|
16
16
|
"prepare": "npm run build",
|
|
17
17
|
"start": "node dist/index.js"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
20
21
|
"commander": "^13.1.0",
|
|
21
22
|
"dotenv": "^16.4.7",
|
|
22
23
|
"nats": "^2.29.1",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ParsedTask, RequiredPermission } from "../types.js";
|
|
2
|
+
import { ClaudeAgent } from "./claude.js";
|
|
3
|
+
import { GeminiAgent } from "./gemini.js";
|
|
4
|
+
import { CodexAgent } from "./codex.js";
|
|
5
|
+
|
|
6
|
+
export interface CommandLine {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Interface that each agent tool must implement.
|
|
13
|
+
* Abstracts how plans are generated and tasks are executed across different AI agents.
|
|
14
|
+
*/
|
|
15
|
+
export interface AgentTool {
|
|
16
|
+
/** Return the command and args used to generate a plan from a prompt. */
|
|
17
|
+
getPlanGenerationCommandLine(prompt: string): CommandLine;
|
|
18
|
+
|
|
19
|
+
/** Return the command and args used to run a task. If prompt is provided, use it instead of the task's prompt.
|
|
20
|
+
* extraPermissions are transient permissions granted for this run only (not persisted in frontmatter). */
|
|
21
|
+
getTaskRunCommandLine(task: ParsedTask, prompt?: string, extraPermissions?: RequiredPermission[]): CommandLine;
|
|
22
|
+
|
|
23
|
+
/** Detect whether the agent CLI is available and perform any agent-specific
|
|
24
|
+
* initialization. Returns true if the agent was detected and initialized successfully. */
|
|
25
|
+
init(): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const agentRegistry: Record<string, AgentTool> = {
|
|
29
|
+
claude: new ClaudeAgent(),
|
|
30
|
+
gemini: new GeminiAgent(),
|
|
31
|
+
codex: new CodexAgent(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const agentLabels: Record<string, string> = {
|
|
35
|
+
claude: "Claude Code",
|
|
36
|
+
gemini: "Gemini CLI",
|
|
37
|
+
codex: "Codex CLI",
|
|
38
|
+
openclaw: "OpenClaw"
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface DetectedAgent {
|
|
42
|
+
key: string;
|
|
43
|
+
label: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function detectAgents(): Promise<DetectedAgent[]> {
|
|
47
|
+
const detected: DetectedAgent[] = [];
|
|
48
|
+
for (const [key, agent] of Object.entries(agentRegistry)) {
|
|
49
|
+
const label = agentLabels[key] ?? key;
|
|
50
|
+
const ok = await agent.init();
|
|
51
|
+
if (ok) detected.push({ key, label });
|
|
52
|
+
}
|
|
53
|
+
return detected;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getAgent(name: string): AgentTool {
|
|
57
|
+
const agent = agentRegistry[name];
|
|
58
|
+
if (!agent) {
|
|
59
|
+
throw new Error(`Unknown agent: "${name}". Available agents: ${Object.keys(agentRegistry).join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
return agent;
|
|
62
|
+
}
|