runcastle 1.0.8 → 1.0.9

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,9 @@ 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()
104
110
  }));
105
111
  });
106
112
 
@@ -136,6 +142,11 @@ function loadConfig(env = process.env) {
136
142
  if (env.RUNCASTLE_BURN_CONCURRENCY) {
137
143
  overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
138
144
  }
145
+ if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
146
+ overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
147
+ }
148
+ if (env.RUNCASTLE_SETUP_COMMAND)
149
+ overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
139
150
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
140
151
  return RuncastleConfig.parse({ ...base, ...overrides });
141
152
  }
@@ -2492,6 +2503,46 @@ var init_skills_root = __esm(() => {
2492
2503
  SKILLS_MARKER = join7("packs", "runcastle");
2493
2504
  });
2494
2505
 
2506
+ // src/services/agent-stream.ts
2507
+ function beginTranscript(ticketId) {
2508
+ transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2509
+ }
2510
+ function appendTranscript(ticketId, chunk) {
2511
+ const t = transcripts.get(ticketId);
2512
+ if (!t)
2513
+ return;
2514
+ const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
2515
+ t.nextIndex += 1;
2516
+ t.chunks.push(full);
2517
+ t.bytes += full.text.length;
2518
+ while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
2519
+ const dropped = t.chunks.shift();
2520
+ if (!dropped)
2521
+ break;
2522
+ t.bytes -= dropped.text.length;
2523
+ }
2524
+ }
2525
+ function endTranscript(ticketId) {
2526
+ const t = transcripts.get(ticketId);
2527
+ if (t)
2528
+ t.live = false;
2529
+ }
2530
+ function readTranscript(ticketId, after = -1) {
2531
+ const t = transcripts.get(ticketId);
2532
+ if (!t)
2533
+ return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
2534
+ return {
2535
+ live: t.live,
2536
+ chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
2537
+ firstIndex: t.chunks[0]?.i ?? t.nextIndex,
2538
+ nextIndex: t.nextIndex
2539
+ };
2540
+ }
2541
+ var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2542
+ var init_agent_stream = __esm(() => {
2543
+ transcripts = new Map;
2544
+ });
2545
+
2495
2546
  // src/workflows/ticket-burner.ts
2496
2547
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2497
2548
  import { dirname as dirname3, join as join8 } from "path";
@@ -2604,6 +2655,64 @@ function parseEnvFile(content) {
2604
2655
  }
2605
2656
  return out;
2606
2657
  }
2658
+ function detectPackageManager(tc) {
2659
+ const pinned = tc.packageManagerField?.split("@")[0];
2660
+ if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
2661
+ return pinned;
2662
+ }
2663
+ if (tc.lockfiles.bun)
2664
+ return "bun";
2665
+ if (tc.lockfiles.pnpm)
2666
+ return "pnpm";
2667
+ if (tc.lockfiles.yarn)
2668
+ return "yarn";
2669
+ if (tc.lockfiles.npm)
2670
+ return "npm";
2671
+ return tc.hasPackageJson ? "npm" : undefined;
2672
+ }
2673
+ function resolveSetupCommand(tc, override) {
2674
+ const trimmed = override?.trim();
2675
+ if (trimmed)
2676
+ return trimmed;
2677
+ const pm = detectPackageManager(tc);
2678
+ if (!pm)
2679
+ return;
2680
+ switch (pm) {
2681
+ case "bun":
2682
+ return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
2683
+ case "pnpm":
2684
+ return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
2685
+ case "yarn":
2686
+ return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
2687
+ case "npm":
2688
+ return tc.lockfiles.npm ? "npm ci" : "npm install";
2689
+ }
2690
+ }
2691
+ function cacheMountFor(pm, hostPath) {
2692
+ return { hostPath, sandboxPath: PM_CACHE_SANDBOX_PATHS[pm] };
2693
+ }
2694
+ function readRepoToolchain(repoPath) {
2695
+ const pkgPath = join8(repoPath, "package.json");
2696
+ const hasPackageJson = existsSync10(pkgPath);
2697
+ let packageManagerField;
2698
+ if (hasPackageJson) {
2699
+ try {
2700
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2701
+ if (typeof pkg.packageManager === "string")
2702
+ packageManagerField = pkg.packageManager;
2703
+ } catch {}
2704
+ }
2705
+ return {
2706
+ hasPackageJson,
2707
+ ...packageManagerField !== undefined ? { packageManagerField } : {},
2708
+ lockfiles: {
2709
+ bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
2710
+ pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
2711
+ yarn: existsSync10(join8(repoPath, "yarn.lock")),
2712
+ npm: existsSync10(join8(repoPath, "package-lock.json"))
2713
+ }
2714
+ };
2715
+ }
2607
2716
  function createStreamThrottle(emit2, opts = {}) {
2608
2717
  const intervalMs = opts.intervalMs ?? 2000;
2609
2718
  const maxChars = opts.maxChars ?? 500;
@@ -2855,8 +2964,11 @@ function buildBurnAgent(config2, token, model) {
2855
2964
  }
2856
2965
  return agent;
2857
2966
  }
