@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
@@ -2,64 +2,39 @@
2
2
  * Agent invoker (ENG-E23 / ENG-122-2) — the daemon's "heart": actually runs a
3
3
  * claimed job instead of immediately reporting it as unimplemented.
4
4
  *
5
- * Two agentModes are implemented, both for `epic_request` jobs only
6
- * (`start_execution` — running planner/orchestrator locally — is NOT
7
- * implemented here):
5
+ * Request conversations (`epic_request` jobs) run one AI reply per claim
6
+ * (invokeEpicRequestStep, on the shared runConversationStep): the reply is
7
+ * posted, the job is left waiting on the person, and the server hands it back
8
+ * when they write again. Nothing waits in between, so a conversation never
9
+ * times out and stays open after the request is saved. Two ways to reply:
8
10
  * - `api`: Anthropic Messages API, directly (Anthropic API key, billed
9
- * per-token). See invokeEpicRequestViaApi.
11
+ * per-token).
10
12
  * - `claude-subscription`: shells out to the local `claude` CLI
11
13
  * non-interactively, using whichever account is already logged in on
12
14
  * this machine (personal Pro/Max or a company Team seat) — no API key
13
- * ever touches CompBox. See invokeEpicRequestViaSubscription.
15
+ * ever touches CompBox.
14
16
  *
