runcastle 1.0.8 → 1.0.10

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/bin/runcastle.js CHANGED
@@ -27,7 +27,8 @@ __export(exports_paths, {
27
27
  envPath: () => envPath,
28
28
  dbPath: () => dbPath,
29
29
  dataDir: () => dataDir,
30
- configPath: () => configPath
30
+ configPath: () => configPath,
31
+ burnCacheDir: () => burnCacheDir
31
32
  });
32
33
  import { homedir } from "os";
33
34
  import { join } from "path";
@@ -52,6 +53,9 @@ function sessionDir(id) {
52
53
  function worktreeDir(projectId, slug) {
53
54
  return join(dataDir(), "worktrees", projectId, slug);
54
55
  }
56
+ function burnCacheDir(pm) {
57
+ return join(dataDir(), "cache", pm);
58
+ }
55
59
  function sandboxBuildDir() {
56
60
  return join(dataDir(), "sandbox-build");
57
61
  }
@@ -100,7 +104,10 @@ var init_config = __esm(() => {
100
104
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
101
105
  mainBranch: z.string().default("main"),
102
106
  sandboxImage: z.string().optional(),
103
- burnConcurrency: z.number().int().min(1).max(8).default(3)
107
+ burnConcurrency: z.number().int().min(1).max(8).default(3),
108
+ burnMaxIterations: z.number().int().min(1).max(10).default(3),
109
+ setupCommand: z.string().optional(),
110
+ burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
104
111
  }));
105
112
  });
106
113
 
@@ -136,6 +143,13 @@ function loadConfig(env = process.env) {
136
143
  if (env.RUNCASTLE_BURN_CONCURRENCY) {
137
144
  overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
138
145
  }
146
+ if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
147
+ overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
148
+ }
149
+ if (env.RUNCASTLE_SETUP_COMMAND)
150
+ overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
151
+ if (env.RUNCASTLE_BURN_WORKSPACE)
152
+ overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
139
153
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
140
154
  return RuncastleConfig.parse({ ...base, ...overrides });
141
155
  }
@@ -2492,6 +2506,46 @@ var init_skills_root = __esm(() => {
2492
2506
  SKILLS_MARKER = join7("packs", "runcastle");
2493
2507
  });
2494
2508
 
2509
+ // src/services/agent-stream.ts
2510
+ function beginTranscript(ticketId) {
2511
+ transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2512
+ }
2513
+ function appendTranscript(ticketId, chunk) {
2514
+ const t = transcripts.get(ticketId);
2515
+ if (!t)
2516
+ return;
2517
+ const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
2518
+ t.nextIndex += 1;
2519
+ t.chunks.push(full);
2520
+ t.bytes += full.text.length;
2521
+ while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
2522
+ const dropped = t.chunks.shift();
2523
+ if (!dropped)
2524
+ break;
2525
+ t.bytes -= dropped.text.length;
2526
+ }
2527
+ }
2528
+ function endTranscript(ticketId) {
2529
+ const t = transcripts.get(ticketId);
2530
+ if (t)
2531
+ t.live = false;
2532
+ }
2533
+ function readTranscript(ticketId, after = -1) {
2534
+ const t = transcripts.get(ticketId);
2535
+ if (!t)
2536
+ return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
2537
+ return {
2538
+ live: t.live,
2539
+ chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
2540
+ firstIndex: t.chunks[0]?.i ?? t.nextIndex,
2541
+ nextIndex: t.nextIndex
2542
+ };
2543
+ }
2544
+ var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2545
+ var init_agent_stream = __esm(() => {
2546
+ transcripts = new Map;
2547
+ });
2548
+
2495
2549
  // src/workflows/ticket-burner.ts
2496
2550
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2497
2551
  import { dirname as dirname3, join as join8 } from "path";
@@ -2604,6 +2658,97 @@ function parseEnvFile(content) {
2604
2658
  }
2605
2659
  return out;
2606
2660
  }
