doer-agent 0.3.0 → 0.3.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/dist/agent.js +268 -6
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, statSync, watch } from "node:fs";
|
|
3
|
-
import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, open, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
6
7
|
import { AckPolicy, connect, DeliverPolicy, JSONCodec, RetentionPolicy, StorageType, StringCodec } from "nats";
|
|
7
8
|
const DEFAULT_SERVER_BASE_URL = "https://doer.cranix.net";
|
|
8
9
|
const AGENT_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -16,6 +17,7 @@ const sessionRpcCodec = StringCodec();
|
|
|
16
17
|
const codexAuthRpcCodec = StringCodec();
|
|
17
18
|
const settingsRpcCodec = StringCodec();
|
|
18
19
|
const gitRpcCodec = StringCodec();
|
|
20
|
+
const skillRpcCodec = StringCodec();
|
|
19
21
|
const retainedRuns = new Map();
|
|
20
22
|
const activeSessionWatchers = new Map();
|
|
21
23
|
const sessionLineIndexCache = new Map();
|
|
@@ -40,6 +42,9 @@ function buildAgentSettingsRpcSubject(userId, agentId) {
|
|
|
40
42
|
function buildAgentGitRpcSubject(userId, agentId) {
|
|
41
43
|
return `doer.agent.git.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
42
44
|
}
|
|
45
|
+
function buildAgentSkillRpcSubject(userId, agentId) {
|
|
46
|
+
return `doer.agent.skill.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
47
|
+
}
|
|
43
48
|
function normalizeNatsServers(value) {
|
|
44
49
|
if (!Array.isArray(value)) {
|
|
45
50
|
return [];
|
|
@@ -1256,11 +1261,12 @@ function spawnManagedCodexCommand(args) {
|
|
|
1256
1261
|
child.stderr?.setEncoding("utf8");
|
|
1257
1262
|
return child;
|
|
1258
1263
|
}
|
|
1259
|
-
async function runLocalCodexCli(args, timeoutMs) {
|
|
1264
|
+
async function runLocalCodexCli(args, timeoutMs, envPatch) {
|
|
1260
1265
|
const command = buildLocalCodexCliCommand(args);
|
|
1261
1266
|
const workspaceRoot = workspaceRootOverride ?? (process.env.WORKSPACE?.trim() || process.cwd());
|
|
1262
1267
|
const env = {
|
|
1263
1268
|
...process.env,
|
|
1269
|
+
...(envPatch ?? {}),
|
|
1264
1270
|
WORKSPACE: workspaceRoot,
|
|
1265
1271
|
CODEX_HOME: resolveCodexHomePath(),
|
|
1266
1272
|
};
|
|
@@ -1306,6 +1312,152 @@ async function runLocalCodexCli(args, timeoutMs) {
|
|
|
1306
1312
|
});
|
|
1307
1313
|
});
|
|
1308
1314
|
}
|
|
1315
|
+
function buildSkillGeneratorPrompt(userPrompt) {
|
|
1316
|
+
return [
|
|
1317
|
+
"Create a Codex skill from the user's description.",
|
|
1318
|
+
"",
|
|
1319
|
+
"Return JSON only with this shape:",
|
|
1320
|
+
'{ "skillName": "kebab-or-dot-or-underscore-name", "skillMd": "full SKILL.md content" }',
|
|
1321
|
+
"",
|
|
1322
|
+
"Requirements:",
|
|
1323
|
+
"- skillName must be lowercase ASCII and use only letters, numbers, dot, underscore, or dash.",
|
|
1324
|
+
"- skillName must not start with a dot and must not be .system.",
|
|
1325
|
+
"- skillMd must be a complete SKILL.md file.",
|
|
1326
|
+
"- skillMd must start with YAML frontmatter containing name and description.",
|
|
1327
|
+
"- Keep the skill concise and action-oriented.",
|
|
1328
|
+
"- Include when to use the skill, the core workflow, and any important constraints.",
|
|
1329
|
+
"- Do not add README, changelog, or any extra files.",
|
|
1330
|
+
"",
|
|
1331
|
+
"User request:",
|
|
1332
|
+
userPrompt.trim(),
|
|
1333
|
+
].join("\n");
|
|
1334
|
+
}
|
|
1335
|
+
function extractJsonObject(value) {
|
|
1336
|
+
const trimmed = value.trim();
|
|
1337
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
1338
|
+
return trimmed;
|
|
1339
|
+
}
|
|
1340
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
1341
|
+
if (fenced?.[1]) {
|
|
1342
|
+
return fenced[1].trim();
|
|
1343
|
+
}
|
|
1344
|
+
const start = trimmed.indexOf("{");
|
|
1345
|
+
const end = trimmed.lastIndexOf("}");
|
|
1346
|
+
if (start >= 0 && end > start) {
|
|
1347
|
+
return trimmed.slice(start, end + 1);
|
|
1348
|
+
}
|
|
1349
|
+
throw new Error("Codex did not return JSON");
|
|
1350
|
+
}
|
|
1351
|
+
function slugifySkillName(value) {
|
|
1352
|
+
return value
|
|
1353
|
+
.trim()
|
|
1354
|
+
.toLowerCase()
|
|
1355
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
1356
|
+
.replace(/^-+|-+$/g, "")
|
|
1357
|
+
.replace(/-{2,}/g, "-");
|
|
1358
|
+
}
|
|
1359
|
+
function normalizeGeneratedSkill(value) {
|
|
1360
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1361
|
+
throw new Error("Invalid generated skill payload");
|
|
1362
|
+
}
|
|
1363
|
+
const row = value;
|
|
1364
|
+
const skillName = slugifySkillName(typeof row.skillName === "string" ? row.skillName : "");
|
|
1365
|
+
const skillMd = typeof row.skillMd === "string" ? row.skillMd.trim() : "";
|
|
1366
|
+
if (!skillName || skillName.startsWith(".") || skillName === ".system") {
|
|
1367
|
+
throw new Error("Codex returned an invalid skill name");
|
|
1368
|
+
}
|
|
1369
|
+
if (!skillMd) {
|
|
1370
|
+
throw new Error("Codex returned an empty SKILL.md");
|
|
1371
|
+
}
|
|
1372
|
+
if (!/^---\s*\n[\s\S]*?\n---\s*\n/m.test(skillMd)) {
|
|
1373
|
+
throw new Error("Generated SKILL.md is missing YAML frontmatter");
|
|
1374
|
+
}
|
|
1375
|
+
if (!/\nname:\s*[^\n]+/i.test(skillMd) || !/\ndescription:\s*[^\n]+/i.test(skillMd)) {
|
|
1376
|
+
throw new Error("Generated SKILL.md frontmatter is incomplete");
|
|
1377
|
+
}
|
|
1378
|
+
return { skillName, skillMd };
|
|
1379
|
+
}
|
|
1380
|
+
function buildSkillGeneratorCodexArgs(prompt, model) {
|
|
1381
|
+
return ["--dangerously-bypass-approvals-and-sandbox", "--model", model, "exec", "--", prompt];
|
|
1382
|
+
}
|
|
1383
|
+
async function generateSkillViaCodex(userPrompt) {
|
|
1384
|
+
const localAgentSettings = await readAgentSettingsConfig(null);
|
|
1385
|
+
const envPatch = buildAgentSettingsEnvPatch(localAgentSettings);
|
|
1386
|
+
const prompt = buildSkillGeneratorPrompt(userPrompt);
|
|
1387
|
+
const result = await runLocalCodexCli(buildSkillGeneratorCodexArgs(prompt, localAgentSettings.codex.model || "gpt-5.4"), 120_000, envPatch);
|
|
1388
|
+
if (result.timedOut) {
|
|
1389
|
+
throw new Error("Codex timed out while generating the skill");
|
|
1390
|
+
}
|
|
1391
|
+
if ((result.code ?? 1) !== 0) {
|
|
1392
|
+
const details = stripAnsi(result.stderr || result.stdout).trim();
|
|
1393
|
+
throw new Error(details || `Codex exited with code ${result.code ?? "null"}`);
|
|
1394
|
+
}
|
|
1395
|
+
const payload = JSON.parse(extractJsonObject(stripAnsi(result.stdout)));
|
|
1396
|
+
const generated = normalizeGeneratedSkill(payload);
|
|
1397
|
+
const skillPath = path.join(resolveCodexHomePath(), "skills", generated.skillName);
|
|
1398
|
+
const skillFilePath = path.join(skillPath, "SKILL.md");
|
|
1399
|
+
try {
|
|
1400
|
+
await stat(skillPath);
|
|
1401
|
+
throw new Error("A skill with that name already exists");
|
|
1402
|
+
}
|
|
1403
|
+
catch (error) {
|
|
1404
|
+
if (!(error instanceof Error) || !/ENOENT/i.test(error.message)) {
|
|
1405
|
+
throw error;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
await mkdir(skillPath, { recursive: true });
|
|
1409
|
+
await writeFile(skillFilePath, `${generated.skillMd}\n`, "utf8");
|
|
1410
|
+
return {
|
|
1411
|
+
skillName: generated.skillName,
|
|
1412
|
+
skillPath: `.codex/skills/${generated.skillName}`,
|
|
1413
|
+
skillFilePath: `.codex/skills/${generated.skillName}/SKILL.md`,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
async function handleSkillRpcMessage(args) {
|
|
1417
|
+
let payload = {};
|
|
1418
|
+
try {
|
|
1419
|
+
payload = JSON.parse(skillRpcCodec.decode(args.msg.data));
|
|
1420
|
+
if (typeof payload.agentId === "string" && payload.agentId.trim() && payload.agentId !== args.agentId) {
|
|
1421
|
+
throw new Error("agent id mismatch");
|
|
1422
|
+
}
|
|
1423
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() : "";
|
|
1424
|
+
if (!prompt) {
|
|
1425
|
+
throw new Error("prompt is required");
|
|
1426
|
+
}
|
|
1427
|
+
const result = await generateSkillViaCodex(prompt);
|
|
1428
|
+
args.msg.respond(skillRpcCodec.encode(JSON.stringify({
|
|
1429
|
+
ok: true,
|
|
1430
|
+
skillName: result.skillName,
|
|
1431
|
+
skillPath: result.skillPath,
|
|
1432
|
+
skillFilePath: result.skillFilePath,
|
|
1433
|
+
})));
|
|
1434
|
+
}
|
|
1435
|
+
catch (error) {
|
|
1436
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
1437
|
+
args.msg.respond(skillRpcCodec.encode(JSON.stringify({
|
|
1438
|
+
ok: false,
|
|
1439
|
+
error: message,
|
|
1440
|
+
})));
|
|
1441
|
+
writeAgentError(`skill rpc failed error=${message}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
function subscribeToSkillRpc(args) {
|
|
1445
|
+
const subject = buildAgentSkillRpcSubject(args.userId, args.agentId);
|
|
1446
|
+
args.jetstream.nc.subscribe(subject, {
|
|
1447
|
+
callback: (error, msg) => {
|
|
1448
|
+
if (error) {
|
|
1449
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1450
|
+
writeAgentError(`skill rpc subscription error: ${message}`);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
void handleSkillRpcMessage({
|
|
1454
|
+
msg,
|
|
1455
|
+
agentId: args.agentId,
|
|
1456
|
+
});
|
|
1457
|
+
},
|
|
1458
|
+
});
|
|
1459
|
+
writeAgentInfo(`skill rpc subscribed subject=${subject}`);
|
|
1460
|
+
}
|
|
1309
1461
|
function parseCodexDeviceAuthOutput(raw) {
|
|
1310
1462
|
const text = stripAnsi(raw);
|
|
1311
1463
|
const urlMatch = text.match(/https?:\/\/[^\s]+/i);
|
|
@@ -2275,7 +2427,10 @@ function parseFsRpcAction(value) {
|
|
|
2275
2427
|
value === "read_text" ||
|
|
2276
2428
|
value === "read_file" ||
|
|
2277
2429
|
value === "write_file" ||
|
|
2278
|
-
value === "download_file"
|
|
2430
|
+
value === "download_file" ||
|
|
2431
|
+
value === "delete_path" ||
|
|
2432
|
+
value === "archive_dir" ||
|
|
2433
|
+
value === "extract_archive") {
|
|
2279
2434
|
return value;
|
|
2280
2435
|
}
|
|
2281
2436
|
throw new Error("unsupported action");
|
|
@@ -2333,6 +2488,34 @@ function inferMimeType(filePath) {
|
|
|
2333
2488
|
}
|
|
2334
2489
|
return "application/octet-stream";
|
|
2335
2490
|
}
|
|
2491
|
+
function normalizeArchiveRelativePath(value) {
|
|
2492
|
+
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
2493
|
+
if (!normalized || normalized.includes("..")) {
|
|
2494
|
+
throw new Error("invalid archive entry path");
|
|
2495
|
+
}
|
|
2496
|
+
return normalized;
|
|
2497
|
+
}
|
|
2498
|
+
async function collectDirectoryFiles(absDir, rootDir = absDir) {
|
|
2499
|
+
const rows = await readdir(absDir, { withFileTypes: true });
|
|
2500
|
+
const files = [];
|
|
2501
|
+
for (const row of rows.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2502
|
+
const child = path.join(absDir, row.name);
|
|
2503
|
+
if (row.isDirectory()) {
|
|
2504
|
+
files.push(...await collectDirectoryFiles(child, rootDir));
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
if (!row.isFile()) {
|
|
2508
|
+
continue;
|
|
2509
|
+
}
|
|
2510
|
+
const bytes = await readFile(child);
|
|
2511
|
+
files.push({
|
|
2512
|
+
relPath: normalizeArchiveRelativePath(path.relative(rootDir, child)),
|
|
2513
|
+
contentBase64: Buffer.from(bytes).toString("base64"),
|
|
2514
|
+
sizeBytes: bytes.byteLength,
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
return files;
|
|
2518
|
+
}
|
|
2336
2519
|
async function executeFsRpc(args) {
|
|
2337
2520
|
const action = parseFsRpcAction(args.request.action);
|
|
2338
2521
|
const { abs, formatPath } = normalizeFsRpcPath(args.request.path);
|
|
@@ -2375,6 +2558,34 @@ async function executeFsRpc(args) {
|
|
|
2375
2558
|
total: items.length,
|
|
2376
2559
|
};
|
|
2377
2560
|
}
|
|
2561
|
+
if (action === "archive_dir") {
|
|
2562
|
+
const entry = await stat(abs);
|
|
2563
|
+
if (!entry.isDirectory()) {
|
|
2564
|
+
throw new Error("path is not a directory");
|
|
2565
|
+
}
|
|
2566
|
+
const rawArchivePath = typeof args.request.archivePath === "string" ? args.request.archivePath : "";
|
|
2567
|
+
if (!rawArchivePath) {
|
|
2568
|
+
throw new Error("archivePath is required");
|
|
2569
|
+
}
|
|
2570
|
+
const archiveTarget = normalizeFsRpcPath(rawArchivePath);
|
|
2571
|
+
const files = await collectDirectoryFiles(abs);
|
|
2572
|
+
if (!files.some((file) => file.relPath === "SKILL.md")) {
|
|
2573
|
+
throw new Error("Selected skill directory must contain SKILL.md");
|
|
2574
|
+
}
|
|
2575
|
+
const payload = gzipSync(Buffer.from(JSON.stringify({
|
|
2576
|
+
files,
|
|
2577
|
+
}), "utf8"));
|
|
2578
|
+
await mkdir(path.dirname(archiveTarget.abs), { recursive: true });
|
|
2579
|
+
await writeFile(archiveTarget.abs, payload);
|
|
2580
|
+
const archiveStat = await stat(archiveTarget.abs);
|
|
2581
|
+
return {
|
|
2582
|
+
ok: true,
|
|
2583
|
+
action,
|
|
2584
|
+
path: formatPath(abs),
|
|
2585
|
+
archivePath: archiveTarget.formatPath(archiveTarget.abs),
|
|
2586
|
+
size: archiveStat.size,
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2378
2589
|
if (action === "fetch_file") {
|
|
2379
2590
|
const entry = await stat(abs);
|
|
2380
2591
|
if (!entry.isFile()) {
|
|
@@ -2435,6 +2646,15 @@ async function executeFsRpc(args) {
|
|
|
2435
2646
|
mtimeMs: entry.mtimeMs,
|
|
2436
2647
|
};
|
|
2437
2648
|
}
|
|
2649
|
+
if (action === "delete_path") {
|
|
2650
|
+
await rm(abs, { recursive: true, force: true });
|
|
2651
|
+
return {
|
|
2652
|
+
ok: true,
|
|
2653
|
+
action,
|
|
2654
|
+
path: formatPath(abs),
|
|
2655
|
+
absolutePath: abs.split(path.sep).join("/"),
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2438
2658
|
if (action === "download_file") {
|
|
2439
2659
|
const downloadPath = typeof args.request.downloadPath === "string" ? args.request.downloadPath.trim() : "";
|
|
2440
2660
|
if (!downloadPath) {
|
|
@@ -2466,6 +2686,37 @@ async function executeFsRpc(args) {
|
|
|
2466
2686
|
mtimeMs: entry.mtimeMs,
|
|
2467
2687
|
};
|
|
2468
2688
|
}
|
|
2689
|
+
if (action === "extract_archive") {
|
|
2690
|
+
const archiveEntry = await stat(abs);
|
|
2691
|
+
if (!archiveEntry.isFile()) {
|
|
2692
|
+
throw new Error("path is not a file");
|
|
2693
|
+
}
|
|
2694
|
+
const rawDestinationPath = typeof args.request.destinationPath === "string" ? args.request.destinationPath : "";
|
|
2695
|
+
if (!rawDestinationPath) {
|
|
2696
|
+
throw new Error("destinationPath is required");
|
|
2697
|
+
}
|
|
2698
|
+
const destinationTarget = normalizeFsRpcPath(rawDestinationPath);
|
|
2699
|
+
const archiveBytes = await readFile(abs);
|
|
2700
|
+
const decoded = JSON.parse(gunzipSync(archiveBytes).toString("utf8"));
|
|
2701
|
+
const files = Array.isArray(decoded.files) ? decoded.files : [];
|
|
2702
|
+
await mkdir(destinationTarget.abs, { recursive: true });
|
|
2703
|
+
for (const file of files) {
|
|
2704
|
+
const relPath = typeof file.relPath === "string" ? normalizeArchiveRelativePath(file.relPath) : "";
|
|
2705
|
+
const contentBase64 = typeof file.contentBase64 === "string" ? file.contentBase64 : "";
|
|
2706
|
+
if (!relPath || !contentBase64) {
|
|
2707
|
+
throw new Error("archive contains an invalid file entry");
|
|
2708
|
+
}
|
|
2709
|
+
const targetPath = path.join(destinationTarget.abs, relPath);
|
|
2710
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
2711
|
+
await writeFile(targetPath, Buffer.from(contentBase64, "base64"));
|
|
2712
|
+
}
|
|
2713
|
+
return {
|
|
2714
|
+
ok: true,
|
|
2715
|
+
action,
|
|
2716
|
+
path: formatPath(abs),
|
|
2717
|
+
absolutePath: destinationTarget.formatPath(destinationTarget.abs),
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2469
2720
|
const entry = await stat(abs);
|
|
2470
2721
|
if (!entry.isFile()) {
|
|
2471
2722
|
throw new Error("path is not a file");
|
|
@@ -3112,6 +3363,7 @@ function stopAllSessionWatchers() {
|
|
|
3112
3363
|
}
|
|
3113
3364
|
async function startSessionWatch(args) {
|
|
3114
3365
|
const resolvedFile = resolveSessionFilePath(args.filePath);
|
|
3366
|
+
const canonicalFile = await realpath(resolvedFile).catch(() => resolvedFile);
|
|
3115
3367
|
const watchId = `watch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
3116
3368
|
let watcher = null;
|
|
3117
3369
|
let active = true;
|
|
@@ -3136,9 +3388,9 @@ async function startSessionWatch(args) {
|
|
|
3136
3388
|
return;
|
|
3137
3389
|
}
|
|
3138
3390
|
active = false;
|
|
3391
|
+
activeSessionWatchers.delete(watchId);
|
|
3139
3392
|
watcher?.close();
|
|
3140
3393
|
watcher = null;
|
|
3141
|
-
activeSessionWatchers.delete(watchId);
|
|
3142
3394
|
};
|
|
3143
3395
|
const notifyFromContent = () => {
|
|
3144
3396
|
emitEvent({
|
|
@@ -3146,8 +3398,13 @@ async function startSessionWatch(args) {
|
|
|
3146
3398
|
at: formatLocalTimestamp(),
|
|
3147
3399
|
});
|
|
3148
3400
|
};
|
|
3149
|
-
watcher = watch(
|
|
3150
|
-
|
|
3401
|
+
watcher = watch(canonicalFile, { persistent: false }, (eventType) => {
|
|
3402
|
+
if (!active) {
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3405
|
+
if (eventType === "change" || eventType === "rename") {
|
|
3406
|
+
notifyFromContent();
|
|
3407
|
+
}
|
|
3151
3408
|
});
|
|
3152
3409
|
activeSessionWatchers.set(watchId, cleanup);
|
|
3153
3410
|
emitEvent({ type: "stream.started", watchId, at: formatLocalTimestamp() });
|
|
@@ -3879,6 +4136,11 @@ async function main() {
|
|
|
3879
4136
|
userId,
|
|
3880
4137
|
agentId: initialAgentId,
|
|
3881
4138
|
});
|
|
4139
|
+
subscribeToSkillRpc({
|
|
4140
|
+
jetstream,
|
|
4141
|
+
userId,
|
|
4142
|
+
agentId: initialAgentId,
|
|
4143
|
+
});
|
|
3882
4144
|
subscribeToRunRpc({
|
|
3883
4145
|
jetstream,
|
|
3884
4146
|
serverBaseUrl,
|