15
- * Common to both:
16
- * - The interview is driven by the shared, declarative INTERVIEW_DEFINITION
17
- * (@mycompbox/shared, ENG-143) so the machine/mailbox interview reaches the
18
- * SAME depth as the CLI request-formulator: it covers all seven sections
19
- * (problem, solution, impact, success metrics, target audience, technical
20
- * context, alternatives) rather than only the 3 required fields. The system
21
- * prompt is rendered from that definition (buildInterviewSystemPrompt), and
22
- * the completion object carries the required fields plus any optional ones
23
- * the employee provided; there is no shortened variant left in this path.
24
- *
25
- * Turn relay: the mailbox has no "wait for a new turn" primitive for a
26
- * machine caller (next-job's "resume active job" path returns immediately
27
- * with whatever the job's current state is — see jobService.ts tryClaimOnce).
28
- * So after posting an agent question, this module polls next-job on a short
29
- * client-side interval until it observes a new trailing user turn, with a
30
- * bounded overall timeout so an abandoned interview eventually fails cleanly
31
- * instead of polling forever.
17
+ * `start_execution` jobs run the planner/orchestrator headless (below).
32
18
  */
33
19
  import Anthropic from '@anthropic-ai/sdk';
34
20
  import { spawn, spawnSync } from 'node:child_process';
35
21
  import { mkdirSync } from 'node:fs';
36
22
  import { homedir } from 'node:os';
37
23
  import { join } from 'node:path';
38
- import { resolveRouting, buildInterviewSystemPrompt, INTERVIEW_DEFINITION, } from '@mycompbox/shared';
24
+ import { resolveRouting, extractCompletionJson, looksLikeCompletionAttempt, meetsComplianceTarget, buildAuditFeedback, COMPLETION_REPAIR_INSTRUCTION, renderConversationPrompt, runConversationStep, savedContextFromRecord, } from '@mycompbox/shared';
25
+ // Completion parsing lives in @mycompbox/shared so CompBox's server-side
26
+ // (API-key) interview finishes the same way; re-exported for existing callers.
27
+ export { extractCompletionJson, looksLikeCompletionAttempt, meetsComplianceTarget, buildAuditFeedback, COMPLETION_REPAIR_INSTRUCTION, };
39
28
  import { claudeChildEnvPath, claudeNotFoundMessage, resolveClaudeBinary, } from './claude-runtime.js';
40
29
  import { isGitRepoDir } from './config-store.js';
30
+ import { keyRunArgs, keyRunEnv, parseClaudeJsonResult } from './key-run.js';
41
31
  // Fable is the model CompBox agents run on (ENG-E26): it is pxpipe's 100/100
42
32
  // image reader, so routing it through the proxy cuts input tokens without
43
33
  // quality loss. Opus/Sonnet are deliberately NOT used here — pxpipe passes
44
34
  // Opus through uncompressed, defeating the token savings.
45
35
  const ANTHROPIC_MODEL = 'claude-fable-5';
46
- // Turn budget for the interview loop. Raised from 8: a full 7-section interview
47
- // (problem / solution / impact + success metrics / audience / technical context /
48
- // alternatives) plus follow-ups AND audit-quality rounds needs more headroom
49
- // than 8 (deep interviews were being cut off mid-way — bug #3).
50
- const MAX_INTERVIEW_TURNS = 18;
51
- // Audit-quality gate (bug #2): the interview must reach the `pass` tier
52
- // (>=90, zero critical issues) before it may finish. After the model says
53
- // "done", we submit, read the server-computed compliance score, and if it is
54
- // not yet `pass` we feed the failing rules back and keep going. This bounds how
55
- // many re-submit/re-audit rounds we allow before finalizing with the best draft
56
- // (so we never loop forever, and never strand the request at a failing score).
57
- const MAX_QUALITY_ROUNDS = 4;
58
- const TARGET_COMPLIANCE_TIER = 'pass';
59
- const REPLY_POLL_INTERVAL_MS = 3_000;
60
- const REPLY_TIMEOUT_MS = 10 * 60 * 1_000; // 10 minutes
61
36
  /** Per-turn wall-clock budget for a single `claude -p` child process. */
62
- const CLAUDE_CLI_TURN_TIMEOUT_MS = 2 * 60 * 1_000; // 2 minutes
37
+ const CLAUDE_CLI_TURN_TIMEOUT_MS = 4 * 60 * 1_000; // 4 minutes: a full write-up can take a while
63
38
  /**
64
39
  * pxpipe routing (ENG-136). Anthropic SDK options carrying the proxy `baseURL`
65
40
  * when routed through pxpipe, otherwise just the API key. Pure — unit tested.
@@ -225,13 +200,6 @@ export function appendAttachmentNote(text, imageCount) {
225
200
  const note = `[${String(imageCount)} ${noun} attached — not shown in subscription mode; vision is handled when the daemon runs in API mode]`;
226
201
  return text.trim().length > 0 ? `${text}\n\n${note}` : note;
227
202
  }
228
- /**
229
- * The interview system prompt, rendered from the shared, declarative
230
- * INTERVIEW_DEFINITION (ENG-143). This is the single wiring point that gives
231
- * the machine/mailbox interview the same section depth as the CLI
232
- * request-formulator — there is no shortened hardcoded variant anymore.
233
- */
234
- const SYSTEM_PROMPT = buildInterviewSystemPrompt();
235
203
  /**
236
204
  * ENG-149 — parse a claimed job's `payload` as an interview-CONTINUATION seed
237
205
  * (the ENG-148 dispatch shape), or return null for a normal fresh-start job
@@ -260,17 +228,6 @@ export function parseSeedPayload(payload) {
260
228
  priorTranscript,
261
229
  };
262
230
  }
263
- /**
264
- * ENG-149 — the interview system prompt for a job: a seed-aware RESUME/ENRICH
265
- * prompt for a continuation, otherwise the module-level fresh-start prompt.
266
- */
267
- export function resolveSystemPrompt(seed) {
268
- return seed
269
- ? buildInterviewSystemPrompt(INTERVIEW_DEFINITION, {
270
- priorTranscript: seed.priorTranscript,
271
- })
272
- : SYSTEM_PROMPT;
273
- }
274
231
  /**
275
232
  * Submit the current draft and read back the server-computed compliance score
276
233
  * (bug #2 — audit-aware finishing). Fresh interview: the FIRST submit
@@ -287,338 +244,6 @@ export async function submitAndScore(ctx, currentErId, draft) {
287
244
  const created = await createPersonalEpicRequest(ctx.apiBaseUrl, ctx.userApiToken, draft);
288
245
  return { erId: created.id, compliance: created.compliance };
289
246
  }
290
- /**
291
- * Whether the request is good enough to finish. Reaching the target tier
292
- * (`pass` = >=90 with no criticals) finishes early. A null score is fail-open:
293
- * the gate can only act on a score the server actually returned.
294
- */
295
- export function meetsComplianceTarget(compliance) {
296
- return !compliance || compliance.tier === TARGET_COMPLIANCE_TIER;
297
- }
298
- /**
299
- * Turn a failing compliance score into a corrective instruction: fix by
300
- * EXPANDING what's already known where possible, otherwise ask ONE question.
301
- */
302
- export function buildAuditFeedback(compliance) {
303
- const ordered = [
304
- ...compliance.issues.filter((i) => i.tier === 'critical'),
305
- ...compliance.issues.filter((i) => i.tier === 'advisory'),
306
- ].slice(0, 8);
307
- const list = ordered.length > 0
308
- ? ordered.map((i) => `- ${i.message}`).join('\n')
309
- : '- (no specific issues returned; expand the required fields with more concrete detail)';
310
- 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:
311
- ${list}
312
-
313
- If you can resolve these by EXPANDING what the employee already told you — a longer, more complete and concrete problemStatement / proposedSolution / impactAssessment / description that comfortably clear the length minimums — 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.`;
314
- }
315
- /** The optional draft keys, kept in one place so the guard and parser agree. */
316
- const OPTIONAL_DRAFT_KEYS = [
317
- 'successMetrics',
318
- 'targetAudience',
319
- 'alternatives',
320
- 'dependencies',
321
- 'estimatedEffort',
322
- ];
323
- /** Type guard validating the model's completion JSON has the required shape. */
324
- function isEpicRequestDraft(value) {
325
- if (typeof value !== 'object' || value === null)
326
- return false;
327
- const v = value;
328
- // 🔴 We deliberately do NOT require a `done: true` marker. Fable frequently
329
- // emits a complete, valid completion object WITHOUT `done` (verified against a
330
- // real stuck interview), which caused the daemon to reject a finished request
331
- // and relay the raw JSON back to the user forever. The completion signal is the
332
- // PRESENCE of all required fields as non-empty strings — a plain-text question
333
- // never carries these, so this is unambiguous.
334
- const nonEmpty = (x) => typeof x === 'string' && x.trim().length > 0;
335
- const requiredOk = nonEmpty(v.title) &&
336
- nonEmpty(v.problemStatement) &&
337
- nonEmpty(v.proposedSolution) &&
338
- nonEmpty(v.impactAssessment) &&
339
- nonEmpty(v.description);
340
- if (!requiredOk)
341
- return false;
342
- // Optional fields, when present, must be strings (a wrong-typed optional is a
343
- // malformed completion, not a silently-ignored one).
344
- for (const key of OPTIONAL_DRAFT_KEYS) {
345
- if (key in v && v[key] !== undefined && typeof v[key] !== 'string')
346
- return false;
347
- }
348
- return true;
349
- }
350
- /** Removes trailing commas before } or ] — a common LLM JSON defect. */
351
- function stripTrailingCommas(json) {
352
- return json.replace(/,(\s*[}\]])/g, '$1');
353
- }
354
- /**
355
- * Escapes raw control chars (newline/carriage-return/tab) that appear INSIDE a
356
- * JSON string literal — the single most common reason a model's completion JSON
357
- * fails `JSON.parse` (long multi-line description/problem fields written with
358
- * literal line breaks). Walks the text tracking string state so control chars
359
- * OUTSIDE strings (formatting whitespace) are left untouched.
360
- */
361
- function escapeRawControlCharsInStrings(json) {
362
- let out = '';
363
- let inString = false;
364
- let escaped = false;
365
- for (const ch of json) {
366
- if (escaped) {
367
- out += ch;
368
- escaped = false;
369
- continue;
370
- }
371
- if (ch === '\\') {
372
- out += ch;
373
- escaped = true;
374
- continue;
375
- }
376
- if (ch === '"') {
377
- inString = !inString;
378
- out += ch;
379
- continue;
380
- }
381
- if (inString && (ch === '\n' || ch === '\r' || ch === '\t')) {
382
- out += ch === '\n' ? '\\n' : ch === '\r' ? '\\r' : '\\t';
383
- continue;
384
- }
385
- out += ch;
386
- }
387
- return out;
388
- }
389
- /** Candidate JSON substrings to try, in priority order, from a model reply. */
390
- function completionJsonCandidates(text) {
391
- const candidates = [];
392
- // A fenced block (```json … ``` or a plain ``` … ``` fence), case-insensitive.
393
- const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
394
- if (fenced?.[1])
395
- candidates.push(fenced[1]);
396
- const trimmed = text.trim();
397
- if (trimmed.startsWith('{'))
398
- candidates.push(trimmed);
399
- // Last resort: the widest {...} span (handles prose wrapped around the JSON).
400
- const first = text.indexOf('{');
401
- const last = text.lastIndexOf('}');
402
- if (first !== -1 && last > first)
403
- candidates.push(text.slice(first, last + 1));
404
- return candidates;
405
- }
406
- /** Builds the draft from a validated parse (required fields + non-empty optionals). */
407
- function toDraft(parsed) {
408
- const draft = {
409
- title: parsed.title,
410
- problemStatement: parsed.problemStatement,
411
- proposedSolution: parsed.proposedSolution,
412
- impactAssessment: parsed.impactAssessment,
413
- description: parsed.description,
414
- };
415
- for (const key of OPTIONAL_DRAFT_KEYS) {
416
- const raw = parsed[key];
417
- if (typeof raw === 'string' && raw.trim().length > 0) {
418
- draft[key] = raw;
419
- }
420
- }
421
- return draft;
422
- }
423
- /**
424
- * Extracts a completion draft from a model reply, tolerant of the common LLM
425
- * JSON defects (fenced/plain/bare, trailing commas, and — the big one — literal
426
- * newlines inside string values). Returns null only when no repair strategy
427
- * yields a VALID {@link isEpicRequestDraft} object.
428
- */
429
- export function extractCompletionJson(text) {
430
- for (const candidate of completionJsonCandidates(text)) {
431
- for (const variant of [
432
- candidate,
433
- stripTrailingCommas(candidate),
434
- escapeRawControlCharsInStrings(candidate),
435
- escapeRawControlCharsInStrings(stripTrailingCommas(candidate)),
436
- ]) {
437
- try {
438
- const parsed = JSON.parse(variant);
439
- if (isEpicRequestDraft(parsed))
440
- return toDraft(parsed);
441
- }
442
- catch {
443
- // Try the next repair variant / candidate.
444
- }
445
- }
446
- }
447
- return null;
448
- }
449
- /**
450
- * True when a reply is clearly an ATTEMPT to finish (a JSON completion) even if
451
- * it failed to parse/validate — so the daemon can correct the model instead of
452
- * relaying raw JSON to the employee as if it were a question (which loops).
453
- */
454
- export function looksLikeCompletionAttempt(text) {
455
- // Must open a JSON object (fenced or bare) — note we do NOT require a closing
456
- // brace, so a truncated/malformed completion still counts.
457
- const hasObject = /```(?:json)?\s*\{/i.test(text) || text.trim().startsWith('{');
458
- if (!hasObject)
459
- return false;
460
- // ...carrying completion field names (or a `done` marker) — even if malformed
461
- // or missing `done`. A plain-text question never matches this.
462
- return /"(?:done|title|problemStatement|proposedSolution|impactAssessment|description)"\s*:/i.test(text);
463
- }
464
- /** Correction sent to the model when its completion JSON couldn't be used. */
465
- const 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 — no other text — 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```';
466
- /** How many times to ask the model to fix a malformed completion before failing. */
467
- const MAX_JSON_REPAIRS = 3;
468
- function sleep(ms) {
469
- return new Promise((resolve) => setTimeout(resolve, ms));
470
- }
471
- /**
472
- * Polls next-job (the "resume active job" path) until a new trailing user
473
- * turn appears, or the timeout elapses. Returns the updated job, or null on
474
- * timeout.
475
- */
476
- async function waitForUserReply(client, machineToken, jobId, turnsSoFar) {
477
- const deadline = Date.now() + REPLY_TIMEOUT_MS;
478
- while (Date.now() < deadline) {
479
- await sleep(REPLY_POLL_INTERVAL_MS);
480
- const job = await client.nextJob(machineToken, 0);
481
- if (job?.id !== jobId)
482
- continue;
483
- const last = job.turns[job.turns.length - 1];
484
- if (job.turns.length > turnsSoFar && last?.role === 'user') {
485
- return job;
486
- }
487
- }
488
- return null;
489
- }
490
- /**
491
- * Runs the epic_request interview end-to-end: converses with the user via
492
- * post-turn/next-job until it has a title + the 3 required structured-desc
493
- * fields, then creates a personal-scope epic request via the real API
494
- * (POST /api/v1/me/epic-requests) using the user's own stored token.
495
- */
496
- export async function invokeEpicRequestViaApi(job, ctx) {
497
- // pxpipe (ENG-136): route API-mode calls through the proxy when enabled+healthy.
498
- const routing = await resolveRouting();
499
- const anthropic = new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing));
500
- // ENG-149: a continuation job carries a seed → resume/enrich prompt + update
501
- // the target ER in place; a fresh job → normal prompt + create a new ER.
502
- const seed = parseSeedPayload(job.payload);
503
- const systemPrompt = resolveSystemPrompt(seed);
504
- // Audit-gate state (bug #2): the ER we are building (a continuation starts with
505
- // its target), and how many re-submit/re-audit rounds we've spent.
506
- let currentErId = seed?.targetEpicRequestId ?? null;
507
- let qualityRounds = 0;
508
- // Malformed-completion recovery: a model can emit "done" JSON we can't parse.
509
- let jsonRepairs = 0;
510
- const messages = [];
511
- // Seed the conversation with any turns already on the job (e.g. a resumed
512
- // daemon restart), so we don't re-ask questions already answered. A seeded
513
- // USER turn may carry image attachments (ENG-153) — fetch + attach them as
514
- // vision blocks; agent turns and image-free user turns are unchanged.
515
- for (const turn of job.turns) {
516
- if (turn.role === 'agent') {
517
- messages.push({ role: 'assistant', content: turn.content });
518
- }
519
- else {
520
- messages.push({ role: 'user', content: await apiUserTurnContent(turn) });
521
- }
522
- }
523
- let turnsSoFar = job.turns.length;
524
- for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
525
- let reply;
526
- try {
527
- const response = await anthropic.messages.create({
528
- model: ANTHROPIC_MODEL,
529
- max_tokens: 1024,
530
- system: systemPrompt,
531
- messages: messages.length > 0 ? messages : [{ role: 'user', content: 'Let\'s get started.' }],
532
- });
533
- const textBlock = response.content.find((b) => b.type === 'text');
534
- reply = textBlock?.type === 'text' ? textBlock.text : '';
535
- }
536
- catch (error) {
537
- const message = error instanceof Error ? error.message : 'Anthropic API request failed';
538
- return { status: 'failed', result: { error: `Agent invocation failed: ${message}` } };
539
- }
540
- const draft = extractCompletionJson(reply);
541
- if (draft) {
542
- let submitted;
543
- try {
544
- submitted = await submitAndScore(ctx, currentErId, draft);
545
- }
546
- catch (error) {
547
- const message = error instanceof Error ? error.message : 'Failed to save the epic request';
548
- return { status: 'failed', result: { error: message } };
549
- }
550
- currentErId = submitted.erId;
551
- const compliance = submitted.compliance;
552
- // Finish only once the request passes the audit (>=90, no criticals), or
553
- // when the quality-round budget is spent — best-effort, never stranded.
554
- if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
555
- return { status: 'done', result: { epicRequestId: currentErId } };
556
- }
557
- // Not good enough: feed the failing rules back (a model-facing round, no
558
- // user turn) and loop — the model re-emits a richer completion or asks the
559
- // employee a targeted question next iteration. (compliance is non-null here:
560
- // meetsComplianceTarget(null) is true, so a null score would have finished.)
561
- qualityRounds++;
562
- if (compliance !== null) {
563
- messages.push({ role: 'assistant', content: reply });
564
- messages.push({ role: 'user', content: buildAuditFeedback(compliance) });
565
- }
566
- continue;
567
- }
568
- // The model TRIED to finish but the JSON was unusable (bug: malformed
569
- // completion relayed to the employee as a question, looping forever). Correct
570
- // the model instead of posting raw JSON — bounded, then fail cleanly.
571
- if (looksLikeCompletionAttempt(reply)) {
572
- if (jsonRepairs >= MAX_JSON_REPAIRS) {
573
- return {
574
- status: 'failed',
575
- result: {
576
- error: 'The interview produced malformed completion data and could not be finalized after several attempts.',
577
- },
578
- };
579
- }
580
- jsonRepairs++;
581
- messages.push({ role: 'assistant', content: reply });
582
- messages.push({ role: 'user', content: COMPLETION_REPAIR_INSTRUCTION });
583
- continue;
584
- }
585
- // Not done yet — `reply` is the next question. Post it and wait for the answer.
586
- messages.push({ role: 'assistant', content: reply });
587
- let updated;
588
- try {
589
- updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, reply);
590
- }
591
- catch (error) {
592
- const message = error instanceof Error ? error.message : 'post-turn failed';
593
- return { status: 'failed', result: { error: message } };
594
- }
595
- turnsSoFar = updated.turns.length;
596
- const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
597
- if (!answered) {
598
- return {
599
- status: 'failed',
600
- result: { error: 'Timed out waiting for a reply — no response within 10 minutes.' },
601
- };
602
- }
603
- const lastTurn = answered.turns[answered.turns.length - 1];
604
- // ENG-153: the employee's reply may include image attachments — attach them
605
- // as vision blocks (image-free replies stay a plain string, as before).
606
- messages.push({
607
- role: 'user',
608
- content: lastTurn ? await apiUserTurnContent(lastTurn) : '',
609
- });
610
- turnsSoFar = answered.turns.length;
611
- }
612
- // Turn budget exhausted. If we already produced an ER (fresh create or a
613
- // continuation's target), finalize with it best-effort rather than losing the
614
- // work (bug #3); only fail outright if nothing was ever submitted.
615
- return currentErId
616
- ? { status: 'done', result: { epicRequestId: currentErId } }
617
- : {
618
- status: 'failed',
619
- result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` },
620
- };
621
- }
622
247
  /**
623
248
  * Fixed working directory for every `claude` CLI invocation made by the
624
249
  * subscription-mode invoker. Two reasons this must be a stable, dedicated
@@ -676,11 +301,11 @@ function claudeCliSessionCwd() {
676
301
  */
