@flumecode/runner 0.9.0 → 0.11.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.
package/dist/cli.js CHANGED
@@ -136,6 +136,38 @@ async function reportHeartbeat(config, claudeCode) {
136
136
  noteServerVersion(res);
137
137
  if (!res.ok) throw new Error(`heartbeat failed: ${res.status} ${await safeText(res)}`);
138
138
  }
139
+ async function uploadJobLog(config, jobId, content) {
140
+ const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/logs`, {
141
+ method: "POST",
142
+ headers: {
143
+ authorization: `Bearer ${config.token}`,
144
+ "content-type": "application/json",
145
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
146
+ },
147
+ body: JSON.stringify({ content })
148
+ });
149
+ noteServerVersion(res);
150
+ if (!res.ok) throw new Error(`log upload failed: ${res.status} ${await safeText(res)}`);
151
+ }
152
+ async function fetchRelatedSessions(config, params) {
153
+ if (!params.prNumbers.length) return [];
154
+ try {
155
+ const res = await fetch(`${config.serverUrl}/api/runner/sessions/related`, {
156
+ method: "POST",
157
+ headers: {
158
+ authorization: `Bearer ${config.token}`,
159
+ "content-type": "application/json",
160
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
161
+ },
162
+ body: JSON.stringify(params)
163
+ });
164
+ noteServerVersion(res);
165
+ if (!res.ok) return [];
166
+ return await res.json();
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
139
171
  async function safeText(res) {
140
172
  try {
141
173
  return await res.text();
@@ -144,6 +176,467 @@ async function safeText(res) {
144
176
  }
145
177
  }
146
178
 
179
+ // src/plugins/socket.ts
180
+ import { exec as execCb } from "node:child_process";
181
+ import { promisify as promisify2 } from "node:util";
182
+
183
+ // src/workspace.ts
184
+ import { execFile } from "node:child_process";
185
+ import { existsSync as existsSync2 } from "node:fs";
186
+ import { mkdtemp, readdir, rm } from "node:fs/promises";
187
+ import { tmpdir } from "node:os";
188
+ import { join as join2 } from "node:path";
189
+ import { promisify } from "node:util";
190
+
191
+ // src/types.ts
192
+ function jobTitle(ctx) {
193
+ return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
194
+ }
195
+
196
+ // src/logger.ts
197
+ var lines = [];
198
+ var secrets = [];
199
+ var MAX_BYTES = 10 * 1024 * 1024;
200
+ function startJobLog(opts) {
201
+ lines = [];
202
+ secrets = opts.secrets.filter(Boolean);
203
+ logEvent("meta", `job ${opts.jobId} (${opts.kind}) started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
204
+ }
205
+ function redact(s) {
206
+ for (const sec of secrets) {
207
+ s = s.split(sec).join("***REDACTED***");
208
+ }
209
+ return s;
210
+ }
211
+ function logEvent(section, text) {
212
+ lines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${section}] ${redact(text)}`);
213
+ }
214
+ function getJobLog() {
215
+ const full = lines.join("\n");
216
+ if (full.length <= MAX_BYTES) return full;
217
+ const half = Math.floor(MAX_BYTES / 2);
218
+ return full.slice(0, half) + `
219
+
220
+ \u2026[truncated ${full.length - MAX_BYTES} bytes]\u2026
221
+
222
+ ` + full.slice(-half);
223
+ }
224
+
225
+ // src/workspace.ts
226
+ var exec = promisify(execFile);
227
+ var WORKSPACE_PREFIX = "flume-runner-";
228
+ var MAX_BUFFER = 1 << 24;
229
+ async function git(args) {
230
+ logEvent("git", `git ${args.join(" ")}`);
231
+ try {
232
+ const result = await exec("git", args, { maxBuffer: MAX_BUFFER });
233
+ if (result.stdout.trim()) logEvent("git:out", result.stdout.trim());
234
+ if (result.stderr.trim()) logEvent("git:err", result.stderr.trim());
235
+ return result;
236
+ } catch (err) {
237
+ logEvent("git:err", String(err.stderr ?? err));
238
+ throw err;
239
+ }
240
+ }
241
+ async function ensureGitIdentity(dir, identity) {
242
+ await git(["-C", dir, "config", "user.email", identity.email]);
243
+ await git(["-C", dir, "config", "user.name", identity.name]);
244
+ }
245
+ function cloneUrl(ctx) {
246
+ const { owner, name, cloneToken } = ctx.repo;
247
+ return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
248
+ }
249
+ function detectPackageManager(dir) {
250
+ if (!existsSync2(join2(dir, "package.json"))) return null;
251
+ if (existsSync2(join2(dir, "pnpm-lock.yaml"))) return "pnpm";
252
+ if (existsSync2(join2(dir, "yarn.lock"))) return "yarn";
253
+ if (existsSync2(join2(dir, "package-lock.json"))) return "npm";
254
+ if (existsSync2(join2(dir, "bun.lockb"))) return "bun";
255
+ return "npm";
256
+ }
257
+ async function installDependencies(dir) {
258
+ const manager = detectPackageManager(dir);
259
+ if (manager === null) return { status: "skipped" };
260
+ const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
261
+ logEvent("install", `${manager} install`);
262
+ try {
263
+ const result = await exec(manager, ["install"], {
264
+ cwd: dir,
265
+ maxBuffer: MAX_BUFFER,
266
+ env,
267
+ timeout: 5 * 6e4
268
+ });
269
+ if (result.stdout.trim()) logEvent("install:out", result.stdout.trim());
270
+ if (result.stderr.trim()) logEvent("install:err", result.stderr.trim());
271
+ return { status: "installed", manager };
272
+ } catch (err) {
273
+ const e = err;
274
+ const detail = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
275
+ logEvent("install:err", detail || (err instanceof Error ? err.message : String(err)));
276
+ return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
277
+ }
278
+ }
279
+ async function makeWorkspace() {
280
+ return mkdtemp(join2(tmpdir(), WORKSPACE_PREFIX));
281
+ }
282
+ var MAX_WORKSPACES = 8;
283
+ var workspaceRegistry = /* @__PURE__ */ new Map();
284
+ async function acquireWorkspace(key) {
285
+ const existing = workspaceRegistry.get(key);
286
+ if (existing !== void 0 && existsSync2(existing)) {
287
+ workspaceRegistry.delete(key);
288
+ workspaceRegistry.set(key, existing);
289
+ return { dir: existing, reused: true };
290
+ }
291
+ const dir = await makeWorkspace();
292
+ workspaceRegistry.set(key, dir);
293
+ if (workspaceRegistry.size > MAX_WORKSPACES) {
294
+ const oldest = workspaceRegistry.keys().next().value;
295
+ const oldDir = workspaceRegistry.get(oldest);
296
+ workspaceRegistry.delete(oldest);
297
+ rm(oldDir, { recursive: true, force: true }).catch(() => {
298
+ });
299
+ }
300
+ return { dir, reused: false };
301
+ }
302
+ async function discardWorkspace(key) {
303
+ const dir = workspaceRegistry.get(key);
304
+ workspaceRegistry.delete(key);
305
+ if (dir !== void 0) {
306
+ await cleanup(dir).catch(() => {
307
+ });
308
+ }
309
+ }
310
+ async function resetWorkspace(dir) {
311
+ await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
312
+ });
313
+ await git(["-C", dir, "clean", "-fd"]).catch(() => {
314
+ });
315
+ }
316
+ async function prepareAtSha(ctx, dir, reused) {
317
+ const identity = { name: ctx.agentName, email: ctx.agentEmail };
318
+ if (!reused) {
319
+ await cloneAtSha(ctx, dir);
320
+ await ensureGitIdentity(dir, identity);
321
+ return;
322
+ }
323
+ await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
324
+ await ensureGitIdentity(dir, identity);
325
+ }
326
+ async function prepareResumingBranch(ctx, dir, reused) {
327
+ const identity = { name: ctx.agentName, email: ctx.agentEmail };
328
+ if (!reused) {
329
+ const result = await cloneResumingBranch(ctx, dir);
330
+ await ensureGitIdentity(dir, identity);
331
+ return result;
332
+ }
333
+ await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
334
+ await ensureGitIdentity(dir, identity);
335
+ return { resumed: true };
336
+ }
337
+ async function sweepWorkspaces() {
338
+ const base = tmpdir();
339
+ let entries;
340
+ try {
341
+ entries = await readdir(base);
342
+ } catch {
343
+ return 0;
344
+ }
345
+ let removed = 0;
346
+ for (const entry of entries) {
347
+ if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
348
+ try {
349
+ await rm(join2(base, entry), { recursive: true, force: true });
350
+ removed++;
351
+ } catch {
352
+ }
353
+ }
354
+ return removed;
355
+ }
356
+ async function cloneAtSha(ctx, dir) {
357
+ await git(["clone", "--quiet", cloneUrl(ctx), dir]);
358
+ await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
359
+ }
360
+ async function cloneResumingBranch(ctx, dir) {
361
+ await git(["clone", "--quiet", cloneUrl(ctx), dir]);
362
+ try {
363
+ await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
364
+ await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
365
+ return { resumed: true };
366
+ } catch {
367
+ await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
368
+ return { resumed: false };
369
+ }
370
+ }
371
+ async function hasChanges(dir) {
372
+ await git(["-C", dir, "add", "-A"]);
373
+ const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
374
+ return stdout2.trim().length > 0;
375
+ }
376
+ var PreCommitError = class extends Error {
377
+ constructor(log) {
378
+ super("pre-commit checks failed");
379
+ this.log = log;
380
+ this.name = "PreCommitError";
381
+ }
382
+ };
383
+ function commitFailureLog(err) {
384
+ const e = err;
385
+ const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
386
+ return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
387
+ }
388
+ function isUnsupportedGitSubcommand(err) {
389
+ const e = err;
390
+ const text = `${typeof e.stderr === "string" ? e.stderr : ""}
391
+ ${e.message ?? ""}`;
392
+ return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
393
+ }
394
+ async function runRepoChecks(dir) {
395
+ try {
396
+ await git(["-C", dir, "hook", "run", "pre-commit"]);
397
+ logEvent("checks", "pre-commit hook passed");
398
+ return { ok: true, log: "", skipped: false };
399
+ } catch (err) {
400
+ if (isUnsupportedGitSubcommand(err)) {
401
+ logEvent("checks", "pre-commit hook skipped (git too old)");
402
+ return { ok: true, log: "", skipped: true };
403
+ }
404
+ const log = commitFailureLog(err);
405
+ logEvent("checks:err", log);
406
+ return { ok: false, log, skipped: false };
407
+ }
408
+ }
409
+ async function commitChanges(ctx, dir) {
410
+ if (!await hasChanges(dir)) return false;
411
+ try {
412
+ await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
413
+ } catch (err) {
414
+ throw new PreCommitError(commitFailureLog(err));
415
+ }
416
+ return true;
417
+ }
418
+ async function pushBranch(ctx, dir) {
419
+ await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
420
+ }
421
+ var RebaseConflictError = class extends Error {
422
+ constructor(mergeBranch, files) {
423
+ const list = files.length ? `: ${files.join(", ")}` : "";
424
+ super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
425
+ this.mergeBranch = mergeBranch;
426
+ this.files = files;
427
+ this.name = "RebaseConflictError";
428
+ }
429
+ };
430
+ async function rebaseOntoMergeBranch(ctx, dir) {
431
+ const { mergeBranch } = ctx.repo;
432
+ if (!mergeBranch) return;
433
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
434
+ try {
435
+ await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
436
+ } catch (err) {
437
+ const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
438
+ () => ({ stdout: "" })
439
+ );
440
+ const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
441
+ await git(["-C", dir, "rebase", "--abort"]).catch(() => {
442
+ });
443
+ if (files.length === 0) throw err;
444
+ throw new RebaseConflictError(mergeBranch, files);
445
+ }
446
+ }
447
+ async function mergeInMergeBranch(ctx, dir) {
448
+ const { mergeBranch } = ctx.repo;
449
+ if (!mergeBranch) return { conflicted: false };
450
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
451
+ try {
452
+ await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
453
+ return { conflicted: false };
454
+ } catch {
455
+ return { conflicted: true };
456
+ }
457
+ }
458
+ async function listUnmergedPaths(dir) {
459
+ const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
460
+ stdout: ""
461
+ }));
462
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
463
+ }
464
+ async function listConflictMarkerPaths(dir, paths) {
465
+ if (paths.length === 0) return [];
466
+ const { stdout: stdout2 } = await git([
467
+ "-C",
468
+ dir,
469
+ "grep",
470
+ "--no-color",
471
+ "-lE",
472
+ "^(<<<<<<<|>>>>>>>|\\|\\|\\|\\|\\|\\|\\|)",
473
+ "--",
474
+ ...paths
475
+ ]).catch(() => ({ stdout: "" }));
476
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
477
+ }
478
+ async function openPullRequest(ctx) {
479
+ const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
480
+ if (!mergeBranch) return null;
481
+ const apiBase = `https://api.github.com/repos/${owner}/${name}`;
482
+ const headers = {
483
+ authorization: `Bearer ${cloneToken}`,
484
+ accept: "application/vnd.github+json",
485
+ "x-github-api-version": "2022-11-28",
486
+ "content-type": "application/json"
487
+ };
488
+ const title = jobTitle(ctx);
489
+ const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
490
+ const res = await fetch(`${apiBase}/pulls`, {
491
+ method: "POST",
492
+ headers,
493
+ body: JSON.stringify({
494
+ title: `FlumeCode: ${title}`,
495
+ head: checkoutBranch,
496
+ base: mergeBranch,
497
+ body
498
+ })
499
+ });
500
+ if (res.status === 201) {
501
+ const data = await res.json();
502
+ return { number: data.number, url: data.html_url };
503
+ }
504
+ if (res.status === 422) {
505
+ const list = await fetch(
506
+ `${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
507
+ { headers }
508
+ );
509
+ if (list.ok) {
510
+ const open = await list.json();
511
+ if (open[0]) return { number: open[0].number, url: open[0].html_url };
512
+ }
513
+ return null;
514
+ }
515
+ throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
516
+ }
517
+ async function cleanup(dir) {
518
+ await rm(dir, { recursive: true, force: true });
519
+ }
520
+ function parsePrFromSubject(subject) {
521
+ const m = subject.match(/\(#(\d+)\)\s*$/);
522
+ return m ? Number(m[1]) : null;
523
+ }
524
+ async function incomingPrNumbers(ctx, dir, paths) {
525
+ if (!paths.length) return [];
526
+ try {
527
+ const mergeHeadResult = await git(["-C", dir, "rev-parse", "MERGE_HEAD"]);
528
+ const mergeHead = mergeHeadResult.stdout.trim();
529
+ const baseResult = await git(["-C", dir, "merge-base", "HEAD", mergeHead]);
530
+ const base = baseResult.stdout.trim();
531
+ const logResult = await git([
532
+ "-C",
533
+ dir,
534
+ "log",
535
+ "--no-merges",
536
+ `--format=%H%x1f%s`,
537
+ `${base}..${mergeHead}`,
538
+ "--",
539
+ ...paths
540
+ ]);
541
+ const nums = /* @__PURE__ */ new Set();
542
+ const needLookup = [];
543
+ for (const line of logResult.stdout.split("\n").filter(Boolean)) {
544
+ const idx = line.indexOf("");
545
+ const sha = line.slice(0, idx);
546
+ const subject = line.slice(idx + 1);
547
+ const n = parsePrFromSubject(subject);
548
+ if (n !== null) nums.add(n);
549
+ else needLookup.push(sha);
550
+ }
551
+ for (const sha of needLookup) {
552
+ for (const n of await prNumbersForCommit(ctx, sha)) nums.add(n);
553
+ }
554
+ return [...nums];
555
+ } catch {
556
+ return [];
557
+ }
558
+ }
559
+ async function prNumbersForCommit(ctx, sha) {
560
+ const { owner, name, cloneToken } = ctx.repo;
561
+ try {
562
+ const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/pulls`, {
563
+ headers: {
564
+ authorization: `Bearer ${cloneToken}`,
565
+ accept: "application/vnd.github+json",
566
+ "x-github-api-version": "2022-11-28"
567
+ }
568
+ });
569
+ if (!res.ok) return [];
570
+ return (await res.json()).map((p) => p.number);
571
+ } catch {
572
+ return [];
573
+ }
574
+ }
575
+
576
+ // src/plugins/manifest.ts
577
+ import { existsSync as existsSync3 } from "node:fs";
578
+ import { readdir as readdir2, readFile } from "node:fs/promises";
579
+ import { join as join3 } from "node:path";
580
+ async function loadPlugins(dir) {
581
+ const pluginsDir = join3(dir, ".flumecode", "plugins");
582
+ if (!existsSync3(pluginsDir)) return [];
583
+ let entries;
584
+ try {
585
+ entries = await readdir2(pluginsDir);
586
+ } catch {
587
+ return [];
588
+ }
589
+ const manifests = [];
590
+ for (const entry of entries) {
591
+ const manifestPath = join3(pluginsDir, entry, "plugin.json");
592
+ try {
593
+ const raw = JSON.parse(await readFile(manifestPath, "utf8"));
594
+ const manifest = parseManifest(raw);
595
+ if (manifest) manifests.push(manifest);
596
+ } catch {
597
+ }
598
+ }
599
+ return manifests;
600
+ }
601
+ function parseManifest(raw) {
602
+ if (typeof raw !== "object" || raw === null) return null;
603
+ const r = raw;
604
+ if (typeof r.key !== "string" || !r.key) return null;
605
+ if (r.socket !== "pre-commit") return null;
606
+ if (typeof r.run !== "string" || !r.run) return null;
607
+ const manifest = { key: r.key, socket: r.socket, run: r.run };
608
+ if (typeof r.heartbeat === "object" && r.heartbeat !== null) {
609
+ const hb = r.heartbeat;
610
+ if (typeof hb.url === "string" && typeof hb.token === "string") {
611
+ manifest.heartbeat = { url: hb.url, token: hb.token };
612
+ }
613
+ }
614
+ return manifest;
615
+ }
616
+
617
+ // src/plugins/socket.ts
618
+ var exec2 = promisify2(execCb);
619
+ async function runSocket(socketName, dir) {
620
+ const plugins = (await loadPlugins(dir)).filter((p) => p.socket === socketName);
621
+ for (const plugin of plugins) {
622
+ const result = await runPluginCommand(plugin.run, dir);
623
+ if (result.exitCode !== 0) {
624
+ throw new PreCommitError(`[plugin:${plugin.key}] ${result.output}`);
625
+ }
626
+ }
627
+ }
628
+ async function runPluginCommand(command2, cwd) {
629
+ try {
630
+ const result = await exec2(command2, { cwd, maxBuffer: 1 << 24 });
631
+ const output = [result.stdout, result.stderr].map((s) => s.trim()).filter(Boolean).join("\n");
632
+ return { exitCode: 0, output };
633
+ } catch (err) {
634
+ const e = err;
635
+ const output = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
636
+ return { exitCode: typeof e.code === "number" ? e.code : 1, output };
637
+ }
638
+ }
639
+
147
640
  // src/executor.ts
