@flumecode/runner 0.3.1 → 0.5.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
@@ -40,6 +40,7 @@ function readVersion() {
40
40
  var RUNNER_VERSION = readVersion();
41
41
  var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
42
42
  var RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
43
+ var RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
43
44
  function compareVersions(a, b) {
44
45
  const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
45
46
  const pa = parse(a);
@@ -55,16 +56,29 @@ function compareVersions(a, b) {
55
56
  // src/api.ts
56
57
  var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
57
58
  var lastOutdatedWarnAt = 0;
59
+ var UPDATE_NUDGE_INTERVAL_MS = 60 * 6e4;
60
+ var lastUpdateNudgeAt = 0;
58
61
  function noteServerVersion(res) {
59
62
  const min = res.headers.get(RUNNER_MIN_VERSION_HEADER)?.trim();
60
- if (!min || RUNNER_VERSION === "unknown") return;
61
- if (compareVersions(RUNNER_VERSION, min) >= 0) return;
62
- const now = Date.now();
63
- if (now - lastOutdatedWarnAt < OUTDATED_WARN_INTERVAL_MS) return;
64
- lastOutdatedWarnAt = now;
65
- console.warn(
66
- `\u26A0\uFE0F This runner (v${RUNNER_VERSION}) is outdated \u2014 the server expects v${min} or newer.
63
+ if (min && RUNNER_VERSION !== "unknown" && compareVersions(RUNNER_VERSION, min) < 0) {
64
+ const now2 = Date.now();
65
+ if (now2 - lastOutdatedWarnAt >= OUTDATED_WARN_INTERVAL_MS) {
66
+ lastOutdatedWarnAt = now2;
67
+ console.warn(
68
+ `\u26A0\uFE0F This runner (v${RUNNER_VERSION}) is outdated \u2014 the server expects v${min} or newer.
67
69
  Update with: npm install -g @flumecode/runner@latest`
70
+ );
71
+ }
72
+ return;
73
+ }
74
+ const latest = res.headers.get(RUNNER_LATEST_VERSION_HEADER)?.trim();
75
+ if (!latest || RUNNER_VERSION === "unknown") return;
76
+ if (compareVersions(RUNNER_VERSION, latest) >= 0) return;
77
+ const now = Date.now();
78
+ if (now - lastUpdateNudgeAt < UPDATE_NUDGE_INTERVAL_MS) return;
79
+ lastUpdateNudgeAt = now;
80
+ console.log(
81
+ `\u2139\uFE0F A newer runner is available (v${latest}; you have v${RUNNER_VERSION}). Update with: npm install -g @flumecode/runner@latest`
68
82
  );
69
83
  }
70
84
  async function claimJob(config) {
@@ -83,6 +97,19 @@ async function claimJob(config) {
83
97
  if (!res.ok) throw new Error(`claim failed: ${res.status} ${await safeText(res)}`);
84
98
  return await res.json();
85
99
  }
100
+ async function pollJobCanceling(config, jobId) {
101
+ const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/cancel`, {
102
+ method: "GET",
103
+ headers: {
104
+ authorization: `Bearer ${config.token}`,
105
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
106
+ }
107
+ });
108
+ noteServerVersion(res);
109
+ if (!res.ok) return false;
110
+ const body = await res.json();
111
+ return body.canceling === true;
112
+ }
86
113
  async function reportJob(config, jobId, result) {
87
114
  const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/complete`, {
88
115
  method: "POST",
@@ -265,19 +292,30 @@ function renderPlan(plan) {
265
292
  lines.push(PLAN_MARKER);
266
293
  return lines.join("\n");
267
294
  }
295
+ var submitPlanInputSchema = {
296
+ plans: z2.array(z2.object(planInputSchema)).min(1).refine(
297
+ (arr) => {
298
+ const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
299
+ return new Set(titles).size === titles.length;
300
+ },
301
+ { message: "Each plan must have a distinct non-empty title" }
302
+ )
303
+ };
304
+ var submitPlanSchema = z2.object(submitPlanInputSchema);
268
305
  function createPlanTooling() {
269
- let renderedPlan = null;
306
+ let renderedPlans = null;
270
307
  const submitPlan = tool2(
271
308
  SUBMIT_PLAN,
272
- "Submit the finished plan. Call this \u2014 and only this \u2014 when the plan is complete and ready to post. The runner renders your structured fields into the canonical plan markdown and posts it as your comment. acceptanceCriteria is required and must contain at least 2 observable, verifiable conditions (behaviors, tests, or states you could check) that together define 'done'. After calling this, end your turn. The `title` field names this specific plan \u2014 make it concise and distinct from the request title.",
273
- planInputSchema,
309
+ "Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once. acceptanceCriteria is required in each plan and must contain at least 2 observable, verifiable conditions. The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles.",
310
+ submitPlanInputSchema,
274
311
  async (args) => {
275
- renderedPlan = renderPlan(planSchema.parse(args));
312
+ const parsed = submitPlanSchema.parse(args);
313
+ renderedPlans = parsed.plans.map(renderPlan);
276
314
  return {
277
315
  content: [
278
316
  {
279
317
  type: "text",
280
- text: "Plan submitted. The runner will render and post it as your comment. End your turn now."
318
+ text: "Plan(s) submitted. The runner will render and post them as your comment(s). End your turn now."
281
319
  }
282
320
  ]
283
321
  };
@@ -287,7 +325,7 @@ function createPlanTooling() {
287
325
  name: SERVER_NAME2,
288
326
  tools: [submitPlan]
289
327
  });
290
- return { mcpServer, getPlan: () => renderedPlan };
328
+ return { mcpServer, getPlans: () => renderedPlans };
291
329
  }
292
330
 
293
331
  // src/executor.ts
@@ -295,7 +333,7 @@ var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.ur
295
333
  async function runClaudeCode(opts) {
296
334
  let finalText = "";
297
335
  const { mcpServer, collected } = createWidgetTooling();
298
- const { mcpServer: planServer, getPlan } = createPlanTooling();
336
+ const { mcpServer: planServer, getPlans } = createPlanTooling();
299
337
  for await (const message of query({
300
338
  prompt: opts.prompt,
301
339
  options: {
@@ -303,6 +341,7 @@ async function runClaudeCode(opts) {
303
341
  permissionMode: opts.permissionMode,
304
342
  allowDangerouslySkipPermissions: opts.permissionMode === "bypassPermissions",
305
343
  ...opts.model ? { model: opts.model } : {},
344
+ ...opts.abortController ? { abortController: opts.abortController } : {},
306
345
  maxTurns: opts.maxTurns ?? 40,
307
346
  // Hermetic: ignore ALL config from the checked-out repo (CLAUDE.md,
308
347
  // .claude/settings.json, .claude/skills, …) and the runner owner's
@@ -333,7 +372,10 @@ async function runClaudeCode(opts) {
333
372
  }
334
373
  }
335
374
  process.stdout.write("\n");
336
- return { text: finalText, widgets: collected, plan: getPlan() };
375
+ if (opts.abortController?.signal.aborted) {
376
+ throw new Error("Run canceled by user");
377
+ }
378
+ return { text: finalText, widgets: collected, plans: getPlans() };
337
379
  }
338
380
 
339
381
  // src/health.ts
@@ -399,6 +441,20 @@ function stripFrontMatter(raw) {
399
441
  }
400
442
 
401
443
  // src/prompt.ts
444
+ function turnHeading(turn, agentName) {
445
+ if (turn.role === "user") return "User";
446
+ if (turn.failed) return `${agentName} (this run ended in an error)`;
447
+ if (turn.kind === "plan") return `${agentName} (proposed a plan)`;
448
+ if (turn.kind === "report") return `${agentName} (implementation report)`;
449
+ return agentName;
450
+ }
451
+ function appendThread(lines, ctx) {
452
+ if (!ctx.thread || ctx.thread.length === 0) return;
453
+ lines.push("", "# Conversation so far");
454
+ for (const turn of ctx.thread) {
455
+ lines.push("", `## ${turnHeading(turn, ctx.agentName)}`, turn.content);
456
+ }
457
+ }
402
458
  function buildPrompt(ctx) {
403
459
  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.`;
404
460
  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.`;
@@ -422,12 +478,7 @@ function buildPrompt(ctx) {
422
478
  if (ctx.request?.body) {
423
479
  lines.push("", ctx.request.body);
424
480
  }
425
- if (ctx.thread && ctx.thread.length > 0) {
426
- lines.push("", "# Conversation so far");
427
- for (const turn of ctx.thread) {
428
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
429
- }
430
- }
481
+ appendThread(lines, ctx);
431
482
  lines.push(
432
483
  "",
433
484
  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."
@@ -435,7 +486,7 @@ function buildPrompt(ctx) {
435
486
  return lines.join("\n");
436
487
  }
437
488
  function buildRevisePrompt(ctx) {
438
- const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its report is included); 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.`;
489
+ 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.`;
439
490
  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.`;
440
491
  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.`;
441
492
  const lines = [
@@ -454,21 +505,39 @@ function buildRevisePrompt(ctx) {
454
505
  if (ctx.request?.body) {
455
506
  lines.push("", ctx.request.body);
456
507
  }
457
- if (ctx.priorReport) {
458
- lines.push("", "# Latest implementation report", "", ctx.priorReport);
459
- }
460
- if (ctx.thread && ctx.thread.length > 0) {
461
- lines.push("", "# Conversation so far");
462
- for (const turn of ctx.thread) {
463
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
464
- }
465
- }
508
+ appendThread(lines, ctx);
466
509
  lines.push(
467
510
  "",
468
511
  "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."
469
512
  );
470
513
  return lines.join("\n");
471
514
  }
515
+ function buildResolvePrompt(ctx) {
516
+ const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
517
+ 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.`;
518
+ 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.`;
519
+ const lines = [
520
+ `You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
521
+ `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}".`,
522
+ task,
523
+ orient,
524
+ "",
525
+ "These coding guidelines apply to all code produced in this run:",
526
+ "",
527
+ loadRule("coding-guideline"),
528
+ "",
529
+ `# Plan: ${ctx.request?.title ?? ""}`
530
+ ];
531
+ if (ctx.request?.body) {
532
+ lines.push("", ctx.request.body);
533
+ }
534
+ appendThread(lines, ctx);
535
+ lines.push(
536
+ "",
537
+ "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)."
538
+ );
539
+ return lines.join("\n");
540
+ }
472
541
  function buildDocumentPrompt(ctx) {
473
542
  const lines = [
474
543
  `You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
@@ -480,12 +549,7 @@ function buildDocumentPrompt(ctx) {
480
549
  if (ctx.request?.body) {
481
550
  lines.push("", ctx.request.body);
482
551
  }
483
- if (ctx.thread && ctx.thread.length > 0) {
484
- lines.push("", "# Conversation so far");
485
- for (const turn of ctx.thread) {
486
- lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
487
- }
488
- }
552
+ appendThread(lines, ctx);
489
553
  lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
490
554
  return lines.join("\n");
491
555
  }
@@ -589,6 +653,12 @@ async function discardWorkspace(key) {
589
653
  });
590
654
  }
591
655
  }
656
+ async function resetWorkspace(dir) {
657
+ await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
658
+ });
659
+ await git(["-C", dir, "clean", "-fd"]).catch(() => {
660
+ });
661
+ }
592
662
  async function prepareAtSha(ctx, dir, reused) {
593
663
  if (!reused) {
594
664
  await cloneAtSha(ctx, dir);
@@ -702,6 +772,33 @@ async function rebaseOntoMergeBranch(ctx, dir) {
702
772
  throw new RebaseConflictError(mergeBranch, files);
703
773
  }
704
774
  }
775
+ async function mergeInMergeBranch(ctx, dir) {
776
+ const { mergeBranch } = ctx.repo;
777
+ if (!mergeBranch) return { conflicted: false };
778
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
779
+ try {
780
+ await git([
781
+ "-C",
782
+ dir,
783
+ "-c",
784
+ "user.email=runner@flumecode.local",
785
+ "-c",
786
+ "user.name=FlumeCode Runner",
787
+ "merge",
788
+ "--no-edit",
789
+ "FETCH_HEAD"
790
+ ]);
791
+ return { conflicted: false };
792
+ } catch {
793
+ return { conflicted: true };
794
+ }
795
+ }
796
+ async function listUnmergedPaths(dir) {
797
+ const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
798
+ stdout: ""
799
+ }));
800
+ return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
801
+ }
705
802
  async function openPullRequest(ctx) {
706
803
  const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
707
804
  if (!mergeBranch) return null;
@@ -747,21 +844,23 @@ async function cleanup(dir) {
747
844
 
748
845
  // src/run.ts
749
846
  var IDLE_MS = 5e3;
847
+ var CANCEL_POLL_MS = 2500;
750
848
  var ORCHESTRATOR_MODEL = "sonnet";
751
849
  var ORCHESTRATOR_MAX_TURNS = 80;
752
850
  var MAX_COMMIT_REPAIRS = 2;
753
851
  var INIT_MAX_TURNS = 200;
754
852
  var DOCUMENT_MAX_TURNS = 120;
755
853
  var HEARTBEAT_MS = 5 * 6e4;
756
- async function pushAndOpenPr(ctx, dir, opts = { rebase: true }) {
757
- const committed = await commitWithRepair(ctx, dir);
854
+ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
855
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
856
+ const committed = await commitWithRepair(ctx, dir, abort);
758
857
  if (!committed) return { kind: "none" };
759
858
  if (opts.rebase) await rebaseOntoMergeBranch(ctx, dir);
760
859
  await pushBranch(ctx, dir);
761
860
  const pr = await openPullRequest(ctx);
762
861
  return pr ? { kind: "pr", pr } : { kind: "pushed" };
763
862
  }
764
- async function commitWithRepair(ctx, dir) {
863
+ async function commitWithRepair(ctx, dir, abort) {
765
864
  for (let attempt = 1; ; attempt++) {
766
865
  try {
767
866
  return await commitChanges(ctx, dir);
@@ -775,7 +874,8 @@ async function commitWithRepair(ctx, dir) {
775
874
  prompt: buildRepairPrompt(ctx, err.log),
776
875
  permissionMode: ctx.permissionMode,
777
876
  model: ORCHESTRATOR_MODEL,
778
- maxTurns: ORCHESTRATOR_MAX_TURNS
877
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
878
+ abortController: abort
779
879
  });
780
880
  }
781
881
  }
@@ -800,51 +900,61 @@ function outcomeBanner(outcome, opts) {
800
900
  \u2139\uFE0F **No code changes were made** \u2014 ${opts.noChange ?? "there was nothing to open a pull request for."}`;
801
901
  }
802
902
  }
803
- async function processJob(ctx) {
903
+ async function processJob(ctx, abort = new AbortController()) {
804
904
  const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
805
905
  let prepared = false;
806
906
  try {
807
907
  if (ctx.kind === "init") {
808
908
  await prepareAtSha(ctx, dir, reused);
809
909
  prepared = true;
810
- return await processInitJob(ctx, dir);
910
+ return await processInitJob(ctx, dir, abort);
811
911
  }
812
912
  if (ctx.kind === "implement") {
813
913
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
814
914
  prepared = true;
815
- return await processImplementJob(ctx, dir, resumed);
915
+ return await processImplementJob(ctx, dir, resumed, abort);
816
916
  }
817
917
  if (ctx.kind === "revise") {
818
918
  const { resumed } = await prepareResumingBranch(ctx, dir, reused);
819
919
  prepared = true;
820
- return await processReviseJob(ctx, dir, resumed);
920
+ return await processReviseJob(ctx, dir, resumed, abort);
921
+ }
922
+ if (ctx.kind === "resolve") {
923
+ await prepareResumingBranch(ctx, dir, reused);
924
+ prepared = true;
925
+ return await processResolveJob(ctx, dir, abort);
821
926
  }
822
927
  await prepareAtSha(ctx, dir, reused);
823
928
  prepared = true;
824
- return await processChatJob(ctx, dir);
929
+ return await processChatJob(ctx, dir, abort);
825
930
  } catch (err) {
826
- if (!prepared) await discardWorkspace(ctx.workspaceKey);
931
+ if (abort.signal.aborted && prepared) {
932
+ await resetWorkspace(dir);
933
+ } else if (!prepared) {
934
+ await discardWorkspace(ctx.workspaceKey);
935
+ }
827
936
  throw err;
828
937
  }
829
938
  }
830
- async function processInitJob(ctx, dir) {
939
+ async function processInitJob(ctx, dir, abort) {
831
940
  console.log(`
832
941
  \u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
833
942
  const summary = (await runClaudeCode({
834
943
  cwd: dir,
835
944
  prompt: buildInitPrompt(ctx),
836
945
  permissionMode: ctx.permissionMode,
837
- maxTurns: INIT_MAX_TURNS
946
+ maxTurns: INIT_MAX_TURNS,
947
+ abortController: abort
838
948
  })).text.trim();
839
949
  let reply = summary || "(the agent produced no summary)";
840
- const outcome = await pushAndOpenPr(ctx, dir);
950
+ const outcome = await pushAndOpenPr(ctx, dir, abort);
841
951
  reply += outcomeBanner(outcome, {
842
952
  branch: ctx.repo.checkoutBranch,
843
953
  noChange: "no files were generated; the wiki may already exist."
844
954
  });
845
955
  return { text: reply, widgets: [] };
846
956
  }
847
- async function processChatJob(ctx, dir) {
957
+ async function processChatJob(ctx, dir, abort) {
848
958
  console.log(`
849
959
  \u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
850
960
  const orchestrating = ctx.permissionMode !== "plan";
@@ -853,18 +963,19 @@ async function processChatJob(ctx, dir) {
853
963
  cwd: dir,
854
964
  prompt: buildPrompt(ctx),
855
965
  permissionMode: ctx.permissionMode,
966
+ abortController: abort,
856
967
  ...orchestrating ? { model: ORCHESTRATOR_MODEL, maxTurns: ORCHESTRATOR_MAX_TURNS } : {}
857
968
  });
858
969
  const summary = result.text.trim();
859
970
  let reply = summary || "(the agent produced no summary)";
860
- if (result.plan) {
861
- reply = result.plan;
862
- }
863
971
  if (installResult?.status === "failed") {
864
972
  reply += `
865
973
 
866
974
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
867
975
  }
976
+ if (result.plans?.length) {
977
+ return { text: result.text.trim(), widgets: [], plans: result.plans };
978
+ }
868
979
  if (result.widgets.length > 0) {
869
980
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
870
981
  return { text: reply, widgets: result.widgets };
@@ -877,18 +988,19 @@ async function processChatJob(ctx, dir) {
877
988
  cwd: dir,
878
989
  prompt: buildDocumentPrompt(ctx),
879
990
  permissionMode: ctx.permissionMode,
880
- maxTurns: DOCUMENT_MAX_TURNS
991
+ maxTurns: DOCUMENT_MAX_TURNS,
992
+ abortController: abort
881
993
  });
882
994
  documented = true;
883
995
  } catch (err) {
884
996
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
885
997
  }
886
998
  }
887
- const outcome = await pushAndOpenPr(ctx, dir);
999
+ const outcome = await pushAndOpenPr(ctx, dir, abort);
888
1000
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
889
1001
  return { text: reply, widgets: [] };
890
1002
  }
891
- async function processImplementJob(ctx, dir, resumed) {
1003
+ async function processImplementJob(ctx, dir, resumed, abort) {
892
1004
  console.log(`
893
1005
  \u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
894
1006
  const installResult = await installDependencies(dir);
@@ -897,7 +1009,8 @@ async function processImplementJob(ctx, dir, resumed) {
897
1009
  prompt: buildPrompt(ctx),
898
1010
  permissionMode: ctx.permissionMode,
899
1011
  model: ORCHESTRATOR_MODEL,
900
- maxTurns: ORCHESTRATOR_MAX_TURNS
1012
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1013
+ abortController: abort
901
1014
  });
902
1015
  let reply = result.text.trim() || "(the agent produced no report)";
903
1016
  if (installResult.status === "failed") {
@@ -913,18 +1026,19 @@ async function processImplementJob(ctx, dir, resumed) {
913
1026
  cwd: dir,
914
1027
  prompt: buildDocumentPrompt(ctx),
915
1028
  permissionMode: ctx.permissionMode,
916
- maxTurns: DOCUMENT_MAX_TURNS
1029
+ maxTurns: DOCUMENT_MAX_TURNS,
1030
+ abortController: abort
917
1031
  });
918
1032
  documented = true;
919
1033
  } catch (err) {
920
1034
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
921
1035
  }
922
1036
  }
923
- const outcome = await pushAndOpenPr(ctx, dir, { rebase: !resumed });
1037
+ const outcome = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
924
1038
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
925
1039
  return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
926
1040
  }
927
- async function processReviseJob(ctx, dir, resumed) {
1041
+ async function processReviseJob(ctx, dir, resumed, abort) {
928
1042
  console.log(`
929
1043
  \u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
930
1044
  const installResult = await installDependencies(dir);
@@ -933,11 +1047,12 @@ async function processReviseJob(ctx, dir, resumed) {
933
1047
  prompt: buildRevisePrompt(ctx),
934
1048
  permissionMode: ctx.permissionMode,
935
1049
  model: ORCHESTRATOR_MODEL,
936
- maxTurns: ORCHESTRATOR_MAX_TURNS
1050
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1051
+ abortController: abort
937
1052
  });
938
1053
  const summary = result.text.trim();
939
1054
  let reply = summary || "(the agent produced no reply)";
940
- if (result.plan) reply = result.plan;
1055
+ if (result.plans?.length) reply = result.plans[0] ?? reply;
941
1056
  if (installResult.status === "failed") {
942
1057
  reply += `
943
1058
 
@@ -955,18 +1070,62 @@ async function processReviseJob(ctx, dir, resumed) {
955
1070
  cwd: dir,
956
1071
  prompt: buildDocumentPrompt(ctx),
957
1072
  permissionMode: ctx.permissionMode,
958
- maxTurns: DOCUMENT_MAX_TURNS
1073
+ maxTurns: DOCUMENT_MAX_TURNS,
1074
+ abortController: abort
959
1075
  });
960
1076
  documented = true;
961
1077
  } catch (err) {
962
1078
  console.warn(` wiki update skipped: ${errorMessage2(err)}`);
963
1079
  }
964
1080
  }
965
- const outcome = await pushAndOpenPr(ctx, dir, { rebase: !resumed });
1081
+ const outcome = await pushAndOpenPr(ctx, dir, abort, { rebase: !resumed });
966
1082
  if (outcome.kind !== "none") {
967
1083
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
968
1084
  }
969
- return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
1085
+ return {
1086
+ text: reply,
1087
+ widgets: [],
1088
+ ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
1089
+ ...result.plans?.length ? { plans: result.plans } : {}
1090
+ };
1091
+ }
1092
+ async function processResolveJob(ctx, dir, abort) {
1093
+ console.log(`
1094
+ \u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1095
+ const installResult = await installDependencies(dir);
1096
+ const { conflicted } = await mergeInMergeBranch(ctx, dir);
1097
+ let reply;
1098
+ if (conflicted) {
1099
+ const result = await runClaudeCode({
1100
+ cwd: dir,
1101
+ prompt: buildResolvePrompt(ctx),
1102
+ permissionMode: ctx.permissionMode,
1103
+ model: ORCHESTRATOR_MODEL,
1104
+ maxTurns: ORCHESTRATOR_MAX_TURNS,
1105
+ abortController: abort
1106
+ });
1107
+ reply = result.text.trim() || "(the agent produced no report)";
1108
+ const unresolved = await listUnmergedPaths(dir);
1109
+ if (unresolved.length > 0) {
1110
+ throw new Error(
1111
+ `Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still conflict: ${unresolved.join(", ")}`
1112
+ );
1113
+ }
1114
+ } else {
1115
+ reply = `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
1116
+ }
1117
+ if (installResult.status === "failed") {
1118
+ reply += `
1119
+
1120
+ > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1121
+ }
1122
+ if (abort.signal.aborted) throw new Error("Run canceled by user");
1123
+ await commitWithRepair(ctx, dir, abort);
1124
+ await pushBranch(ctx, dir);
1125
+ const pr = await openPullRequest(ctx);
1126
+ const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
1127
+ reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
1128
+ return { text: reply, widgets: [], ...pr ? { pr } : {} };
970
1129
  }
971
1130
  async function heartbeat(config) {
972
1131
  const health = await checkClaudeCode();
@@ -986,37 +1145,78 @@ async function pollLoop(config) {
986
1145
  const swept = await sweepWorkspaces();
987
1146
  if (swept > 0) console.log(`Cleared ${swept} stale workspace${swept === 1 ? "" : "s"}.`);
988
1147
  await heartbeat(config);
989
- let nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
990
- for (; ; ) {
991
- if (Date.now() >= nextHeartbeatAt) {
1148
+ let heartbeatTimer;
1149
+ const scheduleHeartbeat = () => {
1150
+ heartbeatTimer = setTimeout(async () => {
992
1151
  await heartbeat(config);
993
- nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
994
- }
995
- let ctx = null;
996
- try {
997
- ctx = await claimJob(config);
998
- } catch (err) {
999
- console.error(`Claim error: ${errorMessage2(err)}`);
1000
- await sleep(IDLE_MS);
1001
- continue;
1002
- }
1003
- if (!ctx) {
1004
- await sleep(IDLE_MS);
1005
- continue;
1006
- }
1007
- try {
1008
- const { text, widgets, pr } = await processJob(ctx);
1009
- await reportJob(config, ctx.jobId, { status: "done", text, widgets, pr });
1010
- console.log(`\u2713 Job ${ctx.jobId} done`);
1011
- } catch (err) {
1012
- const message = errorMessage2(err);
1013
- console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
1152
+ scheduleHeartbeat();
1153
+ }, HEARTBEAT_MS);
1154
+ };
1155
+ scheduleHeartbeat();
1156
+ try {
1157
+ for (; ; ) {
1158
+ let ctx = null;
1159
+ try {
1160
+ ctx = await claimJob(config);
1161
+ } catch (err) {
1162
+ console.error(`Claim error: ${errorMessage2(err)}`);
1163
+ await sleep(IDLE_MS);
1164
+ continue;
1165
+ }
1166
+ if (!ctx) {
1167
+ await sleep(IDLE_MS);
1168
+ continue;
1169
+ }
1170
+ const abort = new AbortController();
1171
+ let stopPolling = false;
1172
+ const scheduleCancelPoll = () => {
1173
+ setTimeout(async () => {
1174
+ if (stopPolling) return;
1175
+ try {
1176
+ if (await pollJobCanceling(config, ctx.jobId)) {
1177
+ console.log(`\u23F9 Stop requested for job ${ctx.jobId} \u2014 aborting\u2026`);
1178
+ abort.abort();
1179
+ return;
1180
+ }
1181
+ } catch {
1182
+ }
1183
+ if (!stopPolling) scheduleCancelPoll();
1184
+ }, CANCEL_POLL_MS);
1185
+ };
1186
+ scheduleCancelPoll();
1014
1187
  try {
1015
- await reportJob(config, ctx.jobId, { status: "error", error: message });
1016
- } catch (reportErr) {
1017
- console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
1188
+ const { text, widgets, pr, plans } = await processJob(ctx, abort);
1189
+ await reportJob(config, ctx.jobId, {
1190
+ status: "done",
1191
+ text,
1192
+ widgets,
1193
+ pr,
1194
+ ...plans?.length ? { plans } : {}
1195
+ });
1196
+ console.log(`\u2713 Job ${ctx.jobId} done`);
1197
+ } catch (err) {
1198
+ if (abort.signal.aborted) {
1199
+ console.log(`\u23F9 Job ${ctx.jobId} canceled`);
1200
+ try {
1201
+ await reportJob(config, ctx.jobId, { status: "canceled" });
1202
+ } catch (reportErr) {
1203
+ console.error(` (failed to report the cancellation: ${errorMessage2(reportErr)})`);
1204
+ }
1205
+ } else {
1206
+ const message = errorMessage2(err);
1207
+ console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
1208
+ try {
1209
+ await reportJob(config, ctx.jobId, { status: "error", error: message });
1210
+ } catch (reportErr) {
1211
+ console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
1212
+ }
1213
+ }
1214
+ } finally {
1215
+ stopPolling = true;
1018
1216
  }
1019
1217
  }
1218
+ } finally {
1219
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
1020
1220
  }
1021
1221
  }
1022
1222
  function sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flumecode/runner",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "FlumeCode local runner — claims jobs and drives your local Claude Code against a real checkout.",
6
6
  "bin": {
@@ -84,13 +84,13 @@ plan without re-deriving it.
84
84
 
85
85
  ### Multiple plans per request
86
86
 
87
- A single request can yield **several** plans — one thread can be accepted into
88
- many. If the work naturally splits into independent pieces, or the user asks for
89
- more than one plan, call `submit_plan` once for each finished plan so each can
90
- be accepted into its own GitHub issue. Give each plan its own distinct `title`
91
- so sibling plans don't collide. After a plan is accepted the user may keep
92
- commenting to refine it; treat a later turn as a fresh **Plan** phase and call
93
- `submit_plan` again with the revised fields.
87
+ A single request can yield **several** plans — one thread can be accepted into many. If the
88
+ work naturally splits into independent pieces, or the user asks for more than one plan, make
89
+ **ONE `submit_plan` call** with all of them in the `plans[]` array (one entry per plan, each
90
+ with a distinct title). Do **not** call `submit_plan` more than once. Each entry becomes its
91
+ own independently-acceptable "Accept as plan" draft. After a plan is accepted the user may
92
+ keep commenting to refine it; treat a later turn as a fresh **Plan** phase and call
93
+ `submit_plan` again with a `plans[]` array containing the revised fields.
94
94
 
95
95
  ## Always
96
96
 
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: resolve-merge-conflict
3
+ description: >-
4
+ Resolve the merge conflicts left by an in-progress merge of a coding session's
5
+ merge branch into its pull-request branch. Use in resolve-job runs. The working
6
+ tree already has conflict markers; integrate both sides of every conflict
7
+ correctly, verify the build/tests, and leave the resolved changes in the working
8
+ tree. Never `git add`, commit, push, or open a PR — the runner finalizes the merge.
9
+ ---
10
+
11
+ # resolve-merge-conflict
12
+
13
+ The plan in the context above was **already implemented** and has an open pull
14
+ request. Its merge branch (e.g. `main`) has since advanced and now **conflicts** with
15
+ the PR branch. The runner has already started a `git merge` of the merge branch into
16
+ your current branch, so your working tree contains the **conflict markers**
17
+ (`<<<<<<<`, `=======`, `>>>>>>>`). Your job is to resolve every conflict so the PR
18
+ becomes mergeable again — without losing this session's work or the incoming changes.
19
+
20
+ ## You are stateless — orient yourself first
21
+
22
+ You see the whole session thread (the plan and its implementation report) but keep
23
+ **no memory** between turns. Read it first: the plan and report tell you what this
24
+ branch was building, which is exactly the intent you must preserve when you choose how
25
+ to integrate each conflict. Check the FlumeCode wiki (`.flumecode/wiki/README.md`) if
26
+ one exists to understand the conflicting code.
27
+
28
+ ## Step 1 — Find every conflict
29
+
30
+ - `git status` shows the unmerged paths; `git diff --diff-filter=U` shows the
31
+ conflicting hunks. Work through **all** of them — a single leftover marker fails the
32
+ run.
33
+
34
+ ## Step 2 — Resolve each conflict correctly
35
+
36
+ For each conflicted file, understand **both** sides before editing:
37
+
38
+ - **Ours** (the conflict's first side) is this session's implementation — the change
39
+ the plan/report describes.
40
+ - **Theirs** (the second side) is what landed on the merge branch since.
41
+
42
+ Integrate them so **both** intents survive. Do **not** blindly pick a side or delete
43
+ one half to make the markers go away — that silently drops work. When the two changes
44
+ are genuinely incompatible, prefer keeping this session's implementation intent while
45
+ adapting it to the incoming code (e.g. a renamed function, a moved file, a new
46
+ signature). Remove every conflict marker; the file must read as clean, correct code.
47
+
48
+ You may delegate isolated, well-scoped resolutions to **Task** subagents, but give
49
+ each a self-contained prompt (the conflicting file's content, both intents, and the
50
+ coding guidelines verbatim) — subagents start blank.
51
+
52
+ ## Step 3 — Verify
53
+
54
+ Run the project's build and tests to confirm the resolved tree is correct (the
55
+ runner's commit will also run the repo's pre-commit hook, so failing checks would
56
+ block the merge anyway). Fix anything the merge broke.
57
+
58
+ ## Never
59
+
60
+ - Never `git add`, `git commit`, `git merge --continue`, `git push`, or open a PR —
61
+ the runner stages the resolved tree, finalizes the merge commit, and updates the
62
+ **existing** pull request. A new PR must never be created.
63
+ - Never leave a conflict marker behind, and never resolve a conflict by discarding one
64
+ side's intent.
65
+
66
+ ## Your final reply
67
+
68
+ Your last message **is** the report posted to the session thread. Write it for the
69
+ user: list which files conflicted and, briefly, how you resolved each, plus how you
70
+ verified (build/tests). The runner appends the pull-request link, so don't add one.
@@ -39,11 +39,11 @@ actual code. Pick exactly one:
39
39
  Explain why in plain prose, offer an alternative if you have one, and end your
40
40
  turn. Make no code changes.
41
41
  - **Re-plan** — the request meaningfully changes scope or direction, enough that a
42
- fresh plan should be agreed before building. Call **`submit_plan`** with the
43
- revised structured fields (same shape as the request-to-plan skill: `scope`,
44
- `goal`, `assumptions`, `steps`, `acceptanceCriteria` — at least 2 —, `risks`,
45
- `outOfScope`). The runner posts it as a revision the user can accept; make no
46
- code changes this turn.
42
+ fresh plan should be agreed before building. Call **`submit_plan`** with a `plans[]` array
43
+ containing the revised structured fields (same per-plan shape as the request-to-plan skill:
44
+ `scope`, `goal`, `assumptions`, `steps`, `acceptanceCriteria` — at least 2 —, `risks`,
45
+ `outOfScope`). Include only one entry for a revise turn. The runner posts it as a revision
46
+ the user can accept; make no code changes this turn.
47
47
  - **Implement** — the request is clear and reasonable. Make the change (via
48
48
  subagents — see Step 2). This is the common case for small fine-tuning.
49
49