677
302
  function runClaudeCliTurn(prompt, opts) {
678
303
  return new Promise((resolve, reject) => {
304
+ const model = opts.model === undefined ? ANTHROPIC_MODEL : opts.model;
679
305
  const args = [
680
306
  '-p',
681
307
  prompt,
682
- '--model',
683
- ANTHROPIC_MODEL,
308
+ ...(model ? ['--model', model] : []),
684
309
  '--output-format',
685
310
  'json',
686
311
  '--tools',
@@ -768,123 +393,91 @@ function runClaudeCliTurn(prompt, opts) {
768
393
  });
769
394
  });
770
395
  }
396
+ // ---------------------------------------------------------------------
397
+ // Request conversations (always open): one AI reply per claim.
398
+ // ---------------------------------------------------------------------
399
+ /** The request this conversation already saved, read with the person's token. */
400
+ async function loadSavedRequest(apiBaseUrl, userApiToken, epicRequestId) {
401
+ const res = await fetch(`${apiBaseUrl.replace(/\/+$/, '')}/api/v1/epic-requests/${encodeURIComponent(epicRequestId)}`, { headers: { Authorization: `Bearer ${userApiToken}` } });
402
+ if (!res.ok)
403
+ return null;
404
+ const body = (await res.json().catch(() => null));
405
+ return savedContextFromRecord(body?.data);
406
+ }
771
407
  /**
772
- * Runs the epic_request interview end-to-end using the local `claude` CLI
773
- * (the employee's own Claude Code login) instead of the Anthropic API.
774
- * Mirrors invokeEpicRequestViaApi's turn loop and completion handling
775
- * exactly — only how a single assistant turn is produced differs.
408
+ * True when the person's Claude plan can't use the requested model (for
409
+ * example Fable needs usage credits on some plans): retry on their default.
776
410
  */
