flowviant 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lib/live.mjs CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  ALLOW_PATCHES,
35
35
  } from './config.mjs';
36
36
  import { c, info, ok, warn } from './ui.mjs';
37
- import { sleep } from './claude.mjs';
37
+ import { sleep, runTurn, mcpFor, sawSentinel, blockedId } from './claude.mjs';
38
38
  import {
39
39
  git,
40
40
  resetWorktree,
@@ -44,12 +44,28 @@ import {
44
44
  clearWip,
45
45
  } from './git.mjs';
46
46
  import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
47
+ import { RUNTIMES, runtimeById, drivableHere, mediated } from './runtimes.mjs';
47
48
  import { loadPreviewConfig, startPreview } from './preview.mjs';
48
49
  import { materializeInto, scrub as envScrub } from './env.mjs';
49
50
 
50
51
  // Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
51
52
  // reviewer then drives it via "Open live preview" in the node.
52
53
  const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
54
+
55
+ /**
56
+ * What THIS WORKER can build, sent on every claim.
57
+ *
58
+ * Deliberately the same predicate the roster report uses (`drivableHere`), not a
59
+ * hand-written list. The claim and the report answer the same question to two
60
+ * different consumers, and if they ever disagree the daemon either claims work it
61
+ * cannot build — the exact bug this argument was added to close — or refuses work
62
+ * it can. One source, so they cannot drift.
63
+ *
64
+ * Note this is NOT the live-session list. A live worker builds Claude tasks
65
+ * through the SDK and everything else through `driveSubprocess`, so both belong
66
+ * here; `live` chooses the driver, it does not gate participation.
67
+ */
68
+ const DRIVABLE_HERE = Object.values(RUNTIMES).filter(drivableHere).map((r) => r.id);
53
69
  // Short TTL + a heartbeat that re-asserts while the tunnel is alive. So a live
54
70
  // preview stays linked indefinitely (survives long reviews), but one whose
55
71
  // daemon DIED ungracefully (no more heartbeats) drops off the card within the
@@ -191,6 +207,36 @@ questions, delivery summaries, commits, or PRs — reference keys by NAME only
191
207
  (e.g. "set STRIPE_KEY"). Never screenshot a terminal or page that displays a
192
208
  credential, and never commit an env file.`;
193
209
 
210
+ /**
211
+ * The same contract, for a runtime that has no live session.
212
+ *
213
+ * A non-live runtime is driven as a SUBPROCESS: one headless turn, then the
214
+ * process exits and the daemon decides what happens next. That transport cannot
215
+ * see tool calls the way the SDK stream can — there is no `tool_use` block to
216
+ * read `complete` or `report_blocker` off — so the turn has to SAY how it ended.
217
+ * Hence the sentinels, which are the same three words the legacy poll path has
218
+ * always used; this is a transport detail bolted onto the contract, not a second
219
+ * contract, which is why it is SYSTEM_LIVE plus an epilogue rather than a
220
+ * parallel prompt that would drift from it.
221
+ *
222
+ * The claim instruction that opens SYSTEM_SINGLE is deliberately absent: the
223
+ * daemon already claimed this task before spawning, so a second claim would come
224
+ * back `active_run` and the turn would waste itself puzzling over it.
225
+ */
226
+ const SYSTEM_SUBPROCESS = `${SYSTEM_LIVE}
227
+
228
+ HOW THIS TURN ENDS. You are running as a one-shot process, not in a live session,
229
+ so the daemon can only see what you print. End your turn by printing EXACTLY ONE
230
+ of these on a line by itself, as the last thing you output:
231
+ DONE — the task is complete (you called complete, and opened the
232
+ PR unless placement is "patch")
233
+ BLOCKED:<blockerId> — you called report_blocker and are waiting on a human. Use
234
+ the id report_blocker returned. STOP after printing it;
235
+ you will be run again with the answer.
236
+ Print nothing else on that line. Do not print a sentinel you have not earned — a
237
+ DONE without a complete call strands the work, and the team is told the task
238
+ finished when it did not.`;
239
+
194
240
  /** The brief minus the parts rendered as prose below (conversations, the ask). */
195
241
  function briefWithoutThread(brief) {
196
242
  const {
@@ -555,6 +601,455 @@ async function landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchB
555
601
  warn(`patch not applied: ${reason}`);
556
602
  }
557
603
 
604
+ /**
605
+ * The FORM a mediated runtime fills in instead of calling tools.
606
+ *
607
+ * Every field maps to one control-plane call the daemon makes on the agent's
608
+ * behalf, which is why the shape is this small: it is not a report, it is the
609
+ * arguments to `complete` / `report_blocker` / `attach_pr` with the runId taken
610
+ * out (the agent has no business naming a run it cannot see).
611
+ */
612
+ const MEDIATED_RESULT_SCHEMA = {
613
+ type: 'object',
614
+ required: ['outcome', 'summary'],
615
+ additionalProperties: false,
616
+ properties: {
617
+ outcome: { type: 'string', enum: ['done', 'blocked', 'failed'] },
618
+ summary: { type: 'string' },
619
+ prUrl: { type: 'string' },
620
+ branch: { type: 'string' },
621
+ blockerQuestion: { type: 'string' },
622
+ blockerOptions: { type: 'array', items: { type: 'string' } },
623
+ criteria: {
624
+ type: 'array',
625
+ items: {
626
+ type: 'object',
627
+ required: ['index', 'met'],
628
+ properties: {
629
+ index: { type: 'number' },
630
+ met: { type: 'boolean' },
631
+ note: { type: 'string' },
632
+ },
633
+ },
634
+ },
635
+ },
636
+ };
637
+
638
+ /**
639
+ * The contract for a runtime that cannot reach the flowviant MCP server.
640
+ *
641
+ * SYSTEM_LIVE tells the agent to call tools. This one tells it there are none —
642
+ * which has to be said explicitly, because the brief it is about to read is full
643
+ * of references to a control plane it cannot touch, and an agent that spends its
644
+ * turn hunting for `report_progress` is an agent that does not build anything.
645
+ */
646
+ const SYSTEM_MEDIATED = `You are a Flowviant build agent working ONE task, running FULLY AUTONOMOUSLY.
647
+ There is NO interactive user, NO terminal to ask in, and — importantly — NO
648
+ Flowviant tools available to you in this session. Do not look for them. A daemon
649
+ is watching this run and reports on your behalf: your file edits, commands and
650
+ progress are already visible to the team as you work.
651
+
652
+ Do the work described in the brief below, in the checkout you are running in.
653
+ Ship it exactly as the brief's "placement" says:
654
+ • placement "patch": commit your change with a one-line message and STOP. No
655
+ branch, no push, no PR — the daemon carries it into the owner's checkout.
656
+ • placement "branch" (the default): create the branch named in "branchName" (use
657
+ that exact name), push it, and open ONE draft pull request with
658
+ \`gh pr create --draft\`. If the brief has a "baseBranch", target it with
659
+ \`--base <baseBranch>\`. NEVER merge.
660
+
661
+ THEN RETURN THE RESULT FORM as your final answer, and nothing else — it is a
662
+ strict JSON schema and it is the only way anything you did gets recorded:
663
+ • outcome "done" — you finished. Include a plain-language "summary" for the
664
+ humans (it becomes your delivery card), the "prUrl" and "branch" if you opened
665
+ one, and a "criteria" self-report indexing into the brief's "done when" list.
666
+ • outcome "blocked" — you hit a decision only a human can make. Put the question
667
+ in "blockerQuestion" and any choices in "blockerOptions", and STOP. You will be
668
+ run again with the answer.
669
+ • outcome "failed" — you could not do it. Say why in "summary".
670
+ Do not invent a prUrl you did not open, and do not report "done" for work you did
671
+ not finish: the summary is shown to a person as a claim about what exists.
672
+ SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their
673
+ VALUES must NEVER appear in the summary, in commits, or in a PR — reference keys
674
+ by NAME only. Never commit an env file.`;
675
+
676
+ /** Pull the result object out of a turn's output. */
677
+ function parseMediatedResult(out) {
678
+ const text = String(out ?? '').trim();
679
+ if (!text) return null;
680
+ // The whole answer SHOULD be the object — that is what schema enforcement
681
+ // buys. Fall back to the last balanced {...} for a runtime that wraps it in a
682
+ // fence or adds a sentence, so one chatty model does not strand a finished
683
+ // build. Last rather than first: any preamble comes before the answer.
684
+ const direct = tryJson(text);
685
+ if (direct) return direct;
686
+ const start = text.lastIndexOf('{');
687
+ for (let i = start; i >= 0; i = text.lastIndexOf('{', i - 1)) {
688
+ const cand = tryJson(text.slice(i, text.lastIndexOf('}') + 1));
689
+ if (cand) return cand;
690
+ if (i === 0) break;
691
+ }
692
+ return null;
693
+ }
694
+ function tryJson(s) {
695
+ try {
696
+ const v = JSON.parse(s);
697
+ return v && typeof v === 'object' && typeof v.outcome === 'string' ? v : null;
698
+ } catch {
699
+ return null;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Drive a task with a runtime that cannot reach the MCP server at all.
705
+ *
706
+ * THE CLI DOES THE WORK; THE DAEMON DOES THE PAPERWORK. Antigravity's server
707
+ * list is machine-wide (measured — a workspace-local config is never read), so
708
+ * handing it a per-lane worker token is impossible and handing it a shared one
709
+ * would make every lane indistinguishable to the control plane. Instead nothing
710
+ * is handed over: the agent gets a brief and returns a filled-in form, and every
711
+ * control-plane call below is made by the daemon with the lane's OWN token, over
712
+ * its own HTTP. Per-lane isolation is preserved by removing the need for the
713
+ * agent to have a credential at all.
714
+ *
715
+ * The cost, and it is real: NO ON-DEMAND CONTEXT. A direct-MCP agent can call
716
+ * search_wiki or get_module_files the moment it realises it does not understand
717
+ * a subsystem. A mediated one only knows what was in the brief. That is a
718
+ * genuine capability difference and it is why this is the fallback shape rather
719
+ * than the default — runtimes that CAN hold an MCP config keep the full tool
720
+ * surface.
721
+ *
722
+ * Also not yet carried: attach_evidence. A mediated agent cannot upload a
723
+ * screenshot, so its delivery card arrives without the proof a Claude lane's
724
+ * would have. Fixable (the agent writes files, the daemon uploads them) and
725
+ * deliberately not in this first pass.
726
+ */
727
+ async function driveMediated({
728
+ runtimeId,
729
+ mcpUrl,
730
+ token,
731
+ runId,
732
+ intentId,
733
+ title,
734
+ cwd,
735
+ brief,
736
+ isPatch,
737
+ patchBase,
738
+ repoRoot,
739
+ baseRef,
740
+ label,
741
+ seedText,
742
+ isAlive,
743
+ onChild,
744
+ markLanded,
745
+ }) {
746
+ const rt = runtimeById(runtimeId);
747
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-schema-'));
748
+ const schemaPath = join(dir, 'result.schema.json');
749
+ writeFileSync(schemaPath, JSON.stringify(MEDIATED_RESULT_SCHEMA), { mode: 0o600 });
750
+
751
+ // The agent cannot call report_progress, so the daemon narrates for it off the
752
+ // parsed activity stream. Throttled: a build touches hundreds of files and the
753
+ // thread is for humans, not for a filesystem log.
754
+ let lastReport = 0;
755
+ const narrate = (activity) => {
756
+ if (!activity?.label) return;
757
+ const now = Date.now();
758
+ if (now - lastReport < 8000) return;
759
+ lastReport = now;
760
+ void mcpCall(mcpUrl, token, 'report_progress', {
761
+ runId,
762
+ kind: activity.kind === 'error' ? 'error' : 'progress',
763
+ message: envScrub(activity.label),
764
+ }).catch(() => {});
765
+ };
766
+
767
+ let prompt = seedText;
768
+ let resume = false;
769
+ let nudges = 0;
770
+ try {
771
+ for (;;) {
772
+ if (!isAlive()) return { outcome: 'blocked', title, intentId };
773
+ let out = '';
774
+ try {
775
+ out = await runTurn({
776
+ prompt,
777
+ resume,
778
+ system: SYSTEM_MEDIATED,
779
+ cwd,
780
+ runtime: runtimeId,
781
+ // NO MCP. That is the entire point of this path.
782
+ resultSchemaArgs: rt.resultSchema?.(schemaPath) ?? [],
783
+ label,
784
+ model: brief.agentModel || undefined,
785
+ effort: brief.agentEffort || undefined,
786
+ onActivity: narrate,
787
+ onSpawn: (ch) => onChild?.(ch),
788
+ });
789
+ } catch (e) {
790
+ return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
791
+ } finally {
792
+ onChild?.(null);
793
+ }
794
+ if (!isAlive()) return { outcome: 'blocked', title, intentId };
795
+ resume = true;
796
+
797
+ const rl = classifyRateLimit(String(out).slice(-4000));
798
+ const result = parseMediatedResult(out);
799
+ if (!result && rl.isRateLimit) {
800
+ await mcpCall(mcpUrl, token, 'report_paused', { runId, resetAt: rl.resetAt }).catch(() => {});
801
+ return { outcome: 'rate_limited', resetAt: rl.resetAt, runId, title, intentId };
802
+ }
803
+
804
+ if (!result) {
805
+ // No form came back. Same posture as a missing sentinel on the other
806
+ // paths: nudge, then give up rather than invent an outcome.
807
+ if (nudges < 2) {
808
+ nudges++;
809
+ prompt =
810
+ 'You did not return the result form. Return ONLY the JSON object described in your instructions, describing what you did.';
811
+ continue;
812
+ }
813
+ return { outcome: 'stalled', title, intentId };
814
+ }
815
+
816
+ if (result.outcome === 'blocked') {
817
+ const q = String(result.blockerQuestion ?? result.summary ?? '').trim();
818
+ const posted = await mcpCall(mcpUrl, token, 'report_blocker', {
819
+ runId,
820
+ taskId: intentId,
821
+ type: 'question',
822
+ payload: {
823
+ question: q || 'The agent stopped and did not say why.',
824
+ options: Array.isArray(result.blockerOptions) ? result.blockerOptions : undefined,
825
+ },
826
+ }).catch(() => null);
827
+ const blockerId = posted?.blockerId ?? posted?.id ?? null;
828
+ if (!blockerId) return { outcome: 'blocked', title, intentId };
829
+ const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
830
+ if (res.status === 'resolved') {
831
+ prompt = `The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue, then return the result form.`;
832
+ nudges = 0;
833
+ continue;
834
+ }
835
+ if (res.status === 'timeout') return { outcome: 'parked', title, intentId };
836
+ return { outcome: 'blocked', title, intentId };
837
+ }
838
+
839
+ // done / failed — either way the turn is over and the thread gets a card.
840
+ if (result.prUrl && !isPatch) {
841
+ await mcpCall(mcpUrl, token, 'attach_pr', {
842
+ runId,
843
+ prUrl: String(result.prUrl),
844
+ ...(result.branch ? { branch: String(result.branch) } : {}),
845
+ }).catch(() => {});
846
+ }
847
+ clearTaskMarker(cwd);
848
+ if (result.outcome === 'done' && isPatch) {
849
+ await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
850
+ }
851
+ await mcpCall(mcpUrl, token, 'complete', {
852
+ runId,
853
+ outcome: result.outcome === 'done' ? 'completed' : 'failed',
854
+ summary: envScrub(String(result.summary ?? '').slice(0, 4000)),
855
+ ...(Array.isArray(result.criteria) ? { criteria: result.criteria } : {}),
856
+ }).catch(() => {});
857
+ if (result.outcome === 'done') markLanded();
858
+ return { outcome: result.outcome === 'done' ? 'done' : 'stalled', title, intentId };
859
+ }
860
+ } finally {
861
+ rmSync(dir, { recursive: true, force: true });
862
+ }
863
+ }
864
+
865
+ /**
866
+ * Drive a task with a runtime that has no live session.
867
+ *
868
+ * Same job, same outcomes, different transport. `runLiveTask` owns everything
869
+ * around this — the claim, the worktree, the branch/patch/stack setup, the WIP
870
+ * checkpoint timer, the diffstat sampler and the teardown — and calls one of two
871
+ * drivers in the middle. That split is the whole point: routing non-live
872
+ * runtimes at the WORKER level instead (the obvious shortcut, since the legacy
873
+ * poll worker already spawns Codex) would have sent them down a path with no
874
+ * patch landing, no WIP checkpoint/restore and no preview, so a `placement:
875
+ * "patch"` task would follow its instructions to commit-and-stop and then wait
876
+ * forever for a daemon that never picks it up.
877
+ *
878
+ * What is genuinely lost versus a live session, stated plainly rather than
879
+ * discovered: a teammate's mid-task message cannot interrupt a running turn. It
880
+ * lands between turns instead, which is the same place a poll-mode message has
881
+ * always landed. Everything else — blockers, stop, teardown, release, patches,
882
+ * checkpoints — behaves the same because it is the same surrounding code.
883
+ */
884
+ async function driveSubprocess({
885
+ runtimeId,
886
+ mcpUrl,
887
+ token,
888
+ runId,
889
+ intentId,
890
+ title,
891
+ cwd,
892
+ brief,
893
+ isPatch,
894
+ patchBase,
895
+ repoRoot,
896
+ baseRef,
897
+ label,
898
+ seedText,
899
+ afterId,
900
+ isAlive,
901
+ onChild,
902
+ markLanded,
903
+ }) {
904
+ let resume = false;
905
+ let nudges = 0;
906
+ let held = false;
907
+ let prompt = seedText;
908
+
909
+ for (;;) {
910
+ if (!isAlive()) return { outcome: 'blocked', title, intentId };
911
+
912
+ // A fresh token hand-off per turn: the worker token is minted per lane and
913
+ // may rotate between turns, and for Codex it rides in the environment rather
914
+ // than on disk, so there is nothing to clean up in that case (`dir` is null).
915
+ const { dir, args: mcpArgs, env: mcpEnv } = mcpFor(runtimeId, token, mcpUrl);
916
+ let out = '';
917
+ try {
918
+ out = await runTurn({
919
+ prompt,
920
+ resume,
921
+ system: SYSTEM_SUBPROCESS,
922
+ cwd,
923
+ runtime: runtimeId,
924
+ mcpArgs,
925
+ mcpEnv,
926
+ label,
927
+ // Per-task first, this machine's default second — off the BRIEF, which is
928
+ // the task we actually hold, never the roster's guess.
929
+ model: brief.agentModel || undefined,
930
+ effort: brief.agentEffort || undefined,
931
+ onSpawn: (ch) => onChild?.(ch),
932
+ });
933
+ } catch (e) {
934
+ // Defensive only. runTurn resolves rather than rejects on a failed child —
935
+ // see the rate-limit note below — so this catches a throw from the
936
+ // plumbing around it, not from the CLI.
937
+ return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
938
+ } finally {
939
+ if (dir) rmSync(dir, { recursive: true, force: true });
940
+ onChild?.(null);
941
+ }
942
+ if (!isAlive()) return { outcome: 'blocked', title, intentId };
943
+
944
+ // A USAGE LIMIT reads differently here than it does in a live session, and
945
+ // getting that wrong would show the user's own plan limit as a Flowviant
946
+ // stall. The SDK THROWS on a 429, which is why the live path classifies an
947
+ // exception; `runTurn` resolves with whatever the child printed no matter
948
+ // how it exited, so the only evidence a subprocess leaves is text.
949
+ //
950
+ // Read the TAIL only, and only when the turn produced no sentinel. The whole
951
+ // transcript is the model's narration, and an agent that writes "we should
952
+ // handle rate limit errors" into a code comment would otherwise park a
953
+ // perfectly healthy run. A fatal CLI error is the last thing printed. Both
954
+ // ways of being wrong here are recoverable — a false park retries after the
955
+ // reset, a missed limit reads as a stall and is re-dispatched — so the tail
956
+ // heuristic buys the common case without risking the work.
957
+ if (!sawSentinel(out, 'DONE') && !blockedId(out)) {
958
+ const rl = classifyRateLimit(out.slice(-4000));
959
+ if (rl.isRateLimit) {
960
+ await mcpCall(mcpUrl, token, 'report_paused', { runId, resetAt: rl.resetAt }).catch(() => {});
961
+ return { outcome: 'rate_limited', resetAt: rl.resetAt, runId, title, intentId };
962
+ }
963
+ }
964
+
965
+ // Every turn after the first continues the CLI's own session where the
966
+ // runtime supports it (`--continue` / `resume --last`), so the agent keeps
967
+ // its reasoning rather than re-reading the brief cold each time.
968
+ resume = true;
969
+
970
+ const bid = blockedId(out);
971
+ if (bid) {
972
+ const res = await waitForResolution(mcpUrl, token, bid, isAlive);
973
+ if (res.status === 'resolved') {
974
+ prompt = `The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue.`;
975
+ nudges = 0;
976
+ continue;
977
+ }
978
+ if (res.status === 'timeout') return { outcome: 'parked', title, intentId };
979
+ return { outcome: 'blocked', title, intentId };
980
+ }
981
+
982
+ if (sawSentinel(out, 'DONE')) {
983
+ // Identical to the live path's completion, and it must stay identical: the
984
+ // marker clear is what stops this worktree being read as a resume of a
985
+ // task that has finished (or been discarded and restarted).
986
+ clearTaskMarker(cwd);
987
+ if (isPatch) {
988
+ await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
989
+ }
990
+ markLanded();
991
+ return { outcome: 'done', title, intentId };
992
+ }
993
+
994
+ // No sentinel: the turn ended without saying how. Before nudging, find out
995
+ // whether the RUN still exists — a restart or a release from the app tears
996
+ // it down out from under us, and nudging a dead run just burns the user's
997
+ // quota. Same three answers the live loop reads, for the same reasons.
998
+ const poll = await mcpCall(mcpUrl, token, 'poll_channel', {
999
+ runId,
1000
+ ...(afterId ? { afterId } : {}),
1001
+ }).catch(() => null);
1002
+ if (poll && poll.ok === false && poll.released) {
1003
+ return { outcome: 'released', title, intentId };
1004
+ }
1005
+ if (poll && poll.ok === false && poll.reason === 'run_not_active') {
1006
+ clearTaskMarker(cwd);
1007
+ try {
1008
+ git(['worktree', 'remove', '--force', cwd], repoRoot);
1009
+ } catch {
1010
+ resetWorktree(cwd, baseRef);
1011
+ }
1012
+ return { outcome: 'torn_down', title, intentId };
1013
+ }
1014
+
1015
+ const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
1016
+ if (fresh.length) afterId = fresh[fresh.length - 1].id;
1017
+
1018
+ if (fresh.some((f) => STOP_RE.test(f.content))) {
1019
+ held = true;
1020
+ prompt =
1021
+ 'A teammate asked you to STOP. Halt, summarize where you are in one line, and wait for direction — do not continue until told.';
1022
+ continue;
1023
+ }
1024
+ if (fresh.length) {
1025
+ prompt = fresh
1026
+ .map((f) => (f.authorName ? `${f.authorName}: ` : '') + f.content)
1027
+ .join('\n');
1028
+ nudges = 0;
1029
+ held = false;
1030
+ continue;
1031
+ }
1032
+ if (held) {
1033
+ const next = await waitForMessage(mcpUrl, token, runId, afterId, isAlive);
1034
+ if (!next) return { outcome: 'parked', title, intentId };
1035
+ held = false;
1036
+ nudges = 0;
1037
+ afterId = next.id;
1038
+ prompt = (next.authorName ? `${next.authorName}: ` : '') + next.content;
1039
+ continue;
1040
+ }
1041
+
1042
+ if (nudges < 2) {
1043
+ nudges++;
1044
+ prompt = isPatch
1045
+ ? 'Continue until the task is complete: commit your change (no branch, no push, no PR) and call complete, then print DONE. Or report a blocker and print BLOCKED:<id>.'
1046
+ : 'Continue until the task is complete: open a draft PR and call complete, then print DONE. Or report a blocker and print BLOCKED:<id>.';
1047
+ continue;
1048
+ }
1049
+ return { outcome: 'stalled', title, intentId };
1050
+ }
1051
+ }
1052
+
558
1053
  export async function runLiveTask({
559
1054
  mcpUrl,
560
1055
  token,
@@ -568,7 +1063,23 @@ export async function runLiveTask({
568
1063
  sampleDiffstat,
569
1064
  agentId,
570
1065
  }) {
571
- const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {}).catch(() => null);
1066
+ // SAY WHAT THIS WORKER CAN DRIVE. The claim is UNPINNED this worker asks for
1067
+ // whatever is next rather than for a named task — and that is deliberate (the
1068
+ // roster hint is a prediction made before anything is claimed, so pinning to it
1069
+ // would sometimes pin to the wrong task). The cost of not pinning is that the
1070
+ // server decides, and until it was told, it decided using the MACHINE's
1071
+ // capability report: on a box with Codex installed it would hand a
1072
+ // codex-addressed task to this worker, which drives the Anthropic Agent SDK
1073
+ // and nothing else, and Claude would build it. Nobody was told. The @mention is
1074
+ // the only dispatch in this product, and silently answering it with a different
1075
+ // CLI is the same class of bug as dispatching from the wrong surface.
1076
+ //
1077
+ // The list is every runtime this daemon can spawn or session, NOT just the
1078
+ // live ones — `driveSubprocess` below builds the rest. An older server ignores
1079
+ // the argument and behaves as before; that degrade is what `daemon:min` is for.
1080
+ const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {
1081
+ runtimes: DRIVABLE_HERE,
1082
+ }).catch(() => null);
572
1083
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
573
1084
  const { runId, intentId } = claim;
574
1085
  const brief = claim.brief ?? {};
@@ -762,8 +1273,16 @@ export async function runLiveTask({
762
1273
  // already told us the real intent.
763
1274
  const stopDiffstat = sampleDiffstat?.(cwd, baseRef, intentId, agentId) ?? null;
764
1275
 
765
- const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
766
- const session = query({
1276
+ // WHICH CLI builds this one, off the brief — the task we actually hold, not
1277
+ // the roster's prediction. Only Claude has a live session (it is an Anthropic
1278
+ // SDK, not a CLI contract); everything else is driven as a subprocess by
1279
+ // `driveSubprocess` below, which is what the registry's `live` flag has always
1280
+ // said would happen and what live mode never implemented.
1281
+ const rt = runtimeById(brief.agentRuntime ?? 'claude');
1282
+ const seedText = seedPrompt(runId, brief, transcript, resumedInPlace);
1283
+ const input = rt.live ? makeInput(seedText) : null;
1284
+ const session = rt.live
1285
+ ? query({
767
1286
  prompt: input.stream(),
768
1287
  options: {
769
1288
  cwd,
@@ -791,26 +1310,32 @@ export async function runLiveTask({
791
1310
  },
792
1311
  },
793
1312
  },
794
- });
1313
+ })
1314
+ : null;
795
1315
 
796
1316
  // Mark this worker BUSY for the daemon's reconcile loop: buildHave keeps the
797
1317
  // worker's token while a session is live (never rotate a credential out from
798
1318
  // under it), and teardown/agent-removal can interrupt the SDK session via this
799
1319
  // marker's kill(). Cleared in finally. Mirrors poll mode's onChild(child).
800
- onChild?.({
801
- kill: () => {
802
- try {
803
- session.interrupt?.();
804
- } catch {
805
- /* already ending */
806
- }
807
- try {
808
- session.return?.();
809
- } catch {
810
- /* already closed */
811
- }
812
- },
813
- });
1320
+ // The subprocess driver registers its own handle per turn (runTurn's onSpawn),
1321
+ // because there the killable thing is a child process and it only exists while
1322
+ // a turn is actually running.
1323
+ if (session) {
1324
+ onChild?.({
1325
+ kill: () => {
1326
+ try {
1327
+ session.interrupt?.();
1328
+ } catch {
1329
+ /* already ending */
1330
+ }
1331
+ try {
1332
+ session.return?.();
1333
+ } catch {
1334
+ /* already closed */
1335
+ }
1336
+ },
1337
+ });
1338
+ }
814
1339
 
815
1340
  let turnId = null;
816
1341
  let turnText = '';
@@ -850,6 +1375,65 @@ export async function runLiveTask({
850
1375
  };
851
1376
 
852
1377
  try {
1378
+ // THE SEAM. Everything above prepared this task — the claim, the checkout,
1379
+ // the branch or patch base, the restored work in progress, the checkpoint
1380
+ // timer and the diffstat sampler — and everything in the `finally` below
1381
+ // tears it down. Only the middle differs by runtime, so only the middle
1382
+ // branches, and a non-live runtime inherits the other two thirds unchanged.
1383
+ if (!session && mediated(rt)) {
1384
+ // No MCP config this runtime can hold, so it is handed none: the daemon
1385
+ // makes every control-plane call itself with this lane's own token.
1386
+ return await driveMediated({
1387
+ runtimeId: rt.id,
1388
+ mcpUrl,
1389
+ token,
1390
+ runId,
1391
+ intentId,
1392
+ title,
1393
+ cwd,
1394
+ brief,
1395
+ isPatch,
1396
+ patchBase,
1397
+ repoRoot,
1398
+ baseRef,
1399
+ label: `[${rt.label}]`,
1400
+ seedText,
1401
+ isAlive,
1402
+ onChild,
1403
+ markLanded: () => {
1404
+ landed = true;
1405
+ },
1406
+ });
1407
+ }
1408
+
1409
+ if (!session) {
1410
+ return await driveSubprocess({
1411
+ runtimeId: rt.id,
1412
+ mcpUrl,
1413
+ token,
1414
+ runId,
1415
+ intentId,
1416
+ title,
1417
+ cwd,
1418
+ brief,
1419
+ isPatch,
1420
+ patchBase,
1421
+ repoRoot,
1422
+ baseRef,
1423
+ label: `[${rt.label}]`,
1424
+ seedText,
1425
+ afterId,
1426
+ isAlive,
1427
+ onChild,
1428
+ // `landed` decides whether the finally deletes this task's WIP ref or
1429
+ // takes one last checkpoint, so the driver has to be able to set it —
1430
+ // returning it would be too late, the finally runs first.
1431
+ markLanded: () => {
1432
+ landed = true;
1433
+ },
1434
+ });
1435
+ }
1436
+
853
1437
  for await (const m of session) {
854
1438
  if (!isAlive()) return { outcome: 'blocked', title, intentId };
855
1439
  beat(); // any session traffic = alive (throttled to 1/min)
@@ -1008,14 +1592,17 @@ export async function runLiveTask({
1008
1592
  }
1009
1593
  onChild?.(null); // no longer busy — token may rotate between tasks
1010
1594
  onIntent?.(null);
1011
- input.close();
1595
+ // Only a live session has a streaming input to close or a generator to
1596
+ // return. The subprocess driver's children are already gone — runTurn awaits
1597
+ // each one — and it clears its own onChild handle per turn.
1598
+ input?.close();
1012
1599
  try {
1013
- await session.interrupt?.();
1600
+ await session?.interrupt?.();
1014
1601
  } catch {
1015
1602
  /* session already ended */
1016
1603
  }
1017
1604
  try {
1018
- await session.return?.();
1605
+ await session?.return?.();
1019
1606
  } catch {
1020
1607
  /* generator already closed */
1021
1608
  }