@rudderhq/agent-runtime-utils 0.1.0-canary.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.
@@ -0,0 +1,1012 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { constants as fsConstants, promises as fs } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ export const runningProcesses = new Map();
7
+ function isChildProcessAlive(child) {
8
+ const pid = child.pid;
9
+ if (typeof pid !== "number" || pid <= 0)
10
+ return false;
11
+ if (child.exitCode !== null || child.signalCode !== null)
12
+ return false;
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ }
17
+ catch (error) {
18
+ const code = error instanceof Error && "code" in error ? error.code : null;
19
+ return code === "EPERM";
20
+ }
21
+ }
22
+ export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
23
+ export const MAX_EXCERPT_BYTES = 32 * 1024;
24
+ const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
25
+ const RUDDER_SKILL_ROOT_RELATIVE_CANDIDATES = [
26
+ "../../server/resources/bundled-skills",
27
+ "../../skills",
28
+ "../../../../../server/resources/bundled-skills",
29
+ ];
30
+ function normalizePathSlashes(value) {
31
+ return value.replaceAll("\\", "/");
32
+ }
33
+ function isMaintainerOnlySkillTarget(candidate) {
34
+ const normalized = normalizePathSlashes(candidate);
35
+ return (normalized.includes("/server/resources/bundled-skills/")
36
+ || normalized.includes("/.agents/skills/"));
37
+ }
38
+ function skillLocationLabel(value) {
39
+ if (typeof value !== "string")
40
+ return null;
41
+ const trimmed = value.trim();
42
+ return trimmed.length > 0 ? trimmed : null;
43
+ }
44
+ function buildManagedSkillOrigin() {
45
+ return {
46
+ origin: "organization_managed",
47
+ readOnly: false,
48
+ };
49
+ }
50
+ function compactSkillText(value) {
51
+ if (typeof value !== "string")
52
+ return null;
53
+ const compacted = value
54
+ .replace(/\r\n/g, "\n")
55
+ .split("\n")
56
+ .map((line) => line.trim())
57
+ .filter(Boolean)
58
+ .join(" ")
59
+ .replace(/\s+/g, " ")
60
+ .trim();
61
+ return compacted.length > 0 ? compacted : null;
62
+ }
63
+ function parseSkillFrontmatterMetadata(markdown) {
64
+ const match = markdown.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
65
+ if (!match) {
66
+ return { name: null, description: null };
67
+ }
68
+ const yaml = match[1];
69
+ const nameMatch = yaml.match(/^name:\s*["']?(.*?)["']?\s*$/m);
70
+ const descriptionMatch = yaml.match(/^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m);
71
+ return {
72
+ name: compactSkillText(nameMatch?.[1] ?? null),
73
+ description: compactSkillText(descriptionMatch?.[1] ?? descriptionMatch?.[2] ?? descriptionMatch?.[3] ?? null),
74
+ };
75
+ }
76
+ async function readSkillMetadataFromDirectory(skillDir) {
77
+ const skillFile = path.join(skillDir, "SKILL.md");
78
+ try {
79
+ const markdown = await fs.readFile(skillFile, "utf8");
80
+ return parseSkillFrontmatterMetadata(markdown);
81
+ }
82
+ catch {
83
+ return { name: null, description: null };
84
+ }
85
+ }
86
+ export async function readSkillMetadataFromPath(candidatePath) {
87
+ if (typeof candidatePath !== "string" || candidatePath.trim().length === 0) {
88
+ return { name: null, description: null };
89
+ }
90
+ const resolvedPath = path.resolve(candidatePath);
91
+ const skillDir = path.basename(resolvedPath).toLowerCase() === "skill.md"
92
+ ? path.dirname(resolvedPath)
93
+ : resolvedPath;
94
+ return readSkillMetadataFromDirectory(skillDir);
95
+ }
96
+ function resolveInstalledEntryTarget(skillsHome, entryName, dirent, linkedPath) {
97
+ const fullPath = path.join(skillsHome, entryName);
98
+ if (dirent.isSymbolicLink()) {
99
+ return {
100
+ targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
101
+ kind: "symlink",
102
+ };
103
+ }
104
+ if (dirent.isDirectory()) {
105
+ return { targetPath: fullPath, kind: "directory" };
106
+ }
107
+ return { targetPath: fullPath, kind: "file" };
108
+ }
109
+ export function parseObject(value) {
110
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
111
+ return {};
112
+ }
113
+ return value;
114
+ }
115
+ export function asString(value, fallback) {
116
+ return typeof value === "string" && value.length > 0 ? value : fallback;
117
+ }
118
+ export function asNumber(value, fallback) {
119
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
120
+ }
121
+ export function asBoolean(value, fallback) {
122
+ return typeof value === "boolean" ? value : fallback;
123
+ }
124
+ export function asStringArray(value) {
125
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
126
+ }
127
+ export function parseJson(value) {
128
+ try {
129
+ return JSON.parse(value);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ export function appendWithCap(prev, chunk, cap = MAX_CAPTURE_BYTES) {
136
+ const combined = prev + chunk;
137
+ return combined.length > cap ? combined.slice(combined.length - cap) : combined;
138
+ }
139
+ export function resolvePathValue(obj, dottedPath) {
140
+ const parts = dottedPath.split(".");
141
+ let cursor = obj;
142
+ for (const part of parts) {
143
+ if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
144
+ return "";
145
+ }
146
+ cursor = cursor[part];
147
+ }
148
+ if (cursor === null || cursor === undefined)
149
+ return "";
150
+ if (typeof cursor === "string")
151
+ return cursor;
152
+ if (typeof cursor === "number" || typeof cursor === "boolean")
153
+ return String(cursor);
154
+ try {
155
+ return JSON.stringify(cursor);
156
+ }
157
+ catch {
158
+ return "";
159
+ }
160
+ }
161
+ export function renderTemplate(template, data) {
162
+ return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
163
+ }
164
+ // Default prompt templates for different wake sources
165
+ export const DEFAULT_AGENT_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). Continue your Rudder work.
166
+
167
+ {{context.rudderWorkspace.orgResourcesPrompt}}`;
168
+ export const ISSUE_ASSIGN_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). You have been assigned to work on an issue.
169
+
170
+ {{context.rudderWorkspace.orgResourcesPrompt}}
171
+
172
+ ## Task Context
173
+
174
+ **Issue:** {{issue.title}}
175
+ **ID:** {{issue.id}}
176
+ **Status:** {{issue.status}}
177
+ **Priority:** {{issue.priority}}
178
+
179
+ **Description:**
180
+ {{issue.description}}
181
+
182
+ Your task is to review this issue and begin working on it. Use the available tools to explore the codebase, understand the requirements, and implement a solution.`;
183
+ export const COMMENT_MENTION_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). You were mentioned in a comment and your attention is needed.
184
+
185
+ {{context.rudderWorkspace.orgResourcesPrompt}}
186
+
187
+ ## Context
188
+
189
+ **Issue:** {{issue.title}}
190
+ **ID:** {{issue.id}}
191
+
192
+ **Issue Description:**
193
+ {{issue.description}}
194
+
195
+ **Comment:**
196
+ {{comment.body}}
197
+
198
+ Please review the comment above and respond or take action as appropriate.`;
199
+ export const ISSUE_COMMENTED_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). There is a new comment on an issue you own.
200
+
201
+ {{context.rudderWorkspace.orgResourcesPrompt}}
202
+
203
+ ## Context
204
+
205
+ **Issue:** {{issue.title}}
206
+ **ID:** {{issue.id}}
207
+ **Status:** {{issue.status}}
208
+
209
+ **Issue Description:**
210
+ {{issue.description}}
211
+
212
+ **Latest Comment:**
213
+ {{comment.body}}
214
+
215
+ Review the new comment and continue the issue from the current state. Respond or take action as needed.`;
216
+ export const ISSUE_RECOVERY_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). This is a recovery run, not a fresh task.
217
+
218
+ {{context.rudderWorkspace.orgResourcesPrompt}}
219
+
220
+ ## Recovery Context
221
+
222
+ **Original Run ID:** {{context.recovery.originalRunId}}
223
+ **Failure Kind:** {{context.recovery.failureKind}}
224
+ **Failure Summary:** {{context.recovery.failureSummary}}
225
+ **Recovery Trigger:** {{context.recovery.recoveryTrigger}}
226
+ **Recovery Mode:** {{context.recovery.recoveryMode}}
227
+
228
+ ## Current Issue Context
229
+
230
+ **Issue:** {{issue.title}}
231
+ **ID:** {{issue.id}}
232
+ **Status:** {{issue.status}}
233
+ **Priority:** {{issue.priority}}
234
+
235
+ **Description:**
236
+ {{issue.description}}
237
+
238
+ Before doing anything else, inspect what the previous run already completed and any side effects it may have caused. Continue the remaining work from the current state. Avoid blindly re-running the whole task.`;
239
+ export const RECOVERY_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). This is a recovery run, not a fresh task.
240
+
241
+ {{context.rudderWorkspace.orgResourcesPrompt}}
242
+
243
+ ## Recovery Context
244
+
245
+ **Original Run ID:** {{context.recovery.originalRunId}}
246
+ **Failure Kind:** {{context.recovery.failureKind}}
247
+ **Failure Summary:** {{context.recovery.failureSummary}}
248
+ **Recovery Trigger:** {{context.recovery.recoveryTrigger}}
249
+ **Recovery Mode:** {{context.recovery.recoveryMode}}
250
+
251
+ Before doing anything else, inspect what the previous run already completed and any side effects it may have caused. Continue the remaining work from the current state. Avoid blindly re-running the whole task.`;
252
+ export const ISSUE_PASSIVE_FOLLOWUP_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). This is a passive issue follow-up, not a fresh assignment and not a failure recovery.
253
+
254
+ {{context.rudderWorkspace.orgResourcesPrompt}}
255
+
256
+ ## Why You Were Woken
257
+
258
+ The previous run ended without sufficient issue close-out.
259
+
260
+ **Origin Run ID:** {{context.passiveFollowup.originRunId}}
261
+ **Previous Run ID:** {{context.passiveFollowup.previousRunId}}
262
+ **Attempt:** {{context.passiveFollowup.attempt}} / {{context.passiveFollowup.maxAttempts}}
263
+ **Reason:** {{context.passiveFollowup.reason}}
264
+
265
+ ## Current Issue Context
266
+
267
+ **Issue:** {{issue.title}}
268
+ **ID:** {{issue.id}}
269
+ **Status:** {{issue.status}}
270
+ **Priority:** {{issue.priority}}
271
+
272
+ **Description:**
273
+ {{issue.description}}
274
+
275
+ Before changing the issue, inspect the current issue state and any side effects from the previous run. Then do exactly one close-out action: add a progress comment, mark the issue done, block it with a reason, or hand it off explicitly with explanation.`;
276
+ /**
277
+ * Selects the base heartbeat prompt template used by runtimes before final prompt assembly.
278
+ *
279
+ * Prompt shape by wake trigger:
280
+ * - assignment:
281
+ * "You are agent ... You have been assigned ..."
282
+ * Includes issue title/id/status/priority/description so the agent can start immediately.
283
+ * - comment.mention:
284
+ * "You were mentioned in a comment ..."
285
+ * Includes issue summary plus mention comment body so the agent can respond without extra fetches.
286
+ * - issue_commented:
287
+ * "There is a new comment on an issue you own ..."
288
+ * Includes issue summary plus the newest comment body so the assignee can continue immediately.
289
+ * - recovery:
290
+ * "This is a recovery run, not a fresh task ..."
291
+ * Includes original run id, failure metadata, and a continue-preferred instruction to
292
+ * inspect prior progress/side effects before resuming.
293
+ * - passive issue follow-up:
294
+ * "This is a passive issue follow-up, not a fresh assignment ..."
295
+ * Includes close-out lineage and tells the agent to comment, finish, block, or hand off.
296
+ * - fallback:
297
+ * Generic "Continue your Rudder work."
298
+ *
299
+ * Concrete rendered example (comment mention):
300
+ * "You are agent agent-456 (Backend Worker). You were mentioned in a comment and your attention is needed.
301
+ * Issue: Stabilize queue worker
302
+ * Comment: @agent please check timeout handling in retry path."
303
+ *
304
+ * Reasoning:
305
+ * - Keep backward compatibility: custom configured templates always win.
306
+ * - Keep first-turn latency low: include the minimum task context directly in prompt text.
307
+ * - Keep behavior deterministic across runtimes: template selection is centralized here.
308
+ *
309
+ * See also:
310
+ * - doc/plans/2026-04-07-agent-prompt-context-injection.md
311
+ * - doc/DEVELOPING.md
312
+ */
313
+ export function selectPromptTemplate(configuredTemplate, context) {
314
+ // If user configured a custom template, use it
315
+ if (configuredTemplate?.trim()) {
316
+ return configuredTemplate;
317
+ }
318
+ // Select based on wake source/reason
319
+ const wakeSource = String(context.wakeSource ?? "");
320
+ const wakeReason = String(context.wakeReason ?? "");
321
+ const recovery = context.recovery;
322
+ const hasRecoveryContext = typeof recovery === "object" &&
323
+ recovery !== null &&
324
+ !Array.isArray(recovery) &&
325
+ typeof recovery.originalRunId === "string";
326
+ if (hasRecoveryContext || wakeReason === "process_lost_retry" || wakeReason === "retry_failed_run") {
327
+ return typeof context.issue === "object" && context.issue !== null && !Array.isArray(context.issue)
328
+ ? ISSUE_RECOVERY_PROMPT_TEMPLATE
329
+ : RECOVERY_PROMPT_TEMPLATE;
330
+ }
331
+ if (wakeReason === "issue_passive_followup") {
332
+ return ISSUE_PASSIVE_FOLLOWUP_PROMPT_TEMPLATE;
333
+ }
334
+ if (wakeSource === "assignment" || wakeReason === "issue_assigned") {
335
+ return ISSUE_ASSIGN_PROMPT_TEMPLATE;
336
+ }
337
+ if (wakeSource === "comment.mention" || wakeReason === "issue_comment_mentioned") {
338
+ return COMMENT_MENTION_PROMPT_TEMPLATE;
339
+ }
340
+ if (wakeReason === "issue_commented") {
341
+ return ISSUE_COMMENTED_PROMPT_TEMPLATE;
342
+ }
343
+ return DEFAULT_AGENT_PROMPT_TEMPLATE;
344
+ }
345
+ export function joinPromptSections(sections, separator = "\n\n") {
346
+ return sections
347
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
348
+ .filter(Boolean)
349
+ .join(separator);
350
+ }
351
+ export function redactEnvForLogs(env) {
352
+ const redacted = {};
353
+ for (const [key, value] of Object.entries(env)) {
354
+ redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
355
+ }
356
+ return redacted;
357
+ }
358
+ export function buildRudderEnv(agent) {
359
+ const resolveHostForUrl = (rawHost) => {
360
+ const host = rawHost.trim();
361
+ if (!host || host === "0.0.0.0" || host === "::")
362
+ return "localhost";
363
+ if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]"))
364
+ return `[${host}]`;
365
+ return host;
366
+ };
367
+ const vars = {
368
+ RUDDER_AGENT_ID: agent.id,
369
+ RUDDER_ORG_ID: agent.orgId,
370
+ };
371
+ const runtimeHost = resolveHostForUrl(process.env.RUDDER_LISTEN_HOST ?? process.env.HOST ?? "localhost");
372
+ const runtimePort = process.env.RUDDER_LISTEN_PORT ?? process.env.PORT ?? "3100";
373
+ const apiUrl = process.env.RUDDER_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
374
+ vars.RUDDER_API_URL = apiUrl;
375
+ return vars;
376
+ }
377
+ export function defaultPathForPlatform() {
378
+ if (process.platform === "win32") {
379
+ return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
380
+ }
381
+ return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
382
+ }
383
+ function windowsPathExts(env) {
384
+ return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
385
+ }
386
+ async function pathExists(candidate) {
387
+ try {
388
+ await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
389
+ return true;
390
+ }
391
+ catch {
392
+ return false;
393
+ }
394
+ }
395
+ async function resolveCommandPath(command, cwd, env) {
396
+ const hasPathSeparator = command.includes("/") || command.includes("\\");
397
+ if (hasPathSeparator) {
398
+ const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
399
+ return (await pathExists(absolute)) ? absolute : null;
400
+ }
401
+ const pathValue = env.PATH ?? env.Path ?? "";
402
+ const delimiter = process.platform === "win32" ? ";" : ":";
403
+ const dirs = pathValue.split(delimiter).filter(Boolean);
404
+ const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
405
+ const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
406
+ for (const dir of dirs) {
407
+ const candidates = process.platform === "win32"
408
+ ? hasExtension
409
+ ? [path.join(dir, command)]
410
+ : exts.map((ext) => path.join(dir, `${command}${ext}`))
411
+ : [path.join(dir, command)];
412
+ for (const candidate of candidates) {
413
+ if (await pathExists(candidate))
414
+ return candidate;
415
+ }
416
+ }
417
+ return null;
418
+ }
419
+ function quoteForCmd(arg) {
420
+ if (!arg.length)
421
+ return '""';
422
+ const escaped = arg.replace(/"/g, '""');
423
+ return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
424
+ }
425
+ async function resolveSpawnTarget(command, args, cwd, env) {
426
+ const resolved = await resolveCommandPath(command, cwd, env);
427
+ const executable = resolved ?? command;
428
+ if (process.platform !== "win32") {
429
+ return { command: executable, args };
430
+ }
431
+ if (/\.(cmd|bat)$/i.test(executable)) {
432
+ const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
433
+ const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
434
+ return {
435
+ command: shell,
436
+ args: ["/d", "/s", "/c", commandLine],
437
+ };
438
+ }
439
+ return { command: executable, args };
440
+ }
441
+ export function ensurePathInEnv(env) {
442
+ if (typeof env.PATH === "string" && env.PATH.length > 0)
443
+ return env;
444
+ if (typeof env.Path === "string" && env.Path.length > 0)
445
+ return env;
446
+ return { ...env, PATH: defaultPathForPlatform() };
447
+ }
448
+ function prependPathEntry(env, entry) {
449
+ const normalized = ensurePathInEnv(env);
450
+ const pathKey = typeof normalized.PATH === "string" ? "PATH" : "Path";
451
+ const current = normalized[pathKey] ?? "";
452
+ const delimiter = process.platform === "win32" ? ";" : ":";
453
+ const segments = current.split(delimiter).filter(Boolean);
454
+ if (segments.includes(entry))
455
+ return normalized;
456
+ return {
457
+ ...normalized,
458
+ [pathKey]: current.length > 0 ? `${entry}${delimiter}${current}` : entry,
459
+ };
460
+ }
461
+ async function findAncestorWithFile(startDir, relativePath, maxDepth = 12) {
462
+ let current = path.resolve(startDir);
463
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
464
+ const candidate = path.join(current, relativePath);
465
+ if (await pathExists(candidate))
466
+ return candidate;
467
+ const parent = path.dirname(current);
468
+ if (parent === current)
469
+ break;
470
+ current = parent;
471
+ }
472
+ return null;
473
+ }
474
+ function shellQuote(arg) {
475
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
476
+ }
477
+ async function resolveRudderCliShimTarget(moduleDir) {
478
+ const packagedCli = await findAncestorWithFile(moduleDir, "desktop-cli.js");
479
+ if (packagedCli) {
480
+ return {
481
+ command: process.execPath,
482
+ args: [packagedCli],
483
+ };
484
+ }
485
+ const repoRoot = await findAncestorWithFile(moduleDir, path.join("cli", "src", "index.ts"));
486
+ if (!repoRoot)
487
+ return null;
488
+ const rootDir = path.dirname(path.dirname(path.dirname(repoRoot)));
489
+ const tsxEntry = path.join(rootDir, "cli", "node_modules", "tsx", "dist", "cli.mjs");
490
+ const cliSource = path.join(rootDir, "cli", "src", "index.ts");
491
+ if (await pathExists(tsxEntry)) {
492
+ return {
493
+ command: process.execPath,
494
+ args: [tsxEntry, cliSource],
495
+ };
496
+ }
497
+ const builtCliEntry = path.join(rootDir, "cli", "dist", "index.js");
498
+ if (await pathExists(builtCliEntry)) {
499
+ return {
500
+ command: process.execPath,
501
+ args: [builtCliEntry],
502
+ };
503
+ }
504
+ return null;
505
+ }
506
+ async function materializeRudderCliShim(target) {
507
+ const hash = createHash("sha1")
508
+ .update(JSON.stringify({ command: target.command, args: target.args, platform: process.platform }))
509
+ .digest("hex")
510
+ .slice(0, 12);
511
+ const shimDir = path.join(os.tmpdir(), "rudder-cli-shims", hash);
512
+ await fs.mkdir(shimDir, { recursive: true });
513
+ if (process.platform === "win32") {
514
+ const shimPath = path.join(shimDir, "rudder.cmd");
515
+ const commandLine = [quoteForCmd(target.command), ...target.args.map(quoteForCmd), "%*"].join(" ");
516
+ await fs.writeFile(shimPath, `@echo off\r\n${commandLine}\r\n`, "utf8");
517
+ return shimPath;
518
+ }
519
+ const shimPath = path.join(shimDir, "rudder");
520
+ const commandLine = [target.command, ...target.args].map(shellQuote).join(" ");
521
+ await fs.writeFile(shimPath, `#!/bin/sh\nexec ${commandLine} "$@"\n`, "utf8");
522
+ await fs.chmod(shimPath, 0o755);
523
+ return shimPath;
524
+ }
525
+ export async function ensureRudderCliInPath(moduleDir, env) {
526
+ const normalized = ensurePathInEnv(env);
527
+ const cwd = process.cwd();
528
+ if (await resolveCommandPath("rudder", cwd, normalized)) {
529
+ return normalized;
530
+ }
531
+ const target = await resolveRudderCliShimTarget(moduleDir);
532
+ if (!target) {
533
+ return normalized;
534
+ }
535
+ const shimPath = await materializeRudderCliShim(target);
536
+ return prependPathEntry(normalized, path.dirname(shimPath));
537
+ }
538
+ export async function ensureAbsoluteDirectory(cwd, opts = {}) {
539
+ if (!path.isAbsolute(cwd)) {
540
+ throw new Error(`Working directory must be an absolute path: "${cwd}"`);
541
+ }
542
+ const assertDirectory = async () => {
543
+ const stats = await fs.stat(cwd);
544
+ if (!stats.isDirectory()) {
545
+ throw new Error(`Working directory is not a directory: "${cwd}"`);
546
+ }
547
+ };
548
+ try {
549
+ await assertDirectory();
550
+ return;
551
+ }
552
+ catch (err) {
553
+ const code = err.code;
554
+ if (!opts.createIfMissing || code !== "ENOENT") {
555
+ if (code === "ENOENT") {
556
+ throw new Error(`Working directory does not exist: "${cwd}"`);
557
+ }
558
+ throw err instanceof Error ? err : new Error(String(err));
559
+ }
560
+ }
561
+ try {
562
+ await fs.mkdir(cwd, { recursive: true });
563
+ await assertDirectory();
564
+ }
565
+ catch (err) {
566
+ const reason = err instanceof Error ? err.message : String(err);
567
+ throw new Error(`Could not create working directory "${cwd}": ${reason}`);
568
+ }
569
+ }
570
+ export async function resolveRudderSkillsDir(moduleDir, additionalCandidates = []) {
571
+ const candidates = [
572
+ ...RUDDER_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
573
+ ...additionalCandidates.map((candidate) => path.resolve(candidate)),
574
+ ];
575
+ const seenRoots = new Set();
576
+ for (const root of candidates) {
577
+ if (seenRoots.has(root))
578
+ continue;
579
+ seenRoots.add(root);
580
+ const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
581
+ if (isDirectory)
582
+ return root;
583
+ }
584
+ return null;
585
+ }
586
+ export async function listRudderSkillEntries(moduleDir, additionalCandidates = []) {
587
+ const root = await resolveRudderSkillsDir(moduleDir, additionalCandidates);
588
+ if (!root)
589
+ return [];
590
+ try {
591
+ const entries = await fs.readdir(root, { withFileTypes: true });
592
+ const skillDirectories = entries
593
+ .filter((entry) => entry.isDirectory())
594
+ .sort((left, right) => left.name.localeCompare(right.name));
595
+ const skillEntries = await Promise.all(skillDirectories.map(async (entry) => {
596
+ const source = path.join(root, entry.name);
597
+ const metadata = await readSkillMetadataFromDirectory(source);
598
+ return {
599
+ key: `rudder/${entry.name}`,
600
+ runtimeName: entry.name,
601
+ source,
602
+ name: metadata.name ?? entry.name,
603
+ description: metadata.description,
604
+ };
605
+ }));
606
+ return skillEntries;
607
+ }
608
+ catch {
609
+ return [];
610
+ }
611
+ }
612
+ export async function readInstalledSkillTargets(skillsHome) {
613
+ const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
614
+ const out = new Map();
615
+ for (const entry of entries) {
616
+ const fullPath = path.join(skillsHome, entry.name);
617
+ const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null;
618
+ out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath));
619
+ }
620
+ return out;
621
+ }
622
+ export function buildPersistentSkillSnapshot(options) {
623
+ const { agentRuntimeType, availableEntries, desiredSkills, installed, skillsHome, locationLabel, installedDetail, missingDetail, externalConflictDetail, externalDetail, } = options;
624
+ const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
625
+ const desiredSet = new Set(desiredSkills);
626
+ const entries = [];
627
+ const warnings = [...(options.warnings ?? [])];
628
+ for (const available of availableEntries) {
629
+ const installedEntry = installed.get(available.runtimeName) ?? null;
630
+ const desired = desiredSet.has(available.key);
631
+ let state = "available";
632
+ let managed = false;
633
+ let detail = null;
634
+ if (installedEntry?.targetPath === available.source) {
635
+ managed = true;
636
+ state = desired ? "installed" : "stale";
637
+ detail = installedDetail ?? null;
638
+ }
639
+ else if (installedEntry) {
640
+ state = "external";
641
+ detail = desired ? externalConflictDetail : externalDetail;
642
+ }
643
+ else if (desired) {
644
+ state = "missing";
645
+ detail = missingDetail;
646
+ }
647
+ entries.push({
648
+ key: available.key,
649
+ runtimeName: available.runtimeName,
650
+ description: available.description ?? null,
651
+ desired,
652
+ managed,
653
+ state,
654
+ sourcePath: available.source,
655
+ targetPath: path.join(skillsHome, available.runtimeName),
656
+ detail,
657
+ ...buildManagedSkillOrigin(),
658
+ });
659
+ }
660
+ for (const desiredSkill of desiredSkills) {
661
+ if (availableByKey.has(desiredSkill))
662
+ continue;
663
+ warnings.push(`Desired skill "${desiredSkill}" is not available from the Rudder skills directory.`);
664
+ entries.push({
665
+ key: desiredSkill,
666
+ runtimeName: null,
667
+ desired: true,
668
+ managed: true,
669
+ state: "missing",
670
+ sourcePath: null,
671
+ targetPath: null,
672
+ detail: "Rudder cannot find this skill in the local runtime skills directory.",
673
+ origin: "external_unknown",
674
+ originLabel: "External or unavailable",
675
+ readOnly: false,
676
+ });
677
+ }
678
+ for (const [name, installedEntry] of installed.entries()) {
679
+ if (availableEntries.some((entry) => entry.runtimeName === name))
680
+ continue;
681
+ entries.push({
682
+ key: name,
683
+ runtimeName: name,
684
+ description: null,
685
+ desired: false,
686
+ managed: false,
687
+ state: "external",
688
+ origin: "user_installed",
689
+ originLabel: "User-installed",
690
+ locationLabel: skillLocationLabel(locationLabel),
691
+ readOnly: true,
692
+ sourcePath: null,
693
+ targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
694
+ detail: externalDetail,
695
+ });
696
+ }
697
+ entries.sort((left, right) => left.key.localeCompare(right.key));
698
+ return {
699
+ agentRuntimeType,
700
+ supported: true,
701
+ mode: "persistent",
702
+ desiredSkills,
703
+ entries,
704
+ warnings,
705
+ };
706
+ }
707
+ function normalizeConfiguredPaperclipRuntimeSkills(value) {
708
+ if (!Array.isArray(value))
709
+ return [];
710
+ const out = [];
711
+ for (const rawEntry of value) {
712
+ const entry = parseObject(rawEntry);
713
+ const key = asString(entry.key, asString(entry.name, "")).trim();
714
+ const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim();
715
+ const source = asString(entry.source, "").trim();
716
+ if (!key || !runtimeName || !source)
717
+ continue;
718
+ out.push({
719
+ key,
720
+ runtimeName,
721
+ source,
722
+ name: compactSkillText(asString(entry.displayName, asString(entry.name, ""))) ?? runtimeName,
723
+ description: compactSkillText(typeof entry.description === "string"
724
+ ? entry.description
725
+ : typeof entry.summary === "string"
726
+ ? entry.summary
727
+ : null),
728
+ });
729
+ }
730
+ return out;
731
+ }
732
+ export async function readRudderRuntimeSkillEntries(config, moduleDir, additionalCandidates = []) {
733
+ const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.rudderRuntimeSkills ?? config.paperclipRuntimeSkills);
734
+ if (configuredEntries.length > 0)
735
+ return configuredEntries;
736
+ return listRudderSkillEntries(moduleDir, additionalCandidates);
737
+ }
738
+ export async function readRudderSkillMarkdown(moduleDir, skillKey) {
739
+ const normalized = skillKey.trim().toLowerCase().replace(/^rudder\/rudder\//, "rudder/");
740
+ if (!normalized)
741
+ return null;
742
+ const entries = await listRudderSkillEntries(moduleDir);
743
+ const match = entries.find((entry) => entry.key === normalized);
744
+ if (!match)
745
+ return null;
746
+ try {
747
+ return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8");
748
+ }
749
+ catch {
750
+ return null;
751
+ }
752
+ }
753
+ export function readRudderSkillSyncPreference(config) {
754
+ const raw = config.rudderSkillSync ?? config.paperclipSkillSync;
755
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
756
+ return { explicit: false, desiredSkills: [] };
757
+ }
758
+ const syncConfig = raw;
759
+ const desiredValues = syncConfig.desiredSkills;
760
+ const desired = Array.isArray(desiredValues)
761
+ ? desiredValues
762
+ .filter((value) => typeof value === "string")
763
+ .map((value) => value.trim())
764
+ .filter(Boolean)
765
+ : [];
766
+ return {
767
+ explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"),
768
+ desiredSkills: Array.from(new Set(desired)),
769
+ };
770
+ }
771
+ function canonicalizeDesiredRudderSkillReference(reference, availableEntries) {
772
+ const normalizedReference = reference.trim().toLowerCase().replace(/^rudder\/rudder\//, "rudder/");
773
+ if (!normalizedReference)
774
+ return "";
775
+ const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference);
776
+ if (exactKey)
777
+ return exactKey.key;
778
+ const byRuntimeName = availableEntries.filter((entry) => typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference);
779
+ if (byRuntimeName.length === 1)
780
+ return byRuntimeName[0].key;
781
+ const slugMatches = availableEntries.filter((entry) => entry.key.trim().toLowerCase().split("/").pop() === normalizedReference);
782
+ if (slugMatches.length === 1)
783
+ return slugMatches[0].key;
784
+ return normalizedReference;
785
+ }
786
+ export function resolveRudderDesiredSkillNames(config, availableEntries) {
787
+ const preference = readRudderSkillSyncPreference(config);
788
+ const desiredSkills = preference.desiredSkills
789
+ .map((reference) => canonicalizeDesiredRudderSkillReference(reference, availableEntries))
790
+ .filter(Boolean);
791
+ return Array.from(new Set(desiredSkills));
792
+ }
793
+ export function writeRudderSkillSyncPreference(config, desiredSkills) {
794
+ const next = { ...config };
795
+ const raw = next.rudderSkillSync;
796
+ const current = typeof raw === "object" && raw !== null && !Array.isArray(raw)
797
+ ? { ...raw }
798
+ : {};
799
+ current.desiredSkills = Array.from(new Set(desiredSkills
800
+ .map((value) => value.trim())
801
+ .filter(Boolean)));
802
+ next.rudderSkillSync = current;
803
+ return next;
804
+ }
805
+ export async function ensureRudderSkillSymlink(source, target, linkSkill = (linkSource, linkTarget) => fs.symlink(linkSource, linkTarget)) {
806
+ const existing = await fs.lstat(target).catch(() => null);
807
+ if (!existing) {
808
+ await linkSkill(source, target);
809
+ return "created";
810
+ }
811
+ if (!existing.isSymbolicLink()) {
812
+ return "skipped";
813
+ }
814
+ const linkedPath = await fs.readlink(target).catch(() => null);
815
+ if (!linkedPath)
816
+ return "skipped";
817
+ const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
818
+ if (resolvedLinkedPath === source) {
819
+ return "skipped";
820
+ }
821
+ const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false);
822
+ if (linkedPathExists) {
823
+ return "skipped";
824
+ }
825
+ await fs.unlink(target);
826
+ await linkSkill(source, target);
827
+ return "repaired";
828
+ }
829
+ export async function removeMaintainerOnlySkillSymlinks(skillsHome, allowedSkillNames) {
830
+ const allowed = new Set(Array.from(allowedSkillNames));
831
+ try {
832
+ const entries = await fs.readdir(skillsHome, { withFileTypes: true });
833
+ const removed = [];
834
+ for (const entry of entries) {
835
+ if (allowed.has(entry.name))
836
+ continue;
837
+ const target = path.join(skillsHome, entry.name);
838
+ const existing = await fs.lstat(target).catch(() => null);
839
+ if (!existing?.isSymbolicLink())
840
+ continue;
841
+ const linkedPath = await fs.readlink(target).catch(() => null);
842
+ if (!linkedPath)
843
+ continue;
844
+ const resolvedLinkedPath = path.isAbsolute(linkedPath)
845
+ ? linkedPath
846
+ : path.resolve(path.dirname(target), linkedPath);
847
+ if (!isMaintainerOnlySkillTarget(linkedPath) &&
848
+ !isMaintainerOnlySkillTarget(resolvedLinkedPath)) {
849
+ continue;
850
+ }
851
+ await fs.unlink(target);
852
+ removed.push(entry.name);
853
+ }
854
+ return removed;
855
+ }
856
+ catch {
857
+ return [];
858
+ }
859
+ }
860
+ export async function ensureCommandResolvable(command, cwd, env) {
861
+ const resolved = await resolveCommandPath(command, cwd, env);
862
+ if (resolved)
863
+ return;
864
+ if (command.includes("/") || command.includes("\\")) {
865
+ const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
866
+ throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
867
+ }
868
+ throw new Error(`Command not found in PATH: "${command}"`);
869
+ }
870
+ export async function runChildProcess(runId, command, args, opts) {
871
+ const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
872
+ return new Promise((resolve, reject) => {
873
+ const rawMerged = { ...process.env, ...opts.env };
874
+ const requestedHome = typeof opts.env.HOME === "string" && opts.env.HOME.trim().length > 0
875
+ ? path.resolve(opts.env.HOME)
876
+ : null;
877
+ const inheritedHome = typeof process.env.HOME === "string" && process.env.HOME.trim().length > 0
878
+ ? path.resolve(process.env.HOME)
879
+ : null;
880
+ const hasExplicitZdotdir = typeof opts.env.ZDOTDIR === "string" && opts.env.ZDOTDIR.trim().length > 0;
881
+ // Strip Claude Code nesting-guard env vars so spawned `claude` processes
882
+ // don't refuse to start with "cannot be launched inside another session".
883
+ // These vars leak in when the Rudder server itself is started from
884
+ // within a Claude Code session (e.g. `npx rudder run` in a terminal
885
+ // owned by Claude Code) or when cron inherits a contaminated shell env.
886
+ const CLAUDE_CODE_NESTING_VARS = [
887
+ "CLAUDECODE",
888
+ "CLAUDE_CODE_ENTRYPOINT",
889
+ "CLAUDE_CODE_SESSION",
890
+ "CLAUDE_CODE_PARENT_SESSION",
891
+ ];
892
+ for (const key of CLAUDE_CODE_NESTING_VARS) {
893
+ delete rawMerged[key];
894
+ }
895
+ // When Rudder isolates HOME for child agents, don't let zsh keep using the
896
+ // host user's startup dir via an inherited ZDOTDIR. That mismatch makes
897
+ // child `zsh -lc` invocations source the host `.zshenv` with the agent HOME.
898
+ if (requestedHome && requestedHome !== inheritedHome && !hasExplicitZdotdir) {
899
+ delete rawMerged.ZDOTDIR;
900
+ }
901
+ const mergedEnv = ensurePathInEnv(rawMerged);
902
+ void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
903
+ .then((target) => {
904
+ if (opts.abortSignal?.aborted) {
905
+ resolve({
906
+ exitCode: null,
907
+ signal: "SIGTERM",
908
+ timedOut: false,
909
+ stdout: "",
910
+ stderr: "",
911
+ pid: null,
912
+ startedAt: null,
913
+ });
914
+ return;
915
+ }
916
+ const child = spawn(target.command, target.args, {
917
+ cwd: opts.cwd,
918
+ env: mergedEnv,
919
+ shell: false,
920
+ stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
921
+ });
922
+ const startedAt = new Date().toISOString();
923
+ if (opts.stdin != null && child.stdin) {
924
+ child.stdin.write(opts.stdin);
925
+ child.stdin.end();
926
+ }
927
+ if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
928
+ void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
929
+ onLogError(err, runId, "failed to record child process metadata");
930
+ });
931
+ }
932
+ runningProcesses.set(runId, { child, graceSec: opts.graceSec });
933
+ let timedOut = false;
934
+ let aborted = false;
935
+ let stdout = "";
936
+ let stderr = "";
937
+ let logChain = Promise.resolve();
938
+ const timeout = opts.timeoutSec > 0
939
+ ? setTimeout(() => {
940
+ timedOut = true;
941
+ child.kill("SIGTERM");
942
+ setTimeout(() => {
943
+ if (isChildProcessAlive(child)) {
944
+ child.kill("SIGKILL");
945
+ }
946
+ }, Math.max(1, opts.graceSec) * 1000);
947
+ }, opts.timeoutSec * 1000)
948
+ : null;
949
+ let abortCleanup = null;
950
+ if (opts.abortSignal) {
951
+ const onAbort = () => {
952
+ aborted = true;
953
+ child.kill("SIGTERM");
954
+ setTimeout(() => {
955
+ if (isChildProcessAlive(child)) {
956
+ child.kill("SIGKILL");
957
+ }
958
+ }, Math.max(1, opts.graceSec) * 1000);
959
+ };
960
+ opts.abortSignal.addEventListener("abort", onAbort, { once: true });
961
+ abortCleanup = () => opts.abortSignal?.removeEventListener("abort", onAbort);
962
+ }
963
+ child.stdout?.on("data", (chunk) => {
964
+ const text = String(chunk);
965
+ stdout = appendWithCap(stdout, text);
966
+ logChain = logChain
967
+ .then(() => opts.onLog("stdout", text))
968
+ .catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
969
+ });
970
+ child.stderr?.on("data", (chunk) => {
971
+ const text = String(chunk);
972
+ stderr = appendWithCap(stderr, text);
973
+ logChain = logChain
974
+ .then(() => opts.onLog("stderr", text))
975
+ .catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
976
+ });
977
+ child.on("error", (err) => {
978
+ if (timeout)
979
+ clearTimeout(timeout);
980
+ if (abortCleanup)
981
+ abortCleanup();
982
+ runningProcesses.delete(runId);
983
+ const errno = err.code;
984
+ const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
985
+ const msg = errno === "ENOENT"
986
+ ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
987
+ : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
988
+ reject(new Error(msg));
989
+ });
990
+ child.on("close", (code, signal) => {
991
+ if (timeout)
992
+ clearTimeout(timeout);
993
+ if (abortCleanup)
994
+ abortCleanup();
995
+ runningProcesses.delete(runId);
996
+ void logChain.finally(() => {
997
+ resolve({
998
+ exitCode: code,
999
+ signal: aborted ? "SIGTERM" : signal,
1000
+ timedOut,
1001
+ stdout,
1002
+ stderr,
1003
+ pid: child.pid ?? null,
1004
+ startedAt,
1005
+ });
1006
+ });
1007
+ });
1008
+ })
1009
+ .catch(reject);
1010
+ });
1011
+ }
1012
+ //# sourceMappingURL=server-utils.js.map