@mycompbox/compbox-cli 1.18.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.
Files changed (49) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/commands/configure.d.ts.map +1 -1
  3. package/commands/configure.js +31 -11
  4. package/commands/configure.js.map +1 -1
  5. package/commands/doctor.d.ts.map +1 -1
  6. package/commands/doctor.js +1 -1
  7. package/commands/doctor.js.map +1 -1
  8. package/commands/login.d.ts +2 -0
  9. package/commands/login.d.ts.map +1 -1
  10. package/commands/login.js +28 -6
  11. package/commands/login.js.map +1 -1
  12. package/commands/run.d.ts +12 -1
  13. package/commands/run.d.ts.map +1 -1
  14. package/commands/run.js +110 -30
  15. package/commands/run.js.map +1 -1
  16. package/index.js +1264 -540
  17. package/package.json +1 -1
  18. package/utils/agent-invoker.d.ts +59 -101
  19. package/utils/agent-invoker.d.ts.map +1 -1
  20. package/utils/agent-invoker.js +183 -508
  21. package/utils/agent-invoker.js.map +1 -1
  22. package/utils/autostart.d.ts +41 -0
  23. package/utils/autostart.d.ts.map +1 -0
  24. package/utils/autostart.js +122 -0
  25. package/utils/autostart.js.map +1 -0
  26. package/utils/key-run.d.ts +32 -0
  27. package/utils/key-run.d.ts.map +1 -0
  28. package/utils/key-run.js +77 -0
  29. package/utils/key-run.js.map +1 -0
  30. package/utils/loopback.d.ts +3 -3
  31. package/utils/loopback.d.ts.map +1 -1
  32. package/utils/loopback.js +3 -3
  33. package/utils/loopback.js.map +1 -1
  34. package/utils/mailbox-client.d.ts +31 -1
  35. package/utils/mailbox-client.d.ts.map +1 -1
  36. package/utils/mailbox-client.js +60 -2
  37. package/utils/mailbox-client.js.map +1 -1
  38. package/utils/mcp-config-writer.d.ts +2 -0
  39. package/utils/mcp-config-writer.d.ts.map +1 -1
  40. package/utils/mcp-config-writer.js +9 -1
  41. package/utils/mcp-config-writer.js.map +1 -1
  42. package/utils/repo-setup.d.ts +44 -0
  43. package/utils/repo-setup.d.ts.map +1 -0
  44. package/utils/repo-setup.js +178 -0
  45. package/utils/repo-setup.js.map +1 -0
  46. package/utils/run-workspace.d.ts +120 -0
  47. package/utils/run-workspace.d.ts.map +1 -0
  48. package/utils/run-workspace.js +298 -0
  49. package/utils/run-workspace.js.map +1 -0
package/index.js CHANGED
@@ -5555,84 +5555,360 @@ var INTERVIEW_DEFINITION = [
5555
5555
  fields: ["alternatives"]
5556
5556
  }
5557
5557
  ];
5558
+ function interviewFieldKeys() {
5559
+ const seen = /* @__PURE__ */ new Set();
5560
+ const out = [];
5561
+ for (const section of INTERVIEW_DEFINITION) {
5562
+ for (const f of section.fields) {
5563
+ if (!seen.has(f)) {
5564
+ seen.add(f);
5565
+ out.push(f);
5566
+ }
5567
+ }
5568
+ }
5569
+ return out;
5570
+ }
5558
5571
  var INTERVIEW_FIELD_MIN_WORDS = {
5559
5572
  problemStatement: 60,
5560
5573
  proposedSolution: 100,
5561
5574
  impactAssessment: 40,
5562
5575
  description: 150
5563
5576
  };
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}`;
5577
+
5578
+ // ../shared/dist/interview-completion.js
5579
+ var TARGET_COMPLIANCE_TIER = "pass";
5580
+ function meetsComplianceTarget(compliance) {
5581
+ return !compliance || compliance.tier === TARGET_COMPLIANCE_TIER;
5582
+ }
5583
+ function buildAuditFeedback(compliance) {
5584
+ const ordered = [
5585
+ ...compliance.issues.filter((i) => i.tier === "critical"),
5586
+ ...compliance.issues.filter((i) => i.tier === "advisory")
5587
+ ].slice(0, 8);
5588
+ const list = ordered.length > 0 ? ordered.map((i) => `- ${i.message}`).join("\n") : "- (no specific issues returned; expand the required fields with more concrete detail)";
5589
+ return `The epic request you just produced scored ${String(compliance.overall)}/100 (tier: ${compliance.tier}). It must reach 90/100 with ZERO critical issues before the interview can finish. Fix these:
5590
+ ${list}
5591
+
5592
+ If you can resolve these by EXPANDING what the employee already told you \u2014 a longer, more complete and concrete problemStatement / proposedSolution / impactAssessment / description that comfortably clear the length minimums \u2014 re-emit the completion JSON now with richer fields. Do NOT re-ask something you already have an answer for. Only if you genuinely need NEW information from the employee, ask ONE focused question to get it.`;
5593
+ }
5594
+ var OPTIONAL_DRAFT_KEYS = [
5595
+ "successMetrics",
5596
+ "targetAudience",
5597
+ "alternatives",
5598
+ "dependencies",
5599
+ "estimatedEffort"
5600
+ ];
5601
+ function isEpicRequestDraft(value) {
5602
+ if (typeof value !== "object" || value === null)
5603
+ return false;
5604
+ const v = value;
5605
+ const nonEmpty = (x) => typeof x === "string" && x.trim().length > 0;
5606
+ const requiredOk = nonEmpty(v.title) && nonEmpty(v.problemStatement) && nonEmpty(v.proposedSolution) && nonEmpty(v.impactAssessment) && nonEmpty(v.description);
5607
+ if (!requiredOk)
5608
+ return false;
5609
+ for (const key of OPTIONAL_DRAFT_KEYS) {
5610
+ if (key in v && v[key] !== void 0 && typeof v[key] !== "string")
5611
+ return false;
5612
+ }
5613
+ return true;
5614
+ }
5615
+ function stripTrailingCommas(json) {
5616
+ return json.replace(/,(\s*[}\]])/g, "$1");
5617
+ }
5618
+ function escapeRawControlCharsInStrings(json) {
5619
+ let out = "";
5620
+ let inString = false;
5621
+ let escaped = false;
5622
+ for (const ch of json) {
5623
+ if (escaped) {
5624
+ out += ch;
5625
+ escaped = false;
5626
+ continue;
5627
+ }
5628
+ if (ch === "\\") {
5629
+ out += ch;
5630
+ escaped = true;
5631
+ continue;
5632
+ }
5633
+ if (ch === '"') {
5634
+ inString = !inString;
5635
+ out += ch;
5636
+ continue;
5637
+ }
5638
+ if (inString && (ch === "\n" || ch === "\r" || ch === " ")) {
5639
+ out += ch === "\n" ? "\\n" : ch === "\r" ? "\\r" : "\\t";
5640
+ continue;
5641
+ }
5642
+ out += ch;
5643
+ }
5644
+ return out;
5645
+ }
5646
+ function completionJsonCandidates(text) {
5647
+ const candidates = [];
5648
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
5649
+ if (fenced?.[1])
5650
+ candidates.push(fenced[1]);
5651
+ const trimmed = text.trim();
5652
+ if (trimmed.startsWith("{"))
5653
+ candidates.push(trimmed);
5654
+ const first = text.indexOf("{");
5655
+ const last = text.lastIndexOf("}");
5656
+ if (first !== -1 && last > first)
5657
+ candidates.push(text.slice(first, last + 1));
5658
+ return candidates;
5659
+ }
5660
+ function toDraft(parsed) {
5661
+ const draft = {
5662
+ title: parsed.title,
5663
+ problemStatement: parsed.problemStatement,
5664
+ proposedSolution: parsed.proposedSolution,
5665
+ impactAssessment: parsed.impactAssessment,
5666
+ description: parsed.description
5573
5667
  };
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.
5668
+ for (const key of OPTIONAL_DRAFT_KEYS) {
5669
+ const raw = parsed[key];
5670
+ if (typeof raw === "string" && raw.trim().length > 0) {
5671
+ draft[key] = raw;
5672
+ }
5673
+ }
5674
+ return draft;
5675
+ }
5676
+ function extractCompletionJson(text) {
5677
+ for (const candidate of completionJsonCandidates(text)) {
5678
+ for (const variant of [
5679
+ candidate,
5680
+ stripTrailingCommas(candidate),
5681
+ escapeRawControlCharsInStrings(candidate),
5682
+ escapeRawControlCharsInStrings(stripTrailingCommas(candidate))
5683
+ ]) {
5684
+ try {
5685
+ const parsed = JSON.parse(variant);
5686
+ if (isEpicRequestDraft(parsed))
5687
+ return toDraft(parsed);
5688
+ } catch {
5689
+ }
5690
+ }
5691
+ }
5692
+ return null;
5693
+ }
5694
+ function looksLikeCompletionAttempt(text) {
5695
+ const hasObject = /```(?:json)?\s*\{/i.test(text) || text.trim().startsWith("{");
5696
+ if (!hasObject)
5697
+ return false;
5698
+ return /"(?:done|title|problemStatement|proposedSolution|impactAssessment|description)"\s*:/i.test(text);
5699
+ }
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```';
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}
5581
5719
 
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):
5720
+ Its current content:
5721
+ title: ${saved.title}
5722
+ ${fieldLines}
5723
+ description: ${saved.description}
5583
5724
 
5584
- REQUIRED sections (an Epic Request cannot omit these):
5585
- ${requiredSections.map(renderSection).join("\n")}
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.
5586
5731
 
5587
- OPTIONAL-BUT-RECOMMENDED sections (ask about each; omit the field if the employee has nothing):
5588
- ${optionalSections.map(renderSection).join("\n")}
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.
5589
5737
 
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.
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.
5595
5741
 
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:
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:
5597
5743
  \`\`\`json
5598
- {"done": true, ${completionShape}}
5744
+ {"done": true, ${shape}}
5599
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).
5600
5747
 
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)}
5748
+ Any reply that isn't that JSON block is shown to the person as your message.`;
5749
+ return saved ? `${base}
5606
5750
 
5607
- ${base}`;
5751
+ ${renderSaved(saved)}` : base;
5608
5752
  }
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.
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:
5612
5767
 
5613
- Prior conversation:
5614
- ${transcript}
5768
+ ${lines.join("\n\n")}
5615
5769
 
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.
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}` : ""}
5621
5784
 
5622
- `;
5623
- }
5624
- function interviewFieldKeysFor(definition) {
5625
- const seen = /* @__PURE__ */ new Set();
5626
- const out = [];
5627
- for (const section of definition) {
5628
- for (const f of section.fields) {
5629
- if (!seen.has(f)) {
5630
- seen.add(f);
5631
- out.push(f);
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.` };
5632
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 };
5633
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 };
5634
5861
  }
5635
- return out;
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 };
5636
5912
  }
5637
5913
 
5638
5914
  // src/utils/config-store.ts
