@mycompbox/compbox-cli 1.19.0 → 1.20.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/index.js CHANGED
@@ -5555,76 +5555,10 @@ var INTERVIEW_DEFINITION = [
5555
5555
  fields: ["alternatives"]
5556
5556
  }
5557
5557
  ];
5558
- var INTERVIEW_FIELD_MIN_WORDS = {
5559
- problemStatement: 60,
5560
- proposedSolution: 100,
5561
- impactAssessment: 40,
5562
- description: 150
5563
- };
5564
- function buildInterviewSystemPrompt(definition = INTERVIEW_DEFINITION, seedContext) {
5565
- const requiredSections = definition.filter((s) => s.required);
5566
- const optionalSections = definition.filter((s) => !s.required);
5567
- const renderSection = (s) => {
5568
- const tag = s.required ? "REQUIRED" : "OPTIONAL \u2014 ask, but the user may decline";
5569
- const followUps = s.followUps.map((q) => ` - ${q}`).join("\n");
5570
- return `- ${s.title} (${tag}) [json keys: ${s.fields.join(", ")}]
5571
- ${s.purpose}
5572
- ${followUps}`;
5573
- };
5574
- const completionKeys = [
5575
- "title",
5576
- ...interviewFieldKeysFor(definition),
5577
- "description"
5578
- ];
5579
- const completionShape = completionKeys.map((k) => `"${k}": "..."`).join(", ");
5580
- const base = `You are the CompBox request-formulator, interviewing an employee to turn their idea into a well-formed Epic Request. Ask exactly ONE short, focused question at a time \u2014 never multiple questions in one message, never preamble or explanation.
5581
-
5582
- Work through these interview sections in order. Cover EVERY required section; for optional sections, ask about them too, but if the employee has nothing to add or declines, move on and leave that field out (never invent or pad it):
5583
-
5584
- REQUIRED sections (an Epic Request cannot omit these):
5585
- ${requiredSections.map(renderSection).join("\n")}
5586
-
5587
- OPTIONAL-BUT-RECOMMENDED sections (ask about each; omit the field if the employee has nothing):
5588
- ${optionalSections.map(renderSection).join("\n")}
5589
-
5590
- The employee's answers may be short or casual \u2014 that's fine, but when you write the final fields below, EXPAND them into complete, well-written prose using everything they told you across the whole conversation, not just a compressed one-liner. Each required field is scored by an automated quality rubric with hard minimums, so undershooting length is a real failure, not just a style nitpick:
5591
- - problemStatement: at least ${String(INTERVIEW_FIELD_MIN_WORDS.problemStatement)} words.
5592
- - proposedSolution: at least ${String(INTERVIEW_FIELD_MIN_WORDS.proposedSolution)} words.
5593
- - impactAssessment: at least ${String(INTERVIEW_FIELD_MIN_WORDS.impactAssessment)} words.
5594
- - description: at least ${String(INTERVIEW_FIELD_MIN_WORDS.description)} words. This is a SEPARATE plain-text field (not shown to the employee) that stands alone as a full narrative summary of the whole request \u2014 restate the problem, the proposed solution, and the expected impact in flowing prose, as if writing the request from scratch for someone who hasn't seen the conversation.
5595
-
5596
- Once you have gathered the required sections (and covered the optional ones), respond with ONLY a fenced JSON code block (no other text) in exactly this shape. Include a key for every optional field you gathered; omit any optional field the employee had nothing for:
5597
- \`\`\`json
5598
- {"done": true, ${completionShape}}
5599
- \`\`\`
5600
-
5601
- Until then, respond with ONLY your next question as plain text \u2014 no JSON, no labels, no numbering.`;
5602
- if (!seedContext || seedContext.priorTranscript.length === 0) {
5603
- return base;
5604
- }
5605
- return `${renderContinuationHeader(seedContext.priorTranscript)}
5606
-
5607
- ${base}`;
5608
- }
5609
- function renderContinuationHeader(priorTranscript) {
5610
- const transcript = priorTranscript.map((t) => `${t.role === "agent" ? "You" : "Employee"}: ${t.content}`).join("\n");
5611
- return `\u26A0\uFE0F CONTINUATION \u2014 this is NOT a fresh interview. You already interviewed this employee; below is that prior conversation, and an Epic Request already exists from it. Your job now is to ENRICH that same request, not restart it.
5612
-
5613
- Prior conversation:
5614
- ${transcript}
5615
-
5616
- How to continue:
5617
- - Read the prior conversation first. Do NOT re-ask anything already answered there.
5618
- - Use the section list below only as a checklist of what a complete request needs. Identify which sections are MISSING or THIN, and ask focused questions to fill ONLY those gaps \u2014 still one short question at a time.
5619
- - If the prior conversation already covers everything well, go straight to the completion JSON.
5620
- - When you emit the final completion JSON, MERGE the prior answers with whatever new detail you gathered \u2014 restate and expand every field using the WHOLE conversation (old + new). Never drop or shorten content that was already captured; the goal is a richer request than before, meeting the same length minimums.
5621
-
5622
- `;
5623
- }
5624
- function interviewFieldKeysFor(definition) {
5558
+ function interviewFieldKeys() {
5625
5559
  const seen = /* @__PURE__ */ new Set();
5626
5560
  const out = [];
5627
- for (const section of definition) {
5561
+ for (const section of INTERVIEW_DEFINITION) {
5628
5562
  for (const f of section.fields) {
5629
5563
  if (!seen.has(f)) {
5630
5564
  seen.add(f);
@@ -5634,6 +5568,12 @@ function interviewFieldKeysFor(definition) {
5634
5568
  }
5635
5569
  return out;
5636
5570
  }
5571
+ var INTERVIEW_FIELD_MIN_WORDS = {
5572
+ problemStatement: 60,
5573
+ proposedSolution: 100,
5574
+ impactAssessment: 40,
5575
+ description: 150
5576
+ };
5637
5577
 
5638
5578
  // ../shared/dist/interview-completion.js
5639
5579
  var TARGET_COMPLIANCE_TIER = "pass";
@@ -5759,6 +5699,218 @@ function looksLikeCompletionAttempt(text) {
5759
5699
  }
5760
5700
  var COMPLETION_REPAIR_INSTRUCTION = 'Your last message looked like a completion but I could NOT parse it as a valid JSON object with all required fields. Re-emit ONLY a single valid JSON object inside a ```json code fence \u2014 no other text \u2014 with every string value properly escaped (escape any internal double-quotes as \\" and any line breaks as \\n), and make sure title, problemStatement, proposedSolution, impactAssessment, and description are all present and non-empty:\n```json\n{"done": true, "title": "...", "problemStatement": "...", "proposedSolution": "...", "impactAssessment": "...", "description": "..."}\n```';
5761
5701
 
5702
+ // ../shared/dist/interview-conversation.js
5703
+ var CONVERSATION_QUALITY_ROUNDS = 2;
5704
+ var JSON_REPAIRS = 3;
5705
+ var STARTER = "Hi! I'd like to propose something.";
5706
+ var NUDGE = "(Please continue.)";
5707
+ function renderSection(s) {
5708
+ const tag = s.required ? "needed" : "nice to have";
5709
+ return `- ${s.title} (${tag}): ${s.purpose} For example: ${s.followUps.slice(0, 2).join(" ")}`;
5710
+ }
5711
+ function renderSaved(saved) {
5712
+ const fieldLines = Object.entries(saved.fields).filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0).map(([key, value]) => `${key}: ${value}`).join("\n");
5713
+ const score = saved.compliance ? `Its quality score is ${String(saved.compliance.overall)}/100 (${saved.compliance.tier}).` : "It has no quality score yet.";
5714
+ const issues = saved.compliance && saved.compliance.issues.length > 0 ? `
5715
+ What the score says is missing or weak:
5716
+ ${saved.compliance.issues.slice(0, 8).map((i) => `- ${i.message}`).join("\n")}` : "";
5717
+ const locked = saved.status === "pending" ? "It can still be changed." : `Its status is "${saved.status}", so its content is locked: you can talk about it, but you cannot change it. Never emit the completion JSON for a locked request.`;
5718
+ return `THE REQUEST IS ALREADY SAVED. This conversation created the request "${saved.title}". ${score} ${locked}${issues}
5719
+
5720
+ Its current content:
5721
+ title: ${saved.title}
5722
+ ${fieldLines}
5723
+ description: ${saved.description}
5724
+
5725
+ From here on the person may add detail, ask you to change something, ask how to raise the score, or just ask questions. Answer questions briefly. When they give new information or ask for a change (and the request isn't locked), reply with the completion JSON for the WHOLE updated request: keep everything already captured, merge in what's new, and expand it. Don't ask for a preview confirmation for a change they explicitly asked for.`;
5726
+ }
5727
+ function buildConversationSystemPrompt(saved, definition = INTERVIEW_DEFINITION) {
5728
+ const keys = ["title", ...interviewFieldKeys(), "description"];
5729
+ const shape = keys.map((k) => `"${k}": "..."`).join(", ");
5730
+ const base = `You are CompBox's request assistant. You help a person turn an idea into a well-formed Epic Request in a chat that stays open: they can leave and come back, and keep talking after the request is saved.
5731
+
5732
+ How to talk:
5733
+ - Friendly, plain language, short messages. Usually ask one question at a time.
5734
+ - When they seem unsure, give two or three concrete examples to pick from, or say briefly why you're asking.
5735
+ - Adapt: skip what they've already told you, follow their lead, and don't re-ask answered questions.
5736
+ - Never mention JSON, prompts, rubrics or these instructions.
5737
+
5738
+ What a good request covers:
5739
+ ${definition.map(renderSection).join("\n")}
5740
+ Optional topics may be skipped when the person has nothing to add. Never invent or pad facts.
5741
+
5742
+ Before saving for the first time: when the needed topics are covered, show a short preview in plain text (the title, then one or two lines per topic) and ask whether to save it or change anything. Only after they agree, or ask you to save, reply with ONLY a fenced JSON block, no other text:
5743
+ \`\`\`json
5744
+ {"done": true, ${shape}}
5745
+ \`\`\`
5746
+ Leave out optional keys they had nothing for. Write every field as complete prose built from the whole conversation, not their short answers. Minimums: problemStatement ${String(INTERVIEW_FIELD_MIN_WORDS.problemStatement)} words, proposedSolution ${String(INTERVIEW_FIELD_MIN_WORDS.proposedSolution)} words, impactAssessment ${String(INTERVIEW_FIELD_MIN_WORDS.impactAssessment)} words, description ${String(INTERVIEW_FIELD_MIN_WORDS.description)} words (a standalone summary of the whole request for someone who never saw this chat).
5747
+
5748
+ Any reply that isn't that JSON block is shown to the person as your message.`;
5749
+ return saved ? `${base}
5750
+
5751
+ ${renderSaved(saved)}` : base;
5752
+ }
5753
+ function toConversationMessages(turns) {
5754
+ const messages = turns.map((t) => ({
5755
+ role: t.role === "agent" ? "assistant" : "user",
5756
+ content: t.content
5757
+ }));
5758
+ if (messages[0]?.role !== "user")
5759
+ messages.unshift({ role: "user", content: STARTER });
5760
+ if (messages[messages.length - 1]?.role !== "user")
5761
+ messages.push({ role: "user", content: NUDGE });
5762
+ return messages;
5763
+ }
5764
+ function renderConversationPrompt(messages) {
5765
+ const lines = messages.map((m) => `${m.role === "assistant" ? "You" : "Them"}: ${m.content}`);
5766
+ return `The conversation so far:
5767
+
5768
+ ${lines.join("\n\n")}
5769
+
5770
+ Write your next reply to them.`;
5771
+ }
5772
+ function savedMessage(saved, created, followUp) {
5773
+ const verb = created ? "Saved your request" : "Updated your request";
5774
+ const c = saved.compliance;
5775
+ let text;
5776
+ if (!c) {
5777
+ text = `${verb} "${saved.title}". You can keep talking here to add detail or change anything.`;
5778
+ } else if (meetsComplianceTarget(c)) {
5779
+ text = `${verb} "${saved.title}". It scores ${String(c.overall)}/100, ready for review. You can keep talking here to change anything.`;
5780
+ } else {
5781
+ const gaps = [...c.issues.filter((i) => i.tier === "critical"), ...c.issues.filter((i) => i.tier === "advisory")].slice(0, 4).map((i) => `- ${i.message}`).join("\n");
5782
+ text = `${verb} "${saved.title}". It scores ${String(c.overall)}/100; 90 is the target.${gaps ? ` To raise it:
5783
+ ${gaps}` : ""}
5784
+
5785
+ Tell me more here and I'll update it, or leave it as it is.`;
5786
+ }
5787
+ return followUp ? `${text}
5788
+
5789
+ ${followUp}` : text;
5790
+ }
5791
+ async function runConversationStep(input, deps) {
5792
+ const system = buildConversationSystemPrompt(input.saved);
5793
+ const messages = toConversationMessages(input.turns);
5794
+ const locked = input.saved !== null && input.saved.status !== "pending";
5795
+ let requestId = input.saved?.id ?? null;
5796
+ let savedNow = null;
5797
+ let createdHere = false;
5798
+ let qualityRounds = 0;
5799
+ let repairs = 0;
5800
+ for (; ; ) {
5801
+ let reply;
5802
+ try {
5803
+ reply = await deps.reply(system, messages);
5804
+ } catch (error) {
5805
+ if (savedNow)
5806
+ return { kind: "saved", text: savedMessage(savedNow.saved, savedNow.created), ...savedNow };
5807
+ const reason = error instanceof Error ? error.message : "Something went wrong reaching the AI.";
5808
+ return {
5809
+ kind: "error",
5810
+ text: `I couldn't reply just now: ${reason}
5811
+
5812
+ Send another message (for example "try again") when you're ready and I'll pick up where we left off.`
5813
+ };
5814
+ }
5815
+ const draft = extractCompletionJson(reply);
5816
+ if (draft) {
5817
+ if (locked) {
5818
+ messages.push({ role: "assistant", content: reply }, { role: "user", content: "That request is locked and can't be changed. Reply to me in plain text instead." });
5819
+ if (++repairs > JSON_REPAIRS)
5820
+ return { kind: "error", text: "This request is locked, so it can't be changed any more." };
5821
+ continue;
5822
+ }
5823
+ let saved;
5824
+ try {
5825
+ saved = await deps.save(requestId, draft);
5826
+ } catch (error) {
5827
+ const reason = error instanceof Error ? error.message : "the save failed";
5828
+ return { kind: "error", text: `I couldn't save the request: ${reason}
5829
+
5830
+ Reply "try again" and I'll retry.` };
5831
+ }
5832
+ createdHere ||= requestId === null;
5833
+ savedNow = { saved, created: createdHere };
5834
+ requestId = saved.id;
5835
+ if (meetsComplianceTarget(saved.compliance) || qualityRounds >= CONVERSATION_QUALITY_ROUNDS || !saved.compliance) {
5836
+ return { kind: "saved", text: savedMessage(saved, savedNow.created), ...savedNow };
5837
+ }
5838
+ qualityRounds++;
5839
+ messages.push({ role: "assistant", content: reply }, {
5840
+ role: "user",
5841
+ content: `${buildAuditFeedback(saved.compliance)}
5842
+
5843
+ The request IS already saved, and the person will be shown its score. If you ask them something, ask it as a way to improve the saved request; never say it hasn't been saved.`
5844
+ });
5845
+ continue;
5846
+ }
5847
+ if (looksLikeCompletionAttempt(reply)) {
5848
+ if (++repairs > JSON_REPAIRS) {
5849
+ return savedNow ? { kind: "saved", text: savedMessage(savedNow.saved, savedNow.created), ...savedNow } : { kind: "error", text: `I had trouble writing up the request. Reply "try again" and I'll have another go.` };
5850
+ }
5851
+ messages.push({ role: "assistant", content: reply }, { role: "user", content: COMPLETION_REPAIR_INSTRUCTION });
5852
+ continue;
5853
+ }
5854
+ const text = reply.trim();
5855
+ if (savedNow) {
5856
+ return { kind: "saved", text: savedMessage(savedNow.saved, savedNow.created, text || void 0), ...savedNow };
5857
+ }
5858
+ if (!text)
5859
+ return { kind: "error", text: "I didn't get a reply that time. Send another message and I'll try again." };
5860
+ return { kind: "message", text };
5861
+ }
5862
+ }
5863
+ function savedContextFromRecord(record) {
5864
+ if (typeof record !== "object" || record === null)
5865
+ return null;
5866
+ const r = record;
5867
+ if (typeof r.id !== "string" || typeof r.title !== "string")
5868
+ return null;
5869
+ let structured = r.structuredDesc;
5870
+ if (typeof structured === "string") {
5871
+ try {
5872
+ structured = JSON.parse(structured);
5873
+ } catch {
5874
+ structured = null;
5875
+ }
5876
+ }
5877
+ const fields = {};
5878
+ if (typeof structured === "object" && structured !== null) {
5879
+ for (const [key, value] of Object.entries(structured)) {
5880
+ if (typeof value === "string")
5881
+ fields[key] = value;
5882
+ }
5883
+ }
5884
+ let score = r.complianceScore;
5885
+ if (typeof score === "string") {
5886
+ try {
5887
+ score = JSON.parse(score);
5888
+ } catch {
5889
+ score = null;
5890
+ }
5891
+ }
5892
+ return {
5893
+ id: r.id,
5894
+ title: r.title,
5895
+ status: typeof r.status === "string" ? r.status : "pending",
5896
+ description: typeof r.description === "string" ? r.description : "",
5897
+ fields,
5898
+ compliance: complianceFromRecord(score)
5899
+ };
5900
+ }
5901
+ function complianceFromRecord(score) {
5902
+ if (typeof score !== "object" || score === null)
5903
+ return null;
5904
+ const s = score;
5905
+ if (typeof s.overall !== "number" || typeof s.tier !== "string")
5906
+ return null;
5907
+ const issues = Array.isArray(s.issues) ? s.issues.filter((i) => typeof i === "object" && i !== null).map((i) => ({
5908
+ message: typeof i.message === "string" ? i.message : typeof i.code === "string" ? i.code : "issue",
5909
+ tier: i.tier === "critical" ? "critical" : "advisory"
5910
+ })) : [];
5911
+ return { overall: s.overall, tier: s.tier, issues };
5912
+ }
5913
+
5762
5914
  // src/utils/config-store.ts
5763
5915
  var CONFIG_DIR_ENV = "COMPBOX_CONFIG_DIR";
5764
5916
  var PROJECT_DIR_ENV = "COMPBOX_PROJECT_DIR";
@@ -6360,12 +6512,18 @@ var MailboxClient = class {
6360
6512
  * here. Cloud Run bills vCPU for the wall-clock life of a request, so a held
6361
6513
  * connection kept one instance permanently awake at ~$64/month.
6362
6514
  */
6363
- async nextJob(machineToken, waitMs = 0) {
6515
+ /**
6516
+ * `runningIds`: jobs this daemon is already running, so the server hands out
6517
+ * new work instead of resuming one of them.
6518
+ */
6519
+ async nextJob(machineToken, waitMs = 0, runningIds = []) {
6364
6520
  let res;
6365
6521
  try {
6366
6522
  res = await this.http.get("/api/v1/machines/next-job", {
6367
6523
  headers: { Authorization: `Bearer ${machineToken}` },
6368
- params: { waitMs }
6524
+ // steps=1: this daemon runs request conversations one reply at a time,
6525
+ // so it never wants back a job that is waiting on the person.
6526
+ params: { waitMs, steps: "1", ...runningIds.length > 0 && { running: runningIds.join(",") } }
6369
6527
  });
6370
6528
  } catch (error) {
6371
6529
  throw toMailboxError(error, "next-job request failed");
@@ -6478,6 +6636,23 @@ var MailboxClient = class {
6478
6636
  const data = res.data;
6479
6637
  return data.data;
6480
6638
  }
6639
+ /**
6640
+ * Tells CompBox that a request conversation saved its epic request. The
6641
+ * conversation stays open; this only links the job to the request.
6642
+ */
6643
+ async markRequestSaved(machineToken, jobId, epicRequestId) {
6644
+ let res;
6645
+ try {
6646
+ res = await this.http.post(`/api/v1/jobs/${jobId}/saved`, { epicRequestId }, {
6647
+ headers: { Authorization: `Bearer ${machineToken}` }
6648
+ });
6649
+ } catch (error) {
6650
+ throw toMailboxError(error, "saved request failed");
6651
+ }
6652
+ if (res.status < 200 || res.status >= 300) {
6653
+ throw errorFromResponse(res, "saved failed");
6654
+ }
6655
+ }
6481
6656
  /** Reports what a run on the person's API key spent. */
6482
6657
  async reportAiUsage(machineToken, jobId, usage) {
6483
6658
  let res;
@@ -7784,11 +7959,7 @@ function parseClaudeJsonResult(stdout2, fallbackModel) {
7784
7959
 
7785
7960
  // src/utils/agent-invoker.ts
7786
7961
  var ANTHROPIC_MODEL = "claude-fable-5";
7787
- var MAX_INTERVIEW_TURNS = 18;
7788
- var MAX_QUALITY_ROUNDS = 4;
7789
- var REPLY_POLL_INTERVAL_MS = 3e3;
7790
- var REPLY_TIMEOUT_MS = 10 * 60 * 1e3;
7791
- var CLAUDE_CLI_TURN_TIMEOUT_MS = 2 * 60 * 1e3;
7962
+ var CLAUDE_CLI_TURN_TIMEOUT_MS = 4 * 60 * 1e3;
7792
7963
  function anthropicClientOptions(apiKey, routing) {
7793
7964
  return routing.mode === "proxy" ? { apiKey, baseURL: routing.anthropicBaseUrl } : { apiKey };
7794
7965
  }
@@ -7801,11 +7972,7 @@ var SUPPORTED_IMAGE_MEDIA_TYPES = [
7801
7972
  "image/gif",
7802
7973
  "image/webp"
7803
7974
  ];
7804
- var MAX_IMAGES_PER_TURN = 8;
7805
7975
  var MAX_IMAGE_BYTES_PER_TURN = 20 * 1024 * 1024;
7806
- var defaultWarn = (message) => {
7807
- console.warn(` \u26A0 ${message}`);
7808
- };
7809
7976
  function normalizeImageMediaType(mime) {
7810
7977
  const base = (mime.split(";")[0] ?? "").trim().toLowerCase();
7811
7978
  return SUPPORTED_IMAGE_MEDIA_TYPES.includes(base) ? base : null;
@@ -7813,67 +7980,10 @@ function normalizeImageMediaType(mime) {
7813
7980
  function isSupportedImageMediaType(mime) {
7814
7981
  return normalizeImageMediaType(mime) !== null;
7815
7982
  }
7816
- function selectImageAttachments(attachments) {
7817
- if (!attachments || attachments.length === 0) return [];
7818
- const selected = [];
7819
- let declaredBytes = 0;
7820
- for (const att of attachments) {
7821
- if (selected.length >= MAX_IMAGES_PER_TURN) break;
7822
- if (!isSupportedImageMediaType(att.mimeType)) continue;
7823
- const size = typeof att.sizeBytes === "number" && att.sizeBytes > 0 ? att.sizeBytes : 0;
7824
- if (declaredBytes + size > MAX_IMAGE_BYTES_PER_TURN) continue;
7825
- declaredBytes += size;
7826
- selected.push(att);
7827
- }
7828
- return selected;
7829
- }
7830
7983
  function imageAttachmentCount(attachments) {
7831
7984
  if (!attachments || attachments.length === 0) return 0;
7832
7985
  return attachments.filter((a) => isSupportedImageMediaType(a.mimeType)).length;
7833
7986
  }
7834
- async function fetchImageBlocks(attachments, warn = defaultWarn, fetchImpl = fetch) {
7835
- const blocks = [];
7836
- let totalBytes = 0;
7837
- for (const att of attachments) {
7838
- const mediaType = normalizeImageMediaType(att.mimeType);
7839
- if (!mediaType) continue;
7840
- try {
7841
- const res = await fetchImpl(att.signedUrl);
7842
- if (!res.ok) {
7843
- warn(`Skipping image attachment ${att.id}: fetch failed (HTTP ${String(res.status)}).`);
7844
- continue;
7845
- }
7846
- const bytes = Buffer.from(await res.arrayBuffer());
7847
- if (bytes.byteLength === 0) {
7848
- warn(`Skipping image attachment ${att.id}: empty response body.`);
7849
- continue;
7850
- }
7851
- if (totalBytes + bytes.byteLength > MAX_IMAGE_BYTES_PER_TURN) {
7852
- warn(`Skipping image attachment ${att.id}: exceeds the per-turn image byte budget.`);
7853
- continue;
7854
- }
7855
- totalBytes += bytes.byteLength;
7856
- blocks.push({
7857
- type: "image",
7858
- source: { type: "base64", media_type: mediaType, data: bytes.toString("base64") }
7859
- });
7860
- } catch (error) {
7861
- const message = error instanceof Error ? error.message : "unknown error";
7862
- warn(`Skipping image attachment ${att.id}: ${message}.`);
7863
- }
7864
- }
7865
- return blocks;
7866
- }
7867
- function buildApiUserContent(text, imageBlocks) {
7868
- if (imageBlocks.length === 0) return text;
7869
- return [...imageBlocks, { type: "text", text }];
7870
- }
7871
- async function apiUserTurnContent(turn, warn = defaultWarn, fetchImpl = fetch) {
7872
- const selected = selectImageAttachments(turn.attachments);
7873
- if (selected.length === 0) return turn.content;
7874
- const blocks = await fetchImageBlocks(selected, warn, fetchImpl);
7875
- return buildApiUserContent(turn.content, blocks);
7876
- }
7877
7987
  function appendAttachmentNote(text, imageCount) {
7878
7988
  if (imageCount <= 0) return text;
7879
7989
  const noun = imageCount === 1 ? "image" : "images";
@@ -7882,7 +7992,6 @@ function appendAttachmentNote(text, imageCount) {
7882
7992
 
7883
7993
  ${note}` : note;
7884
7994
  }
7885
- var SYSTEM_PROMPT = buildInterviewSystemPrompt();
7886
7995
  function parseSeedPayload(payload) {
7887
7996
  if (typeof payload !== "object" || payload === null) return null;
7888
7997
  const p = payload;
@@ -7898,11 +8007,6 @@ function parseSeedPayload(payload) {
7898
8007
  priorTranscript
7899
8008
  };
7900
8009
  }
7901
- function resolveSystemPrompt(seed) {
7902
- return seed ? buildInterviewSystemPrompt(INTERVIEW_DEFINITION, {
7903
- priorTranscript: seed.priorTranscript
7904
- }) : SYSTEM_PROMPT;
7905
- }
7906
8010
  async function submitAndScore(ctx, currentErId, draft) {
7907
8011
  if (currentErId) {
7908
8012
  const compliance = await updatePersonalEpicRequest(
@@ -7916,118 +8020,6 @@ async function submitAndScore(ctx, currentErId, draft) {
7916
8020
  const created = await createPersonalEpicRequest(ctx.apiBaseUrl, ctx.userApiToken, draft);
7917
8021
  return { erId: created.id, compliance: created.compliance };
7918
8022
  }
7919
- var MAX_JSON_REPAIRS = 3;
7920
- function sleep(ms) {
7921
- return new Promise((resolve3) => setTimeout(resolve3, ms));
7922
- }
7923
- async function waitForUserReply(client, machineToken, jobId, turnsSoFar) {
7924
- const deadline = Date.now() + REPLY_TIMEOUT_MS;
7925
- while (Date.now() < deadline) {
7926
- await sleep(REPLY_POLL_INTERVAL_MS);
7927
- const job = await client.nextJob(machineToken, 0);
7928
- if (job?.id !== jobId) continue;
7929
- const last = job.turns[job.turns.length - 1];
7930
- if (job.turns.length > turnsSoFar && last?.role === "user") {
7931
- return job;
7932
- }
7933
- }
7934
- return null;
7935
- }
7936
- async function invokeEpicRequestViaApi(job, ctx) {
7937
- const routing = await resolveRouting();
7938
- const anthropic = new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing));
7939
- const seed = parseSeedPayload(job.payload);
7940
- const systemPrompt = resolveSystemPrompt(seed);
7941
- let currentErId = seed?.targetEpicRequestId ?? null;
7942
- let qualityRounds = 0;
7943
- let jsonRepairs = 0;
7944
- const messages = [];
7945
- for (const turn of job.turns) {
7946
- if (turn.role === "agent") {
7947
- messages.push({ role: "assistant", content: turn.content });
7948
- } else {
7949
- messages.push({ role: "user", content: await apiUserTurnContent(turn) });
7950
- }
7951
- }
7952
- let turnsSoFar = job.turns.length;
7953
- for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
7954
- let reply;
7955
- try {
7956
- const response = await anthropic.messages.create({
7957
- model: ANTHROPIC_MODEL,
7958
- max_tokens: 1024,
7959
- system: systemPrompt,
7960
- messages: messages.length > 0 ? messages : [{ role: "user", content: "Let's get started." }]
7961
- });
7962
- const textBlock = response.content.find((b) => b.type === "text");
7963
- reply = textBlock?.type === "text" ? textBlock.text : "";
7964
- } catch (error) {
7965
- const message = error instanceof Error ? error.message : "Anthropic API request failed";
7966
- return { status: "failed", result: { error: `Agent invocation failed: ${message}` } };
7967
- }
7968
- const draft = extractCompletionJson(reply);
7969
- if (draft) {
7970
- let submitted;
7971
- try {
7972
- submitted = await submitAndScore(ctx, currentErId, draft);
7973
- } catch (error) {
7974
- const message = error instanceof Error ? error.message : "Failed to save the epic request";
7975
- return { status: "failed", result: { error: message } };
7976
- }
7977
- currentErId = submitted.erId;
7978
- const compliance = submitted.compliance;
7979
- if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
7980
- return { status: "done", result: { epicRequestId: currentErId } };
7981
- }
7982
- qualityRounds++;
7983
- if (compliance !== null) {
7984
- messages.push({ role: "assistant", content: reply });
7985
- messages.push({ role: "user", content: buildAuditFeedback(compliance) });
7986
- }
7987
- continue;
7988
- }
7989
- if (looksLikeCompletionAttempt(reply)) {
7990
- if (jsonRepairs >= MAX_JSON_REPAIRS) {
7991
- return {
7992
- status: "failed",
7993
- result: {
7994
- error: "The interview produced malformed completion data and could not be finalized after several attempts."
7995
- }
7996
- };
7997
- }
7998
- jsonRepairs++;
7999
- messages.push({ role: "assistant", content: reply });
8000
- messages.push({ role: "user", content: COMPLETION_REPAIR_INSTRUCTION });
8001
- continue;
8002
- }
8003
- messages.push({ role: "assistant", content: reply });
8004
- let updated;
8005
- try {
8006
- updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, reply);
8007
- } catch (error) {
8008
- const message = error instanceof Error ? error.message : "post-turn failed";
8009
- return { status: "failed", result: { error: message } };
8010
- }
8011
- turnsSoFar = updated.turns.length;
8012
- const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
8013
- if (!answered) {
8014
- return {
8015
- status: "failed",
8016
- result: { error: "Timed out waiting for a reply \u2014 no response within 10 minutes." }
8017
- };
8018
- }
8019
- const lastTurn = answered.turns[answered.turns.length - 1];
8020
- messages.push({
8021
- role: "user",
8022
- content: lastTurn ? await apiUserTurnContent(lastTurn) : ""
8023
- });
8024
- turnsSoFar = answered.turns.length;
8025
- }
8026
- return currentErId ? { status: "done", result: { epicRequestId: currentErId } } : {
8027
- status: "failed",
8028
- result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` }
8029
- };
8030
- }
8031
8023
  function claudeCliSessionCwd() {
8032
8024
  const dir = join9(homedir5(), ".compbox", "claude-sessions");
8033
8025
  try {
@@ -8038,11 +8030,11 @@ function claudeCliSessionCwd() {
8038
8030
  }
8039
8031
  function runClaudeCliTurn(prompt, opts) {
8040
8032
  return new Promise((resolve3, reject) => {
8033
+ const model = opts.model === void 0 ? ANTHROPIC_MODEL : opts.model;
8041
8034
  const args = [
8042
8035
  "-p",
8043
8036
  prompt,
8044
- "--model",
8045
- ANTHROPIC_MODEL,
8037
+ ...model ? ["--model", model] : [],
8046
8038
  "--output-format",
8047
8039
  "json",
8048
8040
  "--tools",
@@ -8116,91 +8108,78 @@ function runClaudeCliTurn(prompt, opts) {
8116
8108
  });
8117
8109
  });
8118
8110
  }
8119
- async function invokeEpicRequestViaSubscription(job, ctx) {
8120
- let sessionId;
8121
- let turnsSoFar = job.turns.length;
8111
+ async function loadSavedRequest(apiBaseUrl, userApiToken, epicRequestId) {
8112
+ const res = await fetch(
8113
+ `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/epic-requests/${encodeURIComponent(epicRequestId)}`,
8114
+ { headers: { Authorization: `Bearer ${userApiToken}` } }
8115
+ );
8116
+ if (!res.ok) return null;
8117
+ const body = await res.json().catch(() => null);
8118
+ return savedContextFromRecord(body?.data);
8119
+ }
8120
+ function needsDefaultModel(error) {
8121
+ const message = error instanceof Error ? error.message : String(error);
8122
+ return /requires usage credits|switch to another model|model.*not available|not have access to/i.test(message);
8123
+ }
8124
+ function savedRequestIdOf(job) {
8125
+ const result = job.result;
8126
+ if (typeof result?.epicRequestId === "string") return result.epicRequestId;
8127
+ return parseSeedPayload(job.payload)?.targetEpicRequestId ?? null;
8128
+ }
8129
+ async function invokeEpicRequestStep(job, ctx) {
8130
+ try {
8131
+ return await takeConversationStep(job, ctx);
8132
+ } catch (error) {
8133
+ const reason = error instanceof Error ? error.message : "something went wrong";
8134
+ await ctx.mailboxClient.postTurn(
8135
+ ctx.machineToken,
8136
+ job.id,
8137
+ `I couldn't continue: ${reason}
8138
+
8139
+ Send another message (for example "try again") and I'll pick up where we left off.`
8140
+ );
8141
+ return { status: "yield" };
8142
+ }
8143
+ }
8144
+ async function takeConversationStep(job, ctx) {
8122
8145
  const routing = await resolveRouting();
8123
- const seed = parseSeedPayload(job.payload);
8124
- const systemPrompt = resolveSystemPrompt(seed);
8125
- let currentErId = seed?.targetEpicRequestId ?? null;
8126
- let qualityRounds = 0;
8127
- let jsonRepairs = 0;
8128
- let nextPrompt = job.turns.length > 0 ? job.turns.map(
8129
- (t) => t.role === "agent" ? `You asked: ${t.content}` : (
8130
- // ENG-153 (A4): subscription mode has no inline-image channel, so a
8131
- // user turn's images degrade to an explicit textual note.
8132
- `They replied: ${appendAttachmentNote(t.content, imageAttachmentCount(t.attachments))}`
8133
- )
8134
- ).join("\n") + "\n\nContinue the interview from exactly where this left off \u2014 ask the next question, or if you already have everything, respond with the completion JSON." : "Let's begin the interview.";
8135
- for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
8136
- let turn;
8137
- try {
8138
- turn = await runClaudeCliTurn(nextPrompt, {
8139
- systemPrompt,
8140
- resumeSessionId: sessionId,
8141
- routing
8142
- });
8143
- } catch (error) {
8144
- const message = error instanceof Error ? error.message : "claude CLI invocation failed";
8145
- return { status: "failed", result: { error: `Agent invocation failed: ${message}` } };
8146
- }
8147
- sessionId = turn.sessionId;
8148
- const draft = extractCompletionJson(turn.text);
8149
- if (draft) {
8150
- let submitted;
8151
- try {
8152
- submitted = await submitAndScore(ctx, currentErId, draft);
8153
- } catch (error) {
8154
- const message = error instanceof Error ? error.message : "Failed to save the epic request";
8155
- return { status: "failed", result: { error: message } };
8156
- }
8157
- currentErId = submitted.erId;
8158
- const compliance = submitted.compliance;
8159
- if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
8160
- return { status: "done", result: { epicRequestId: currentErId } };
8161
- }
8162
- qualityRounds++;
8163
- if (compliance !== null) {
8164
- nextPrompt = buildAuditFeedback(compliance);
8165
- }
8166
- continue;
8167
- }
8168
- if (looksLikeCompletionAttempt(turn.text)) {
8169
- if (jsonRepairs >= MAX_JSON_REPAIRS) {
8170
- return {
8171
- status: "failed",
8172
- result: {
8173
- error: "The interview produced malformed completion data and could not be finalized after several attempts."
8174
- }
8175
- };
8146
+ const requestId = savedRequestIdOf(job);
8147
+ const saved = requestId ? await loadSavedRequest(ctx.apiBaseUrl, ctx.userApiToken, requestId) : null;
8148
+ const turns = job.turns.map((t) => ({
8149
+ role: t.role,
8150
+ content: t.role === "user" ? appendAttachmentNote(t.content, imageAttachmentCount(t.attachments)) : t.content
8151
+ }));
8152
+ const anthropic = ctx.anthropicApiKey ? new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing)) : null;
8153
+ let cliModel = ANTHROPIC_MODEL;
8154
+ const outcome = await runConversationStep(
8155
+ { turns, saved },
8156
+ {
8157
+ reply: async (system, messages) => {
8158
+ if (anthropic) {
8159
+ const response = await anthropic.messages.create({ model: ANTHROPIC_MODEL, max_tokens: 4096, system, messages });
8160
+ const text = response.content.find((b) => b.type === "text");
8161
+ return text?.type === "text" ? text.text : "";
8162
+ }
8163
+ const prompt = renderConversationPrompt(messages);
8164
+ try {
8165
+ return (await runClaudeCliTurn(prompt, { systemPrompt: system, resumeSessionId: void 0, routing, model: cliModel })).text;
8166
+ } catch (error) {
8167
+ if (cliModel === null || !needsDefaultModel(error)) throw error;
8168
+ cliModel = null;
8169
+ return (await runClaudeCliTurn(prompt, { systemPrompt: system, resumeSessionId: void 0, routing, model: null })).text;
8170
+ }
8171
+ },
8172
+ save: async (existingId, draft) => {
8173
+ const submitted = await submitAndScore(ctx, existingId, draft);
8174
+ return { id: submitted.erId, title: draft.title, compliance: submitted.compliance };
8176
8175
  }
8177
- jsonRepairs++;
8178
- nextPrompt = COMPLETION_REPAIR_INSTRUCTION;
8179
- continue;
8180
- }
8181
- let updated;
8182
- try {
8183
- updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, turn.text);
8184
- } catch (error) {
8185
- const message = error instanceof Error ? error.message : "post-turn failed";
8186
- return { status: "failed", result: { error: message } };
8187
- }
8188
- turnsSoFar = updated.turns.length;
8189
- const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
8190
- if (!answered) {
8191
- return {
8192
- status: "failed",
8193
- result: { error: "Timed out waiting for a reply \u2014 no response within 10 minutes." }
8194
- };
8195
8176
  }
8196
- const lastTurn = answered.turns[answered.turns.length - 1];
8197
- nextPrompt = lastTurn ? appendAttachmentNote(lastTurn.content, imageAttachmentCount(lastTurn.attachments)) : "";
8198
- turnsSoFar = answered.turns.length;
8177
+ );
8178
+ if (outcome.kind === "saved") {
8179
+ await ctx.mailboxClient.markRequestSaved(ctx.machineToken, job.id, outcome.saved.id);
8199
8180
  }
8200
- return currentErId ? { status: "done", result: { epicRequestId: currentErId } } : {
8201
- status: "failed",
8202
- result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` }
8203
- };
8181
+ await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, outcome.text);
8182
+ return { status: "yield" };
8204
8183
  }
8205
8184
  var PLANNER_RUN_TIMEOUT_MS = 30 * 60 * 1e3;
8206
8185
  var PLANNER_HEARTBEAT_INTERVAL_MS = 20 * 1e3;
@@ -8834,6 +8813,17 @@ function githubCompareUrl(remoteUrl, baseBranch, branch) {
8834
8813
  if (!m) return null;
8835
8814
  return `https://github.com/${m[1] ?? ""}/${m[2] ?? ""}/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(branch)}?expand=1`;
8836
8815
  }
8816
+ var repoQueues = /* @__PURE__ */ new Map();
8817
+ function withRepoLock(projectDir, task) {
8818
+ const previous = repoQueues.get(projectDir) ?? Promise.resolve();
8819
+ const result = previous.catch(() => void 0).then(task);
8820
+ const tail = result.catch(() => void 0);
8821
+ repoQueues.set(projectDir, tail);
8822
+ void tail.then(() => {
8823
+ if (repoQueues.get(projectDir) === tail) repoQueues.delete(projectDir);
8824
+ });
8825
+ return result;
8826
+ }
8837
8827
  var RunWorkspaceManager = class {
8838
8828
  run;
8839
8829
  now;
@@ -8857,6 +8847,31 @@ var RunWorkspaceManager = class {
8857
8847
  }
8858
8848
  /** Creates the branch + worktree for a run. Never touches the person's checkout. */
8859
8849
  async prepare(projectDir, epicId) {
8850
+ const created = await withRepoLock(projectDir, () => this.createWorktree(projectDir, epicId));
8851
+ if (!created.ok) return created;
8852
+ const { workspace, notes } = created;
8853
+ const { dir, branch } = workspace;
8854
+ const install = installCommand2(dir, this.exists);
8855
+ if (install) {
8856
+ const [command, args] = install;
8857
+ const res = await this.run(command, args, dir);
8858
+ if (res.status !== 0) {
8859
+ await withRepoLock(projectDir, async () => {
8860
+ await this.git(["worktree", "remove", "--force", dir], projectDir);
8861
+ await this.git(["branch", "-D", branch], projectDir);
8862
+ });
8863
+ return {
8864
+ ok: false,
8865
+ error: [
8866
+ `Installing the project's dependencies failed (${command} ${args.join(" ")}): ${firstLine(res.stderr) || firstLine(res.stdout) || "unknown error"}`,
8867
+ "The run was not started. Check that the same command works in your project folder."
8868
+ ].join("\n")
8869
+ };
8870
+ }
8871
+ }
8872
+ return { ok: true, workspace, notes };
8873
+ }
8874
+ async createWorktree(projectDir, epicId) {
8860
8875
  const notes = [];
8861
8876
  const remotes = (await this.git(["remote"], projectDir)).stdout.split("\n").map((r) => r.trim()).filter(Boolean);
8862
8877
  const remote = remotes.includes("origin") ? "origin" : remotes[0] ?? null;
@@ -8911,26 +8926,13 @@ ${untracked.stdout}`.split("\n").map((p) => p.trim()).filter((p) => p && !p.ends
8911
8926
  notes.push(`Couldn't copy ${path3} into the run workspace.`);
8912
8927
  }
8913
8928
  }
8914
- const install = installCommand2(dir, this.exists);
8915
- if (install) {
8916
- const [command, args] = install;
8917
- const res = await this.run(command, args, dir);
8918
- if (res.status !== 0) {
8919
- await this.git(["worktree", "remove", "--force", dir], projectDir);
8920
- await this.git(["branch", "-D", branch], projectDir);
8921
- return {
8922
- ok: false,
8923
- error: [
8924
- `Installing the project's dependencies failed (${command} ${args.join(" ")}): ${firstLine(res.stderr) || firstLine(res.stdout) || "unknown error"}`,
8925
- "The run was not started. Check that the same command works in your project folder."
8926
- ].join("\n")
8927
- };
8928
- }
8929
- }
8930
8929
  return { ok: true, workspace: { dir, projectDir, branch, baseRef, baseBranch, remote }, notes };
8931
8930
  }
8932
8931
  /** Commits, pushes and opens the pull request; then cleans up the worktree. */
8933
- async finish(input) {
8932
+ finish(input) {
8933
+ return withRepoLock(input.workspace.projectDir, () => this.finishLocked(input));
8934
+ }
8935
+ async finishLocked(input) {
8934
8936
  const { workspace: ws } = input;
8935
8937
  const notes = [];
8936
8938
  if ((await this.git(["status", "--porcelain"], ws.dir)).stdout.trim()) {
@@ -9142,9 +9144,20 @@ var POLL_WAIT_MS = 0;
9142
9144
  var IDLE_POLL_INTERVAL_MS = 15e3;
9143
9145
  var ACTIVE_POLL_INTERVAL_MS = 2e3;
9144
9146
  var ACTIVE_WINDOW_MS = 6e4;
9145
- function idleDelay(now, lastJobAt) {
9147
+ var CONVERSATION_POLL_INTERVAL_MS = 3e3;
9148
+ var CONVERSATION_WINDOW_MS = 10 * 6e4;
9149
+ function idleDelay(now, lastJobAt, lastConversationAt = 0) {
9146
9150
  const warm = lastJobAt > 0 && now - lastJobAt < ACTIVE_WINDOW_MS;
9147
- return warm ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
9151
+ if (warm) return ACTIVE_POLL_INTERVAL_MS;
9152
+ const talking = lastConversationAt > 0 && now - lastConversationAt < CONVERSATION_WINDOW_MS;
9153
+ return talking ? CONVERSATION_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
9154
+ }
9155
+ var DEFAULT_MAX_PARALLEL_JOBS = 3;
9156
+ var MAX_JOBS_ENV = "COMPBOX_MAX_JOBS";
9157
+ function maxParallelJobs(env = process.env) {
9158
+ const raw = Number.parseInt(env[MAX_JOBS_ENV] ?? "", 10);
9159
+ if (!Number.isFinite(raw) || raw < 1) return DEFAULT_MAX_PARALLEL_JOBS;
9160
+ return Math.min(raw, 10);
9148
9161
  }
9149
9162
  var BACKOFF_BASE_MS = 1e3;
9150
9163
  var BACKOFF_MAX_MS = 3e4;
@@ -9152,7 +9165,7 @@ var HEARTBEAT_INTERVAL_MS = 2e4;
9152
9165
  function runtimeHeartbeatDue(nowMs, lastRuntimeSentMs, intervalMs = HEARTBEAT_INTERVAL_MS) {
9153
9166
  return nowMs - lastRuntimeSentMs >= intervalMs;
9154
9167
  }
9155
- function sleep2(ms) {
9168
+ function sleep(ms) {
9156
9169
  return new Promise((resolve3) => setTimeout(resolve3, ms));
9157
9170
  }
9158
9171
  function backoffDelay(consecutiveErrors) {
@@ -9307,6 +9320,10 @@ async function handleStartExecution(ctx, job) {
9307
9320
  async function runInvokerAndComplete(ctx, job, invoke) {
9308
9321
  try {
9309
9322
  const outcome = await invoke();
9323
+ if (outcome.status === "yield") {
9324
+ console.log(chalk9.gray(` Job ${job.id}: replied; waiting for the person.`));
9325
+ return;
9326
+ }
9310
9327
  await ctx.client.completeJob(ctx.machineToken, job.id, outcome.status, outcome.result);
9311
9328
  console.log(
9312
9329
  outcome.status === "done" ? chalk9.green(` Job ${job.id} completed: ${JSON.stringify(outcome.result)}`) : chalk9.yellow(` Job ${job.id} failed: ${JSON.stringify(outcome.result)}`)
@@ -9331,37 +9348,22 @@ async function handleJob(ctx, job) {
9331
9348
  \u2192 Claimed job ${job.id} (type: ${job.type}, status: ${job.status})`)
9332
9349
  );
9333
9350
  const userApiToken = ctx.userApiToken;
9334
- const isEpicRequest = job.type === "epic_request" && !!userApiToken;
9335
- if (isEpicRequest && userApiToken) {
9351
+ if (job.type === "epic_request" && userApiToken) {
9352
+ let anthropicApiKey;
9336
9353
  if (ctx.aiMode === "api-key") {
9337
- const { value: anthropicApiKey } = await retrieveSecret(AI_KEY_ACCOUNT);
9338
- if (anthropicApiKey) {
9339
- console.log(chalk9.gray(" Running the request-formulator interview (api mode)..."));
9340
- await runInvokerAndComplete(
9341
- ctx,
9342
- job,
9343
- () => invokeEpicRequestViaApi(job, {
9344
- mailboxClient: ctx.client,
9345
- machineToken: ctx.machineToken,
9346
- apiBaseUrl: ctx.apiBaseUrl,
9347
- userApiToken,
9348
- anthropicApiKey
9349
- })
9350
- );
9351
- return;
9352
- }
9353
- } else if (ctx.aiMode === "claude-subscription") {
9354
- console.log(
9355
- chalk9.gray(" Running the request-formulator interview (claude-subscription mode, local `claude` CLI)...")
9356
- );
9354
+ anthropicApiKey = (await retrieveSecret(AI_KEY_ACCOUNT)).value ?? void 0;
9355
+ }
9356
+ if (ctx.aiMode === "claude-subscription" || anthropicApiKey) {
9357
+ console.log(chalk9.gray(` Replying in the request conversation (${anthropicApiKey ? "api mode" : "local claude CLI"})...`));
9357
9358
  await runInvokerAndComplete(
9358
9359
  ctx,
9359
9360
  job,
9360
- () => invokeEpicRequestViaSubscription(job, {
9361
+ () => invokeEpicRequestStep(job, {
9361
9362
  mailboxClient: ctx.client,
9362
9363
  machineToken: ctx.machineToken,
9363
9364
  apiBaseUrl: ctx.apiBaseUrl,
9364
- userApiToken
9365
+ userApiToken,
9366
+ ...anthropicApiKey && { anthropicApiKey }
9365
9367
  })
9366
9368
  );
9367
9369
  return;
@@ -9459,6 +9461,9 @@ var runCommand = new Command8("run").description("Start the Agent Runner daemon
9459
9461
  `
9460
9462
  )
9461
9463
  );
9464
+ const maxJobs = maxParallelJobs();
9465
+ console.log(chalk9.gray(` Runs up to ${String(maxJobs)} job${maxJobs === 1 ? "" : "s"} at once (${MAX_JOBS_ENV} to change).
9466
+ `));
9462
9467
  const runtimeProbe = () => probeAgentRuntime();
9463
9468
  let agentRuntime = await safeProbeAgentRuntime(runtimeProbe, void 0);
9464
9469
  if (agentRuntime) {
@@ -9489,10 +9494,14 @@ Received ${signal}, shutting down...`));
9489
9494
  });
9490
9495
  let consecutiveErrors = 0;
9491
9496
  let lastJobAt = 0;
9497
+ let lastConversationAt = 0;
9498
+ const inFlight = /* @__PURE__ */ new Map();
9492
9499
  do {
9493
9500
  let job = null;
9494
9501
  try {
9495
- job = await client.nextJob(machineToken, waitMs);
9502
+ if (inFlight.size < maxJobs) {
9503
+ job = await client.nextJob(machineToken, waitMs, [...inFlight.keys()]);
9504
+ }
9496
9505
  consecutiveErrors = 0;
9497
9506
  } catch (error) {
9498
9507
  consecutiveErrors += 1;
@@ -9506,12 +9515,21 @@ Received ${signal}, shutting down...`));
9506
9515
  );
9507
9516
  console.error(chalk9.gray(` Reconnecting in ${String(Math.round(delay / 1e3))}s...`));
9508
9517
  if (options.once) break;
9509
- await sleep2(delay);
9518
+ await sleep(delay);
9510
9519
  continue;
9511
9520
  }
9521
+ if (job && inFlight.has(job.id)) job = null;
9512
9522
  if (job) {
9513
- await handleJob(jobContext, job);
9514
- lastJobAt = Date.now();
9523
+ const jobId = job.id;
9524
+ const isConversation = job.type === "epic_request";
9525
+ const work = handleJob(jobContext, job).catch((error) => {
9526
+ console.error(chalk9.red(` Job ${jobId} stopped unexpectedly: ${error instanceof Error ? error.message : String(error)}`));
9527
+ }).finally(() => {
9528
+ inFlight.delete(jobId);
9529
+ lastJobAt = Date.now();
9530
+ if (isConversation) lastConversationAt = lastJobAt;
9531
+ });
9532
+ inFlight.set(jobId, work);
9515
9533
  }
9516
9534
  if (runtimeHeartbeatDue(Date.now(), lastRuntimeSentAt)) {
9517
9535
  const cycle = await runHeartbeatCycle({
@@ -9528,9 +9546,12 @@ Received ${signal}, shutting down...`));
9528
9546
  lastRuntimeSentAt = Date.now();
9529
9547
  }
9530
9548
  }
9531
- if (options.once) break;
9549
+ if (options.once) {
9550
+ await Promise.all(inFlight.values());
9551
+ break;
9552
+ }
9532
9553
  if (!job) {
9533
- await sleep2(idleDelay(Date.now(), lastJobAt));
9554
+ await sleep(idleDelay(Date.now(), lastJobAt, lastConversationAt));
9534
9555
  }
9535
9556
  } while (running);
9536
9557
  console.log(chalk9.gray("Agent Runner stopped."));
@@ -9793,7 +9814,7 @@ machinesCommand.command("rm <id>").description("Remove (revoke) a paired machine
9793
9814
  // src/index.ts
9794
9815
  function resolveCliVersion() {
9795
9816
  if (true) {
9796
- return "1.19.0";
9817
+ return "1.20.0";
9797
9818
  }
9798
9819
  const require2 = createRequire(import.meta.url);
9799
9820
  const pkg = require2("../package.json");