148
641
  import { fileURLToPath as fileURLToPath2 } from "node:url";
149
642
  import { query } from "@anthropic-ai/claude-agent-sdk";
@@ -252,59 +745,59 @@ var planInputSchema = {
252
745
  };
253
746
  var planSchema = z2.object(planInputSchema);
254
747
  function renderPlan(plan) {
255
- const lines = [];
256
- lines.push(`# ${plan.title}`);
257
- lines.push("");
258
- lines.push(`**Scope** \u2014 \`${plan.scope}\``);
259
- lines.push("");
260
- lines.push(`**Goal** \u2014 ${plan.goal}`);
748
+ const lines2 = [];
749
+ lines2.push(`# ${plan.title}`);
750
+ lines2.push("");
751
+ lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
752
+ lines2.push("");
753
+ lines2.push(`**Goal** \u2014 ${plan.goal}`);
261
754
  if (plan.assumptions.length > 0) {
262
- lines.push("");
263
- lines.push("**Assumptions**");
755
+ lines2.push("");
756
+ lines2.push("**Assumptions**");
264
757
  for (const assumption of plan.assumptions) {
265
- lines.push(`- ${assumption}`);
758
+ lines2.push(`- ${assumption}`);
266
759
  }
267
760
  }
268
- lines.push("");
269
- lines.push("## Steps");
761
+ lines2.push("");
762
+ lines2.push("## Steps");
270
763
  for (const [i, step] of plan.steps.entries()) {
271
- lines.push("");
272
- lines.push(`### ${i + 1}. ${step.title}`);
273
- lines.push("");
274
- lines.push(step.description);
764
+ lines2.push("");
765
+ lines2.push(`### ${i + 1}. ${step.title}`);
766
+ lines2.push("");
767
+ lines2.push(step.description);
275
768
  if (step.pseudoCode && step.pseudoCode.length > 0) {
276
769
  for (const entry of step.pseudoCode) {
277
- lines.push("");
278
- lines.push(`\`${entry.file}\``);
279
- lines.push("");
280
- lines.push("```");
281
- lines.push(entry.pseudoCode);
282
- lines.push("```");
770
+ lines2.push("");
771
+ lines2.push(`\`${entry.file}\``);
772
+ lines2.push("");
773
+ lines2.push("```");
774
+ lines2.push(entry.pseudoCode);
775
+ lines2.push("```");
283
776
  }
284
777
  }
285
778
  }
286
- lines.push("");
287
- lines.push("## Acceptance criteria");
779
+ lines2.push("");
780
+ lines2.push("## Acceptance criteria");
288
781
  for (const criterion of plan.acceptanceCriteria) {
289
- lines.push(`- [ ] ${criterion}`);
782
+ lines2.push(`- [ ] ${criterion}`);
290
783
  }
291
784
  if (plan.risks.length > 0) {
292
- lines.push("");
293
- lines.push("**Risks / open questions**");
785
+ lines2.push("");
786
+ lines2.push("**Risks / open questions**");
294
787
  for (const risk of plan.risks) {
295
- lines.push(`- ${risk}`);
788
+ lines2.push(`- ${risk}`);
296
789
  }
297
790
  }
298
791
  if (plan.outOfScope.length > 0) {
299
- lines.push("");
300
- lines.push("**Out of scope**");
792
+ lines2.push("");
793
+ lines2.push("**Out of scope**");
301
794
  for (const item of plan.outOfScope) {
302
- lines.push(`- ${item}`);
795
+ lines2.push(`- ${item}`);
303
796
  }
304
797
  }
305
- lines.push("");
306
- lines.push(PLAN_MARKER);
307
- return lines.join("\n");
798
+ lines2.push("");
799
+ lines2.push(PLAN_MARKER);
800
+ return lines2.join("\n");
308
801
  }