@@ -5779,8 +6055,8 @@ async function promptText(question, options = {}) {
5779
6055
  const rendered = defaultValue !== void 0 ? `${question} [${defaultValue}]: ` : question;
5780
6056
  const rl = readline.createInterface({ input: stdin, output: stdout });
5781
6057
  try {
5782
- const answer = await new Promise((resolve2) => {
5783
- rl.question(rendered, resolve2);
6058
+ const answer = await new Promise((resolve3) => {
6059
+ rl.question(rendered, resolve3);
5784
6060
  });
5785
6061
  const trimmed = answer.trim();
5786
6062
  if (trimmed.length === 0 && defaultValue !== void 0) {
@@ -5792,7 +6068,7 @@ async function promptText(question, options = {}) {
5792
6068
  }
5793
6069
  }
5794
6070
  function promptSecret(question) {
5795
- return new Promise((resolve2) => {
6071
+ return new Promise((resolve3) => {
5796
6072
  const rl = readline.createInterface({
5797
6073
  input: stdin,
5798
6074
  output: stdout,
@@ -5804,7 +6080,7 @@ function promptSecret(question) {
5804
6080
  rl.question("", (answer) => {
5805
6081
  rl.close();
5806
6082
  stdout.write("\n");
5807
- resolve2(answer.trim());
6083
+ resolve3(answer.trim());
5808
6084
  });
5809
6085
  });
5810
6086
  }
@@ -5842,7 +6118,7 @@ async function promptConfirm(question, defaultYes = true) {
5842
6118
  }
5843
6119
 
5844
6120
  // src/utils/loopback.ts
5845
- var LOOPBACK_IMPACT = "your work will NOT reach the cloud (mycompbox.web.app)";
6121
+ var LOOPBACK_IMPACT = "your work will NOT reach the cloud (mycompbox.com)";
5846
6122
  var LOOPBACK_NOTICE = `This is a LOCAL dev endpoint \u2014 ${LOOPBACK_IMPACT}. Use \`compbox login --url <cloud-url>\` for production.`;
5847
6123
  function isLoopbackHostname(hostname3) {
5848
6124
  let host = hostname3.trim().toLowerCase();
@@ -5882,6 +6158,7 @@ function isLoopbackUrl(rawUrl) {
5882
6158
  }
5883
6159
 
5884
6160
  // src/commands/login.ts
6161
+ var DEFAULT_BASE_URL = "https://mycompbox.com";
5885
6162
  async function resolveBaseUrl(option) {
5886
6163
  if (option && option.trim().length > 0) {
5887
6164
  return option.trim();
@@ -5890,19 +6167,40 @@ async function resolveBaseUrl(option) {
5890
6167
  if (existing && existing.trim().length > 0) {
5891
6168
  return existing.trim();
5892
6169
  }
5893
- const entered = await promptText("Central CompBox API URL: ");
5894
- if (entered.length === 0) {
5895
- throw new Error("A central API URL is required.");
5896
- }
5897
- return entered;
6170
+ const entered = await promptText(`CompBox URL [${DEFAULT_BASE_URL}]: `);
6171
+ return entered.length === 0 ? DEFAULT_BASE_URL : entered;
5898
6172
  }
5899
6173
  var loginCommand = new Command5("login").description("Authenticate to central CompBox and store your pairing config").option("--url <url>", "Central CompBox API base URL").option("--email <email>", "Account email (otherwise prompted)").option(
5900
6174
  "--password <password>",
5901
6175
  "Account password (otherwise prompted; prefer the prompt to keep it out of shell history)"
5902
- ).option("--token-name <name>", "Name for the generated API token").action(async (options) => {
6176
+ ).option("--token-name <name>", "Name for the generated API token").option(
6177
+ "--api-token <token>",
6178
+ "Sign in with an existing CompBox API token (st_...) from web Settings \u2192 API Keys \u2014 use this if you sign in with Google"
6179
+ ).action(async (options) => {
5903
6180
  try {
5904
6181
  const baseUrl = await resolveBaseUrl(options.url);
5905
6182
  const client = new AuthClient(baseUrl);
6183
+ if (options.apiToken && options.apiToken.trim().length > 0) {
6184
+ const token = options.apiToken.trim();
6185
+ console.log(chalk6.gray(`
6186
+ Verifying the API token against ${baseUrl}...`));
6187
+ const me = await client.getMe(token);
6188
+ updateConfig({
6189
+ apiBaseUrl: client.baseUrl,
6190
+ apiToken: token,
6191
+ userId: me.id,
6192
+ email: me.email,
6193
+ userName: me.name
6194
+ });
6195
+ console.log(chalk6.green(`
6196
+ \u2714 Paired as ${me.name} <${me.email}> using your API token`));
6197
+ console.log(chalk6.gray(` Central URL: ${client.baseUrl}`));
6198
+ console.log("\n" + chalk6.bold("Next step:"));
6199
+ console.log(
6200
+ chalk6.gray(" Run `compbox configure` to select your AI mode, pair this device, and write the MCP config.")
6201
+ );
6202
+ return;
6203
+ }
5906
6204
  const email = options.email && options.email.trim().length > 0 ? options.email.trim() : await promptText("Email: ");
5907
6205
  if (email.length === 0) {
5908
6206
  throw new Error("Email is required.");
@@ -5952,7 +6250,17 @@ Authenticating to ${baseUrl}...`));
5952
6250
  console.error(chalk6.red(`
5953
6251
  Login failed: ${error.message}`));
5954
6252
  if (error.status === 401) {
5955
- console.error(chalk6.yellow("Tip: double-check your email and password."));
6253
+ console.error(
6254
+ chalk6.yellow(
6255
+ "Tip: double-check your email and password. If you sign in with Google, use `compbox login --api-token <st_...>` with a key from web Settings \u2192 API Keys."
6256
+ )
6257
+ );
6258
+ } else if (error.status === 403) {
6259
+ console.error(
6260
+ chalk6.yellow(
6261
+ "Tip: if you just signed up, open the verification link we emailed you, then run `compbox login` again."
6262
+ )
6263
+ );
5956
6264
  } else if (error.status === 0) {
5957
6265
  console.error(chalk6.yellow("Tip: verify the central API URL and your network connection."));
5958
6266
  }
@@ -5969,7 +6277,7 @@ Login failed: ${error.message}`));
5969
6277
  // src/commands/configure.ts
5970
6278
  import { Command as Command6 } from "commander";
5971
6279
  import chalk7 from "chalk";
5972
- import { execFileSync as execFileSync3 } from "child_process";
6280
+ import { execFileSync as execFileSync4 } from "child_process";
5973
6281
  import { hostname as hostname2 } from "os";
5974
6282
 
5975
6283
  // src/utils/keychain.ts
@@ -6204,12 +6512,18 @@ var MailboxClient = class {
6204
6512
  * here. Cloud Run bills vCPU for the wall-clock life of a request, so a held
6205
6513
  * connection kept one instance permanently awake at ~$64/month.
6206
6514
  */
6207
- 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 = []) {
6208
6520
  let res;
6209
6521
  try {
6210
6522
  res = await this.http.get("/api/v1/machines/next-job", {
6211
6523
  headers: { Authorization: `Bearer ${machineToken}` },
6212
- 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(",") } }
6213
6527
  });
6214
6528
  } catch (error) {
6215
6529
  throw toMailboxError(error, "next-job request failed");
@@ -6300,16 +6614,74 @@ var MailboxClient = class {
6300
6614
  const data = res.data;
6301
6615
  return data.data;
6302
6616
  }
6617
+ /**
6618
+ * The person's saved API key for one job sent with "Run with: My API key".
6619
+ * Kept in memory and passed to Claude Code's environment only — never
6620
+ * written to disk or logged.
6621
+ */
6622
+ async getAiKey(machineToken, jobId) {
6623
+ let res;
6624
+ try {
6625
+ res = await this.http.post(
6626
+ `/api/v1/jobs/${jobId}/ai-key`,
6627
+ {},
6628
+ { headers: { Authorization: `Bearer ${machineToken}` } }
6629
+ );
6630
+ } catch (error) {
6631
+ throw toMailboxError(error, "ai-key request failed");
6632
+ }
6633
+ if (res.status < 200 || res.status >= 300) {
6634
+ throw errorFromResponse(res, "ai-key failed");
6635
+ }
6636
+ const data = res.data;
6637
+ return data.data;
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
+ }
6656
+ /** Reports what a run on the person's API key spent. */
6657
+ async reportAiUsage(machineToken, jobId, usage) {
6658
+ let res;
6659
+ try {
6660
+ res = await this.http.post(`/api/v1/jobs/${jobId}/ai-usage`, usage, {
6661
+ headers: { Authorization: `Bearer ${machineToken}` }
6662
+ });
6663
+ } catch (error) {
6664
+ throw toMailboxError(error, "ai-usage request failed");
6665
+ }
6666
+ if (res.status < 200 || res.status >= 300) {
6667
+ throw errorFromResponse(res, "ai-usage failed");
6668
+ }
6669
+ }
6303
6670
  };
6304
6671
 
6305
6672
  // src/utils/mcp-config-writer.ts
6306
6673
  import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
6307
- import { dirname as dirname2, join as join6 } from "path";
6674
+ import { basename, dirname as dirname2, join as join6 } from "path";
6308
6675
  var DEFAULT_MCP_CONFIG_FILENAME = ".mcp.json";
6309
6676
  var DEFAULT_SERVER_KEY = "compbox";
6310
6677
  var MCP_SERVER_PACKAGE = "@mycompbox/compbox-mcp";
6311
6678
  function resolveInstalledMcpBundle(opts) {
6312
6679
  const exists = opts?.exists ?? existsSync6;
6680
+ const self = opts && "self" in opts ? opts.self : process.argv[1];
6681
+ if (self && basename(self) === "compbox.cjs") {
6682
+ const sibling = join6(dirname2(self), "compbox-mcp.cjs");
6683
+ if (exists(sibling)) return sibling;
6684
+ }
6313
6685
  const prefixes = [];
6314
6686
  if (opts?.prefix) prefixes.push(opts.prefix);
6315
6687
  if (process.env.HOMEBREW_PREFIX) prefixes.push(process.env.HOMEBREW_PREFIX);
@@ -6454,6 +6826,100 @@ function findBrew(deps = {}) {
6454
6826
  return pathProbe() ? "brew" : null;
6455
6827
  }
6456
6828
 
6829
+ // src/utils/autostart.ts
6830
+ import { execFileSync as execFileSync3 } from "node:child_process";
6831
+ import { mkdirSync as mkdirSync4, realpathSync, writeFileSync as writeFileSync4 } from "node:fs";
6832
+ import { homedir as homedir3 } from "node:os";
6833
+ import { dirname as dirname3, join as join7 } from "node:path";
6834
+ var LAUNCHD_LABEL = "com.mycompbox.agent-runner";
6835
+ var SYSTEMD_UNIT = "compbox-agent-runner.service";
6836
+ function scriptInstallPath(home = homedir3()) {
6837
+ return join7(home, ".compbox", "bin", "compbox.cjs");
6838
+ }
6839
+ function isScriptInstall(entry = process.argv[1], home = homedir3()) {
6840
+ if (!entry) return false;
6841
+ try {
6842
+ return realpathSync(entry) === realpathSync(scriptInstallPath(home));
6843
+ } catch {
6844
+ return false;
6845
+ }
6846
+ }
6847
+ function xmlEscape(value) {
6848
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6849
+ }
6850
+ function launchdPlist(opts) {
6851
+ const args = [opts.node, opts.script, "run"].map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
6852
+ return `<?xml version="1.0" encoding="UTF-8"?>
6853
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
6854
+ <plist version="1.0">
6855
+ <dict>
6856
+ <key>Label</key>
6857
+ <string>${LAUNCHD_LABEL}</string>
6858
+ <key>ProgramArguments</key>
6859
+ <array>
6860
+ ${args}
6861
+ </array>
6862
+ <key>EnvironmentVariables</key>
6863
+ <dict>
6864
+ <key>PATH</key>
6865
+ <string>${xmlEscape(opts.path)}</string>
6866
+ </dict>
6867
+ <key>RunAtLoad</key>
6868
+ <true/>
6869
+ <key>KeepAlive</key>
6870
+ <true/>
6871
+ <key>StandardOutPath</key>
6872
+ <string>${xmlEscape(opts.logFile)}</string>
6873
+ <key>StandardErrorPath</key>
6874
+ <string>${xmlEscape(opts.logFile)}</string>
6875
+ </dict>
6876
+ </plist>
6877
+ `;
6878
+ }
6879
+ function systemdUnit(opts) {
6880
+ const quote = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
6881
+ return `[Unit]
6882
+ Description=CompBox Agent Runner
6883
+
6884
+ [Service]
6885
+ ExecStart=${quote(opts.node)} ${quote(opts.script)} run
6886
+ Environment=${quote(`PATH=${opts.path}`)}
6887
+ Restart=always
6888
+ RestartSec=10
6889
+
6890
+ [Install]
6891
+ WantedBy=default.target
6892
+ `;
6893
+ }
6894
+ function installAutostart() {
6895
+ const home = homedir3();
6896
+ const script = scriptInstallPath(home);
6897
+ const node = process.execPath;
6898
+ const path3 = process.env.PATH ?? "/usr/bin:/bin";
6899
+ if (process.platform === "darwin") {
6900
+ const plistPath = join7(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
6901
+ const logFile = join7(home, ".compbox", "agent-runner.log");
6902
+ mkdirSync4(dirname3(plistPath), { recursive: true });
6903
+ writeFileSync4(plistPath, launchdPlist({ node, script, path: path3, logFile }));
6904
+ const domain = `gui/${String(process.getuid?.() ?? 0)}`;
6905
+ try {
6906
+ execFileSync3("launchctl", ["bootout", domain, plistPath], { stdio: "ignore" });
6907
+ } catch {
6908
+ }
6909
+ execFileSync3("launchctl", ["bootstrap", domain, plistPath], { stdio: "pipe" });
6910
+ return `it starts at every login (stop it with \`launchctl bootout ${domain} ${plistPath}\`; log: ${logFile})`;
6911
+ }
6912
+ if (process.platform === "linux") {
6913
+ const unitPath = join7(home, ".config", "systemd", "user", SYSTEMD_UNIT);
6914
+ mkdirSync4(dirname3(unitPath), { recursive: true });
6915
+ writeFileSync4(unitPath, systemdUnit({ node, script, path: path3 }));
6916
+ execFileSync3("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
6917
+ execFileSync3("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT], { stdio: "pipe" });
6918
+ return `it starts at every login (stop it with \`systemctl --user disable --now ${SYSTEMD_UNIT}\`)`;
6919
+ }
6920
+ throw new Error(`Automatic start isn't supported on ${process.platform}.`);
6921
+ }
6922
+
6457
6923
  // src/commands/configure.ts
6458
6924
  function parseAiMode(value) {
6459
6925
  if (value === "claude-subscription" || value === "api-key") {
@@ -6550,13 +7016,13 @@ Device pairing failed: ${message}`));
6550
7016
  Device already paired as "${config.machineName ?? config.machineId}".`));
6551
7017
  }
6552
7018
  const defaultDir = isGitRepoDir(process.cwd()) ? process.cwd() : void 0;
6553
- console.log("\n" + chalk7.bold("Project directory for dispatched runs:"));
7019
+ console.log("\n" + chalk7.bold("Project folder:"));
6554
7020
  console.log(
6555
7021
  chalk7.gray(
6556
- " This is where dispatched @planner/@orchestrator runs read, edit, and commit \u2014 leave blank to skip."
7022
+ " The folder with your code (a git project) where the AI plans, edits and commits.\n Leave blank to skip; add folders per project later with `compbox machines dirs add`."
6557
7023
  )
6558
7024
  );
6559
- const projectDirQuestion = "Which local repository should dispatched plan/orchestrate runs use?";
7025
+ const projectDirQuestion = "Which project folder should the AI work in?";
6560
7026
  const MAX_PROJECT_DIR_ATTEMPTS = 5;
6561
7027
  let projectDirPersisted = false;
6562
7028
  let projectDirSkipped = false;
@@ -6673,8 +7139,8 @@ Device already paired as "${config.machineName ?? config.machineId}".`));
6673
7139
  " Agent traffic would pass through it; if it is not running, calls fall back to a direct connection automatically."
6674
7140
  )
6675
7141
  );
6676
- updateConfig({ pxpipeEnabled: enablePxpipe === true });
6677
- if (enablePxpipe === true) {
7142
+ updateConfig({ pxpipeEnabled: enablePxpipe });
7143
+ if (enablePxpipe) {
6678
7144
  console.log(chalk7.green("\u2714 pxpipe routing enabled for this machine."));
6679
7145
  console.log(
6680
7146
  chalk7.yellow(" Note: which models are actually compressed is pxpipe's own setting, not CompBox's.")
@@ -6693,9 +7159,26 @@ Device already paired as "${config.machineName ?? config.machineId}".`));
6693
7159
  chalk7.gray(" Re-run `compbox configure` to change this later.")
6694
7160
  );
6695
7161
  }
6696
- const startServiceCmd = process.platform === "darwin" ? "brew services start compbox" : process.platform === "win32" ? "compbox service install" : "compbox run";
7162
+ const scriptInstall = process.platform !== "win32" && isScriptInstall();
7163
+ const startServiceCmd = process.platform === "win32" ? "compbox service install" : process.platform === "darwin" && !scriptInstall ? "brew services start compbox" : "compbox run";
6697
7164
  let daemonStarted = false;
6698
- if (process.platform === "darwin") {
7165
+ if (scriptInstall) {
7166
+ const startNow = await promptConfirm(
7167
+ "Start the Agent Runner now and at every login? (lets CompBox send work to this computer)",
7168
+ true
7169
+ );
7170
+ if (startNow) {
7171
+ try {
7172
+ const how = installAutostart();
7173
+ daemonStarted = true;
7174
+ console.log(chalk7.green(`\u2714 Agent Runner started \u2014 ${how}.`));
7175
+ } catch (error) {
7176
+ const message = (error instanceof Error ? error.message.split("\n")[0] : void 0) ?? "unknown error";
7177
+ console.warn(chalk7.yellow(`\u26A0 Could not start the Agent Runner automatically: ${message}`));
7178
+ console.warn(chalk7.gray(" Run `compbox run` in a terminal and keep it open instead."));
7179
+ }
7180
+ }
7181
+ } else if (process.platform === "darwin") {
6699
7182
  const brew = findBrew();
6700
7183
  if (brew !== null) {
6701
7184
  const startNow = await promptConfirm(
@@ -6704,7 +7187,7 @@ Device already paired as "${config.machineName ?? config.machineId}".`));
6704
7187
  );
6705
7188
  if (startNow) {
6706
7189
  try {
6707
- execFileSync3(brew, ["services", "start", "compbox"], {
7190
+ execFileSync4(brew, ["services", "start", "compbox"], {
6708
7191
  encoding: "utf-8",
6709
7192
  timeout: 3e4,
6710
7193
  stdio: ["ignore", "pipe", "pipe"]
@@ -6716,7 +7199,7 @@ Device already paired as "${config.machineName ?? config.machineId}".`));
6716
7199
  )
6717
7200
  );
6718
7201
  } catch (error) {
6719
- const message = error instanceof Error ? error.message.split("\n")[0] : "unknown error";
7202
+ const message = (error instanceof Error ? error.message.split("\n")[0] : void 0) ?? "unknown error";
6720
7203
  console.warn(chalk7.yellow(`\u26A0 Could not start the service: ${message}`));
6721
7204
  console.warn(chalk7.gray(` Start it manually with \`${startServiceCmd}\`.`));
6722
7205
  }
@@ -6743,22 +7226,22 @@ Configuration failed: ${error.message}`));
6743
7226
  });
6744
7227
 
6745
7228
  // src/commands/doctor.ts
6746
- import { execFileSync as execFileSync5, spawn as spawn2 } from "child_process";
7229
+ import { execFileSync as execFileSync6, spawn as spawn2 } from "child_process";
6747
7230
  import { existsSync as existsSync8 } from "fs";
6748
7231
  import { Command as Command7 } from "commander";
6749
7232
  import chalk8 from "chalk";
6750
7233
 
6751
7234
  // src/utils/claude-runtime.ts
6752
7235
  import { spawn } from "node:child_process";
6753
- import { execFileSync as execFileSync4 } from "node:child_process";
7236
+ import { execFileSync as execFileSync5 } from "node:child_process";
6754
7237
  import { accessSync, constants as fsConstants, statSync as statSync2 } from "node:fs";
6755
- import { homedir as homedir3 } from "node:os";
6756
- import { delimiter, join as join7 } from "node:path";
7238
+ import { homedir as homedir4 } from "node:os";
7239
+ import { delimiter, join as join8 } from "node:path";
6757
7240
  var WELL_KNOWN_DIRS = buildWellKnownDirs();
6758
7241
  function buildWellKnownDirs() {
6759
- const home = homedir3();
7242
+ const home = homedir4();
6760
7243
  const dirs = [
6761
- join7(home, ".local", "bin"),
7244
+ join8(home, ".local", "bin"),
6762
7245
  // default Claude Code install location
6763
7246
  "/opt/homebrew/bin",
6764
7247
  // Homebrew on Apple Silicon
@@ -6766,14 +7249,14 @@ function buildWellKnownDirs() {
6766
7249
  // Homebrew on Intel / common Unix prefix
6767
7250
  ];
6768
7251
  if (process.env.npm_config_prefix) {
6769
- dirs.push(join7(process.env.npm_config_prefix, "bin"));
7252
+ dirs.push(join8(process.env.npm_config_prefix, "bin"));
6770
7253
  }
6771
- dirs.push(join7(home, ".npm-global", "bin"));
7254
+ dirs.push(join8(home, ".npm-global", "bin"));
6772
7255
  if (process.env.PNPM_HOME) {
6773
7256
  dirs.push(process.env.PNPM_HOME);
6774
7257
  }
6775
- dirs.push(join7(home, "Library", "pnpm"));
6776
- dirs.push(join7(home, ".local", "share", "pnpm"));
7258
+ dirs.push(join8(home, "Library", "pnpm"));
7259
+ dirs.push(join8(home, ".local", "share", "pnpm"));
6777
7260
  return dirs;
6778
7261
  }
6779
7262
  var LOGIN_SHELL_PATH_TIMEOUT_MS = 2e3;
@@ -6801,7 +7284,7 @@ function getLoginShellPath(exec = defaultExecFileSync) {
6801
7284
  return cachedLoginShellPath;
6802
7285
  }
6803
7286
  function defaultExecFileSync(file, args, options) {
6804
- return execFileSync4(file, args, {
7287
+ return execFileSync5(file, args, {
6805
7288
  timeout: options.timeout,
6806
7289
  encoding: options.encoding,
6807
7290
  stdio: ["ignore", "pipe", "ignore"]
@@ -6839,7 +7322,7 @@ function resolveClaudeBinary(deps = {}) {
6839
7322
  continue;
6840
7323
  }
6841
7324
  seen.add(dir);
6842
- const candidate = join7(dir, "claude");
7325
+ const candidate = join8(dir, "claude");
6843
7326
  if (isExec(candidate)) {
6844
7327
  return candidate;
6845
7328
  }
@@ -6878,7 +7361,7 @@ function probeAgentRuntime(deps = {}) {
6878
7361
  checkedAt: (/* @__PURE__ */ new Date()).toISOString()
6879
7362
  });
6880
7363
  }
6881
- return new Promise((resolve2) => {
7364
+ return new Promise((resolve3) => {
6882
7365
  let settled = false;
6883
7366
  let child;
6884
7367
  let stdout2 = "";
@@ -6893,7 +7376,7 @@ function probeAgentRuntime(deps = {}) {
6893
7376
  child.kill("SIGKILL");
6894
7377
  } catch {
6895
7378
  }
6896
- resolve2({ ...result, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
7379
+ resolve3({ ...result, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
6897
7380
  };
6898
7381
  const timer = setTimeout(() => {
6899
7382
  settle({
@@ -6905,7 +7388,7 @@ function probeAgentRuntime(deps = {}) {
6905
7388
  child = spawnFn(abs, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
6906
7389
  } catch (error) {
6907
7390
  clearTimeout(timer);
6908
- resolve2({
7391
+ resolve3({
6909
7392
  status: "error",
6910
7393
  detail: error instanceof Error ? error.message : "failed to spawn claude",
6911
7394
  checkedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -6997,7 +7480,7 @@ function countFailures(results) {
6997
7480
  }
6998
7481
  var LAUNCH_PROBE_TIMEOUT_MS = 5e3;
6999
7482
  function probeLaunchTarget(command, args, timeoutMs = LAUNCH_PROBE_TIMEOUT_MS) {
7000
- return new Promise((resolve2) => {
7483
+ return new Promise((resolve3) => {
7001
7484
  let settled = false;
7002
7485
  const child = spawn2(command, args, {
7003
7486
  stdio: ["ignore", "pipe", "pipe"],
@@ -7019,7 +7502,7 @@ function probeLaunchTarget(command, args, timeoutMs = LAUNCH_PROBE_TIMEOUT_MS) {
7019
7502
  child.kill("SIGKILL");
7020
7503
  } catch {
7021
7504
  }
7022
- resolve2(result);
7505
+ resolve3(result);
7023
7506
  };
7024
7507
  const timer = setTimeout(() => {
7025
7508
  settle({ status: "timeout" });
@@ -7108,7 +7591,7 @@ var BREW_SERVICE_LABEL = "homebrew.mxcl.compbox";
7108
7591
  function checkBrewService(deps = {}) {
7109
7592
  const name = "Homebrew service running (macOS)";
7110
7593
  const locateBrew = deps.findBrewFn ?? findBrew;
7111
- const exec = deps.execFn ?? ((file, args) => execFileSync5(file, args, {
7594
+ const exec = deps.execFn ?? ((file, args) => execFileSync6(file, args, {
7112
7595
  encoding: "utf-8",
7113
7596
  timeout: 1e4,
7114
7597
  stdio: ["ignore", "pipe", "ignore"]
@@ -7126,7 +7609,7 @@ function checkBrewService(deps = {}) {
7126
7609
  try {
7127
7610
  output = exec(brew, ["services", "list"]);
7128
7611
  } catch (error) {
7129
- const cause = error instanceof Error ? error.message.split("\n")[0] : "unknown error";
7612
+ const cause = (error instanceof Error ? error.message.split("\n")[0] : void 0) ?? "unknown error";
7130
7613
  try {
7131
7614
  exec("launchctl", ["list", BREW_SERVICE_LABEL]);
7132
7615
  return {
@@ -7416,16 +7899,67 @@ import chalk9 from "chalk";
7416
7899
  // src/utils/agent-invoker.ts
7417
7900
  import Anthropic from "@anthropic-ai/sdk";
7418
7901
  import { spawn as spawn3, spawnSync } from "node:child_process";
7419
- import { mkdirSync as mkdirSync4 } from "node:fs";
7420
- import { homedir as homedir4 } from "node:os";
7421
- import { join as join8 } from "node:path";
7902
+ import { mkdirSync as mkdirSync5 } from "node:fs";
7903
+ import { homedir as homedir5 } from "node:os";
7904
+ import { join as join9 } from "node:path";
7905
+
7906
+ // src/utils/key-run.ts
7907
+ function keyRunEnv(env, keyRun) {
7908
+ const next = { ...env, ANTHROPIC_API_KEY: keyRun.apiKey, ANTHROPIC_MODEL: keyRun.model };
7909
+ delete next.ANTHROPIC_AUTH_TOKEN;
7910
+ delete next.CLAUDE_CODE_OAUTH_TOKEN;
7911
+ return next;
7912
+ }
7913
+ function keyRunArgs(args) {
7914
+ return [...args, "--output-format", "json"];
7915
+ }
7916
+ function num(value) {
7917
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
7918
+ }
7919
+ function parseClaudeJsonResult(stdout2, fallbackModel) {
7920
+ let data;
7921
+ try {
7922
+ const start = stdout2.indexOf("{");
7923
+ if (start === -1) return null;
7924
+ data = JSON.parse(stdout2.slice(start));
7925
+ } catch {
7926
+ return null;
7927
+ }
7928
+ if (typeof data.result !== "string" && data.type !== "result") return null;
7929
+ const usage = {
7930
+ model: fallbackModel,
7931
+ inputTokens: 0,
7932
+ outputTokens: 0,
7933
+ cacheReadTokens: 0,
7934
+ cacheWriteTokens: 0,
7935
+ costUsd: typeof data.total_cost_usd === "number" ? data.total_cost_usd : null
7936
+ };
7937
+ const byModel = data.modelUsage;
7938
+ if (typeof byModel === "object" && byModel !== null && Object.keys(byModel).length > 0) {
7939
+ let topCost = -1;
7940
+ for (const [model, raw] of Object.entries(byModel)) {
7941
+ usage.inputTokens += num(raw.inputTokens);
7942
+ usage.outputTokens += num(raw.outputTokens);
7943
+ usage.cacheReadTokens += num(raw.cacheReadInputTokens);
7944
+ usage.cacheWriteTokens += num(raw.cacheCreationInputTokens);
7945
+ if (num(raw.costUSD) > topCost) {
7946
+ topCost = num(raw.costUSD);
7947
+ usage.model = model;
7948
+ }
7949
+ }
7950
+ } else if (typeof data.usage === "object" && data.usage !== null) {
7951
+ const u = data.usage;
7952
+ usage.inputTokens = num(u.input_tokens);
7953
+ usage.outputTokens = num(u.output_tokens);
7954
+ usage.cacheReadTokens = num(u.cache_read_input_tokens);
7955
+ usage.cacheWriteTokens = num(u.cache_creation_input_tokens);
7956
+ }
7957
+ return { text: typeof data.result === "string" ? data.result : "", usage };
7958
+ }
7959
+
7960
+ // src/utils/agent-invoker.ts
7422
7961
  var ANTHROPIC_MODEL = "claude-fable-5";
7423
- var MAX_INTERVIEW_TURNS = 18;
7424
- var MAX_QUALITY_ROUNDS = 4;
7425
- var TARGET_COMPLIANCE_TIER = "pass";
7426
- var REPLY_POLL_INTERVAL_MS = 3e3;
7427
- var REPLY_TIMEOUT_MS = 10 * 60 * 1e3;
7428
- var CLAUDE_CLI_TURN_TIMEOUT_MS = 2 * 60 * 1e3;
7962
+ var CLAUDE_CLI_TURN_TIMEOUT_MS = 4 * 60 * 1e3;
7429
7963
  function anthropicClientOptions(apiKey, routing) {
7430
7964
  return routing.mode === "proxy" ? { apiKey, baseURL: routing.anthropicBaseUrl } : { apiKey };
7431
7965
  }
@@ -7438,11 +7972,7 @@ var SUPPORTED_IMAGE_MEDIA_TYPES = [
7438
7972
  "image/gif",
7439
7973
  "image/webp"
7440
7974
  ];
7441
- var MAX_IMAGES_PER_TURN = 8;
7442
7975
  var MAX_IMAGE_BYTES_PER_TURN = 20 * 1024 * 1024;
7443
- var defaultWarn = (message) => {
7444
- console.warn(` \u26A0 ${message}`);
7445
- };
7446
7976
  function normalizeImageMediaType(mime) {
7447
7977
  const base = (mime.split(";")[0] ?? "").trim().toLowerCase();
7448
7978
  return SUPPORTED_IMAGE_MEDIA_TYPES.includes(base) ? base : null;
@@ -7450,67 +7980,10 @@ function normalizeImageMediaType(mime) {
7450
7980
  function isSupportedImageMediaType(mime) {
7451
7981
  return normalizeImageMediaType(mime) !== null;
7452
7982
  }
7453
- function selectImageAttachments(attachments) {
7454
- if (!attachments || attachments.length === 0) return [];
7455
- const selected = [];
7456
- let declaredBytes = 0;
7457
- for (const att of attachments) {
7458
- if (selected.length >= MAX_IMAGES_PER_TURN) break;
7459
- if (!isSupportedImageMediaType(att.mimeType)) continue;
7460
- const size = typeof att.sizeBytes === "number" && att.sizeBytes > 0 ? att.sizeBytes : 0;
7461
- if (declaredBytes + size > MAX_IMAGE_BYTES_PER_TURN) continue;
7462
- declaredBytes += size;
7463
- selected.push(att);
7464
- }
7465
- return selected;
7466
- }
7467
7983
  function imageAttachmentCount(attachments) {
7468
7984
  if (!attachments || attachments.length === 0) return 0;
7469
7985
  return attachments.filter((a) => isSupportedImageMediaType(a.mimeType)).length;
7470
7986
  }
7471
- async function fetchImageBlocks(attachments, warn = defaultWarn, fetchImpl = fetch) {
7472
- const blocks = [];
7473
- let totalBytes = 0;
7474
- for (const att of attachments) {
7475
- const mediaType = normalizeImageMediaType(att.mimeType);
7476
- if (!mediaType) continue;
7477
- try {
7478
- const res = await fetchImpl(att.signedUrl);
7479
- if (!res.ok) {
7480
- warn(`Skipping image attachment ${att.id}: fetch failed (HTTP ${String(res.status)}).`);
7481
- continue;
7482
- }
7483
- const bytes = Buffer.from(await res.arrayBuffer());
7484
- if (bytes.byteLength === 0) {
7485
- warn(`Skipping image attachment ${att.id}: empty response body.`);
7486
- continue;
7487
- }
7488
- if (totalBytes + bytes.byteLength > MAX_IMAGE_BYTES_PER_TURN) {
7489
- warn(`Skipping image attachment ${att.id}: exceeds the per-turn image byte budget.`);
7490
- continue;
7491
- }
7492
- totalBytes += bytes.byteLength;
7493
- blocks.push({
7494
- type: "image",
7495
- source: { type: "base64", media_type: mediaType, data: bytes.toString("base64") }
7496
- });
7497
- } catch (error) {
7498
- const message = error instanceof Error ? error.message : "unknown error";
7499
- warn(`Skipping image attachment ${att.id}: ${message}.`);
7500
- }
7501
- }
7502
- return blocks;
7503
- }
7504
- function buildApiUserContent(text, imageBlocks) {
7505
- if (imageBlocks.length === 0) return text;
7506
- return [...imageBlocks, { type: "text", text }];
7507
- }
7508
- async function apiUserTurnContent(turn, warn = defaultWarn, fetchImpl = fetch) {
7509
- const selected = selectImageAttachments(turn.attachments);
7510
- if (selected.length === 0) return turn.content;
7511
- const blocks = await fetchImageBlocks(selected, warn, fetchImpl);
7512
- return buildApiUserContent(turn.content, blocks);
7513
- }
7514
7987
  function appendAttachmentNote(text, imageCount) {
7515
7988
  if (imageCount <= 0) return text;
7516
7989
  const noun = imageCount === 1 ? "image" : "images";
@@ -7519,7 +7992,6 @@ function appendAttachmentNote(text, imageCount) {
7519
7992
 
7520
7993
  ${note}` : note;
7521
7994
  }
7522
- var SYSTEM_PROMPT = buildInterviewSystemPrompt();
7523
7995
  function parseSeedPayload(payload) {
7524
7996
  if (typeof payload !== "object" || payload === null) return null;
7525
7997
  const p = payload;
@@ -7535,11 +8007,6 @@ function parseSeedPayload(payload) {
7535
8007
  priorTranscript
7536
8008
  };
7537
8009
  }
7538
- function resolveSystemPrompt(seed) {
7539
- return seed ? buildInterviewSystemPrompt(INTERVIEW_DEFINITION, {
7540
- priorTranscript: seed.priorTranscript
7541
- }) : SYSTEM_PROMPT;
7542
- }
7543
8010
  async function submitAndScore(ctx, currentErId, draft) {
7544
8011
  if (currentErId) {
7545
8012
  const compliance = await updatePersonalEpicRequest(
@@ -7553,248 +8020,21 @@ async function submitAndScore(ctx, currentErId, draft) {
7553
8020
  const created = await createPersonalEpicRequest(ctx.apiBaseUrl, ctx.userApiToken, draft);
7554
8021
  return { erId: created.id, compliance: created.compliance };
7555
8022
  }
7556
- function meetsComplianceTarget(compliance) {
7557
- return !compliance || compliance.tier === TARGET_COMPLIANCE_TIER;
7558
- }
7559
- function buildAuditFeedback(compliance) {
7560
- const ordered = [
7561
- ...compliance.issues.filter((i) => i.tier === "critical"),
7562
- ...compliance.issues.filter((i) => i.tier === "advisory")
7563
- ].slice(0, 8);
7564
- const list = ordered.length > 0 ? ordered.map((i) => `- ${i.message}`).join("\n") : "- (no specific issues returned; expand the required fields with more concrete detail)";
7565
- return `The epic request you just produced scored ${String(compliance.overall)}/100 (tier: ${compliance.tier}). It must reach 90/100 with ZERO critical issues before the interview can finish. Fix these:
7566
- ${list}
7567
-
7568
- If you can resolve these by EXPANDING what the employee already told you \u2014 a longer, more complete and concrete problemStatement / proposedSolution / impactAssessment / description that comfortably clear the length minimums \u2014 re-emit the completion JSON now with richer fields. Do NOT re-ask something you already have an answer for. Only if you genuinely need NEW information from the employee, ask ONE focused question to get it.`;
7569
- }
7570
- var OPTIONAL_DRAFT_KEYS = [
7571
- "successMetrics",
7572
- "targetAudience",
7573
- "alternatives",
7574
- "dependencies",
7575
- "estimatedEffort"
7576
- ];
7577
- function isEpicRequestDraft(value) {
7578
- if (typeof value !== "object" || value === null) return false;
7579
- const v = value;
7580
- const nonEmpty = (x) => typeof x === "string" && x.trim().length > 0;
7581
- const requiredOk = nonEmpty(v.title) && nonEmpty(v.problemStatement) && nonEmpty(v.proposedSolution) && nonEmpty(v.impactAssessment) && nonEmpty(v.description);
7582
- if (!requiredOk) return false;
7583
- for (const key of OPTIONAL_DRAFT_KEYS) {
7584
- if (key in v && v[key] !== void 0 && typeof v[key] !== "string") return false;
7585
- }
7586
- return true;
7587
- }
7588
- function stripTrailingCommas(json) {
7589
- return json.replace(/,(\s*[}\]])/g, "$1");
7590
- }
7591
- function escapeRawControlCharsInStrings(json) {
7592
- let out = "";
7593
- let inString = false;
7594
- let escaped = false;
7595
- for (const ch of json) {
7596
- if (escaped) {
7597
- out += ch;
7598
- escaped = false;
7599
- continue;
7600
- }
7601
- if (ch === "\\") {
7602
- out += ch;
7603
- escaped = true;
7604
- continue;
7605
- }
7606
- if (ch === '"') {
7607
- inString = !inString;
7608
- out += ch;
7609
- continue;
7610
- }
7611
- if (inString && (ch === "\n" || ch === "\r" || ch === " ")) {
7612
- out += ch === "\n" ? "\\n" : ch === "\r" ? "\\r" : "\\t";
7613
- continue;
7614
- }
7615
- out += ch;
7616
- }
7617
- return out;
7618
- }
7619
- function completionJsonCandidates(text) {
7620
- const candidates = [];
7621
- const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
7622
- if (fenced?.[1]) candidates.push(fenced[1]);
7623
- const trimmed = text.trim();
7624
- if (trimmed.startsWith("{")) candidates.push(trimmed);
7625
- const first = text.indexOf("{");
7626
- const last = text.lastIndexOf("}");
7627
- if (first !== -1 && last > first) candidates.push(text.slice(first, last + 1));
7628
- return candidates;
7629
- }
7630
- function toDraft(parsed) {
7631
- const draft = {
7632
- title: parsed.title,
7633
- problemStatement: parsed.problemStatement,
7634
- proposedSolution: parsed.proposedSolution,
7635
- impactAssessment: parsed.impactAssessment,
7636
- description: parsed.description
7637
- };
7638
- for (const key of OPTIONAL_DRAFT_KEYS) {
7639
- const raw = parsed[key];
7640
- if (typeof raw === "string" && raw.trim().length > 0) {
7641
- draft[key] = raw;
7642
- }
7643
- }
7644
- return draft;
7645
- }
7646
- function extractCompletionJson(text) {
7647
- for (const candidate of completionJsonCandidates(text)) {
7648
- for (const variant of [
7649
- candidate,
7650
- stripTrailingCommas(candidate),
7651
- escapeRawControlCharsInStrings(candidate),
7652
- escapeRawControlCharsInStrings(stripTrailingCommas(candidate))
7653
- ]) {
7654
- try {
7655
- const parsed = JSON.parse(variant);
7656
- if (isEpicRequestDraft(parsed)) return toDraft(parsed);
7657
- } catch {
7658
- }
7659
- }
7660
- }
7661
- return null;
7662
- }
7663
- function looksLikeCompletionAttempt(text) {
7664
- const hasObject = /```(?:json)?\s*\{/i.test(text) || text.trim().startsWith("{");
7665
- if (!hasObject) return false;
7666
- return /"(?:done|title|problemStatement|proposedSolution|impactAssessment|description)"\s*:/i.test(
7667
- text
7668
- );
7669
- }
7670
- 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```';
7671
- var MAX_JSON_REPAIRS = 3;
7672
- function sleep(ms) {
7673
- return new Promise((resolve2) => setTimeout(resolve2, ms));
7674
- }
7675
- async function waitForUserReply(client, machineToken, jobId, turnsSoFar) {
7676
- const deadline = Date.now() + REPLY_TIMEOUT_MS;
7677
- while (Date.now() < deadline) {
7678
- await sleep(REPLY_POLL_INTERVAL_MS);
7679
- const job = await client.nextJob(machineToken, 0);
7680
- if (job?.id !== jobId) continue;
7681
- const last = job.turns[job.turns.length - 1];
7682
- if (job.turns.length > turnsSoFar && last?.role === "user") {
7683
- return job;
7684
- }
7685
- }
7686
- return null;
7687
- }
7688
- async function invokeEpicRequestViaApi(job, ctx) {
7689
- const routing = await resolveRouting();
7690
- const anthropic = new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing));
7691
- const seed = parseSeedPayload(job.payload);
7692
- const systemPrompt = resolveSystemPrompt(seed);
7693
- let currentErId = seed?.targetEpicRequestId ?? null;
7694
- let qualityRounds = 0;
7695
- let jsonRepairs = 0;
7696
- const messages = [];
7697
- for (const turn of job.turns) {
7698
- if (turn.role === "agent") {
7699
- messages.push({ role: "assistant", content: turn.content });
7700
- } else {
7701
- messages.push({ role: "user", content: await apiUserTurnContent(turn) });
7702
- }
7703
- }
7704
- let turnsSoFar = job.turns.length;
7705
- for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
7706
- let reply;
7707
- try {
7708
- const response = await anthropic.messages.create({
7709
- model: ANTHROPIC_MODEL,
7710
- max_tokens: 1024,
7711
- system: systemPrompt,
7712
- messages: messages.length > 0 ? messages : [{ role: "user", content: "Let's get started." }]
7713
- });
7714
- const textBlock = response.content.find((b) => b.type === "text");
7715
- reply = textBlock?.type === "text" ? textBlock.text : "";
7716
- } catch (error) {
7717
- const message = error instanceof Error ? error.message : "Anthropic API request failed";
7718
- return { status: "failed", result: { error: `Agent invocation failed: ${message}` } };
7719
- }
7720
- const draft = extractCompletionJson(reply);
7721
- if (draft) {
7722
- let submitted;
7723
- try {
7724
- submitted = await submitAndScore(ctx, currentErId, draft);
7725
- } catch (error) {
7726
- const message = error instanceof Error ? error.message : "Failed to save the epic request";
7727
- return { status: "failed", result: { error: message } };
7728
- }
7729
- currentErId = submitted.erId;
7730
- const compliance = submitted.compliance;
7731
- if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
7732
- return { status: "done", result: { epicRequestId: currentErId } };
7733
- }
7734
- qualityRounds++;
7735
- if (compliance !== null) {
7736
- messages.push({ role: "assistant", content: reply });
7737
- messages.push({ role: "user", content: buildAuditFeedback(compliance) });
7738
- }
7739
- continue;
7740
- }
7741
- if (looksLikeCompletionAttempt(reply)) {
7742
- if (jsonRepairs >= MAX_JSON_REPAIRS) {
7743
- return {
7744
- status: "failed",
7745
- result: {
7746
- error: "The interview produced malformed completion data and could not be finalized after several attempts."
7747
- }
7748
- };
7749
- }
7750
- jsonRepairs++;
7751
- messages.push({ role: "assistant", content: reply });
7752
- messages.push({ role: "user", content: COMPLETION_REPAIR_INSTRUCTION });
7753
- continue;
7754
- }
7755
- messages.push({ role: "assistant", content: reply });
7756
- let updated;
7757
- try {
7758
- updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, reply);
7759
- } catch (error) {
7760
- const message = error instanceof Error ? error.message : "post-turn failed";
7761
- return { status: "failed", result: { error: message } };
7762
- }
7763
- turnsSoFar = updated.turns.length;
7764
- const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
7765
- if (!answered) {
7766
- return {
7767
- status: "failed",
7768
- result: { error: "Timed out waiting for a reply \u2014 no response within 10 minutes." }
7769
- };
7770
- }
7771
- const lastTurn = answered.turns[answered.turns.length - 1];
7772
- messages.push({
7773
- role: "user",
7774
- content: lastTurn ? await apiUserTurnContent(lastTurn) : ""
7775
- });
7776
- turnsSoFar = answered.turns.length;
7777
- }
7778
- return currentErId ? { status: "done", result: { epicRequestId: currentErId } } : {
7779
- status: "failed",
7780
- result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` }
7781
- };
7782
- }
7783
8023
  function claudeCliSessionCwd() {
7784
- const dir = join8(homedir4(), ".compbox", "claude-sessions");
8024
+ const dir = join9(homedir5(), ".compbox", "claude-sessions");
7785
8025
  try {
7786
- mkdirSync4(dir, { recursive: true });
8026
+ mkdirSync5(dir, { recursive: true });
7787
8027
  } catch {
7788
8028
  }
7789
8029
  return dir;
7790
8030
  }
7791
8031
  function runClaudeCliTurn(prompt, opts) {
7792
- return new Promise((resolve2, reject) => {
8032
+ return new Promise((resolve3, reject) => {
8033
+ const model = opts.model === void 0 ? ANTHROPIC_MODEL : opts.model;
7793
8034
  const args = [
7794
8035
  "-p",
7795
8036
  prompt,
7796
- "--model",
7797
- ANTHROPIC_MODEL,
8037
+ ...model ? ["--model", model] : [],
7798
8038
  "--output-format",
7799
8039
  "json",
7800
8040
  "--tools",
@@ -7864,100 +8104,98 @@ function runClaudeCliTurn(prompt, opts) {
7864
8104
  reject(new Error("claude CLI returned an unexpected JSON shape (missing result/session_id)."));
7865
8105
  return;
7866
8106
  }
7867
- resolve2({ text: shaped.result, sessionId: shaped.session_id });
8107
+ resolve3({ text: shaped.result, sessionId: shaped.session_id });
7868
8108
  });
7869
8109
  });
7870
8110
  }
7871
- async function invokeEpicRequestViaSubscription(job, ctx) {
7872
- let sessionId;
7873
- 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) {
7874
8145
  const routing = await resolveRouting();
7875
- const seed = parseSeedPayload(job.payload);
7876
- const systemPrompt = resolveSystemPrompt(seed);
7877
- let currentErId = seed?.targetEpicRequestId ?? null;
7878
- let qualityRounds = 0;
7879
- let jsonRepairs = 0;
7880
- let nextPrompt = job.turns.length > 0 ? job.turns.map(
7881
- (t) => t.role === "agent" ? `You asked: ${t.content}` : (
7882
- // ENG-153 (A4): subscription mode has no inline-image channel, so a
7883
- // user turn's images degrade to an explicit textual note.
7884
- `They replied: ${appendAttachmentNote(t.content, imageAttachmentCount(t.attachments))}`
7885
- )
7886
- ).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.";
7887
- for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
7888
- let turn;
7889
- try {
7890
- turn = await runClaudeCliTurn(nextPrompt, {
7891
- systemPrompt,
7892
- resumeSessionId: sessionId,
7893
- routing
7894
- });
7895
- } catch (error) {
7896
- const message = error instanceof Error ? error.message : "claude CLI invocation failed";
7897
- return { status: "failed", result: { error: `Agent invocation failed: ${message}` } };
7898
- }
7899
- sessionId = turn.sessionId;
7900
- const draft = extractCompletionJson(turn.text);
7901
- if (draft) {
7902
- let submitted;
7903
- try {
7904
- submitted = await submitAndScore(ctx, currentErId, draft);
7905
- } catch (error) {
7906
- const message = error instanceof Error ? error.message : "Failed to save the epic request";
7907
- return { status: "failed", result: { error: message } };
7908
- }
7909
- currentErId = submitted.erId;
7910
- const compliance = submitted.compliance;
7911
- if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
7912
- return { status: "done", result: { epicRequestId: currentErId } };
7913
- }
7914
- qualityRounds++;
7915
- if (compliance !== null) {
7916
- nextPrompt = buildAuditFeedback(compliance);
7917
- }
7918
- continue;
7919
- }
7920
- if (looksLikeCompletionAttempt(turn.text)) {
7921
- if (jsonRepairs >= MAX_JSON_REPAIRS) {
7922
- return {
7923
- status: "failed",
7924
- result: {
7925
- error: "The interview produced malformed completion data and could not be finalized after several attempts."
7926
- }
7927
- };
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 };
7928
8175
  }
7929
- jsonRepairs++;
7930
- nextPrompt = COMPLETION_REPAIR_INSTRUCTION;
7931
- continue;
7932
- }
7933
- let updated;
7934
- try {
7935
- updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, turn.text);
7936
- } catch (error) {
7937
- const message = error instanceof Error ? error.message : "post-turn failed";
7938
- return { status: "failed", result: { error: message } };
7939
8176
  }
7940
- turnsSoFar = updated.turns.length;
7941
- const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
7942
- if (!answered) {
7943
- return {
7944
- status: "failed",
7945
- result: { error: "Timed out waiting for a reply \u2014 no response within 10 minutes." }
7946
- };
7947
- }
7948
- const lastTurn = answered.turns[answered.turns.length - 1];
7949
- nextPrompt = lastTurn ? appendAttachmentNote(lastTurn.content, imageAttachmentCount(lastTurn.attachments)) : "";
7950
- turnsSoFar = answered.turns.length;
8177
+ );
8178
+ if (outcome.kind === "saved") {
8179
+ await ctx.mailboxClient.markRequestSaved(ctx.machineToken, job.id, outcome.saved.id);
7951
8180
  }
7952
- return currentErId ? { status: "done", result: { epicRequestId: currentErId } } : {
7953
- status: "failed",
7954
- result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` }
7955
- };
8181
+ await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, outcome.text);
8182
+ return { status: "yield" };
7956
8183
  }
7957
8184
  var PLANNER_RUN_TIMEOUT_MS = 30 * 60 * 1e3;
7958
8185
  var PLANNER_HEARTBEAT_INTERVAL_MS = 20 * 1e3;
7959
8186
  var PLANNER_FINAL_TURN_MAX_CHARS = 4e3;
7960
8187
  var ORCHESTRATOR_REVIEW_MARKER = "COMPBOX_NEEDS_HUMAN_REVIEW";
8188
+ function takeKeyRunResult(ctx, keyRun, stdout2) {
8189
+ const parsed = parseClaudeJsonResult(stdout2, keyRun.model);
8190
+ if (!parsed) return { text: stdout2, reported: Promise.resolve() };
8191
+ const reported = ctx.mailboxClient.reportAiUsage(ctx.machineToken, ctx.jobId, parsed.usage).catch(() => void 0);
8192
+ return { text: parsed.text, reported };
8193
+ }
8194
+ async function settleKeyRun(ctx, keyRun, stdout2) {
8195
+ const { text, reported } = takeKeyRunResult(ctx, keyRun, stdout2);
8196
+ await reported;
8197
+ return text;
8198
+ }
7961
8199
  function buildPlannerArgs(epicRequestId) {
7962
8200
  return [
7963
8201
  "-p",
@@ -8078,13 +8316,14 @@ async function invokeStartExecutionPlan(ctx, deps = {}) {
8078
8316
  }
8079
8317
  const routing = await resolveRoutingImpl();
8080
8318
  const command = resolveClaudeBinary() ?? "claude";
8081
- const args = buildPlannerArgs(ctx.epicRequestId);
8319
+ const args = ctx.keyRun ? keyRunArgs(buildPlannerArgs(ctx.epicRequestId)) : buildPlannerArgs(ctx.epicRequestId);
8082
8320
  const routedEnv = claudeSpawnEnv(process.env, routing);
8083
- const childEnv = {
8321
+ const baseEnv = {
8084
8322
  ...routedEnv,
8085
8323
  COMPBOX_NONINTERACTIVE: "1",
8086
8324
  PATH: claudeChildEnvPath(routedEnv.PATH)
8087
8325
  };
8326
+ const childEnv = ctx.keyRun ? keyRunEnv(baseEnv, ctx.keyRun) : baseEnv;
8088
8327
  try {
8089
8328
  await ctx.mailboxClient.postTurn(
8090
8329
  ctx.machineToken,
@@ -8093,7 +8332,7 @@ async function invokeStartExecutionPlan(ctx, deps = {}) {
8093
8332
  );
8094
8333
  } catch {
8095
8334
  }
8096
- const outcome = await new Promise((resolve2) => {
8335
+ const outcome = await new Promise((resolve3) => {
8097
8336
  let stdout2 = "";
8098
8337
  let stderr = "";
8099
8338
  let settled = false;
@@ -8111,7 +8350,7 @@ async function invokeStartExecutionPlan(ctx, deps = {}) {
8111
8350
  settled = true;
8112
8351
  if (heartbeatTimer) clearInterval(heartbeatTimer);
8113
8352
  clearTimeout(timer);
8114
- resolve2(result);
8353
+ resolve3(result);
8115
8354
  };
8116
8355
  const timer = setTimeout(() => {
8117
8356
  child.kill("SIGKILL");
@@ -8138,6 +8377,7 @@ async function invokeStartExecutionPlan(ctx, deps = {}) {
8138
8377
  });
8139
8378
  child.on("close", (code) => {
8140
8379
  void (async () => {
8380
+ if (ctx.keyRun) stdout2 = await settleKeyRun(ctx, ctx.keyRun, stdout2);
8141
8381
  const combined = `${stdout2}
8142
8382
  ${stderr}`;
8143
8383
  const review = detectNeedsHumanReview(combined);
@@ -8248,6 +8488,13 @@ function defaultToolchainPresent(_dir) {
8248
8488
  }
8249
8489
  return false;
8250
8490
  }
8491
+ function describeRunBranch(finish) {
8492
+ if (finish.prUrl) return `Pull request: ${finish.prUrl}`;
8493
+ if (finish.compareUrl) return `Branch ${finish.branch} was pushed. Open the pull request here: ${finish.compareUrl}`;
8494
+ if (finish.commits === 0) return "No code changes were made.";
8495
+ if (finish.pushed) return `Branch ${finish.branch} was pushed.`;
8496
+ return `The work is on branch ${finish.branch}${finish.keptWorktree ? ` (in ${finish.keptWorktree})` : ""}.`;
8497
+ }
8251
8498
  function markerString(marker, key) {
8252
8499
  const v = marker?.[key];
8253
8500
  return typeof v === "string" && v.length > 0 ? v : void 0;
@@ -8289,29 +8536,41 @@ async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
8289
8536
  if (!toolchainPresent(projectDir)) {
8290
8537
  return { status: "failed", result: { error: toolchainMissingMessage(projectDir) } };
8291
8538
  }
8539
+ let workspace = null;
8540
+ let workspaceNotes = [];
8541
+ if (deps.workspace) {
8542
+ const prepared = await deps.workspace.prepare(projectDir, ctx.epicId);
8543
+ if (!prepared.ok) {
8544
+ return { status: "failed", result: { error: prepared.error } };
8545
+ }
8546
+ workspace = prepared.workspace;
8547
+ workspaceNotes = prepared.notes;
8548
+ }
8549
+ const runDir = workspace?.dir ?? projectDir;
8292
8550
  const routing = await resolveRoutingImpl();
8293
8551
  const command = resolveClaudeBinary() ?? "claude";
8294
- const args = buildOrchestratorArgs(ctx.epicId);
8552
+ const args = ctx.keyRun ? keyRunArgs(buildOrchestratorArgs(ctx.epicId)) : buildOrchestratorArgs(ctx.epicId);
8295
8553
  const routedEnv = claudeSpawnEnv(process.env, routing);
8296
- const childEnv = {
8554
+ const baseEnv = {
8297
8555
  ...routedEnv,
8298
8556
  COMPBOX_NONINTERACTIVE: "1",
8299
8557
  PATH: claudeChildEnvPath(routedEnv.PATH)
8300
8558
  };
8559
+ const childEnv = ctx.keyRun ? keyRunEnv(baseEnv, ctx.keyRun) : baseEnv;
8301
8560
  try {
8302
8561
  await ctx.mailboxClient.postTurn(
8303
8562
  ctx.machineToken,
8304
8563
  ctx.jobId,
8305
- `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic \u2014 every phase and feature \u2014 and may take a long time.`
8564
+ `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic \u2014 every phase and feature \u2014 and may take a long time.` + (workspace ? ` Working on a new branch, ${workspace.branch}, which will end as a pull request into ${workspace.baseBranch}.` : "") + (workspaceNotes.length > 0 ? ` Note: ${workspaceNotes.join(" ")}` : "")
8306
8565
  );
8307
8566
  } catch {
8308
8567
  }
8309
- const outcome = await new Promise((resolve2) => {
8568
+ const outcome = await new Promise((resolve3) => {
8310
8569
  let stdout2 = "";
8311
8570
  let stderr = "";
8312
8571
  let settled = false;
8313
8572
  const child = spawnImpl(command, args, {
8314
- cwd: projectDir,
8573
+ cwd: runDir,
8315
8574
  stdio: ["ignore", "pipe", "pipe"],
8316
8575
  env: childEnv
8317
8576
  });
@@ -8324,7 +8583,7 @@ async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
8324
8583
  settled = true;
8325
8584
  if (heartbeatTimer) clearInterval(heartbeatTimer);
8326
8585
  clearTimeout(timer);
8327
- resolve2(result);
8586
+ resolve3(result);
8328
8587
  };
8329
8588
  const timer = setTimeout(() => {
8330
8589
  child.kill("SIGKILL");
@@ -8350,6 +8609,7 @@ async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
8350
8609
  });
8351
8610
  });
8352
8611
  child.on("close", (code) => {
8612
+ if (ctx.keyRun) stdout2 = takeKeyRunResult(ctx, ctx.keyRun, stdout2).text;
8353
8613
  const combined = `${stdout2}
8354
8614
  ${stderr}`;
8355
8615
  const review = detectNeedsHumanReview(combined);
@@ -8372,8 +8632,31 @@ ${stderr}`;
8372
8632
  finish({ status: "done", result: { epicId: ctx.epicId } });
8373
8633
  });
8374
8634
  });
8635
+ let branchLine = "";
8636
+ if (deps.workspace && workspace) {
8637
+ const resultError = outcome.result;
8638
+ const stopReason = typeof resultError.message === "string" ? resultError.message : typeof resultError.error === "string" ? resultError.error.split("\n")[0] : void 0;
8639
+ const finished = await deps.workspace.finish({
8640
+ workspace,
8641
+ epicId: ctx.epicId,
8642
+ jobId: ctx.jobId,
8643
+ completed: outcome.status === "done",
8644
+ ...stopReason ? { stopReason } : {}
8645
+ });
8646
+ branchLine = describeRunBranch(finished);
8647
+ outcome.result = {
8648
+ ...outcome.result,
8649
+ branch: finished.branch,
8650
+ commits: finished.commits,
8651
+ pushed: finished.pushed,
8652
+ ...finished.prUrl ? { prUrl: finished.prUrl } : {},
8653
+ ...finished.compareUrl ? { compareUrl: finished.compareUrl } : {},
8654
+ ...finished.notes.length > 0 ? { branchNotes: finished.notes } : {}
8655
+ };
8656
+ }
8375
8657
  try {
8376
- const summary = outcome.status === "done" ? `Execution complete \u2014 epic ${ctx.epicId} finished all phases.` : `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}`;
8658
+ const summary = (outcome.status === "done" ? `Execution complete \u2014 epic ${ctx.epicId} finished all phases.` : `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}`) + (branchLine ? `
8659
+ ${branchLine}` : "");
8377
8660
  await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, summary);
8378
8661
  } catch {
8379
8662
  }
@@ -8462,14 +8745,419 @@ async function updatePersonalEpicRequest(apiBaseUrl, userApiToken, epicRequestId
8462
8745
  return parseComplianceFromData(data?.data);
8463
8746
  }
8464
8747
 
8748
+ // src/utils/run-workspace.ts
8749
+ import { spawn as spawn4 } from "node:child_process";
8750
+ import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync6 } from "node:fs";
8751
+ import { basename as basename2, dirname as dirname4, join as join10 } from "node:path";
8752
+ var RUN_PUSH_ENV = "COMPBOX_RUN_PUSH";
8753
+ var INSTALL_TIMEOUT_MS = 20 * 60 * 1e3;
8754
+ var GIT_TIMEOUT_MS = 5 * 60 * 1e3;
8755
+ var defaultCommandRunner = (command, args, cwd) => new Promise((resolve3) => {
8756
+ let stdout2 = "";
8757
+ let stderr = "";
8758
+ let settled = false;
8759
+ const done = (result) => {
8760
+ if (settled) return;
8761
+ settled = true;
8762
+ clearTimeout(timer);
8763
+ resolve3(result);
8764
+ };
8765
+ const child = spawn4(command, args, {
8766
+ cwd,
8767
+ stdio: ["ignore", "pipe", "pipe"],
8768
+ env: { ...process.env, PATH: claudeChildEnvPath(process.env.PATH), GIT_TERMINAL_PROMPT: "0" }
8769
+ });
8770
+ const timer = setTimeout(
8771
+ () => {
8772
+ child.kill("SIGKILL");
8773
+ done({ status: 1, stdout: stdout2, stderr: `${command} timed out` });
8774
+ },
8775
+ command === "git" || command === "gh" ? GIT_TIMEOUT_MS : INSTALL_TIMEOUT_MS
8776
+ );
8777
+ child.stdout.on("data", (chunk) => {
8778
+ stdout2 += chunk.toString("utf-8");
8779
+ });
8780
+ child.stderr.on("data", (chunk) => {
8781
+ stderr += chunk.toString("utf-8");
8782
+ });
8783
+ child.on("error", (error) => {
8784
+ done({ status: 1, stdout: stdout2, stderr: error.message });
8785
+ });
8786
+ child.on("close", (code) => {
8787
+ done({ status: code ?? 1, stdout: stdout2, stderr });
8788
+ });
8789
+ });
8790
+ function isLocalConfigFile(path3) {
8791
+ if (path3.split("/").includes("node_modules")) return false;
8792
+ const name = basename2(path3);
8793
+ return name === ".mcp.json" || name === "CLAUDE.local.md" || path3 === ".claude/settings.local.json" || name === ".env" || name.startsWith(".env.") && !name.endsWith(".example") && !name.endsWith(".sample");
8794
+ }
8795
+ function slugForBranch(value) {
8796
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "run";
8797
+ }
8798
+ function stamp(now) {
8799
+ const pad = (n) => String(n).padStart(2, "0");
8800
+ return `${String(now.getFullYear())}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
8801
+ }
8802
+ function firstLine(text) {
8803
+ return text.trim().split("\n")[0] ?? "";
8804
+ }
8805
+ function installCommand2(dir, exists = existsSync9) {
8806
+ if (exists(join10(dir, "pnpm-lock.yaml"))) return ["pnpm", ["install", "--frozen-lockfile", "--prefer-offline"]];
8807
+ if (exists(join10(dir, "package-lock.json"))) return ["npm", ["ci", "--prefer-offline"]];
8808
+ if (exists(join10(dir, "yarn.lock"))) return ["yarn", ["install", "--frozen-lockfile"]];
8809
+ return null;
8810
+ }
8811
+ function githubCompareUrl(remoteUrl, baseBranch, branch) {
8812
+ const m = /^https?:\/\/(?:[^@/]+@)?github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remoteUrl.trim()) ?? /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remoteUrl.trim());
8813
+ if (!m) return null;
8814
+ return `https://github.com/${m[1] ?? ""}/${m[2] ?? ""}/compare/${encodeURIComponent(baseBranch)}...${encodeURIComponent(branch)}?expand=1`;
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
+ }
8827
+ var RunWorkspaceManager = class {
8828
+ run;
8829
+ now;
8830
+ runsRoot;
8831
+ exists;
8832
+ copyFile;
8833
+ env;
8834
+ constructor(deps = {}) {
8835
+ this.run = deps.run ?? defaultCommandRunner;
8836
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
8837
+ this.runsRoot = deps.runsRoot ?? join10(getConfigDir(), "runs");
8838
+ this.exists = deps.exists ?? existsSync9;
8839
+ this.copyFile = deps.copyFile ?? ((from, to) => {
8840
+ mkdirSync6(dirname4(to), { recursive: true });
8841
+ copyFileSync(from, to);
8842
+ });
8843
+ this.env = deps.env ?? process.env;
8844
+ }
8845
+ git(args, cwd) {
8846
+ return this.run("git", args, cwd);
8847
+ }
8848
+ /** Creates the branch + worktree for a run. Never touches the person's checkout. */
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) {
8875
+ const notes = [];
8876
+ const remotes = (await this.git(["remote"], projectDir)).stdout.split("\n").map((r) => r.trim()).filter(Boolean);
8877
+ const remote = remotes.includes("origin") ? "origin" : remotes[0] ?? null;
8878
+ let baseBranch = "";
8879
+ let baseRef = "HEAD";
8880
+ if (remote) {
8881
+ if ((await this.git(["fetch", remote, "--quiet"], projectDir)).status !== 0) {
8882
+ notes.push(`Couldn't fetch from ${remote}, so the run starts from the last fetched copy.`);
8883
+ }
8884
+ const head = await this.git(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], projectDir);
8885
+ if (head.status === 0 && head.stdout.trim().startsWith(`${remote}/`)) {
8886
+ baseBranch = head.stdout.trim().slice(remote.length + 1);
8887
+ } else {
8888
+ for (const candidate of ["main", "master"]) {
8889
+ if ((await this.git(["rev-parse", "--verify", "--quiet", `${remote}/${candidate}`], projectDir)).status === 0) {
8890
+ baseBranch = candidate;
8891
+ break;
8892
+ }
8893
+ }
8894
+ }
8895
+ if (baseBranch) baseRef = `${remote}/${baseBranch}`;
8896
+ }
8897
+ if (!baseBranch) {
8898
+ baseBranch = (await this.git(["rev-parse", "--abbrev-ref", "HEAD"], projectDir)).stdout.trim() || "main";
8899
+ baseRef = "HEAD";
8900
+ }
8901
+ const when = stamp(this.now());
8902
+ const branch = `compbox/epic-${slugForBranch(epicId)}-${when}`;
8903
+ const dir = join10(this.runsRoot, `${slugForBranch(basename2(projectDir))}-${slugForBranch(epicId)}-${when}`);
8904
+ mkdirSync6(this.runsRoot, { recursive: true });
8905
+ const add = await this.git(["worktree", "add", "-b", branch, dir, baseRef], projectDir);
8906
+ if (add.status !== 0) {
8907
+ return {
8908
+ ok: false,
8909
+ error: [
8910
+ `Couldn't create a separate workspace for this run (git worktree add failed): ${firstLine(add.stderr) || "unknown error"}`,
8911
+ "The run was not started, and your project folder was not changed."
8912
+ ].join("\n")
8913
+ };
8914
+ }
8915
+ const ignored = await this.git(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], projectDir);
8916
+ const untracked = await this.git(["ls-files", "--others", "--exclude-standard"], projectDir);
8917
+ const candidates = new Set(
8918
+ `${ignored.stdout}
8919
+ ${untracked.stdout}`.split("\n").map((p) => p.trim()).filter((p) => p && !p.endsWith("/"))
8920
+ );
8921
+ for (const path3 of candidates) {
8922
+ if (!isLocalConfigFile(path3)) continue;
8923
+ try {
8924
+ this.copyFile(join10(projectDir, path3), join10(dir, path3));
8925
+ } catch {
8926
+ notes.push(`Couldn't copy ${path3} into the run workspace.`);
8927
+ }
8928
+ }
8929
+ return { ok: true, workspace: { dir, projectDir, branch, baseRef, baseBranch, remote }, notes };
8930
+ }
8931
+ /** Commits, pushes and opens the pull request; then cleans up the worktree. */
8932
+ finish(input) {
8933
+ return withRepoLock(input.workspace.projectDir, () => this.finishLocked(input));
8934
+ }
8935
+ async finishLocked(input) {
8936
+ const { workspace: ws } = input;
8937
+ const notes = [];
8938
+ if ((await this.git(["status", "--porcelain"], ws.dir)).stdout.trim()) {
8939
+ await this.git(["add", "-A"], ws.dir);
8940
+ const commit = await this.git(
8941
+ ["commit", "-m", `chore(compbox): save work left uncommitted by the orchestrator (epic ${input.epicId})`],
8942
+ ws.dir
8943
+ );
8944
+ if (commit.status !== 0) {
8945
+ notes.push(`Couldn't commit the remaining changes: ${firstLine(commit.stderr) || firstLine(commit.stdout)}`);
8946
+ }
8947
+ }
8948
+ const commits = Number.parseInt((await this.git(["rev-list", "--count", `${ws.baseRef}..HEAD`], ws.dir)).stdout.trim(), 10) || 0;
8949
+ const base = { branch: ws.branch, commits, pushed: false, notes };
8950
+ if (commits === 0) {
8951
+ await this.git(["worktree", "remove", "--force", ws.dir], ws.projectDir);
8952
+ await this.git(["branch", "-D", ws.branch], ws.projectDir);
8953
+ notes.push("The run made no code changes, so there is nothing to review.");
8954
+ return base;
8955
+ }
8956
+ if (!ws.remote || this.env[RUN_PUSH_ENV] === "0") {
8957
+ await this.git(["worktree", "remove", "--force", ws.dir], ws.projectDir);
8958
+ notes.push(
8959
+ ws.remote ? `Pushing is turned off (${RUN_PUSH_ENV}=0). The work is on the local branch ${ws.branch}.` : `The repository has no remote, so the work is on the local branch ${ws.branch}.`
8960
+ );
8961
+ return base;
8962
+ }
8963
+ const push = await this.git(["push", "--set-upstream", ws.remote, ws.branch], ws.dir);
8964
+ if (push.status !== 0) {
8965
+ notes.push(
8966
+ `Couldn't push the branch to ${ws.remote}: ${firstLine(push.stderr) || "unknown error"}. The work is safe in ${ws.dir} on branch ${ws.branch}.`
8967
+ );
8968
+ return { ...base, keptWorktree: ws.dir };
8969
+ }
8970
+ const title = `CompBox: epic ${input.epicId}${input.completed ? "" : " (stopped early, needs review)"}`;
8971
+ const body = [
8972
+ `Code written by the CompBox orchestrator for epic **${input.epicId}** (job \`${input.jobId}\`).`,
8973
+ "",
8974
+ input.completed ? "The orchestrator finished every phase. Review the changes, check that CI passes, then merge." : `The orchestrator stopped before finishing${input.stopReason ? `: ${input.stopReason}` : "."} This is a draft so the partial work can be reviewed.`,
8975
+ "",
8976
+ `${String(commits)} commit${commits === 1 ? "" : "s"} on \`${ws.branch}\`, based on \`${ws.baseBranch}\`.`
8977
+ ].join("\n");
8978
+ const prArgs = ["pr", "create", "--base", ws.baseBranch, "--head", ws.branch, "--title", title, "--body", body];
8979
+ if (!input.completed) prArgs.push("--draft");
8980
+ const pr = await this.run("gh", prArgs, ws.dir);
8981
+ const prUrl = pr.status === 0 ? /https:\/\/\S+/.exec(pr.stdout)?.[0] : void 0;
8982
+ let compareUrl;
8983
+ if (!prUrl) {
8984
+ const remoteUrl = (await this.git(["remote", "get-url", ws.remote], ws.dir)).stdout.trim();
8985
+ compareUrl = githubCompareUrl(remoteUrl, ws.baseBranch, ws.branch) ?? void 0;
8986
+ notes.push(
8987
+ `The branch was pushed, but the pull request couldn't be opened automatically${firstLine(pr.stderr) ? ` (${firstLine(pr.stderr)})` : ""}.` + (compareUrl ? " Use the link to open it." : ` Open a pull request for ${ws.branch} on your git host.`)
8988
+ );
8989
+ }
8990
+ await this.git(["worktree", "remove", "--force", ws.dir], ws.projectDir);
8991
+ return { ...base, pushed: true, ...prUrl ? { prUrl } : {}, ...compareUrl ? { compareUrl } : {} };
8992
+ }
8993
+ };
8994
+
8995
+ // src/utils/repo-setup.ts
8996
+ import { spawn as spawn5 } from "node:child_process";
8997
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync } from "node:fs";
8998
+ import { homedir as homedir6 } from "node:os";
8999
+ import { dirname as dirname5, isAbsolute as isAbsolute2, join as join11, resolve as resolve2 } from "node:path";
9000
+ var CLONE_TIMEOUT_MS = 15 * 60 * 1e3;
9001
+ var defaultGitCloner = (url, target) => new Promise((done) => {
9002
+ let stderr = "";
9003
+ const child = spawn5("git", ["clone", "--quiet", url, target], {
9004
+ stdio: ["ignore", "ignore", "pipe"],
9005
+ // Never hang on a username/password prompt: fail and explain instead.
9006
+ env: { ...process.env, PATH: claudeChildEnvPath(process.env.PATH), GIT_TERMINAL_PROMPT: "0" }
9007
+ });
9008
+ const timer = setTimeout(() => {
9009
+ child.kill("SIGKILL");
9010
+ stderr += "git clone timed out";
9011
+ }, CLONE_TIMEOUT_MS);
9012
+ child.stderr.on("data", (chunk) => {
9013
+ stderr += chunk.toString("utf-8");
9014
+ });
9015
+ child.on("error", (error) => {
9016
+ clearTimeout(timer);
9017
+ done({ status: 1, stderr: error.message });
9018
+ });
9019
+ child.on("close", (code) => {
9020
+ clearTimeout(timer);
9021
+ done({ status: code ?? 1, stderr });
9022
+ });
9023
+ });
9024
+ function repoFolderName(remote) {
9025
+ const last = remote.replace(/\/+$/, "").split("/").pop() ?? "";
9026
+ return last.replace(/[^A-Za-z0-9._-]+/g, "-") || "project";
9027
+ }
9028
+ function resolveFolder(folder, home) {
9029
+ const trimmed = folder.trim();
9030
+ if (trimmed === "~") return home;
9031
+ if (trimmed.startsWith("~/")) return join11(home, trimmed.slice(2));
9032
+ return isAbsolute2(trimmed) ? resolve2(trimmed) : join11(home, trimmed);
9033
+ }
9034
+ function cloneUrls(remote) {
9035
+ const urls = [`${remote}.git`];
9036
+ const gh = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/.exec(remote);
9037
+ if (gh) urls.push(`git@github.com:${gh[1] ?? ""}/${gh[2] ?? ""}.git`);
9038
+ return urls;
9039
+ }
9040
+ function parsePayload(payload) {
9041
+ if (typeof payload !== "object" || payload === null) return null;
9042
+ const p = payload;
9043
+ if (typeof p.teamId !== "string" || typeof p.projectRemote !== "string") return null;
9044
+ if (!p.projectRemote.startsWith("https://")) return null;
9045
+ return {
9046
+ teamId: p.teamId,
9047
+ projectRemote: p.projectRemote,
9048
+ ...typeof p.folder === "string" ? { folder: p.folder } : {},
9049
+ ...typeof p.projectName === "string" ? { projectName: p.projectName } : {}
9050
+ };
9051
+ }
9052
+ function firstLine2(text) {
9053
+ return text.trim().split("\n").filter(Boolean).pop() ?? "";
9054
+ }
9055
+ async function invokeConnectRepo(payload, deps = {}) {
9056
+ const clone = deps.clone ?? defaultGitCloner;
9057
+ const home = deps.home ?? homedir6();
9058
+ const exists = deps.exists ?? existsSync10;
9059
+ const isEmptyDir = deps.isEmptyDir ?? ((path3) => {
9060
+ try {
9061
+ return readdirSync(path3).length === 0;
9062
+ } catch {
9063
+ return false;
9064
+ }
9065
+ });
9066
+ const isGitRepo = deps.isGitRepo ?? isGitRepoDir;
9067
+ const readOrigin = deps.readOrigin ?? readOriginRemote;
9068
+ const listDirs = deps.listDirs ?? listProjectDirs;
9069
+ const addDir = deps.addDir ?? ((path3) => addProjectDir(path3));
9070
+ const mkdir2 = deps.mkdir ?? ((path3) => void mkdirSync7(path3, { recursive: true }));
9071
+ const p = parsePayload(payload);
9072
+ if (!p) {
9073
+ return { status: "failed", result: { error: "The job was missing the project repository. Try again from the project page." } };
9074
+ }
9075
+ const remote = normalizeGitRemote(p.projectRemote);
9076
+ const name = p.projectName ?? repoFolderName(remote);
9077
+ const existing = listDirs().find((entry) => entry.remote === remote && exists(entry.path));
9078
+ if (existing) {
9079
+ return {
9080
+ status: "done",
9081
+ result: { path: existing.path, remote, outcome: "already_set_up", message: `${name} is already set up on this computer at ${existing.path}.` }
9082
+ };
9083
+ }
9084
+ const target = resolveFolder(p.folder ?? join11("CompBox", repoFolderName(remote)), home);
9085
+ try {
9086
+ if (exists(target) && !isEmptyDir(target)) {
9087
+ let origin = "";
9088
+ try {
9089
+ origin = isGitRepo(target) ? normalizeGitRemote(readOrigin(target)) : "";
9090
+ } catch {
9091
+ origin = "";
9092
+ }
9093
+ if (origin !== remote) {
9094
+ return {
9095
+ status: "failed",
9096
+ result: {
9097
+ error: `The folder ${target} already exists and isn't a copy of ${remote}. Choose a different folder and try again.`
9098
+ }
9099
+ };
9100
+ }
9101
+ const entry2 = addDir(target);
9102
+ return {
9103
+ status: "done",
9104
+ result: { path: entry2.path, remote, outcome: "adopted", message: `Found an existing copy of ${name} at ${entry2.path} and set it up.` }
9105
+ };
9106
+ }
9107
+ mkdir2(dirname5(target));
9108
+ const errors = [];
9109
+ let cloned = false;
9110
+ for (const url of cloneUrls(remote)) {
9111
+ const res = await clone(url, target);
9112
+ if (res.status === 0) {
9113
+ cloned = true;
9114
+ break;
9115
+ }
9116
+ errors.push(firstLine2(res.stderr));
9117
+ }
9118
+ if (!cloned) {
9119
+ return {
9120
+ status: "failed",
9121
+ result: {
9122
+ error: [
9123
+ `This computer couldn't download ${remote}: ${errors.filter(Boolean).join(" / ") || "unknown error"}.`,
9124
+ "Make sure this computer can access the repository, for example by signing in with `gh auth login` or adding an SSH key to your Git account, then try again."
9125
+ ].join(" ")
9126
+ }
9127
+ };
9128
+ }
9129
+ const entry = addDir(target);
9130
+ return {
9131
+ status: "done",
9132
+ result: { path: entry.path, remote, outcome: "cloned", message: `Downloaded ${name} to ${entry.path} and set it up.` }
9133
+ };
9134
+ } catch (error) {
9135
+ return {
9136
+ status: "failed",
9137
+ result: { error: `Setting up ${name} failed: ${error instanceof Error ? error.message : String(error)}` }
9138
+ };
9139
+ }
9140
+ }
9141
+
8465
9142
  // src/commands/run.ts
8466
9143
  var POLL_WAIT_MS = 0;
8467
9144
  var IDLE_POLL_INTERVAL_MS = 15e3;
8468
9145
  var ACTIVE_POLL_INTERVAL_MS = 2e3;
8469
9146
  var ACTIVE_WINDOW_MS = 6e4;
8470
- 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) {
8471
9150
  const warm = lastJobAt > 0 && now - lastJobAt < ACTIVE_WINDOW_MS;
8472
- 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);
8473
9161
  }
8474
9162
  var BACKOFF_BASE_MS = 1e3;
8475
9163
  var BACKOFF_MAX_MS = 3e4;
@@ -8477,8 +9165,8 @@ var HEARTBEAT_INTERVAL_MS = 2e4;
8477
9165
  function runtimeHeartbeatDue(nowMs, lastRuntimeSentMs, intervalMs = HEARTBEAT_INTERVAL_MS) {
8478
9166
  return nowMs - lastRuntimeSentMs >= intervalMs;
8479
9167
  }
8480
- function sleep2(ms) {
8481
- return new Promise((resolve2) => setTimeout(resolve2, ms));
9168
+ function sleep(ms) {
9169
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
8482
9170
  }
8483
9171
  function backoffDelay(consecutiveErrors) {
8484
9172
  return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
@@ -8523,11 +9211,12 @@ function parseStartExecutionPayload(payload) {
8523
9211
  const p = parsed;
8524
9212
  if (!START_EXECUTION_KINDS.includes(p.kind)) return null;
8525
9213
  const projectRemote = typeof p.projectRemote === "string" && p.projectRemote.length > 0 ? p.projectRemote : void 0;
9214
+ const useAiKey = p.useAiKey === true;
8526
9215
  if (p.kind === "plan" && typeof p.epicRequestId === "string" && p.epicRequestId.length > 0) {
8527
- return { kind: "plan", epicRequestId: p.epicRequestId, ...projectRemote && { projectRemote } };
9216
+ return { kind: "plan", epicRequestId: p.epicRequestId, ...projectRemote && { projectRemote }, ...useAiKey && { useAiKey } };
8528
9217
  }
8529
9218
  if (p.kind === "orchestrate" && typeof p.epicId === "string" && p.epicId.length > 0) {
8530
- return { kind: "orchestrate", epicId: p.epicId, ...projectRemote && { projectRemote } };
9219
+ return { kind: "orchestrate", epicId: p.epicId, ...projectRemote && { projectRemote }, ...useAiKey && { useAiKey } };
8531
9220
  }
8532
9221
  return null;
8533
9222
  }
@@ -8550,6 +9239,18 @@ async function handleStartExecution(ctx, job) {
8550
9239
  return;
8551
9240
  }
8552
9241
  const jobProjectDir = routed.status === "registered" || routed.status === "fallback" ? routed.path : void 0;
9242
+ let keyRun;
9243
+ if (payload.useAiKey) {
9244
+ try {
9245
+ const key = await ctx.client.getAiKey(ctx.machineToken, job.id);
9246
+ keyRun = { apiKey: key.apiKey, model: key.model };
9247
+ console.log(chalk9.gray(` Running on your saved API key (${key.provider}, ${key.model}).`));
9248
+ } catch (error) {
9249
+ const message = error instanceof MailboxClientError ? error.message : "the key request failed";
9250
+ await completeWithFailure(ctx, job, `Couldn't get your saved API key from CompBox (${message}). Check Settings \u2192 AI, or run with this computer's own sign-in.`);
9251
+ return;
9252
+ }
9253
+ }
8553
9254
  if (payload.kind === "plan") {
8554
9255
  const userApiToken2 = ctx.userApiToken;
8555
9256
  if (!userApiToken2) {
@@ -8572,7 +9273,8 @@ async function handleStartExecution(ctx, job) {
8572
9273
  apiBaseUrl: ctx.apiBaseUrl,
8573
9274
  userApiToken: userApiToken2,
8574
9275
  epicRequestId: payload.epicRequestId,
8575
- projectDir: jobProjectDir
9276
+ projectDir: jobProjectDir,
9277
+ ...keyRun && { keyRun }
8576
9278
  },
8577
9279
  {
8578
9280
  // H4: keep this machine online during the long planner run — the poll
@@ -8602,12 +9304,15 @@ async function handleStartExecution(ctx, job) {
8602
9304
  machineToken: ctx.machineToken,
8603
9305
  jobId: job.id,
8604
9306
  epicId: payload.epicId,
8605
- projectDir: jobProjectDir
9307
+ projectDir: jobProjectDir,
9308
+ ...keyRun && { keyRun }
8606
9309
  },
8607
9310
  {
8608
9311
  // H4: keep this machine online during the long orchestrator run — the
8609
9312
  // poll loop (its own heartbeat) is blocked for the whole job.
8610
- heartbeat: () => ctx.client.heartbeat(ctx.machineToken).catch(() => void 0)
9313
+ heartbeat: () => ctx.client.heartbeat(ctx.machineToken).catch(() => void 0),
9314
+ // Own branch + worktree per run, finishing with a pull request.
9315
+ workspace: new RunWorkspaceManager()
8611
9316
  }
8612
9317
  )
8613
9318
  );
@@ -8615,6 +9320,10 @@ async function handleStartExecution(ctx, job) {
8615
9320
  async function runInvokerAndComplete(ctx, job, invoke) {
8616
9321
  try {
8617
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
+ }
8618
9327
  await ctx.client.completeJob(ctx.machineToken, job.id, outcome.status, outcome.result);
8619
9328
  console.log(
8620
9329
  outcome.status === "done" ? chalk9.green(` Job ${job.id} completed: ${JSON.stringify(outcome.result)}`) : chalk9.yellow(` Job ${job.id} failed: ${JSON.stringify(outcome.result)}`)
@@ -8639,42 +9348,38 @@ async function handleJob(ctx, job) {
8639
9348
  \u2192 Claimed job ${job.id} (type: ${job.type}, status: ${job.status})`)
8640
9349
  );
8641
9350
  const userApiToken = ctx.userApiToken;
8642
- const isEpicRequest = job.type === "epic_request" && !!userApiToken;
8643
- if (isEpicRequest && userApiToken) {
9351
+ if (job.type === "epic_request" && userApiToken) {
9352
+ let anthropicApiKey;
8644
9353
  if (ctx.aiMode === "api-key") {
8645
- const { value: anthropicApiKey } = await retrieveSecret(AI_KEY_ACCOUNT);
8646
- if (anthropicApiKey) {
8647
- console.log(chalk9.gray(" Running the request-formulator interview (api mode)..."));
8648
- await runInvokerAndComplete(
8649
- ctx,
8650
- job,
8651
- () => invokeEpicRequestViaApi(job, {
8652
- mailboxClient: ctx.client,
8653
- machineToken: ctx.machineToken,
8654
- apiBaseUrl: ctx.apiBaseUrl,
8655
- userApiToken,
8656
- anthropicApiKey
8657
- })
8658
- );
8659
- return;
8660
- }
8661
- } else if (ctx.aiMode === "claude-subscription") {
8662
- console.log(
8663
- chalk9.gray(" Running the request-formulator interview (claude-subscription mode, local `claude` CLI)...")
8664
- );
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"})...`));
8665
9358
  await runInvokerAndComplete(
8666
9359
  ctx,
8667
9360
  job,
8668
- () => invokeEpicRequestViaSubscription(job, {
9361
+ () => invokeEpicRequestStep(job, {
8669
9362
  mailboxClient: ctx.client,
8670
9363
  machineToken: ctx.machineToken,
8671
9364
  apiBaseUrl: ctx.apiBaseUrl,
8672
- userApiToken
9365
+ userApiToken,
9366
+ ...anthropicApiKey && { anthropicApiKey }
8673
9367
  })
8674
9368
  );
8675
9369
  return;
8676
9370
  }
8677
9371
  }
9372
+ if (job.type === "connect_repo") {
9373
+ console.log(chalk9.gray(" Setting up a project repository on this computer (connect_repo)..."));
9374
+ await runInvokerAndComplete(ctx, job, async () => {
9375
+ const outcome = await invokeConnectRepo(job.payload);
9376
+ if (outcome.status === "done") {
9377
+ await ctx.client.heartbeat(ctx.machineToken, void 0, gatherServicedRemotes()).catch(() => void 0);
9378
+ }
9379
+ return outcome;
9380
+ });
9381
+ return;
9382
+ }
8678
9383
  if (job.type === "start_execution") {
8679
9384
  await handleStartExecution(ctx, job);
8680
9385
  return;
@@ -8756,6 +9461,9 @@ var runCommand = new Command8("run").description("Start the Agent Runner daemon
8756
9461
  `
8757
9462
  )
8758
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
+ `));
8759
9467
  const runtimeProbe = () => probeAgentRuntime();
8760
9468
  let agentRuntime = await safeProbeAgentRuntime(runtimeProbe, void 0);
8761
9469
  if (agentRuntime) {
@@ -8786,10 +9494,14 @@ Received ${signal}, shutting down...`));
8786
9494
  });
8787
9495
  let consecutiveErrors = 0;
8788
9496
  let lastJobAt = 0;
9497
+ let lastConversationAt = 0;
9498
+ const inFlight = /* @__PURE__ */ new Map();
8789
9499
  do {
8790
9500
  let job = null;
8791
9501
  try {
8792
- job = await client.nextJob(machineToken, waitMs);
9502
+ if (inFlight.size < maxJobs) {
9503
+ job = await client.nextJob(machineToken, waitMs, [...inFlight.keys()]);
9504
+ }
8793
9505
  consecutiveErrors = 0;
8794
9506
  } catch (error) {
8795
9507
  consecutiveErrors += 1;
@@ -8803,12 +9515,21 @@ Received ${signal}, shutting down...`));
8803
9515
  );
8804
9516
  console.error(chalk9.gray(` Reconnecting in ${String(Math.round(delay / 1e3))}s...`));
8805
9517
  if (options.once) break;
8806
- await sleep2(delay);
9518
+ await sleep(delay);
8807
9519
  continue;
8808
9520
  }
9521
+ if (job && inFlight.has(job.id)) job = null;
8809
9522
  if (job) {
8810
- await handleJob(jobContext, job);
8811
- 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);
8812
9533
  }
8813
9534
  if (runtimeHeartbeatDue(Date.now(), lastRuntimeSentAt)) {
8814
9535
  const cycle = await runHeartbeatCycle({
@@ -8825,9 +9546,12 @@ Received ${signal}, shutting down...`));
8825
9546
  lastRuntimeSentAt = Date.now();
8826
9547
  }
8827
9548
  }
8828
- if (options.once) break;
9549
+ if (options.once) {
9550
+ await Promise.all(inFlight.values());
9551
+ break;
9552
+ }
8829
9553
  if (!job) {
8830
- await sleep2(idleDelay(Date.now(), lastJobAt));
9554
+ await sleep(idleDelay(Date.now(), lastJobAt, lastConversationAt));
8831
9555
  }
8832
9556
  } while (running);
8833
9557
  console.log(chalk9.gray("Agent Runner stopped."));
@@ -9090,7 +9814,7 @@ machinesCommand.command("rm <id>").description("Remove (revoke) a paired machine
9090
9814
  // src/index.ts
9091
9815
  function resolveCliVersion() {
9092
9816
  if (true) {
9093
- return "1.18.0";
9817
+ return "1.20.0";
9094
9818
  }
9095
9819
  const require2 = createRequire(import.meta.url);
9096
9820
  const pkg = require2("../package.json");