777
- export async function invokeEpicRequestViaSubscription(job, ctx) {
778
- let sessionId;
779
- let turnsSoFar = job.turns.length;
780
- // pxpipe (ENG-136): resolve routing once per invocation; reused for every turn.
411
+ export function needsDefaultModel(error) {
412
+ const message = error instanceof Error ? error.message : String(error);
413
+ return /requires usage credits|switch to another model|model.*not available|not have access to/i.test(message);
414
+ }
415
+ function savedRequestIdOf(job) {
416
+ const result = job.result;
417
+ if (typeof result?.epicRequestId === 'string')
418
+ return result.epicRequestId;
419
+ return parseSeedPayload(job.payload)?.targetEpicRequestId ?? null;
420
+ }
421
+ /**
422
+ * Takes one step of a request conversation on this computer: the AI's reply to
423
+ * the person's latest message, which may save or update their request. Then
424
+ * the job is left waiting on the person (never completed), so the chat stays
425
+ * open: there is no reply timeout, and the computer is free for other work in
426
+ * the meantime. The server hands the job back when they write again.
427
+ *
428
+ * `anthropicApiKey` set → the Anthropic API; otherwise the local `claude` CLI
429
+ * on the person's own Claude sign-in.
430
+ */
431
+ export async function invokeEpicRequestStep(job, ctx) {
432
+ try {
433
+ return await takeConversationStep(job, ctx);
434
+ }
435
+ catch (error) {
436
+ // Leave the conversation waiting on the person rather than retrying a
437
+ // broken step over and over; their next message tries again.
438
+ const reason = error instanceof Error ? error.message : 'something went wrong';
439
+ await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, `I couldn't continue: ${reason}\n\nSend another message (for example "try again") and I'll pick up where we left off.`);
440
+ return { status: 'yield' };
441
+ }
442
+ }
443
+ async function takeConversationStep(job, ctx) {
781
444
  const routing = await resolveRouting();
782
- // ENG-149: continuation-aware system prompt + submit path (see api-mode above).
783
- const seed = parseSeedPayload(job.payload);
784
- const systemPrompt = resolveSystemPrompt(seed);
785
- // Audit-gate state (bug #2) — mirrors api-mode.
786
- let currentErId = seed?.targetEpicRequestId ?? null;
787
- let qualityRounds = 0;
788
- let jsonRepairs = 0;
789
- // If the daemon restarted mid-interview, there's no local `claude` session
790
- // left to --resume (session IDs aren't persisted across daemon restarts
791
- // today) — replay the transcript so far as the seed prompt instead of
792
- // silently losing context, mirroring how the api-key path seeds its
793
- // Anthropic `messages` array from job.turns.
794
- let nextPrompt = job.turns.length > 0
795
- ? job.turns
796
- .map((t) => t.role === 'agent'
797
- ? `You asked: ${t.content}`
798
- : // ENG-153 (A4): subscription mode has no inline-image channel, so a
799
- // user turn's images degrade to an explicit textual note.
800
- `They replied: ${appendAttachmentNote(t.content, imageAttachmentCount(t.attachments))}`)
801
- .join('\n') +
802
- '\n\nContinue the interview from exactly where this left off — ask the next question, or if you already have everything, respond with the completion JSON.'
803
- : "Let's begin the interview.";
804
- for (let i = 0; i < MAX_INTERVIEW_TURNS; i++) {
805
- let turn;
806
- try {
807
- turn = await runClaudeCliTurn(nextPrompt, {
808
- systemPrompt,
809
- resumeSessionId: sessionId,
810
- routing,
811
- });
812
- }
813
- catch (error) {
814
- const message = error instanceof Error ? error.message : 'claude CLI invocation failed';
815
- return { status: 'failed', result: { error: `Agent invocation failed: ${message}` } };
816
- }
817
- sessionId = turn.sessionId;
818
- const draft = extractCompletionJson(turn.text);
819
- if (draft) {
820
- let submitted;
445
+ const requestId = savedRequestIdOf(job);
446
+ const saved = requestId ? await loadSavedRequest(ctx.apiBaseUrl, ctx.userApiToken, requestId) : null;
447
+ const turns = job.turns.map((t) => ({
448
+ role: t.role,
449
+ content: t.role === 'user' ? appendAttachmentNote(t.content, imageAttachmentCount(t.attachments)) : t.content,
450
+ }));
451
+ const anthropic = ctx.anthropicApiKey ? new Anthropic(anthropicClientOptions(ctx.anthropicApiKey, routing)) : null;
452
+ let cliModel = ANTHROPIC_MODEL;
453
+ const outcome = await runConversationStep({ turns, saved }, {
454
+ reply: async (system, messages) => {
455
+ if (anthropic) {
456
+ const response = await anthropic.messages.create({ model: ANTHROPIC_MODEL, max_tokens: 4096, system, messages });
457
+ const text = response.content.find((b) => b.type === 'text');
458
+ return text?.type === 'text' ? text.text : '';
459
+ }
460
+ const prompt = renderConversationPrompt(messages);
821
461
  try {
822
- submitted = await submitAndScore(ctx, currentErId, draft);
462
+ return (await runClaudeCliTurn(prompt, { systemPrompt: system, resumeSessionId: undefined, routing, model: cliModel })).text;
823
463
  }
824
464
  catch (error) {
825
- const message = error instanceof Error ? error.message : 'Failed to save the epic request';
826
- return { status: 'failed', result: { error: message } };
827
- }
828
- currentErId = submitted.erId;
829
- const compliance = submitted.compliance;
830
- if (meetsComplianceTarget(compliance) || qualityRounds >= MAX_QUALITY_ROUNDS) {
831
- return { status: 'done', result: { epicRequestId: currentErId } };
832
- }
833
- // Feed the failing rules back into the SAME resumed session (no user turn);
834
- // the model re-emits a richer completion or asks a targeted question next.
835
- // (compliance is non-null here — see the api-mode note above.)
836
- qualityRounds++;
837
- if (compliance !== null) {
838
- nextPrompt = buildAuditFeedback(compliance);
839
- }
840
- continue;
841
- }
842
- // Malformed completion (see api-mode): correct the model rather than relaying
843
- // raw JSON to the employee; bounded, then fail cleanly.
844
- if (looksLikeCompletionAttempt(turn.text)) {
845
- if (jsonRepairs >= MAX_JSON_REPAIRS) {
846
- return {
847
- status: 'failed',
848
- result: {
849
- error: 'The interview produced malformed completion data and could not be finalized after several attempts.',
850
- },
851
- };
465
+ if (cliModel === null || !needsDefaultModel(error))
466
+ throw error;
467
+ cliModel = null;
468
+ return (await runClaudeCliTurn(prompt, { systemPrompt: system, resumeSessionId: undefined, routing, model: null })).text;
852
469
  }
853
- jsonRepairs++;
854
- nextPrompt = COMPLETION_REPAIR_INSTRUCTION;
855
- continue;
856
- }
857
- // Not done yet — turn.text is the next question. Post it and wait for the answer.
858
- let updated;
859
- try {
860
- updated = await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, turn.text);
861
- }
862
- catch (error) {
863
- const message = error instanceof Error ? error.message : 'post-turn failed';
864
- return { status: 'failed', result: { error: message } };
865
- }
866
- turnsSoFar = updated.turns.length;
867
- const answered = await waitForUserReply(ctx.mailboxClient, ctx.machineToken, job.id, turnsSoFar);
868
- if (!answered) {
869
- return {
870
- status: 'failed',
871
- result: { error: 'Timed out waiting for a reply — no response within 10 minutes.' },
872
- };
873
- }
874
- const lastTurn = answered.turns[answered.turns.length - 1];
875
- // ENG-153 (A4): note any attached images textually (subscription-mode degradation).
876
- nextPrompt = lastTurn
877
- ? appendAttachmentNote(lastTurn.content, imageAttachmentCount(lastTurn.attachments))
878
- : '';
879
- turnsSoFar = answered.turns.length;
470
+ },
471
+ save: async (existingId, draft) => {
472
+ const submitted = await submitAndScore(ctx, existingId, draft);
473
+ return { id: submitted.erId, title: draft.title, compliance: submitted.compliance };
474
+ },
475
+ });
476
+ if (outcome.kind === 'saved') {
477
+ await ctx.mailboxClient.markRequestSaved(ctx.machineToken, job.id, outcome.saved.id);
880
478
  }
881
- // Budget exhausted — finalize best-effort if we produced an ER (bug #3).
882
- return currentErId
883
- ? { status: 'done', result: { epicRequestId: currentErId } }
884
- : {
885
- status: 'failed',
886
- result: { error: `Interview did not converge after ${String(MAX_INTERVIEW_TURNS)} turns.` },
887
- };
479
+ await ctx.mailboxClient.postTurn(ctx.machineToken, job.id, outcome.text);
480
+ return { status: 'yield' };
888
481
  }
