palmier 0.2.0 → 0.2.1
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 +166 -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 +157 -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 +195 -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 +180 -0
- package/src/transports/nats-transport.ts +82 -0
- package/src/types.ts +36 -13
- package/src/commands/task-generation.md +0 -28
package/src/task.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
|
-
import type { ParsedTask, TaskFrontmatter } from "./types.js";
|
|
4
|
+
import type { ParsedTask, TaskFrontmatter, TaskStatus, HistoryEntry } from "./types.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Parse a TASK.md file from the given task directory.
|
|
@@ -35,7 +35,8 @@ function parseTaskContent(content: string): ParsedTask {
|
|
|
35
35
|
throw new Error("TASK.md frontmatter must include at least: id");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
frontmatter.
|
|
38
|
+
frontmatter.name ??= frontmatter.user_prompt?.slice(0, 60) ?? "";
|
|
39
|
+
frontmatter.agent ??= "claude";
|
|
39
40
|
frontmatter.triggers_enabled ??= true;
|
|
40
41
|
|
|
41
42
|
return { frontmatter, body };
|
|
@@ -56,34 +57,66 @@ export function writeTaskFile(taskDir: string, task: ParsedTask): void {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
*
|
|
60
|
+
* Append a task ID to the project-level tasks.jsonl file.
|
|
60
61
|
*/
|
|
61
|
-
export function
|
|
62
|
-
const
|
|
62
|
+
export function appendTaskList(projectRoot: string, taskId: string): void {
|
|
63
|
+
const listPath = path.join(projectRoot, "tasks.jsonl");
|
|
64
|
+
fs.appendFileSync(listPath, JSON.stringify({ task_id: taskId }) + "\n", "utf-8");
|
|
65
|
+
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Remove a task ID from the project-level tasks.jsonl file.
|
|
69
|
+
* Returns true if the entry was found and removed.
|
|
70
|
+
*/
|
|
71
|
+
export function removeFromTaskList(projectRoot: string, taskId: string): boolean {
|
|
72
|
+
const listPath = path.join(projectRoot, "tasks.jsonl");
|
|
73
|
+
if (!fs.existsSync(listPath)) return false;
|
|
74
|
+
|
|
75
|
+
const lines = fs.readFileSync(listPath, "utf-8").split("\n").filter(Boolean);
|
|
76
|
+
let found = false;
|
|
77
|
+
const remaining: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
try {
|
|
81
|
+
const entry = JSON.parse(line) as { task_id: string };
|
|
82
|
+
if (entry.task_id === taskId) {
|
|
83
|
+
found = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
} catch { /* keep malformed lines */ }
|
|
87
|
+
remaining.push(line);
|
|
66
88
|
}
|
|
67
89
|
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
if (!found) return false;
|
|
91
|
+
fs.writeFileSync(listPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
70
94
|
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
/**
|
|
96
|
+
* List all tasks referenced in tasks.jsonl.
|
|
97
|
+
*/
|
|
98
|
+
export function listTasks(projectRoot: string): ParsedTask[] {
|
|
99
|
+
const listPath = path.join(projectRoot, "tasks.jsonl");
|
|
100
|
+
if (!fs.existsSync(listPath)) return [];
|
|
73
101
|
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
const lines = fs.readFileSync(listPath, "utf-8").split("\n").filter(Boolean);
|
|
103
|
+
const tasks: ParsedTask[] = [];
|
|
76
104
|
|
|
77
|
-
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
let taskId: string;
|
|
107
|
+
try {
|
|
108
|
+
taskId = (JSON.parse(line) as { task_id: string }).task_id;
|
|
109
|
+
} catch { continue; }
|
|
78
110
|
|
|
111
|
+
const taskDir = getTaskDir(projectRoot, taskId);
|
|
79
112
|
try {
|
|
80
113
|
tasks.push(parseTaskFile(taskDir));
|
|
81
114
|
} catch (err) {
|
|
82
|
-
console.error(`Warning: failed to parse task
|
|
115
|
+
console.error(`Warning: failed to parse task ${taskId}: ${err}`);
|
|
83
116
|
}
|
|
84
117
|
}
|
|
85
118
|
|
|
86
|
-
return tasks;
|
|
119
|
+
return tasks.reverse();
|
|
87
120
|
}
|
|
88
121
|
|
|
89
122
|
/**
|
|
@@ -92,3 +125,120 @@ export function listTasks(projectRoot: string): ParsedTask[] {
|
|
|
92
125
|
export function getTaskDir(projectRoot: string, taskId: string): string {
|
|
93
126
|
return path.join(projectRoot, "tasks", taskId);
|
|
94
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get the creation time (birthtime) of a TASK.md file in ms since epoch.
|
|
131
|
+
*/
|
|
132
|
+
export function getTaskCreatedAt(taskDir: string): number {
|
|
133
|
+
const filePath = path.join(taskDir, "TASK.md");
|
|
134
|
+
try {
|
|
135
|
+
return fs.statSync(filePath).birthtimeMs;
|
|
136
|
+
} catch {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Write task status to status.json in the task directory.
|
|
143
|
+
*/
|
|
144
|
+
export function writeTaskStatus(taskDir: string, status: TaskStatus): void {
|
|
145
|
+
const filePath = path.join(taskDir, "status.json");
|
|
146
|
+
fs.writeFileSync(filePath, JSON.stringify(status), "utf-8");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read task status from status.json in the task directory.
|
|
151
|
+
* Returns undefined if the file doesn't exist.
|
|
152
|
+
*/
|
|
153
|
+
export function readTaskStatus(taskDir: string): TaskStatus | undefined {
|
|
154
|
+
const filePath = path.join(taskDir, "status.json");
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as TaskStatus;
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Append a history entry to the project-level history.jsonl file.
|
|
164
|
+
*/
|
|
165
|
+
export function appendHistory(projectRoot: string, entry: HistoryEntry): void {
|
|
166
|
+
const historyPath = path.join(projectRoot, "history.jsonl");
|
|
167
|
+
fs.appendFileSync(historyPath, JSON.stringify(entry) + "\n", "utf-8");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Delete a history entry and its associated result/task-snapshot files.
|
|
172
|
+
* Returns true if the entry was found and removed.
|
|
173
|
+
*/
|
|
174
|
+
export function deleteHistoryEntry(
|
|
175
|
+
projectRoot: string,
|
|
176
|
+
taskId: string,
|
|
177
|
+
resultFile: string,
|
|
178
|
+
): boolean {
|
|
179
|
+
const historyPath = path.join(projectRoot, "history.jsonl");
|
|
180
|
+
if (!fs.existsSync(historyPath)) return false;
|
|
181
|
+
|
|
182
|
+
const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(Boolean);
|
|
183
|
+
let found = false;
|
|
184
|
+
const remaining: string[] = [];
|
|
185
|
+
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
try {
|
|
188
|
+
const entry = JSON.parse(line) as HistoryEntry;
|
|
189
|
+
if (entry.task_id === taskId && entry.result_file === resultFile) {
|
|
190
|
+
found = true;
|
|
191
|
+
continue; // skip this entry
|
|
192
|
+
}
|
|
193
|
+
} catch { /* keep malformed lines */ }
|
|
194
|
+
remaining.push(line);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!found) return false;
|
|
198
|
+
|
|
199
|
+
// Rewrite history.jsonl without the deleted entry
|
|
200
|
+
fs.writeFileSync(historyPath, remaining.length > 0 ? remaining.join("\n") + "\n" : "", "utf-8");
|
|
201
|
+
|
|
202
|
+
// Delete the result file
|
|
203
|
+
const resultPath = path.join(projectRoot, "tasks", taskId, resultFile);
|
|
204
|
+
if (fs.existsSync(resultPath)) {
|
|
205
|
+
fs.unlinkSync(resultPath);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Delete the corresponding task snapshot (TASK-<timestamp>.md)
|
|
209
|
+
const tsMatch = resultFile.match(/^RESULT-(\d+)\.md$/);
|
|
210
|
+
if (tsMatch) {
|
|
211
|
+
const snapshotFile = `TASK-${tsMatch[1]}.md`;
|
|
212
|
+
const snapshotPath = path.join(projectRoot, "tasks", taskId, snapshotFile);
|
|
213
|
+
if (fs.existsSync(snapshotPath)) {
|
|
214
|
+
fs.unlinkSync(snapshotPath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read history entries from history.jsonl with pagination.
|
|
223
|
+
* Returns entries sorted most-recent-first.
|
|
224
|
+
*/
|
|
225
|
+
export function readHistory(
|
|
226
|
+
projectRoot: string,
|
|
227
|
+
opts: { offset?: number; limit?: number; task_id?: string },
|
|
228
|
+
): { entries: HistoryEntry[]; total: number } {
|
|
229
|
+
const historyPath = path.join(projectRoot, "history.jsonl");
|
|
230
|
+
if (!fs.existsSync(historyPath)) return { entries: [], total: 0 };
|
|
231
|
+
|
|
232
|
+
const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(Boolean);
|
|
233
|
+
let all: HistoryEntry[] = [];
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
try { all.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
236
|
+
}
|
|
237
|
+
all.reverse();
|
|
238
|
+
if (opts.task_id) {
|
|
239
|
+
all = all.filter((e) => e.task_id === opts.task_id);
|
|
240
|
+
}
|
|
241
|
+
const offset = opts.offset ?? 0;
|
|
242
|
+
const limit = opts.limit ?? 10;
|
|
243
|
+
return { entries: all.slice(offset, offset + limit), total: all.length };
|
|
244
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import { validateSession, hasSessions } from "../session-store.js";
|
|
3
|
+
import type { HostConfig, RpcMessage } from "../types.js";
|
|
4
|
+
|
|
5
|
+
type SseClient = http.ServerResponse;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Start the HTTP transport: Express-like server with RPC, SSE, and health endpoints.
|
|
9
|
+
*/
|
|
10
|
+
export async function startHttpTransport(
|
|
11
|
+
config: HostConfig,
|
|
12
|
+
handleRpc: (req: RpcMessage) => Promise<unknown>,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const port = config.directPort ?? 7400;
|
|
15
|
+
const sseClients = new Set<SseClient>();
|
|
16
|
+
|
|
17
|
+
function broadcastSseEvent(data: unknown) {
|
|
18
|
+
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
19
|
+
for (const client of sseClients) {
|
|
20
|
+
client.write(payload);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setCorsHeaders(res: http.ServerResponse) {
|
|
25
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
26
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
27
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkAuth(req: http.IncomingMessage): boolean {
|
|
31
|
+
const auth = req.headers.authorization;
|
|
32
|
+
if (!auth || !auth.startsWith("Bearer ")) return false;
|
|
33
|
+
const token = auth.slice(7);
|
|
34
|
+
// Accept the original directToken or any valid session token
|
|
35
|
+
if (token === config.directToken) return true;
|
|
36
|
+
if (hasSessions() && validateSession(token)) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractSessionToken(req: http.IncomingMessage): string | undefined {
|
|
41
|
+
const auth = req.headers.authorization;
|
|
42
|
+
if (!auth || !auth.startsWith("Bearer ")) return undefined;
|
|
43
|
+
return auth.slice(7);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendJson(res: http.ServerResponse, status: number, body: unknown) {
|
|
47
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
48
|
+
res.end(JSON.stringify(body));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const chunks: Buffer[] = [];
|
|
54
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
55
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
56
|
+
req.on("error", reject);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isLocalhost(req: http.IncomingMessage): boolean {
|
|
61
|
+
const addr = req.socket.remoteAddress;
|
|
62
|
+
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const server = http.createServer(async (req, res) => {
|
|
66
|
+
setCorsHeaders(res);
|
|
67
|
+
|
|
68
|
+
// Handle CORS preflight
|
|
69
|
+
if (req.method === "OPTIONS") {
|
|
70
|
+
res.writeHead(204);
|
|
71
|
+
res.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
76
|
+
const pathname = url.pathname;
|
|
77
|
+
|
|
78
|
+
// Internal event endpoint — localhost only, no auth
|
|
79
|
+
if (req.method === "POST" && pathname === "/internal/event") {
|
|
80
|
+
if (!isLocalhost(req)) {
|
|
81
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const body = await readBody(req);
|
|
86
|
+
const event = JSON.parse(body);
|
|
87
|
+
broadcastSseEvent(event);
|
|
88
|
+
sendJson(res, 200, { ok: true });
|
|
89
|
+
} catch {
|
|
90
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// All other endpoints require auth
|
|
96
|
+
if (!checkAuth(req)) {
|
|
97
|
+
sendJson(res, 401, { error: "Unauthorized" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// SSE event stream
|
|
102
|
+
if (req.method === "GET" && pathname === "/events") {
|
|
103
|
+
res.writeHead(200, {
|
|
104
|
+
"Content-Type": "text/event-stream",
|
|
105
|
+
"Cache-Control": "no-cache",
|
|
106
|
+
Connection: "keep-alive",
|
|
107
|
+
"Access-Control-Allow-Origin": "*",
|
|
108
|
+
});
|
|
109
|
+
res.write(":ok\n\n");
|
|
110
|
+
|
|
111
|
+
// Send heartbeat every 5 seconds
|
|
112
|
+
const heartbeat = setInterval(() => {
|
|
113
|
+
res.write("data: {\"heartbeat\":true}\n\n");
|
|
114
|
+
}, 5000);
|
|
115
|
+
|
|
116
|
+
sseClients.add(res);
|
|
117
|
+
req.on("close", () => {
|
|
118
|
+
clearInterval(heartbeat);
|
|
119
|
+
sseClients.delete(res);
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// RPC endpoint: POST /rpc/<method>
|
|
125
|
+
if (req.method === "POST" && pathname.startsWith("/rpc/")) {
|
|
126
|
+
const method = pathname.slice("/rpc/".length);
|
|
127
|
+
if (!method) {
|
|
128
|
+
sendJson(res, 400, { error: "Missing RPC method" });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let params: Record<string, unknown> = {};
|
|
133
|
+
try {
|
|
134
|
+
const body = await readBody(req);
|
|
135
|
+
if (body.trim().length > 0) {
|
|
136
|
+
params = JSON.parse(body);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
sendJson(res, 400, { error: "Invalid JSON" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sessionToken = extractSessionToken(req);
|
|
144
|
+
console.log(`[http] RPC: ${method}`);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const response = await handleRpc({ method, params, sessionToken });
|
|
148
|
+
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
149
|
+
sendJson(res, 200, response);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(`[http] RPC error (${method}):`, err);
|
|
152
|
+
sendJson(res, 500, { error: String(err) });
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
sendJson(res, 404, { error: "Not found" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return new Promise<void>((resolve, reject) => {
|
|
161
|
+
server.listen(port, () => {
|
|
162
|
+
console.log(`[http] Listening on port ${port}`);
|
|
163
|
+
console.log(`[http] SSE clients can connect to /events`);
|
|
164
|
+
|
|
165
|
+
// Graceful shutdown
|
|
166
|
+
const shutdown = () => {
|
|
167
|
+
console.log("[http] Shutting down...");
|
|
168
|
+
for (const client of sseClients) {
|
|
169
|
+
client.end();
|
|
170
|
+
}
|
|
171
|
+
server.close(() => process.exit(0));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
process.on("SIGINT", shutdown);
|
|
175
|
+
process.on("SIGTERM", shutdown);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
server.on("error", reject);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { StringCodec, type Msg, type Subscription } from "nats";
|
|
2
|
+
import { connectNats } from "../nats-client.js";
|
|
3
|
+
import type { HostConfig, RpcMessage } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Start the NATS transport: connect, subscribe to RPC subjects, dispatch to handler.
|
|
7
|
+
*/
|
|
8
|
+
export async function startNatsTransport(
|
|
9
|
+
config: HostConfig,
|
|
10
|
+
handleRpc: (req: RpcMessage) => Promise<unknown>,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const nc = await connectNats(config);
|
|
13
|
+
const sc = StringCodec();
|
|
14
|
+
|
|
15
|
+
const subject = `host.${config.hostId}.rpc.>`;
|
|
16
|
+
console.log(`[nats] Subscribing to: ${subject}`);
|
|
17
|
+
const sub = nc.subscribe(subject);
|
|
18
|
+
|
|
19
|
+
// Graceful shutdown
|
|
20
|
+
const shutdown = async () => {
|
|
21
|
+
console.log("[nats] Shutting down...");
|
|
22
|
+
sub.unsubscribe();
|
|
23
|
+
await nc.drain();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
process.on("SIGINT", shutdown);
|
|
28
|
+
process.on("SIGTERM", shutdown);
|
|
29
|
+
|
|
30
|
+
async function processMessage(msg: Msg) {
|
|
31
|
+
// Derive RPC method from subject: ...rpc.<method parts>
|
|
32
|
+
const subjectTokens = msg.subject.split(".");
|
|
33
|
+
const rpcIdx = subjectTokens.indexOf("rpc");
|
|
34
|
+
const method = rpcIdx >= 0 ? subjectTokens.slice(rpcIdx + 1).join(".") : "";
|
|
35
|
+
|
|
36
|
+
// Parse params from message body
|
|
37
|
+
let params: Record<string, unknown> = {};
|
|
38
|
+
if (msg.data && msg.data.length > 0) {
|
|
39
|
+
const raw = sc.decode(msg.data).trim();
|
|
40
|
+
if (raw.length > 0) {
|
|
41
|
+
try {
|
|
42
|
+
params = JSON.parse(raw);
|
|
43
|
+
} catch {
|
|
44
|
+
console.error(`[nats] Failed to parse RPC params for ${method}`);
|
|
45
|
+
if (msg.reply) {
|
|
46
|
+
msg.respond(sc.encode(JSON.stringify({ error: "Invalid JSON" })));
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extract sessionToken from params (PWA includes it in the payload)
|
|
54
|
+
const sessionToken = typeof params.sessionToken === "string" ? params.sessionToken : undefined;
|
|
55
|
+
delete params.sessionToken;
|
|
56
|
+
|
|
57
|
+
console.log(`[nats] RPC: ${method}`);
|
|
58
|
+
|
|
59
|
+
let response: unknown;
|
|
60
|
+
try {
|
|
61
|
+
response = await handleRpc({ method, params, sessionToken });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(`[nats] RPC error (${method}):`, err);
|
|
64
|
+
response = { error: String(err) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`[nats] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
68
|
+
if (msg.reply) {
|
|
69
|
+
msg.respond(sc.encode(JSON.stringify(response)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function consumeSubscription(subscription: Subscription) {
|
|
74
|
+
for await (const msg of subscription) {
|
|
75
|
+
// Handle RPC without blocking the message loop so heartbeats keep flowing
|
|
76
|
+
processMessage(msg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log("[nats] Waiting for RPC messages...");
|
|
81
|
+
await consumeSubscription(sub);
|
|
82
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
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
|
+
|
|
6
|
+
// NATS fields (required when mode === "nats" or "auto")
|
|
7
|
+
natsUrl?: string;
|
|
8
|
+
natsWsUrl?: string;
|
|
9
|
+
natsToken?: string;
|
|
10
|
+
|
|
11
|
+
// Direct/LAN fields (required when mode === "lan" or "auto")
|
|
12
|
+
directPort?: number;
|
|
13
|
+
directToken?: string;
|
|
14
|
+
|
|
15
|
+
// Detected agent CLIs
|
|
16
|
+
agents?: Array<{ key: string; label: string }>;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
19
|
export interface TaskFrontmatter {
|
|
11
20
|
id: string;
|
|
21
|
+
name: string;
|
|
12
22
|
user_prompt: string;
|
|
13
|
-
|
|
23
|
+
agent: string;
|
|
14
24
|
triggers: Trigger[];
|
|
15
25
|
triggers_enabled: boolean;
|
|
16
26
|
requires_confirmation: boolean;
|
|
27
|
+
permissions?: RequiredPermission[];
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export interface Trigger {
|
|
@@ -26,16 +37,28 @@ export interface ParsedTask {
|
|
|
26
37
|
body: string;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
export
|
|
30
|
-
|
|
40
|
+
export type TaskRunningState = "start" | "finish" | "abort" | "fail";
|
|
41
|
+
|
|
42
|
+
export interface TaskStatus {
|
|
43
|
+
running_state: TaskRunningState;
|
|
44
|
+
time_stamp: number;
|
|
45
|
+
pending_confirmation?: boolean;
|
|
46
|
+
pending_permission?: RequiredPermission[];
|
|
47
|
+
user_input?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HistoryEntry {
|
|
31
51
|
task_id: string;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
result_file: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RequiredPermission {
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
export interface RpcMessage {
|
|
39
61
|
method: string;
|
|
40
62
|
params: Record<string, unknown>;
|
|
63
|
+
sessionToken?: string;
|
|
41
64
|
}
|
|
@@ -1,28 +0,0 @@
|
|
|
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:**
|