2661
+ function detectPackageManager(tc) {
2662
+ const pinned = tc.packageManagerField?.split("@")[0];
2663
+ if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
2664
+ return pinned;
2665
+ }
2666
+ if (tc.lockfiles.bun)
2667
+ return "bun";
2668
+ if (tc.lockfiles.pnpm)
2669
+ return "pnpm";
2670
+ if (tc.lockfiles.yarn)
2671
+ return "yarn";
2672
+ if (tc.lockfiles.npm)
2673
+ return "npm";
2674
+ return tc.hasPackageJson ? "npm" : undefined;
2675
+ }
2676
+ function resolveSetupCommand(tc, override) {
2677
+ const trimmed = override?.trim();
2678
+ if (trimmed)
2679
+ return trimmed;
2680
+ const pm = detectPackageManager(tc);
2681
+ if (!pm)
2682
+ return;
2683
+ switch (pm) {
2684
+ case "bun":
2685
+ return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
2686
+ case "pnpm":
2687
+ return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
2688
+ case "yarn":
2689
+ return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
2690
+ case "npm":
2691
+ return tc.lockfiles.npm ? "npm ci" : "npm install";
2692
+ }
2693
+ }
2694
+ function cacheMountFor(pm, hostPath) {
2695
+ const sandboxPath = PM_CACHE_SANDBOX_PATHS[pm];
2696
+ return sandboxPath ? { hostPath, sandboxPath } : undefined;
2697
+ }
2698
+ function resolveBurnWorkspaceMode(config2, platform = process.platform) {
2699
+ if (config2.sandbox === "noSandbox")
2700
+ return "mounted";
2701
+ if (config2.burnWorkspace === "auto")
2702
+ return platform === "linux" ? "mounted" : "isolated";
2703
+ return config2.burnWorkspace;
2704
+ }
2705
+ function buildIsolatedSetupCommand(tempBranch, setupCommand) {
2706
+ const hookFile = `${ISOLATED_REPO_PATH}/.git/hooks/post-commit`;
2707
+ const parts = [
2708
+ `git -C ${SANDBOX_WORKSPACE_PATH} config receive.denyCurrentBranch updateInstead`,
2709
+ `git clone ${SANDBOX_WORKSPACE_PATH} ${ISOLATED_REPO_PATH}`,
2710
+ `printf '#!/bin/sh\\nexec git push --quiet origin HEAD:%s\\n' '${tempBranch}' > ${hookFile}`,
2711
+ `chmod +x ${hookFile}`
2712
+ ];
2713
+ if (setupCommand)
2714
+ parts.push(`cd ${ISOLATED_REPO_PATH} && ${setupCommand}`);
2715
+ return parts.join(" && ");
2716
+ }
2717
+ function buildWorkspaceNotes(mode) {
2718
+ if (mode === "mounted") {
2719
+ return "Work in the current directory \u2014 it is the repo checkout on your branch.";
2720
+ }
2721
+ return [
2722
+ `Your working repository is \`${ISOLATED_REPO_PATH}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${ISOLATED_REPO_PATH}\` first; every file you read, edit, test, and commit lives under it.`,
2723
+ "",
2724
+ `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal.`,
2725
+ "",
2726
+ `If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`
2727
+ ].join(`
2728
+ `);
2729
+ }
2730
+ function readRepoToolchain(repoPath) {
2731
+ const pkgPath = join8(repoPath, "package.json");
2732
+ const hasPackageJson = existsSync10(pkgPath);
2733
+ let packageManagerField;
2734
+ if (hasPackageJson) {
2735
+ try {
2736
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2737
+ if (typeof pkg.packageManager === "string")
2738
+ packageManagerField = pkg.packageManager;
2739
+ } catch {}
2740
+ }
2741
+ return {
2742
+ hasPackageJson,
2743
+ ...packageManagerField !== undefined ? { packageManagerField } : {},
2744
+ lockfiles: {
2745
+ bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
2746
+ pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
2747
+ yarn: existsSync10(join8(repoPath, "yarn.lock")),
2748
+ npm: existsSync10(join8(repoPath, "package-lock.json"))
2749
+ }
2750
+ };
2751
+ }
2607
2752
  function createStreamThrottle(emit2, opts = {}) {
2608
2753
  const intervalMs = opts.intervalMs ?? 2000;
2609
2754
  const maxChars = opts.maxChars ?? 500;
@@ -2855,8 +3000,11 @@ function buildBurnAgent(config2, token, model) {
2855
3000
  }
2856
3001
  return agent;
2857
3002
  }