889
482
  // ---------------------------------------------------------------------
890
483
  // start_execution / kind:'plan' (ENG-164) — run the local @planner headless
@@ -922,6 +515,25 @@ export const PLANNER_HEARTBEAT_INTERVAL_MS = 20 * 1_000;
922
515
  const PLANNER_FINAL_TURN_MAX_CHARS = 4_000;
923
516
  /** The literal marker token the orchestrator emits (ENG-162); kept in sync for compatibility. */
924
517
  const ORCHESTRATOR_REVIEW_MARKER = 'COMPBOX_NEEDS_HUMAN_REVIEW';
518
+ /**
519
+ * Finishes a run made on the person's API key: reads Claude Code's JSON
520
+ * result, reports the run's usage to CompBox (best-effort), and returns the
521
+ * plain text the run would otherwise have printed.
522
+ */
523
+ export function takeKeyRunResult(ctx, keyRun, stdout) {
524
+ const parsed = parseClaudeJsonResult(stdout, keyRun.model);
525
+ if (!parsed)
526
+ return { text: stdout, reported: Promise.resolve() };
527
+ const reported = ctx.mailboxClient
528
+ .reportAiUsage(ctx.machineToken, ctx.jobId, parsed.usage)
529
+ .catch(() => undefined);
530
+ return { text: parsed.text, reported };
531
+ }
532
+ async function settleKeyRun(ctx, keyRun, stdout) {
533
+ const { text, reported } = takeKeyRunResult(ctx, keyRun, stdout);
534
+ await reported;
535
+ return text;
536
+ }
925
537
  /**
926
538
  * Build the `claude` argv for a headless planner run (H1). Pure + exported so a
927
539
  * unit test can assert the exact command shape: the `@planner` routing prompt,
@@ -1099,16 +711,17 @@ export async function invokeStartExecutionPlan(ctx, deps = {}) {
1099
711
  }
1100
712
  const routing = await resolveRoutingImpl();
1101
713
  const command = resolveClaudeBinary() ?? 'claude';
1102
- const args = buildPlannerArgs(ctx.epicRequestId);
714
+ const args = ctx.keyRun ? keyRunArgs(buildPlannerArgs(ctx.epicRequestId)) : buildPlannerArgs(ctx.epicRequestId);
1103
715
  // pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed. COMPBOX_NONINTERACTIVE
1104
716
  // forces the planner's headless path even if the prompt flag were dropped. PATH
1105
717
  // is augmented (ENG-135) so `claude` and any helpers it spawns resolve.
1106
718
  const routedEnv = claudeSpawnEnv(process.env, routing);
1107
- const childEnv = {
719
+ const baseEnv = {
1108
720
  ...routedEnv,
1109
721
  COMPBOX_NONINTERACTIVE: '1',
1110
722
  PATH: claudeChildEnvPath(routedEnv.PATH),
1111
723
  };
724
+ const childEnv = ctx.keyRun ? keyRunEnv(baseEnv, ctx.keyRun) : baseEnv;
1112
725
  // H4: relay a START turn immediately so the web shows the run began even if the
1113
726
  // planner produces no stdout for minutes. postTurn failures are swallowed —
1114
727
  // streaming is best-effort and must never fail the job.
@@ -1171,6 +784,8 @@ export async function invokeStartExecutionPlan(ctx, deps = {}) {
1171
784
  });
1172
785
  child.on('close', (code) => {
1173
786
  void (async () => {
787
+ if (ctx.keyRun)
788
+ stdout = await settleKeyRun(ctx, ctx.keyRun, stdout);
1174
789
  const combined = `${stdout}\n${stderr}`;
1175
790
  // A needs-review outcome wins regardless of exit code: the planner may
1176
791
  // exit 0 after emitting the marker. Surface it to the web (not silent).
@@ -1356,6 +971,18 @@ export function defaultToolchainPresent(_dir) {
1356
971
  }
1357
972
  return false;
1358
973
  }
974
+ /** Plain-language summary of where a run's code ended up. */
975
+ export function describeRunBranch(finish) {
976
+ if (finish.prUrl)
977
+ return `Pull request: ${finish.prUrl}`;
978
+ if (finish.compareUrl)
979
+ return `Branch ${finish.branch} was pushed. Open the pull request here: ${finish.compareUrl}`;
980
+ if (finish.commits === 0)
981
+ return 'No code changes were made.';
982
+ if (finish.pushed)
983
+ return `Branch ${finish.branch} was pushed.`;
984
+ return `The work is on branch ${finish.branch}${finish.keptWorktree ? ` (in ${finish.keptWorktree})` : ''}.`;
985
+ }
1359
986
  /** Read a string field off a parsed marker object, or undefined. */