309
802
  var submitPlanInputSchema = {
310
803
  plans: z2.array(z2.object(planInputSchema)).min(1).refine(
@@ -379,27 +872,27 @@ var reportInputSchema = {
379
872
  };
380
873
  var reportSchema = z3.object(reportInputSchema);
381
874
  function renderReport(report) {
382
- const lines = [];
383
- lines.push(report.summary.trim());
384
- lines.push("");
385
- lines.push(report.prose.trim());
386
- lines.push("");
387
- lines.push("## Acceptance criteria");
875
+ const lines2 = [];
876
+ lines2.push(report.summary.trim());
877
+ lines2.push("");
878
+ lines2.push(report.prose.trim());
879
+ lines2.push("");
880
+ lines2.push("## Acceptance criteria");
388
881
  for (const ac of report.acceptanceCriteria) {
389
- lines.push("");
390
- lines.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
391
- lines.push("");
392
- lines.push(ac.rationale.trim());
882
+ lines2.push("");
883
+ lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
884
+ lines2.push("");
885
+ lines2.push(ac.rationale.trim());
393
886
  for (const ev of ac.evidence) {
394
- lines.push("");
395
- lines.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
396
- lines.push("");
397
- lines.push("```diff");
398
- lines.push(ev.hunk.replace(/\n+$/, ""));
399
- lines.push("```");
887
+ lines2.push("");
888
+ lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
889
+ lines2.push("");
890
+ lines2.push("```diff");
891
+ lines2.push(ev.hunk.replace(/\n+$/, ""));
892
+ lines2.push("```");
400
893
  }
401
894
  }
402
- return lines.join("\n");
895
+ return lines2.join("\n");
403
896
  }
404
897
  function createReportTooling() {
405
898
  let submittedReport = null;
@@ -428,6 +921,32 @@ function createReportTooling() {
428
921
 
429
922
  // src/executor.ts
430
923
  var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.url));
924
+ function emptyUsage() {
925
+ return {
926
+ inputTokens: 0,
927
+ outputTokens: 0,
928
+ cacheCreationTokens: 0,
929
+ cacheReadTokens: 0,
930
+ costUsd: 0
931
+ };
932
+ }
933
+ var usageAcc = emptyUsage();
934
+ function resetUsage() {
935
+ usageAcc = emptyUsage();
936
+ }
937
+ function getUsage() {
938
+ const totalTokens = usageAcc.inputTokens + usageAcc.outputTokens + usageAcc.cacheCreationTokens + usageAcc.cacheReadTokens;
939
+ return { ...usageAcc, totalTokens };
940
+ }
941
+ function stringifyResult(content) {
942
+ if (typeof content === "string") return content;
943
+ if (Array.isArray(content)) {
944
+ return content.map(
945
+ (c) => typeof c === "object" && c !== null && "text" in c ? String(c.text) : JSON.stringify(c)
946
+ ).join("\n");
947
+ }
948
+ return JSON.stringify(content);
949
+ }
431
950
  async function runClaudeCode(opts) {
432
951
  let finalText = "";
433
952
  const { mcpServer, collected } = createWidgetTooling();
@@ -463,11 +982,34 @@ async function runClaudeCode(opts) {
463
982
  for (const block of content) {
464
983
  if (block && block.type === "text" && typeof block.text === "string") {
465
984
  process.stdout.write(block.text);
985
+ logEvent("agent", block.text);
986
+ } else if (block && block.type === "tool_use") {
987
+ logEvent("tool_use", `${block.name} ${JSON.stringify(block.input)}`);
988
+ }
989
+ }
990
+ }
991
+ } else if (message.type === "user") {
992
+ const content = message.message?.content;
993
+ if (Array.isArray(content)) {
994
+ for (const block of content) {
995
+ if (block && block.type === "tool_result") {
996
+ logEvent("tool_result", stringifyResult(block.content));
466
997
  }
467
998
  }
468
999
  }
469
1000
  } else if (message.type === "result") {
470
1001
  finalText = message.result ?? "";
1002
+ logEvent("result", finalText);
1003
+ const resultMsg = message;
1004
+ if (resultMsg.usage) {
1005
+ usageAcc.inputTokens += resultMsg.usage.input_tokens ?? 0;
1006
+ usageAcc.outputTokens += resultMsg.usage.output_tokens ?? 0;
1007
+ usageAcc.cacheCreationTokens += resultMsg.usage.cache_creation_input_tokens ?? 0;
1008
+ usageAcc.cacheReadTokens += resultMsg.usage.cache_read_input_tokens ?? 0;
1009
+ }
1010
+ usageAcc.costUsd += resultMsg.total_cost_usd ?? 0;
1011
+ } else if (message.type === "system") {
1012
+ logEvent("system", JSON.stringify(message));
471
1013
  }
472
1014
  }
473
1015
  process.stdout.write("\n");
@@ -527,11 +1069,11 @@ function errorMessage(err) {
527
1069
 
528
1070
  // src/rules.ts
529
1071
  import { readFileSync as readFileSync3 } from "node:fs";
530
- import { join as join2 } from "node:path";
1072
+ import { join as join4 } from "node:path";
531
1073
  import { fileURLToPath as fileURLToPath3 } from "node:url";
532
1074
  var RULES_DIR = fileURLToPath3(new URL("../skills-plugin/rules", import.meta.url));
533
1075
  function loadRule(name) {
534
- const raw = readFileSync3(join2(RULES_DIR, `${name}.md`), "utf8");
1076
+ const raw = readFileSync3(join4(RULES_DIR, `${name}.md`), "utf8");
535
1077
  return stripFrontMatter(raw).trim();
536
1078
  }
537
1079
  function stripFrontMatter(raw) {
@@ -547,18 +1089,18 @@ function turnHeading(turn, agentName) {
547
1089
  if (turn.kind === "report") return `${agentName} (implementation report)`;
548
1090
  return agentName;
549
1091
  }
550
- function appendThread(lines, ctx) {
1092
+ function appendThread(lines2, ctx) {
551
1093
  if (!ctx.thread || ctx.thread.length === 0) return;
552
- lines.push("", "# Conversation so far");
1094
+ lines2.push("", "# Conversation so far");
553
1095
  for (const turn of ctx.thread) {
554
- lines.push("", `## ${turnHeading(turn, ctx.agentName)}`, turn.content);
1096
+ lines2.push("", `## ${turnHeading(turn, ctx.agentName)}`, turn.content);
555
1097
  }
556
1098
  }
557
1099
  function buildPrompt(ctx) {
558
1100
  const task = ctx.permissionMode === "plan" ? `Use the \`flumecode:request-to-plan\` skill to handle this request. You are read-only and cannot modify files \u2014 clarify any ambiguity with the user first, then produce a concrete, actionable plan (the specific changes you would make and why). Cite the relevant files. Do NOT call ExitPlanMode or write the plan to a file. When the plan is ready, call the \`submit_plan\` tool with the structured plan fields; the runner renders it into the canonical plan markdown and posts it as your comment.` : `Use the \`flumecode:implement-plan\` skill to handle this request. You are the ORCHESTRATOR: do not implement, review, or write the report yourself \u2014 follow the skill to delegate each phase to subagents via the Task tool, picking the right model for each. Do not commit or push \u2014 the runner handles that.`;
559
1101
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this request. If there is no wiki, work from the code directly.`;
560
1102
  const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
561
- const lines = [
1103
+ const lines2 = [
562
1104
  `You are "${ctx.agentName}", an autonomous coding agent working inside a FlumeCode request.`,
563
1105
  `The repository ${ctx.repo.fullName} is checked out in your current working directory on branch "${ctx.repo.checkoutBranch}" at commit ${ctx.repo.checkoutSha.slice(0, 7)}.`,
564
1106
  task,
@@ -566,29 +1108,29 @@ function buildPrompt(ctx) {
566
1108
  widgets
567
1109
  ];
568
1110
  if (ctx.permissionMode !== "plan") {
569
- lines.push(
1111
+ lines2.push(
570
1112
  "",
571
1113
  "These coding guidelines apply to all code produced in this run:",
572
1114
  "",
573
1115
  loadRule("coding-guideline")
574
1116
  );
575
1117
  }
576
- lines.push("", `# Request: ${ctx.request?.title ?? ""}`);
1118
+ lines2.push("", `# Request: ${ctx.request?.title ?? ""}`);
577
1119
  if (ctx.request?.body) {
578
- lines.push("", ctx.request.body);
1120
+ lines2.push("", ctx.request.body);
579
1121
  }
580
- appendThread(lines, ctx);
581
- lines.push(
1122
+ appendThread(lines2, ctx);
1123
+ lines2.push(
582
1124
  "",
583
1125
  ctx.permissionMode === "plan" ? "Your final reply is posted verbatim as your comment in the thread \u2014 if you called `submit_plan`, the rendered plan is posted automatically; for clarifying questions, your reply text is posted as-is." : "Your final reply is posted verbatim as your comment in the thread \u2014 make it the implementation report your report subagent produced, with nothing added. The runner appends the pull-request link."
584
1126
  );
585
- return lines.join("\n");
1127
+ return lines2.join("\n");
586
1128
  }
587
1129
  function buildRevisePrompt(ctx) {
588
1130
  const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its implementation report appears in the conversation below, tagged as such); the user is now asking to fine-tune that implementation. Decide how to respond to their latest message: if it's unclear, ask a clarifying question (as a widget); if it's a bad idea or not feasible, push back with your reasoning; if it warrants rethinking the plan, call \`submit_plan\` with a revised plan; otherwise implement the requested change. When you implement, you are the ORCHESTRATOR: delegate the work to subagents via the Task tool as the skill directs, and do not commit or push \u2014 the runner handles that, updating the existing pull request.`;
589
1131
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this change. If there is no wiki, work from the code directly.`;
590
1132
  const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
591
- const lines = [
1133
+ const lines2 = [
592
1134
  `You are "${ctx.agentName}", an autonomous coding agent fine-tuning an implemented FlumeCode plan in an ongoing thread with the user.`,
593
1135
  `The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from, so any change you push updates that PR.`,
594
1136
  task,
@@ -602,20 +1144,20 @@ function buildRevisePrompt(ctx) {
602
1144
  `# Plan: ${ctx.request?.title ?? ""}`
603
1145
  ];
604
1146
  if (ctx.request?.body) {
605
- lines.push("", ctx.request.body);
1147
+ lines2.push("", ctx.request.body);
606
1148
  }
607
- appendThread(lines, ctx);
608
- lines.push(
1149
+ appendThread(lines2, ctx);
1150
+ lines2.push(
609
1151
  "",
610
1152
  "The last message above is the user's request for this turn. Your final reply is posted verbatim as your comment in the plan thread: if you implemented a change, make it a short report of what you changed (the runner appends the pull-request link); if you asked a question, called `submit_plan`, or pushed back, your reply text is posted as-is."
611
1153
  );
612
- return lines.join("\n");
1154
+ return lines2.join("\n");
613
1155
  }
614
- function buildResolvePrompt(ctx) {
1156
+ function buildResolvePrompt(ctx, related = []) {
615
1157
  const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
616
1158
  const task = `Use the \`flumecode:resolve-merge-conflict\` skill to handle this turn. A merge of \`${mergeBranch}\` into this branch is IN PROGRESS and has left conflict markers in your working tree. Resolve every conflicted file by correctly integrating BOTH sides \u2014 the change this session implemented (described below) and the incoming changes from \`${mergeBranch}\` \u2014 never blindly discard either side. Remove all conflict markers and verify the result builds and tests pass. Do NOT \`git add\`, commit, push, or open a pull request \u2014 the runner finalizes the merge commit and updates the existing pull request.`;
617
1159
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to the conflicting code. If there is no wiki, work from the code directly.`;
618
- const lines = [
1160
+ const lines2 = [
619
1161
  `You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
620
1162
  `The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from \u2014 with an in-progress merge of "${mergeBranch}".`,
621
1163
  task,
@@ -628,17 +1170,29 @@ function buildResolvePrompt(ctx) {
628
1170
  `# Plan: ${ctx.request?.title ?? ""}`
629
1171
  ];
630
1172
  if (ctx.request?.body) {
631
- lines.push("", ctx.request.body);
1173
+ lines2.push("", ctx.request.body);
1174
+ }
1175
+ appendThread(lines2, ctx);
1176
+ if (related.length > 0) {
1177
+ lines2.push(
1178
+ "",
1179
+ "# Related sessions behind the incoming changes",
1180
+ "Each conflicting change on the merge branch came from another coding session whose plan and report follow. Preserve THEIR intent too while integrating them with this session's work \u2014 do not undo what they built."
1181
+ );
1182
+ for (const r of related) {
1183
+ lines2.push("", `## PR #${r.prNumber} \u2014 ${r.title}`);
1184
+ if (r.plan) lines2.push("", "### Accepted plan", r.plan);
1185
+ if (r.report) lines2.push("", "### Final report", r.report);
1186
+ }
632
1187
  }
633
- appendThread(lines, ctx);
634
- lines.push(
1188
+ lines2.push(
635
1189
  "",
636
1190
  "Resolve the conflicts now. Your final reply is posted as a report in the plan thread: summarize which files conflicted and how you resolved each (the runner appends the pull-request link, so don't add one)."
637
1191
  );
638
- return lines.join("\n");
1192
+ return lines2.join("\n");
639
1193
  }
640
1194
  function buildDocumentPrompt(ctx) {
641
- const lines = [
1195
+ const lines2 = [
642
1196
  `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
643
1197
  `An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
644
1198
  `Use the \`flumecode:document\` skill to bring the wiki in sync with those changes. Only edit files under \`.flumecode/wiki/\` \u2014 do not touch application code. The runner commits the wiki alongside the implementation in the same pull request.`,
@@ -646,14 +1200,14 @@ function buildDocumentPrompt(ctx) {
646
1200
  `# Request: ${ctx.request?.title ?? ""}`
647
1201
  ];
648
1202
  if (ctx.request?.body) {
649
- lines.push("", ctx.request.body);
1203
+ lines2.push("", ctx.request.body);
650
1204
  }
651
- appendThread(lines, ctx);
652
- lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
653
- return lines.join("\n");
1205
+ appendThread(lines2, ctx);
1206
+ lines2.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
1207
+ return lines2.join("\n");
654
1208
  }
655
1209
  function buildRepairPrompt(ctx, hookLog) {
656
- const lines = [
1210
+ const lines2 = [
657
1211
  `You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
658
1212
  `The changes from the previous step are still uncommitted in the working tree. When the runner tried to commit them, the repository's pre-commit hook \u2014 which runs the project's own checks (lint / typecheck / unit tests) \u2014 failed. Make the working tree pass those checks: fix the failing code or tests at their root. Do NOT delete or skip tests, weaken assertions, or disable the checks to silence the failure. Preserve the intent of the original change; repair only what's broken. Do NOT commit or push \u2014 the runner re-commits once the checks pass.`,
659
1213
  "",
@@ -669,13 +1223,13 @@ function buildRepairPrompt(ctx, hookLog) {
669
1223
  "",
670
1224
  "When done, reply with a one-line summary of what you fixed."
671
1225
  ];
672
- return lines.join("\n");
1226
+ return lines2.join("\n");
673
1227
  }
674
1228
  function buildReleasePrompt(ctx, baseChecks) {
675
1229
  const task = `Use the \`flumecode:create-release\` skill to handle this turn. You are driving a release: first analyse commits since the last tag, propose version bumps, and ask the user to confirm via widgets (Phase 1); once the user's widget answers appear in the thread, apply the bumps to package.json files and update CHANGELOG.md (Phase 2). Do NOT commit or push \u2014 the runner handles that and opens the bump PR.`;
676
1230
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this release. If there is no wiki, work from the code directly.`;
677
1231
  const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
678
- const lines = [
1232
+ const lines2 = [
679
1233
  `You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
680
1234
  `The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
681
1235
  task,
@@ -689,10 +1243,10 @@ function buildReleasePrompt(ctx, baseChecks) {
689
1243
  `# Release: ${ctx.request?.title ?? ""}`
690
1244
  ];
691
1245
  if (ctx.request?.body) {
692
- lines.push("", ctx.request.body);
1246
+ lines2.push("", ctx.request.body);
693
1247
  }
694
1248
  if (baseChecks && !baseChecks.ok) {
695
- lines.push(
1249
+ lines2.push(
696
1250
  "",
697
1251
  "# Pre-release check status",
698
1252
  "",
@@ -708,12 +1262,12 @@ function buildReleasePrompt(ctx, baseChecks) {
708
1262
  "```"
709
1263
  );
710
1264
  }
711
- appendThread(lines, ctx);
712
- lines.push(
1265
+ appendThread(lines2, ctx);
1266
+ lines2.push(
713
1267
  "",
714
1268
  "Your final reply is posted verbatim as your comment in the release thread \u2014 if you called widgets (Phase 1), your reply text accompanies the questions; if you applied the bumps (Phase 2), make it the report the skill produced. The runner appends the pull-request link."
715
1269
  );
716
- return lines.join("\n");
1270
+ return lines2.join("\n");
717
1271
  }
718
1272
  function buildInitPrompt(ctx) {
719
1273
  return [
@@ -724,273 +1278,6 @@ function buildInitPrompt(ctx) {
724
1278
  ].join("\n");
725
1279
  }
726
1280
 
727
- // src/types.ts
728
- function jobTitle(ctx) {
729
- return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
730
- }
731
-
732
- // src/workspace.ts
733
- import { execFile } from "node:child_process";
734
- import { existsSync as existsSync2 } from "node:fs";
735
- import { mkdtemp, readdir, rm } from "node:fs/promises";
736
- import { tmpdir } from "node:os";
737
- import { join as join3 } from "node:path";
738
- import { promisify } from "node:util";
739
- var exec = promisify(execFile);
740
- var WORKSPACE_PREFIX = "flume-runner-";
741
- var MAX_BUFFER = 1 << 24;
742
- async function git(args) {
743
- return exec("git", args, { maxBuffer: MAX_BUFFER });
744
- }
745
- var RUNNER_GIT_EMAIL = "runner@flumecode.local";
746
- var RUNNER_GIT_NAME = "FlumeCode Runner";
747
- async function ensureGitIdentity(dir) {
748
- await git(["-C", dir, "config", "user.email", RUNNER_GIT_EMAIL]);
749
- await git(["-C", dir, "config", "user.name", RUNNER_GIT_NAME]);
750
- }
751
- function cloneUrl(ctx) {
752
- const { owner, name, cloneToken } = ctx.repo;
753
- return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
754
- }
755
- function detectPackageManager(dir) {
756
- if (!existsSync2(join3(dir, "package.json"))) return null;
757
- if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
758
- if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
759
- if (existsSync2(join3(dir, "package-lock.json"))) return "npm";
760
- if (existsSync2(join3(dir, "bun.lockb"))) return "bun";
761
- return "npm";
762
- }
763
- async function installDependencies(dir) {
764
- const manager = detectPackageManager(dir);
765
- if (manager === null) return { status: "skipped" };
766
- const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
767
- try {
768
- await exec(manager, ["install"], { cwd: dir, maxBuffer: MAX_BUFFER, env, timeout: 5 * 6e4 });
769
- return { status: "installed", manager };
770
- } catch (err) {
771
- return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
772
- }
773
- }
774
- async function makeWorkspace() {
775
- return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
776
- }
777
- var MAX_WORKSPACES = 8;
778
- var workspaceRegistry = /* @__PURE__ */ new Map();
779
- async function acquireWorkspace(key) {
780
- const existing = workspaceRegistry.get(key);
781
- if (existing !== void 0 && existsSync2(existing)) {
782
- workspaceRegistry.delete(key);
783
- workspaceRegistry.set(key, existing);
784
- return { dir: existing, reused: true };
785
- }
786
- const dir = await makeWorkspace();
787
- workspaceRegistry.set(key, dir);
788
- if (workspaceRegistry.size > MAX_WORKSPACES) {
789
- const oldest = workspaceRegistry.keys().next().value;
790
- const oldDir = workspaceRegistry.get(oldest);
791
- workspaceRegistry.delete(oldest);
792
- rm(oldDir, { recursive: true, force: true }).catch(() => {
793
- });
794
- }
795
- return { dir, reused: false };
796
- }
797
- async function discardWorkspace(key) {
798
- const dir = workspaceRegistry.get(key);
799
- workspaceRegistry.delete(key);
800
- if (dir !== void 0) {
801
- await cleanup(dir).catch(() => {
802
- });
803
- }
804
- }
805
- async function resetWorkspace(dir) {
806
- await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
807
- });
808
- await git(["-C", dir, "clean", "-fd"]).catch(() => {
809
- });
810
- }
811
- async function prepareAtSha(ctx, dir, reused) {
812
- if (!reused) {
813
- await cloneAtSha(ctx, dir);
814
- await ensureGitIdentity(dir);
815
- return;
816
- }
817
- await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
818
- await ensureGitIdentity(dir);
819
- }
820
- async function prepareResumingBranch(ctx, dir, reused) {
821
- if (!reused) {
822
- const result = await cloneResumingBranch(ctx, dir);
823
- await ensureGitIdentity(dir);
824
- return result;
825
- }
826
- await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
827
- await ensureGitIdentity(dir);
828
- return { resumed: true };
829
- }
830
- async function sweepWorkspaces() {
831
- const base = tmpdir();
832
- let entries;
833
- try {
834
- entries = await readdir(base);
835
- } catch {
836
- return 0;
837
- }
838
- let removed = 0;
839
- for (const entry of entries) {
840
- if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
841
- try {
842
- await rm(join3(base, entry), { recursive: true, force: true });
843
- removed++;
844
- } catch {
845
- }
846
- }
847
- return removed;
848
- }
849
- async function cloneAtSha(ctx, dir) {
850
- await git(["clone", "--quiet", cloneUrl(ctx), dir]);
851
- await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
852
- }
853
- async function cloneResumingBranch(ctx, dir) {
854
- await git(["clone", "--quiet", cloneUrl(ctx), dir]);
855
- try {
856
- await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
857
- await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
858
- return { resumed: true };
859
- } catch {
860
- await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
861
- return { resumed: false };
862
- }
863
- }
864
- async function hasChanges(dir) {
865
- await git(["-C", dir, "add", "-A"]);
866
- const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
867
- return stdout2.trim().length > 0;
868
- }
869
- var PreCommitError = class extends Error {
870
- constructor(log) {
871
- super("pre-commit checks failed");
872
- this.log = log;
873
- this.name = "PreCommitError";
874
- }
875
- };
876
- function commitFailureLog(err) {
877
- const e = err;
878
- const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
879
- return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
880
- }
881
- function isUnsupportedGitSubcommand(err) {
882
- const e = err;
883
- const text = `${typeof e.stderr === "string" ? e.stderr : ""}
884
- ${e.message ?? ""}`;
885
- return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
886
- }
887
- async function runRepoChecks(dir) {
888
- try {
889
- await git(["-C", dir, "hook", "run", "pre-commit"]);
890
- return { ok: true, log: "", skipped: false };
891
- } catch (err) {
892
- if (isUnsupportedGitSubcommand(err)) return { ok: true, log: "", skipped: true };
893
- return { ok: false, log: commitFailureLog(err), skipped: false };
894
- }
895
- }
896
- async function commitChanges(ctx, dir) {
897
- if (!await hasChanges(dir)) return false;
898
- try {
899
- await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
900
- } catch (err) {
901
- throw new PreCommitError(commitFailureLog(err));
902
- }
903
- return true;
904
- }
905
- async function pushBranch(ctx, dir) {
906
- await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
907
- }
908
- var RebaseConflictError = class extends Error {
909
- constructor(mergeBranch, files) {
910
- const list = files.length ? `: ${files.join(", ")}` : "";
911
- super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
912
- this.mergeBranch = mergeBranch;
913
- this.files = files;
914
- this.name = "RebaseConflictError";
915
- }
916
- };
917
- async function rebaseOntoMergeBranch(ctx, dir) {
918
- const { mergeBranch } = ctx.repo;
919
- if (!mergeBranch) return;
920
- await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
921
- try {
922
- await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
923
- } catch (err) {
924
- const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
925
- () => ({ stdout: "" })
926
- );
927
- const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
928
- await git(["-C", dir, "rebase", "--abort"]).catch(() => {
929
- });
930
- if (files.length === 0) throw err;
931
- throw new RebaseConflictError(mergeBranch, files);
932
- }
933
- }
934
- async function mergeInMergeBranch(ctx, dir) {
935
- const { mergeBranch } = ctx.repo;
936
- if (!mergeBranch) return { conflicted: false };
937
- await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
938
- try {
939
- await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
940
- return { conflicted: false };
941
- } catch {
942
- return { conflicted: true };
943
- }
944
- }
945
- async function listUnmergedPaths(dir) {
946
- const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
947
- stdout: ""
948
- }));
949
- return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
950
- }
951
- async function openPullRequest(ctx) {
952
- const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
953
- if (!mergeBranch) return null;
954
- const apiBase = `https://api.github.com/repos/${owner}/${name}`;
955
- const headers = {
956
- authorization: `Bearer ${cloneToken}`,
957
- accept: "application/vnd.github+json",
958
- "x-github-api-version": "2022-11-28",
959
- "content-type": "application/json"
960
- };
961
- const title = jobTitle(ctx);
962
- const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
963
- const res = await fetch(`${apiBase}/pulls`, {
964
- method: "POST",
965
- headers,
966
- body: JSON.stringify({
967
- title: `FlumeCode: ${title}`,
968
- head: checkoutBranch,
969
- base: mergeBranch,
970
- body
971
- })
972
- });
973
- if (res.status === 201) {
974
- const data = await res.json();
975
- return { number: data.number, url: data.html_url };
976
- }
977
- if (res.status === 422) {
978
- const list = await fetch(
979
- `${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
980
- { headers }
981
- );
982
- if (list.ok) {
983
- const open = await list.json();
984
- if (open[0]) return { number: open[0].number, url: open[0].html_url };
985
- }
986
- return null;
987
- }
988
- throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
989
- }
990
- async function cleanup(dir) {
991
- await rm(dir, { recursive: true, force: true });
992
- }
993
-
994
1281
  // src/run.ts
995
1282
  var IDLE_MS = 5e3;
996
1283
  var CANCEL_POLL_MS = 2500;
@@ -1001,7 +1288,7 @@ var MAX_IMPLEMENT_RETRIES = 1;
1001
1288
  var INIT_MAX_TURNS = 200;
1002
1289
  var DOCUMENT_MAX_TURNS = 120;
1003
1290
  var HEARTBEAT_MS = 5 * 6e4;
1004
- async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
1291
+ async function pushAndOpenPr(ctx, dir, config, abort, opts = { rebase: true }) {
1005
1292
  if (abort.signal.aborted) throw new Error("Run canceled by user");
1006
1293
  const committed = await commitWithRepair(ctx, dir, abort);
1007
1294
  if (!committed) return { outcome: { kind: "none" }, autoMerged: false };
@@ -1015,8 +1302,8 @@ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
1015
1302
  console.warn(
1016
1303
  ` rebase onto ${ctx.repo.mergeBranch} conflicted \u2014 merging it in and resolving with the agent\u2026`
1017
1304
  );
1018
- await mergeAndResolveConflicts(ctx, dir, abort);
1019
- await commitWithRepair(ctx, dir, abort);
1305
+ await mergeAndResolveConflicts(ctx, dir, config, abort);
1306
+ await commitWithRepair(ctx, dir, abort, { skipSocket: true });
1020
1307
  autoMerged = true;
1021
1308
  }
1022
1309
  }
@@ -1024,28 +1311,36 @@ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
1024
1311
  const pr = await openPullRequest(ctx);
1025
1312
  return { outcome: pr ? { kind: "pr", pr } : { kind: "pushed" }, autoMerged };
1026
1313
  }
1027
- async function mergeAndResolveConflicts(ctx, dir, abort) {
1314
+ async function mergeAndResolveConflicts(ctx, dir, config, abort) {
1028
1315
  const { conflicted } = await mergeInMergeBranch(ctx, dir);
1029
1316
  if (!conflicted) return { resolved: false, text: null };
1317
+ const conflictedPaths = await listUnmergedPaths(dir);
1318
+ const prNumbers = await incomingPrNumbers(ctx, dir, conflictedPaths);
1319
+ const related = await fetchRelatedSessions(config, {
1320
+ owner: ctx.repo.owner,
1321
+ name: ctx.repo.name,
1322
+ prNumbers
1323
+ });
1030
1324
  const result = await runClaudeCode({
1031
1325
  cwd: dir,
1032
- prompt: buildResolvePrompt(ctx),
1326
+ prompt: buildResolvePrompt(ctx, related),
1033
1327
  permissionMode: ctx.permissionMode,
1034
1328
  model: ORCHESTRATOR_MODEL,
1035
1329
  maxTurns: ORCHESTRATOR_MAX_TURNS,
1036
1330
  abortController: abort
1037
1331
  });
1038
- const unresolved = await listUnmergedPaths(dir);
1332
+ const unresolved = await listConflictMarkerPaths(dir, conflictedPaths);
1039
1333
  if (unresolved.length > 0) {
1040
1334
  throw new Error(
1041
- `Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still conflict: ${unresolved.join(", ")}`
1335
+ `Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still contain conflict markers: ${unresolved.join(", ")}`
1042
1336
  );
1043
1337
  }
1044
1338
  return { resolved: true, text: result.text.trim() || null };
1045
1339
  }
1046
- async function commitWithRepair(ctx, dir, abort) {
1340
+ async function commitWithRepair(ctx, dir, abort, opts = {}) {
1047
1341
  for (let attempt = 1; ; attempt++) {
1048
1342
  try {
1343
+ if (!opts.skipSocket) await runSocket("pre-commit", dir);
1049
1344
  return await commitChanges(ctx, dir);
1050
1345
  } catch (err) {
1051
1346
  if (!(err instanceof PreCommitError) || attempt > MAX_COMMIT_REPAIRS) throw err;
@@ -1084,38 +1379,38 @@ function outcomeBanner(outcome, opts) {
1084
1379
  \u2139\uFE0F **No code changes were made** \u2014 ${opts.noChange ?? "there was nothing to open a pull request for."}`;
1085
1380
  }
1086
1381
  }
1087
- async function processJob(ctx, abort = new AbortController()) {
1382
+ async function processJob(ctx, config, abort = new AbortController()) {
1088
1383
  const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
1089
1384
  let prepared = false;
1090
1385
  try {
1091
1386
  if (ctx.kind === "init") {
1092
1387
  await prepareAtSha(ctx, dir, reused);
1093
1388
  prepared = true;
1094
- return await processInitJob(ctx, dir, abort);
1389
+ return await processInitJob(ctx, dir, config, abort);
1095
1390
  }
1096
1391
  if (ctx.kind === "implement") {
1097
1392
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
1098
1393
  prepared = true;
1099
- return await processImplementJob(ctx, dir, resumed, abort);
1394
+ return await processImplementJob(ctx, dir, resumed, config, abort);
1100
1395
  }
1101
1396
  if (ctx.kind === "revise") {
1102
1397
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
1103
1398
  prepared = true;
1104
- return await processReviseJob(ctx, dir, resumed, abort);
1399
+ return await processReviseJob(ctx, dir, resumed, config, abort);
1105
1400
  }
1106
1401
  if (ctx.kind === "resolve") {
1107
1402
  await prepareResumingBranch(ctx, dir, reused);
1108
1403
  prepared = true;
1109
- return await processResolveJob(ctx, dir, abort);
1404
+ return await processResolveJob(ctx, dir, config, abort);
1110
1405
  }
1111
1406
  if (ctx.kind === "release") {
1112
1407
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
1113
1408
  prepared = true;
1114
- return await processReleaseJob(ctx, dir, resumed, abort);
1409
+ return await processReleaseJob(ctx, dir, resumed, config, abort);
1115
1410
  }
1116
1411
  await prepareAtSha(ctx, dir, reused);
1117
1412
  prepared = true;
1118
- return await processChatJob(ctx, dir, abort);
1413
+ return await processChatJob(ctx, dir, config, abort);
1119
1414
  } catch (err) {
1120
1415
  if (abort.signal.aborted && prepared) {
1121
1416
  await resetWorkspace(dir);
@@ -1125,7 +1420,7 @@ async function processJob(ctx, abort = new AbortController()) {
1125
1420
  throw err;
1126
1421
  }
1127
1422
  }
1128
- async function processInitJob(ctx, dir, abort) {
1423
+ async function processInitJob(ctx, dir, config, abort) {
1129
1424
  console.log(`
1130
1425
  \u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
1131
1426
  const summary = (await runClaudeCode({
@@ -1136,7 +1431,7 @@ async function processInitJob(ctx, dir, abort) {
1136
1431
  abortController: abort
1137
1432
  })).text.trim();
1138
1433
  let reply = summary || "(the agent produced no summary)";
1139
- const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
1434
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort);
1140
1435
  reply += outcomeBanner(outcome, {
1141
1436
  branch: ctx.repo.checkoutBranch,
1142
1437
  noChange: "no files were generated; the wiki may already exist.",
@@ -1144,7 +1439,7 @@ async function processInitJob(ctx, dir, abort) {
1144
1439
  });
1145
1440
  return { text: reply, widgets: [] };
1146
1441
  }
1147
- async function processChatJob(ctx, dir, abort) {
1442
+ async function processChatJob(ctx, dir, config, abort) {
1148
1443
  console.log(`
1149
1444
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1150
1445
  const orchestrating = ctx.permissionMode !== "plan";
@@ -1186,14 +1481,14 @@ async function processChatJob(ctx, dir, abort) {
1186
1481
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
1187
1482
  }
1188
1483
  }
1189
- const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
1484
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort);
1190
1485
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1191
1486
  return { text: reply, widgets: [] };
1192
1487
  }
1193
1488
  function reportClaimsWork(report) {
1194
1489
  return !!report && report.acceptanceCriteria.some((ac) => ac.status === "met" && ac.evidence.length > 0);
1195
1490
  }
1196
- async function processImplementJob(ctx, dir, resumed, abort) {
1491
+ async function processImplementJob(ctx, dir, resumed, config, abort) {
1197
1492
  console.log(`
1198
1493
  \u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1199
1494
  const installResult = await installDependencies(dir);
@@ -1241,7 +1536,9 @@ async function processImplementJob(ctx, dir, resumed, abort) {
1241
1536
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
1242
1537
  }
1243
1538
  }
1244
- const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1539
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
1540
+ rebase: !resumed
1541
+ });
1245
1542
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1246
1543
  return {
1247
1544
  text: reply,
@@ -1250,7 +1547,7 @@ async function processImplementJob(ctx, dir, resumed, abort) {
1250
1547
  ...outcome.kind === "pr" ? { pr: outcome.pr } : {}
1251
1548
  };
1252
1549
  }
1253
- async function processReviseJob(ctx, dir, resumed, abort) {
1550
+ async function processReviseJob(ctx, dir, resumed, config, abort) {
1254
1551
  console.log(`
1255
1552
  \u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1256
1553
  const installResult = await installDependencies(dir);
@@ -1290,7 +1587,9 @@ async function processReviseJob(ctx, dir, resumed, abort) {
1290
1587
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
1291
1588
  }
1292
1589
  }
1293
- const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1590
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
1591
+ rebase: !resumed
1592
+ });
1294
1593
  if (outcome.kind !== "none") {
1295
1594
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1296
1595
  }
@@ -1301,11 +1600,11 @@ async function processReviseJob(ctx, dir, resumed, abort) {
1301
1600
  ...result.plans?.length ? { plans: result.plans } : {}
1302
1601
  };
1303
1602
  }
1304
- async function processResolveJob(ctx, dir, abort) {
1603
+ async function processResolveJob(ctx, dir, config, abort) {
1305
1604
  console.log(`
1306
1605
  \u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1307
1606
  const installResult = await installDependencies(dir);
1308
- const { resolved, text } = await mergeAndResolveConflicts(ctx, dir, abort);
1607
+ const { resolved, text } = await mergeAndResolveConflicts(ctx, dir, config, abort);
1309
1608
  let reply = resolved ? text || "(the agent produced no report)" : `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
1310
1609
  if (installResult.status === "failed") {
1311
1610
  reply += `
@@ -1313,14 +1612,14 @@ async function processResolveJob(ctx, dir, abort) {
1313
1612
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1314
1613
  }
1315
1614
  if (abort.signal.aborted) throw new Error("Run canceled by user");
1316
- await commitWithRepair(ctx, dir, abort);
1615
+ await commitWithRepair(ctx, dir, abort, { skipSocket: true });
1317
1616
  await pushBranch(ctx, dir);
1318
1617
  const pr = await openPullRequest(ctx);
1319
1618
  const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
1320
1619
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
1321
1620
  return { text: reply, widgets: [], ...pr ? { pr } : {} };
1322
1621
  }
1323
- async function processReleaseJob(ctx, dir, resumed, abort) {
1622
+ async function processReleaseJob(ctx, dir, resumed, config, abort) {
1324
1623
  console.log(`
1325
1624
  \u25B6 Release ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1326
1625
  const installResult = await installDependencies(dir);
@@ -1351,7 +1650,9 @@ async function processReleaseJob(ctx, dir, resumed, abort) {
1351
1650
  );
1352
1651
  return { text: reply, widgets: result.widgets };
1353
1652
  }
1354
- const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
1653
+ const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
1654
+ rebase: !resumed
1655
+ });
1355
1656
  if (outcome.kind !== "none") {
1356
1657
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, autoMerged });
1357
1658
  }
@@ -1401,6 +1702,11 @@ async function pollLoop(config) {
1401
1702
  await sleep(IDLE_MS);
1402
1703
  continue;
1403
1704
  }
1705
+ startJobLog({
1706
+ jobId: ctx.jobId,
1707
+ kind: ctx.kind,
1708
+ secrets: [ctx.repo?.cloneToken ?? ""].filter(Boolean)
1709
+ });
1404
1710
  const abort = new AbortController();
1405
1711
  let stopPolling = false;
1406
1712
  const scheduleCancelPoll = () => {
@@ -1419,14 +1725,17 @@ async function pollLoop(config) {
1419
1725
  };
1420
1726
  scheduleCancelPoll();
1421
1727
  try {
1422
- const { text, widgets, pr, plans, report } = await processJob(ctx, abort);
1728
+ resetUsage();
1729
+ const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
1730
+ const usage = getUsage();
1423
1731
  await reportJob(config, ctx.jobId, {
1424
1732
  status: "done",
1425
1733
  text,
1426
1734
  widgets,
1427
1735
  pr,
1428
1736
  ...plans?.length ? { plans } : {},
1429
- ...report ? { report } : {}
1737
+ ...report ? { report } : {},
1738
+ ...usage.totalTokens > 0 ? { usage } : {}
1430
1739
  });
1431
1740
  console.log(`\u2713 Job ${ctx.jobId} done`);
1432
1741
  } catch (err) {
@@ -1450,6 +1759,13 @@ async function pollLoop(config) {
1450
1759
  }
1451
1760
  } finally {
1452
1761
  stopPolling = true;
1762
+ if (!abort.signal.aborted) {
1763
+ try {
1764
+ await uploadJobLog(config, ctx.jobId, getJobLog());
1765
+ } catch (e) {
1766
+ console.error(` (failed to upload logs: ${errorMessage2(e)})`);
1767
+ }
1768
+ }
1453
1769
  }
1454
1770
  }
1455
1771
  } finally {
@@ -1467,9 +1783,9 @@ var MAX_HOOK_LOG_CHARS = 4e3;
1467
1783
  function trimHookLog(log) {
1468
1784
  let trimmed = log.trimEnd();
1469
1785
  let elided = false;
1470
- const lines = trimmed.split("\n");
1471
- if (lines.length > MAX_HOOK_LOG_LINES) {
1472
- trimmed = lines.slice(-MAX_HOOK_LOG_LINES).join("\n");
1786
+ const lines2 = trimmed.split("\n");
1787
+ if (lines2.length > MAX_HOOK_LOG_LINES) {
1788
+ trimmed = lines2.slice(-MAX_HOOK_LOG_LINES).join("\n");
1473
1789
  elided = true;
1474
1790
  }
1475
1791
  if (trimmed.length > MAX_HOOK_LOG_CHARS) {