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.
Files changed (2) hide show
  1. package/dist/agent.js +268 -6
  2. 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(resolvedFile, () => {
3150
- notifyFromContent();
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",