1360
987
  function markerString(marker, key) {
1361
988
  const v = marker?.[key];
@@ -1427,23 +1054,39 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
1427
1054
  if (!toolchainPresent(projectDir)) {
1428
1055
  return { status: 'failed', result: { error: toolchainMissingMessage(projectDir) } };
1429
1056
  }
1057
+ // Each run gets its own branch + worktree, so it never touches the person's
1058
+ // checkout and always ends as a reviewable pull request.
1059
+ let workspace = null;
1060
+ let workspaceNotes = [];
1061
+ if (deps.workspace) {
1062
+ const prepared = await deps.workspace.prepare(projectDir, ctx.epicId);
1063
+ if (!prepared.ok) {
1064
+ return { status: 'failed', result: { error: prepared.error } };
1065
+ }
1066
+ workspace = prepared.workspace;
1067
+ workspaceNotes = prepared.notes;
1068
+ }
1069
+ const runDir = workspace?.dir ?? projectDir;
1430
1070
  const routing = await resolveRoutingImpl();
1431
1071
  const command = resolveClaudeBinary() ?? 'claude';
1432
- const args = buildOrchestratorArgs(ctx.epicId);
1072
+ const args = ctx.keyRun ? keyRunArgs(buildOrchestratorArgs(ctx.epicId)) : buildOrchestratorArgs(ctx.epicId);
1433
1073
  // pxpipe (ENG-136): inject ANTHROPIC_BASE_URL when routed. COMPBOX_NONINTERACTIVE
1434
1074
  // forces the orchestrator's headless serial-in-process path (H3) even if the
1435
1075
  // prompt flag were dropped. PATH is augmented (ENG-135) so `claude` and any
1436
1076
  // helpers it spawns resolve on a launchd/GUI-spawned daemon.
1437
1077
  const routedEnv = claudeSpawnEnv(process.env, routing);
1438
- const childEnv = {
1078
+ const baseEnv = {
1439
1079
  ...routedEnv,
1440
1080
  COMPBOX_NONINTERACTIVE: '1',
1441
1081
  PATH: claudeChildEnvPath(routedEnv.PATH),
1442
1082
  };
1083
+ const childEnv = ctx.keyRun ? keyRunEnv(baseEnv, ctx.keyRun) : baseEnv;
1443
1084
  // H4: relay a START turn immediately so the web shows the run began even if the
1444
1085
  // orchestrator produces no stdout for minutes. postTurn failures are swallowed.
1445
1086
  try {
1446
- await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic — every phase and feature — and may take a long time.`);
1087
+ await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, `Started the orchestrator for epic ${ctx.epicId} (headless, non-interactive). This executes the full epic — every phase and feature — and may take a long time.` +
1088
+ (workspace ? ` Working on a new branch, ${workspace.branch}, which will end as a pull request into ${workspace.baseBranch}.` : '') +
1089
+ (workspaceNotes.length > 0 ? ` Note: ${workspaceNotes.join(' ')}` : ''));
1447
1090
  }
1448
1091
  catch {
1449
1092
  // Best-effort start turn.
@@ -1453,7 +1096,7 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
1453
1096
  let stderr = '';
1454
1097
  let settled = false;
1455
1098
  const child = spawnImpl(command, args, {
1456
- cwd: projectDir,
1099
+ cwd: runDir,
1457
1100
  stdio: ['ignore', 'pipe', 'pipe'],
1458
1101
  env: childEnv,
1459
1102
  });
@@ -1502,6 +1145,9 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
1502
1145
  });
1503
1146
  });
1504
1147
  child.on('close', (code) => {
1148
+ // Usage is reported in the background; the run's outcome doesn't wait on it.
1149
+ if (ctx.keyRun)
1150
+ stdout = takeKeyRunResult(ctx, ctx.keyRun, stdout).text;
1505
1151
  const combined = `${stdout}\n${stderr}`;
1506
1152
  // ENG-165-3: a HARD-gate needs-review outcome wins regardless of exit code
1507
1153
  // — the orchestrator emits the marker and STOPs, and may exit 0. Surface
@@ -1529,11 +1175,40 @@ export async function invokeStartExecutionOrchestrate(ctx, deps = {}) {
1529
1175
  finish({ status: 'done', result: { epicId: ctx.epicId } });
1530
1176
  });
1531
1177
  });
1178
+ // Commit what's left, push the branch and open the pull request (draft when
1179
+ // the run stopped early), then attach where the code is to the job result.
1180
+ let branchLine = '';
1181
+ if (deps.workspace && workspace) {
1182
+ const resultError = outcome.result;
1183
+ const stopReason = typeof resultError.message === 'string'
1184
+ ? resultError.message
1185
+ : typeof resultError.error === 'string'
1186
+ ? resultError.error.split('\n')[0]
1187
+ : undefined;
1188
+ const finished = await deps.workspace.finish({
1189
+ workspace,
1190
+ epicId: ctx.epicId,
1191
+ jobId: ctx.jobId,
1192
+ completed: outcome.status === 'done',
1193
+ ...(stopReason ? { stopReason } : {}),
1194
+ });
1195
+ branchLine = describeRunBranch(finished);
1196
+ outcome.result = {
1197
+ ...outcome.result,
1198
+ branch: finished.branch,
1199
+ commits: finished.commits,
1200
+ pushed: finished.pushed,
1201
+ ...(finished.prUrl ? { prUrl: finished.prUrl } : {}),
1202
+ ...(finished.compareUrl ? { compareUrl: finished.compareUrl } : {}),
1203
+ ...(finished.notes.length > 0 ? { branchNotes: finished.notes } : {}),
1204
+ };
1205
+ }
1532
1206
  // H4: relay a FINAL turn summarizing the outcome (best-effort).
1533
1207
  try {
1534
- const summary = outcome.status === 'done'
1208
+ const summary = (outcome.status === 'done'
1535
1209
  ? `Execution complete — epic ${ctx.epicId} finished all phases.`
1536
- : `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}`;
1210
+ : `Orchestrator run ended: ${JSON.stringify(outcome.result).slice(0, ORCHESTRATOR_FINAL_TURN_MAX_CHARS)}`) +
1211
+ (branchLine ? `\n${branchLine}` : '');
1537
1212
  await ctx.mailboxClient.postTurn(ctx.machineToken, ctx.jobId, summary);
1538
1213
  }
1539
1214
  catch {