2858
- function selectSandbox(config2) {
2859
- const imageOpts = { imageName: resolveSandboxImage(config2) };
2967
+ function selectSandbox(config2, mounts = []) {
2968
+ const imageOpts = {
2969
+ imageName: resolveSandboxImage(config2),
2970
+ ...mounts.length > 0 ? { mounts } : {}
2971
+ };
2860
2972
  switch (config2.sandbox) {
2861
2973
  case "docker":
2862
2974
  return docker(imageOpts);
@@ -2876,24 +2988,62 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2876
2988
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
2877
2989
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
2878
2990
  });
2991
+ const toolchain = readRepoToolchain(project.repoPath);
2992
+ const pm = detectPackageManager(toolchain);
2993
+ const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
2994
+ const mounts = [];
2995
+ if (config2.sandbox !== "noSandbox" && pm) {
2996
+ const hostCache = burnCacheDir(pm);
2997
+ mkdirSync4(hostCache, { recursive: true });
2998
+ mounts.push(cacheMountFor(pm, hostCache));
2999
+ }
3000
+ if (setupCommand) {
3001
+ ctx.emitEvent({
3002
+ type: "burn.setup",
3003
+ message: `installing deps before agent start: ${setupCommand}`,
3004
+ ticketId: ticket.id
3005
+ });
3006
+ }
2879
3007
  mkdirSync4(logsDir(), { recursive: true });
2880
3008
  const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
2881
3009
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3010
+ beginTranscript(ticket.id);
3011
+ const onStreamEvent = (event) => {
3012
+ throttle.onEvent(event);
3013
+ if (event.type === "text") {
3014
+ appendTranscript(ticket.id, { kind: "text", text: event.message });
3015
+ } else if (event.type === "toolCall") {
3016
+ appendTranscript(ticket.id, {
3017
+ kind: "tool",
3018
+ text: event.formattedArgs ?? "",
3019
+ name: event.name
3020
+ });
3021
+ }
3022
+ };
2882
3023
  const runOptions = {
2883
3024
  agent: buildBurnAgent(config2, token, model),
2884
- sandbox: selectSandbox(config2),
3025
+ sandbox: selectSandbox(config2, mounts),
2885
3026
  cwd: project.repoPath,
2886
3027
  prompt,
2887
3028
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2888
3029
  signal: ctx.signal,
2889
3030
  name: `ticket-${ticket.seq}`,
2890
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3031
+ maxIterations: config2.burnMaxIterations,
3032
+ ...setupCommand ? {
3033
+ hooks: {
3034
+ sandbox: {
3035
+ onSandboxReady: [{ command: setupCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3036
+ }
3037
+ }
3038
+ } : {},
3039
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
2891
3040
  };
2892
3041
  let result;
2893
3042
  try {
2894
3043
  result = await run(runOptions);
2895
3044
  } catch (err) {
2896
3045
  throttle.flush();
3046
+ endTranscript(ticket.id);
2897
3047
  if (ctx.signal.aborted)
2898
3048
  throw err;
2899
3049
  const msg = err instanceof Error ? err.message : String(err);
@@ -2910,6 +3060,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2910
3060
  return { status: "failed", error: msg };
2911
3061
  }
2912
3062
  throttle.flush();
3063
+ endTranscript(ticket.id);
2913
3064
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2914
3065
  const outcome = interpretRunResult(result, blocked);
2915
3066
  if (outcome.status !== "done")
@@ -2958,14 +3109,23 @@ function readTokenFromEnvFile(path) {
2958
3109
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2959
3110
  return token && token.length > 0 ? token : undefined;
2960
3111
  }
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;
3112
+ 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, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
2962
3113
  var init_ticket_burner = __esm(() => {
2963
3114
  init_src();
2964
3115
  init_config_load();
2965
3116
  init_paths();
2966
3117
  init_skills_root();
3118
+ init_agent_stream();
2967
3119
  init_git();
2968
3120
  PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
3121
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3122
+ PM_CACHE_SANDBOX_PATHS = {
3123
+ bun: "~/.bun/install/cache",
3124
+ pnpm: "~/.local/share/pnpm/store",
3125
+ yarn: "~/.cache/yarn",
3126
+ npm: "~/.npm"
3127
+ };
3128
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
2969
3129
  ticketBurner = {
2970
3130
  id: "ticket-burner",
2971
3131
  run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
@@ -4844,11 +5004,13 @@ var init_project = __esm(() => {
4844
5004
  import * as z8 from "zod";
4845
5005
  var runRouter;
4846
5006
  var init_run = __esm(() => {
5007
+ init_agent_stream();
4847
5008
  init_repo();
4848
5009
  init_runner();
4849
5010
  init_context();
4850
5011
  runRouter = router({
4851
5012
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
5013
+ agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
4852
5014
  cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
4853
5015
  cancelRun(input.runId);
4854
5016
  return { ok: true };
@@ -5084,6 +5246,22 @@ var init_settings = __esm(() => {
5084
5246
  valueSchema: z9.number().int().min(1).max(8),
5085
5247
  parseEnv: (raw) => Number(raw)
5086
5248
  },
5249
+ {
5250
+ key: "burnMaxIterations",
5251
+ configKey: "burnMaxIterations",
5252
+ envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
5253
+ restartRequired: false,
5254
+ valueSchema: z9.number().int().min(1).max(10),
5255
+ parseEnv: (raw) => Number(raw)
5256
+ },
5257
+ {
5258
+ key: "setupCommand",
5259
+ configKey: "setupCommand",
5260
+ envVar: "RUNCASTLE_SETUP_COMMAND",
5261
+ restartRequired: false,
5262
+ valueSchema: z9.string().min(1),
5263
+ parseEnv: idEnv
5264
+ },
5087
5265
  {
5088
5266
  key: "mainBranch",
5089
5267
  configKey: "mainBranch",
package/index.js CHANGED
@@ -26,7 +26,8 @@ __export(exports_paths, {
26
26
  envPath: () => envPath,
27
27
  dbPath: () => dbPath,
28
28
  dataDir: () => dataDir,
29
- configPath: () => configPath
29
+ configPath: () => configPath,
30
+ burnCacheDir: () => burnCacheDir
30
31
  });
31
32
  import { homedir } from "os";
32
33
  import { join } from "path";
@@ -51,6 +52,9 @@ function sessionDir(id) {
51
52
  function worktreeDir(projectId, slug) {
52
53
  return join(dataDir(), "worktrees", projectId, slug);
53
54
  }
55
+ function burnCacheDir(pm) {
56
+ return join(dataDir(), "cache", pm);
57
+ }
54
58
  function sandboxBuildDir() {
55
59
  return join(dataDir(), "sandbox-build");
56
60
  }
@@ -99,7 +103,9 @@ var init_config = __esm(() => {
99
103
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
100
104
  mainBranch: z.string().default("main"),
101
105
  sandboxImage: z.string().optional(),
102
- burnConcurrency: z.number().int().min(1).max(8).default(3)
106
+ burnConcurrency: z.number().int().min(1).max(8).default(3),
107
+ burnMaxIterations: z.number().int().min(1).max(10).default(3),
108
+ setupCommand: z.string().optional()
103
109
  }));
104
110
  });
105
111
 
@@ -135,6 +141,11 @@ function loadConfig(env = process.env) {
135
141
  if (env.RUNCASTLE_BURN_CONCURRENCY) {
136
142
  overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
137
143
  }
144
+ if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
145
+ overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
146
+ }
147
+ if (env.RUNCASTLE_SETUP_COMMAND)
148
+ overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
138
149
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
139
150
  return RuncastleConfig.parse({ ...base, ...overrides });
140
151
  }
@@ -2491,6 +2502,46 @@ var init_skills_root = __esm(() => {
2491
2502
  SKILLS_MARKER = join7("packs", "runcastle");
2492
2503
  });
2493
2504
 
2505
+ // src/services/agent-stream.ts
2506
+ function beginTranscript(ticketId) {
2507
+ transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
2508
+ }
2509
+ function appendTranscript(ticketId, chunk) {
2510
+ const t = transcripts.get(ticketId);
2511
+ if (!t)
2512
+ return;
2513
+ const full = { ...chunk, i: t.nextIndex, ts: chunk.ts ?? Date.now() };
2514
+ t.nextIndex += 1;
2515
+ t.chunks.push(full);
2516
+ t.bytes += full.text.length;
2517
+ while (t.chunks.length > MAX_CHUNKS || t.bytes > MAX_BYTES && t.chunks.length > 1) {
2518
+ const dropped = t.chunks.shift();
2519
+ if (!dropped)
2520
+ break;
2521
+ t.bytes -= dropped.text.length;
2522
+ }
2523
+ }
2524
+ function endTranscript(ticketId) {
2525
+ const t = transcripts.get(ticketId);
2526
+ if (t)
2527
+ t.live = false;
2528
+ }
2529
+ function readTranscript(ticketId, after = -1) {
2530
+ const t = transcripts.get(ticketId);
2531
+ if (!t)
2532
+ return { live: false, chunks: [], firstIndex: 0, nextIndex: 0 };
2533
+ return {
2534
+ live: t.live,
2535
+ chunks: after < 0 ? [...t.chunks] : t.chunks.filter((c) => c.i > after),
2536
+ firstIndex: t.chunks[0]?.i ?? t.nextIndex,
2537
+ nextIndex: t.nextIndex
2538
+ };
2539
+ }
2540
+ var MAX_CHUNKS = 4000, MAX_BYTES = 1500000, transcripts;
2541
+ var init_agent_stream = __esm(() => {
2542
+ transcripts = new Map;
2543
+ });
2544
+
2494
2545
  // src/workflows/ticket-burner.ts
2495
2546
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2496
2547
  import { dirname as dirname3, join as join8 } from "path";
@@ -2603,6 +2654,64 @@ function parseEnvFile(content) {
2603
2654
  }
2604
2655
  return out;
2605
2656
  }
2657
+ function detectPackageManager(tc) {
2658
+ const pinned = tc.packageManagerField?.split("@")[0];
2659
+ if (pinned && PACKAGE_MANAGERS.includes(pinned)) {
2660
+ return pinned;
2661
+ }
2662
+ if (tc.lockfiles.bun)
2663
+ return "bun";
2664
+ if (tc.lockfiles.pnpm)
2665
+ return "pnpm";
2666
+ if (tc.lockfiles.yarn)
2667
+ return "yarn";
2668
+ if (tc.lockfiles.npm)
2669
+ return "npm";
2670
+ return tc.hasPackageJson ? "npm" : undefined;
2671
+ }
2672
+ function resolveSetupCommand(tc, override) {
2673
+ const trimmed = override?.trim();
2674
+ if (trimmed)
2675
+ return trimmed;
2676
+ const pm = detectPackageManager(tc);
2677
+ if (!pm)
2678
+ return;
2679
+ switch (pm) {
2680
+ case "bun":
2681
+ return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
2682
+ case "pnpm":
2683
+ return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
2684
+ case "yarn":
2685
+ return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
2686
+ case "npm":
2687
+ return tc.lockfiles.npm ? "npm ci" : "npm install";
2688
+ }
2689
+ }
2690
+ function cacheMountFor(pm, hostPath) {
2691
+ return { hostPath, sandboxPath: PM_CACHE_SANDBOX_PATHS[pm] };
2692
+ }
2693
+ function readRepoToolchain(repoPath) {
2694
+ const pkgPath = join8(repoPath, "package.json");
2695
+ const hasPackageJson = existsSync10(pkgPath);
2696
+ let packageManagerField;
2697
+ if (hasPackageJson) {
2698
+ try {
2699
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2700
+ if (typeof pkg.packageManager === "string")
2701
+ packageManagerField = pkg.packageManager;
2702
+ } catch {}
2703
+ }
2704
+ return {
2705
+ hasPackageJson,
2706
+ ...packageManagerField !== undefined ? { packageManagerField } : {},
2707
+ lockfiles: {
2708
+ bun: existsSync10(join8(repoPath, "bun.lock")) || existsSync10(join8(repoPath, "bun.lockb")),
2709
+ pnpm: existsSync10(join8(repoPath, "pnpm-lock.yaml")),
2710
+ yarn: existsSync10(join8(repoPath, "yarn.lock")),
2711
+ npm: existsSync10(join8(repoPath, "package-lock.json"))
2712
+ }
2713
+ };
2714
+ }
2606
2715
  function createStreamThrottle(emit2, opts = {}) {
2607
2716
  const intervalMs = opts.intervalMs ?? 2000;
2608
2717
  const maxChars = opts.maxChars ?? 500;
@@ -2854,8 +2963,11 @@ function buildBurnAgent(config2, token, model) {
2854
2963
  }
2855
2964
  return agent;
2856
2965
  }
2857
- function selectSandbox(config2) {
2858
- const imageOpts = { imageName: resolveSandboxImage(config2) };
2966
+ function selectSandbox(config2, mounts = []) {
2967
+ const imageOpts = {
2968
+ imageName: resolveSandboxImage(config2),
2969
+ ...mounts.length > 0 ? { mounts } : {}
2970
+ };
2859
2971
  switch (config2.sandbox) {
2860
2972
  case "docker":
2861
2973
  return docker(imageOpts);
@@ -2875,24 +2987,62 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2875
2987
  DOCS_DIGEST: readDocsDigestFromDisk(project.id, feature.slug),
2876
2988
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`
2877
2989
  });
2990
+ const toolchain = readRepoToolchain(project.repoPath);
2991
+ const pm = detectPackageManager(toolchain);
2992
+ const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
2993
+ const mounts = [];
2994
+ if (config2.sandbox !== "noSandbox" && pm) {
2995
+ const hostCache = burnCacheDir(pm);
2996
+ mkdirSync4(hostCache, { recursive: true });
2997
+ mounts.push(cacheMountFor(pm, hostCache));
2998
+ }
2999
+ if (setupCommand) {
3000
+ ctx.emitEvent({
3001
+ type: "burn.setup",
3002
+ message: `installing deps before agent start: ${setupCommand}`,
3003
+ ticketId: ticket.id
3004
+ });
3005
+ }
2878
3006
  mkdirSync4(logsDir(), { recursive: true });
2879
3007
  const logFilePath = join8(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
2880
3008
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3009
+ beginTranscript(ticket.id);
3010
+ const onStreamEvent = (event) => {
3011
+ throttle.onEvent(event);
3012
+ if (event.type === "text") {
3013
+ appendTranscript(ticket.id, { kind: "text", text: event.message });
3014
+ } else if (event.type === "toolCall") {
3015
+ appendTranscript(ticket.id, {
3016
+ kind: "tool",
3017
+ text: event.formattedArgs ?? "",
3018
+ name: event.name
3019
+ });
3020
+ }
3021
+ };
2881
3022
  const runOptions = {
2882
3023
  agent: buildBurnAgent(config2, token, model),
2883
- sandbox: selectSandbox(config2),
3024
+ sandbox: selectSandbox(config2, mounts),
2884
3025
  cwd: project.repoPath,
2885
3026
  prompt,
2886
3027
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2887
3028
  signal: ctx.signal,
2888
3029
  name: `ticket-${ticket.seq}`,
2889
- logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3030
+ maxIterations: config2.burnMaxIterations,
3031
+ ...setupCommand ? {
3032
+ hooks: {
3033
+ sandbox: {
3034
+ onSandboxReady: [{ command: setupCommand, timeoutMs: SETUP_HOOK_TIMEOUT_MS }]
3035
+ }
3036
+ }
3037
+ } : {},
3038
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: onStreamEvent }
2890
3039
  };
2891
3040
  let result;
2892
3041
  try {
2893
3042
  result = await run(runOptions);
2894
3043
  } catch (err) {
2895
3044
  throttle.flush();
3045
+ endTranscript(ticket.id);
2896
3046
  if (ctx.signal.aborted)
2897
3047
  throw err;
2898
3048
  const msg = err instanceof Error ? err.message : String(err);
@@ -2909,6 +3059,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2909
3059
  return { status: "failed", error: msg };
2910
3060
  }
2911
3061
  throttle.flush();
3062
+ endTranscript(ticket.id);
2912
3063
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2913
3064
  const outcome = interpretRunResult(result, blocked);
2914
3065
  if (outcome.status !== "done")
@@ -2957,14 +3108,23 @@ function readTokenFromEnvFile(path) {
2957
3108
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2958
3109
  return token && token.length > 0 ? token : undefined;
2959
3110
  }
2960
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
3111
+ 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, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
2961
3112
  var init_ticket_burner = __esm(() => {
2962
3113
  init_src();
2963
3114
  init_config_load();
2964
3115
  init_paths();
2965
3116
  init_skills_root();
3117
+ init_agent_stream();
2966
3118
  init_git();
2967
3119
  PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
3120
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3121
+ PM_CACHE_SANDBOX_PATHS = {
3122
+ bun: "~/.bun/install/cache",
3123
+ pnpm: "~/.local/share/pnpm/store",
3124
+ yarn: "~/.cache/yarn",
3125
+ npm: "~/.npm"
3126
+ };
3127
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
2968
3128
  ticketBurner = {
2969
3129
  id: "ticket-burner",
2970
3130
  run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
@@ -4843,11 +5003,13 @@ var init_project = __esm(() => {
4843
5003
  import * as z8 from "zod";
4844
5004
  var runRouter;
4845
5005
  var init_run = __esm(() => {
5006
+ init_agent_stream();
4846
5007
  init_repo();
4847
5008
  init_runner();
4848
5009
  init_context();
4849
5010
  runRouter = router({
4850
5011
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
5012
+ agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
4851
5013
  cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
4852
5014
  cancelRun(input.runId);
4853
5015
  return { ok: true };
@@ -5083,6 +5245,22 @@ var init_settings = __esm(() => {
5083
5245
  valueSchema: z9.number().int().min(1).max(8),
5084
5246
  parseEnv: (raw) => Number(raw)
5085
5247
  },
5248
+ {
5249
+ key: "burnMaxIterations",
5250
+ configKey: "burnMaxIterations",
5251
+ envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
5252
+ restartRequired: false,
5253
+ valueSchema: z9.number().int().min(1).max(10),
5254
+ parseEnv: (raw) => Number(raw)
5255
+ },
5256
+ {
5257
+ key: "setupCommand",
5258
+ configKey: "setupCommand",
5259
+ envVar: "RUNCASTLE_SETUP_COMMAND",
5260
+ restartRequired: false,
5261
+ valueSchema: z9.string().min(1),
5262
+ parseEnv: idEnv
5263
+ },
5086
5264
  {
5087
5265
  key: "mainBranch",
5088
5266
  configKey: "mainBranch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,6 +4,14 @@
4
4
 
5
5
  You are a single agent in a sandbox on branch `feature/<slug>`. You have **one ticket**. There is **no human to ask** — no follow-up questions are possible. Everything you need is in this prompt and in the repo. Work carefully, commit only green work, and stop when the ticket is done.
6
6
 
7
+ ## How you run
8
+
9
+ You run **non-interactively** (`claude --print`), for up to a few fresh iterations against the same worktree:
10
+
11
+ - **Ending your turn ends your process.** There are no background-task completion notifications in print mode — a "the notification will re-invoke me" plan never fires. Never end your turn to wait on a background command; run long commands (dependency installs, full test suites) in the foreground with a generous timeout, or poll a backgrounded command to completion *within* the same turn.
12
+ - **You may not be the first iteration.** A previous iteration may have left commits or uncommitted work on this branch. Check `git status` and `git log` before starting; continue the work, don't redo it.
13
+ - **Signal completion.** When the ticket is fully done — every acceptance criterion verified, self-review finished, all work committed — print exactly `<promise>COMPLETE</promise>` as the last line of your final message. Do the same after writing `BLOCKED.md` (see hard rules). Without the signal, the harness assumes you were cut off and spends another iteration.
14
+
7
15
  ## The ticket
8
16
 
9
17
  ```json
@@ -39,4 +47,4 @@ You are a single agent in a sandbox on branch `feature/<slug>`. You have **one t
39
47
 
40
48
  - **Never expand scope beyond the ticket.** If you notice adjacent work, worthwhile refactors, or another ticket's territory, leave it. Note it in your final commit body if it matters; do not do it.
41
49
  - **No questions, no guessing into the void.** Resolve ambiguity from the ticket context and the code. If two readings both satisfy the acceptance criteria, take the smaller one.
42
- - **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.
50
+ - **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, print `<promise>COMPLETE</promise>`, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.