2858
- function selectSandbox(config2) {
2859
- const imageOpts = { imageName: resolveSandboxImage(config2) };
3003
+ function selectSandbox(config2, mounts = []) {
3004
+ const imageOpts = {
3005
+ imageName: resolveSandboxImage(config2),
3006
+ ...mounts.length > 0 ? { mounts } : {}
3007
+ };
2860
3008
  switch (config2.sandbox) {
2861
3009
  case "docker":
2862
3010
  return docker(imageOpts);
@@ -2869,31 +3017,74 @@ function selectSandbox(config2) {
2869
3017
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2870
3018
  const { project, feature } = ctx;
2871
3019
  const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3020
+ const workspaceMode = resolveBurnWorkspaceMode(config2);
2872
3021
  const template = readFileSync4(burnerTemplatePath(), "utf8");
2873
3022
  const prompt = renderTicketPrompt(template, {
2874
3023
  TICKET_JSON: buildTicketJson(ticket),
2875
3024
  FEATURE_BRIEF: buildFeatureBrief(feature),
2876
3025
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
2877
- COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
3026
+ COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3027
+ WORKSPACE_NOTES: buildWorkspaceNotes(workspaceMode)
2878
3028
  });
3029
+ const toolchain = readRepoToolchain(project.repoPath);
3030
+ const pm = detectPackageManager(toolchain);
3031
+ const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
3032
+ const mounts = [];
3033
+ if (config2.sandbox !== "noSandbox" && pm) {
3034
+ const mount = cacheMountFor(pm, burnCacheDir(pm));
3035
+ if (mount) {
3036
+ mkdirSync4(mount.hostPath, { recursive: true });
3037
+ mounts.push(mount);
3038
+ }
3039
+ }
3040
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand) : setupCommand;
3041
+ if (hookCommand) {
3042
+ ctx.emitEvent({
3043
+ type: "burn.setup",
3044
+ message: workspaceMode === "isolated" ? `preparing isolated workspace (native-FS clone)${setupCommand ? ` + deps install: ${setupCommand}` : ""}` : `installing deps before agent start: ${setupCommand}`,
3045
+ ticketId: ticket.id
3046
+ });
3047
+ }
2879
3048
  mkdirSync4(logsDir(), { recursive: true });
2880
3049
  const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
2881
3050
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3051
+ beginTranscript(ticket.id);
3052
+ const onStreamEvent = (event) => {
3053
+ throttle.onEvent(event);
3054
+ if (event.type === "text") {
3055
+ appendTranscript(ticket.id, { kind: "text", text: event.message });
3056
+ } else if (event.type === "toolCall") {
3057
+ appendTranscript(ticket.id, {
3058
+ kind: "tool",
3059
+ text: event.formattedArgs ?? "",
3060
+ name: event.name
3061
+ });
3062
+ }
3063
+ };
2882
3064
  const runOptions = {
2883
3065
  agent: buildBurnAgent(config2, token, model),
2884
- sandbox: selectSandbox(config2),
3066
+ sandbox: selectSandbox(config2, mounts),
2885
3067
  cwd: project.repoPath,
2886
3068
  prompt,
2887
3069
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2888
3070
  signal: ctx.signal,
2889
3071
  name: `ticket-${ticket.seq}`,
2890
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3072
+ maxIterations: config2.burnMaxIterations,
3073
+ ...hookCommand ? {
3074
+ hooks: {
3075
+ sandbox: {
3076
+ onSandboxReady: [{ command: hookCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3077
+ }
3078
+ }
3079
+ } : {},
3080
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
2891
3081
  };
2892
3082
  let result;
2893
3083
  try {
2894
3084
  result = await run(runOptions);
2895
3085
  } catch (err) {
2896
3086
  throttle.flush();
3087
+ endTranscript(ticket.id);
2897
3088
  if (ctx.signal.aborted)
2898
3089
  throw err;
2899
3090
  const msg = err instanceof Error ? err.message : String(err);
@@ -2910,6 +3101,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2910
3101
  return { status: "failed", error: msg };
2911
3102
  }
2912
3103
  throttle.flush();
3104
+ endTranscript(ticket.id);
2913
3105
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2914
3106
  const outcome = interpretRunResult(result, blocked);
2915
3107
  if (outcome.status !== "done")
@@ -2958,14 +3150,28 @@ function readTokenFromEnvFile(path) {
2958
3150
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2959
3151
  return token && token.length > 0 ? token : undefined;
2960
3152
  }
2961
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
3153
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", SETUP_HOOK_TIMEOUT_MS, ticketBurner;
2962
3154
  var init_ticket_burner = __esm(() => {
2963
3155
  init_src();
2964
3156
  init_config_load();
2965
3157
  init_paths();
2966
3158
  init_skills_root();
3159
+ init_agent_stream();
2967
3160
  init_git();
2968
- PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
3161
+ PLACEHOLDERS = [
3162
+ "TICKET_JSON",
3163
+ "FEATURE_BRIEF",
3164
+ "DOCS_DIGEST",
3165
+ "COMMIT_CONVENTION",
3166
+ "WORKSPACE_NOTES"
3167
+ ];
3168
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3169
+ PM_CACHE_SANDBOX_PATHS = {
3170
+ bun: "~/.bun/install/cache",
3171
+ yarn: "~/.cache/yarn",
3172
+ npm: "~/.npm"
3173
+ };
3174
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
2969
3175
  ticketBurner = {
2970
3176
  id: "ticket-burner",
2971
3177
  run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
@@ -4742,6 +4948,17 @@ import { eq as eq9, isNull } from "drizzle-orm";
4742
4948
  function listProjects(ctx) {
4743
4949
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
4744
4950
  }
4951
+ function isTranslatedWindowsMount(repoPath, platform = process.platform) {
4952
+ return platform === "linux" && /^\/mnt\/[a-z](\/|$)/i.test(repoPath);
4953
+ }
4954
+ function warnIfTranslatedMount(ctx, projectId, repoPath) {
4955
+ if (!isTranslatedWindowsMount(repoPath))
4956
+ return;
4957
+ emitProject(ctx, projectId, {
4958
+ type: "project.slow-path",
4959
+ message: `${repoPath} is a Windows drive mounted into Linux (/mnt) \u2014 git and burns pay a per-file translation tax here; clone the repo into the Linux filesystem (e.g. ~/projects) for native speed`
4960
+ });
4961
+ }
4745
4962
  async function openProject(ctx, repoPath) {
4746
4963
  await assertRepoTolerant(ctx, repoPath);
4747
4964
  const mainBranch = await detectMainBranchTolerant(ctx, repoPath);
@@ -4754,6 +4971,7 @@ async function openProject(ctx, repoPath) {
4754
4971
  type: "project.opened",
4755
4972
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
4756
4973
  });
4974
+ warnIfTranslatedMount(ctx, existing.id, repoPath);
4757
4975
  return reopened;
4758
4976
  }
4759
4977
  const row = {
@@ -4770,6 +4988,7 @@ async function openProject(ctx, repoPath) {
4770
4988
  type: "project.opened",
4771
4989
  message: `project ${name} at ${repoPath} (${mainBranch})`
4772
4990
  });
4991
+ warnIfTranslatedMount(ctx, project.id, repoPath);
4773
4992
  return project;
4774
4993
  }
4775
4994
  function closeProject(ctx, projectId) {
@@ -4844,11 +5063,13 @@ var init_project = __esm(() => {
4844
5063
  import * as z8 from "zod";
4845
5064
  var runRouter;
4846
5065
  var init_run = __esm(() => {
5066
+ init_agent_stream();
4847
5067
  init_repo();
4848
5068
  init_runner();
4849
5069
  init_context();
4850
5070
  runRouter = router({
4851
5071
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
5072
+ agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
4852
5073
  cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
4853
5074
  cancelRun(input.runId);
4854
5075
  return { ok: true };
@@ -5084,6 +5305,22 @@ var init_settings = __esm(() => {
5084
5305
  valueSchema: z9.number().int().min(1).max(8),
5085
5306
  parseEnv: (raw) => Number(raw)
5086
5307
  },
5308
+ {
5309
+ key: "burnMaxIterations",
5310
+ configKey: "burnMaxIterations",
5311
+ envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
5312
+ restartRequired: false,
5313
+ valueSchema: z9.number().int().min(1).max(10),
5314
+ parseEnv: (raw) => Number(raw)
5315
+ },
5316
+ {
5317
+ key: "setupCommand",
5318
+ configKey: "setupCommand",
5319
+ envVar: "RUNCASTLE_SETUP_COMMAND",
5320
+ restartRequired: false,
5321
+ valueSchema: z9.string().min(1),
5322
+ parseEnv: idEnv
5323
+ },
5087
5324
  {
5088
5325
  key: "mainBranch",
5089
5326
  configKey: "mainBranch",