@azure-id/orc 0.55.2 → 0.56.1

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/cli.js CHANGED
@@ -22,11 +22,19 @@ const ui = require("./ui.js");
22
22
  const { SECTIONS: ONBOARDING } = require("./onboarding-content.js");
23
23
 
24
24
  // Where `orc upgrade` fetches a fresh package from. Override with --from <spec>
25
- // or ORC_INSTALL_SPEC (e.g. a fork, a tarball URL, or "orc" for the npm registry
26
- // once published). By default the tarball is tried FIRST (straight HTTPS, works
27
- // everywhere), the github: spec second the github: spec shells out to git and
28
- // fails on machines with restricted git / NVM quirks, so leading with it burnt a
29
- // guaranteed failure + npm error wall on every upgrade.
25
+ // or ORC_INSTALL_SPEC (e.g. a fork or a tarball URL).
26
+ //
27
+ // ORDER (v0.56.0): the npm REGISTRY first, then the tarball (straight HTTPS),
28
+ // then the github: spec last. The registry is the published home of this
29
+ // package and is the only source that resolves a VERSION rather than a branch
30
+ // tip; the github: spec shells out to git and fails on machines with restricted
31
+ // git / NVM quirks, so it stays last.
32
+ //
33
+ // PKG_NAME is this package's published name. It is NOT cosmetic: it moved from
34
+ // the unscoped `orc` to the scoped `@azure-id/orc`, and that rename is what
35
+ // broke every upgrade path in the field — see LEGACY_BIN_OWNERS below.
36
+ const PKG_NAME = "@azure-id/orc";
37
+ const NPM_SPEC = PKG_NAME;
30
38
  const GITHUB_SPEC = "github:azure-id/orc";
31
39
 
32
40
  const PKG_ROOT = path.join(__dirname, "..");
@@ -655,12 +663,109 @@ function targetFlags() {
655
663
  const TARBALL_SPEC =
656
664
  "https://github.com/azure-id/orc/archive/refs/heads/main.tar.gz";
657
665
 
666
+ // ---------------------------------------------------------------------------
667
+ // The bin-shim collision (v0.56.0) — why every upgrade path died at once.
668
+ //
669
+ // This package was published as the unscoped `orc` and is now `@azure-id/orc`.
670
+ // Both declare the SAME bin name, `orc`. npm links a bin only if the shim is
671
+ // unowned or owned by the package doing the linking, so with the old `orc`
672
+ // package still on disk globally, installing the new scoped one fails with:
673
+ //
674
+ // npm error code EEXIST
675
+ // npm error path <prefix>\orc
676
+ // npm error File exists: <prefix>\orc
677
+ //
678
+ // That error is about a FILE, not a source — which is why swapping sources did
679
+ // nothing: the tarball, the github: spec and the registry all failed
680
+ // identically, and `orc upgrade`'s fallback ladder walked all three and then
681
+ // printed npm's wall. `npm i -g -f <spec>` "worked" only because --force
682
+ // overwrites the shim recklessly, leaving the superseded package installed
683
+ // underneath as a ghost that owns nothing and is never updated again.
684
+ //
685
+ // The fix is to remove the package that OWNS the shim before installing, not to
686
+ // force over it. Detection is by ownership, never by name alone: we look for a
687
+ // globally-installed package that is not us and whose `bin` declares `orc`.
688
+ // ---------------------------------------------------------------------------
689
+
690
+ // Names ORC has shipped under. Ordered oldest-first; PKG_NAME is excluded on
691
+ // purpose — evicting ourselves is how an upgrade uninstalls the tool.
692
+ const LEGACY_BIN_OWNERS = ["orc"];
693
+
694
+ // `npm root -g`, cached for the life of the process (it shells out).
695
+ let _npmRootG;
696
+ function npmRootGlobal() {
697
+ if (_npmRootG !== undefined) return _npmRootG;
698
+ const r = spawnSync("npm root -g", { shell: true, encoding: "utf8" });
699
+ _npmRootG = r.status === 0 && r.stdout ? r.stdout.trim() : null;
700
+ return _npmRootG;
701
+ }
702
+
703
+ // Read <global node_modules>/<name>/package.json, or null.
704
+ function globalPkgManifest(name) {
705
+ const root = npmRootGlobal();
706
+ if (!root) return null;
707
+ try {
708
+ const f = path.join(root, ...name.split("/"), "package.json");
709
+ return JSON.parse(fs.readFileSync(f, "utf8"));
710
+ } catch (_) {
711
+ return null;
712
+ }
713
+ }
714
+
715
+ // Any globally-installed package that is NOT this one but declares the `orc`
716
+ // bin — i.e. the thing holding the shim hostage. Returns {name, version, dir}
717
+ // or null. Fail-silent: if npm cannot be asked, we simply do not know, and
718
+ // "unknown" must never be reported as "clean".
719
+ function detectLegacyBinOwner() {
720
+ for (const name of LEGACY_BIN_OWNERS) {
721
+ if (name === PKG_NAME) continue;
722
+ const m = globalPkgManifest(name);
723
+ if (!m) continue;
724
+ // Same name AND same identity is not legacy — a package can legitimately
725
+ // still be called `orc` on a machine that never saw the rename land.
726
+ if (m.name === PKG_NAME) continue;
727
+ const bins = typeof m.bin === "string" ? { [m.name]: m.bin } : m.bin || {};
728
+ if (!Object.prototype.hasOwnProperty.call(bins, "orc")) continue;
729
+ return {
730
+ name,
731
+ version: m.version || "unknown",
732
+ dir: path.join(npmRootGlobal() || "", ...name.split("/")),
733
+ };
734
+ }
735
+ return null;
736
+ }
737
+
738
+ // Remove the legacy owner so the scoped package can link `orc` cleanly.
739
+ // Returns { ok, output }. This is the ONLY global npm mutation ORC makes on the
740
+ // user's behalf, and it is announced before it runs.
741
+ function evictLegacyBinOwner(owner) {
742
+ console.log(
743
+ ` → npm uninstall -g ${owner.name} (legacy ${owner.name}@${owner.version} owns the \`orc\` command)`
744
+ );
745
+ const r = spawnSync(`npm uninstall -g ${owner.name}`, { shell: true, encoding: "utf8" });
746
+ return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") };
747
+ }
748
+
749
+ // Does npm's output describe the bin-shim collision? Matching on the CODE plus
750
+ // the shim path keeps this from firing on an unrelated EEXIST deeper in a tree.
751
+ function isBinShimCollision(output) {
752
+ const o = String(output || "");
753
+ if (!/EEXIST/.test(o)) return false;
754
+ // Normalise separators first so the check needs no backslash character
755
+ // class, then require a path component that IS the bin name (plus the two
756
+ // Windows shim extensions). Matching the code alone would fire on an
757
+ // unrelated EEXIST deeper in a dependency tree.
758
+ const norm = o.split(String.fromCharCode(92)).join("/");
759
+ return new RegExp("/orc([.]cmd|[.]ps1)?([^A-Za-z0-9_-]|$)", "im").test(norm);
760
+ }
761
+
658
762
  // Try `npm install -g <spec>`; return { ok, output }. Captures stdio (pipe)
659
763
  // instead of inheriting it, so a failed probe with a remaining fallback stays
660
764
  // quiet — the loud npm error wall is only shown if EVERY spec fails.
661
- function npmInstallGlobal(spec) {
662
- console.log(" → npm install -g " + spec);
663
- const r = spawnSync(`npm install -g ${spec}`, { shell: true, encoding: "utf8" });
765
+ function npmInstallGlobal(spec, opts) {
766
+ const force = opts && opts.force ? " --force" : "";
767
+ console.log(" → npm install -g " + spec + force);
768
+ const r = spawnSync(`npm install -g ${spec}${force}`, { shell: true, encoding: "utf8" });
664
769
  return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") };
665
770
  }
666
771
 
@@ -688,11 +793,25 @@ function writeLastGoodSpec(spec) {
688
793
  // code regardless of how PATH resolves `orc` (important under NVM, where the
689
794
  // running shim and the global prefix can differ). Falls back to null if the path
690
795
  // can't be determined — the caller then spawns `orc` by name.
796
+ //
797
+ // v0.56.0: the SCOPED directory is checked first. This looked only under
798
+ // `<root>/orc`, which after the rename is the LEGACY package — so on a machine
799
+ // mid-rename it resolved a path that existed, and step 2 re-applied the OLD
800
+ // templates from the very package step 1 had just superseded. A hit is accepted
801
+ // only if the manifest there actually says PKG_NAME; a directory that exists is
802
+ // not proof of identity.
691
803
  function freshCliPath() {
692
- const r = spawnSync("npm root -g", { shell: true, encoding: "utf8" });
693
- if (r.status !== 0 || !r.stdout) return null;
694
- const p = path.join(r.stdout.trim(), "orc", "bin", "cli.js");
695
- return fs.existsSync(p) ? p : null;
804
+ const root = npmRootGlobal();
805
+ if (!root) return null;
806
+ for (const name of [PKG_NAME, ...LEGACY_BIN_OWNERS]) {
807
+ const dir = path.join(root, ...name.split("/"));
808
+ const cli = path.join(dir, "bin", "cli.js");
809
+ if (!fs.existsSync(cli)) continue;
810
+ const m = globalPkgManifest(name);
811
+ if (m && m.name !== PKG_NAME) continue;
812
+ return cli;
813
+ }
814
+ return null;
696
815
  }
697
816
 
698
817
  // `orc upgrade` = fetch the latest package from the source, THEN apply it.
@@ -706,25 +825,60 @@ function upgrade() {
706
825
  const fromFlag = typeof flag("--from") === "string" ? flag("--from") : null;
707
826
  // Specs to try in order. `--from` and ORC_INSTALL_SPEC still win OUTRIGHT
708
827
  // (single spec, no fallback). Otherwise: the remembered last_good_spec first
709
- // (if any), then the tarball (straight HTTPS — works everywhere), then the
710
- // github: spec last. Deduped so a remembered tarball doesn't retry twice.
828
+ // (if any), then the npm REGISTRY, then the tarball (straight HTTPS), then
829
+ // the github: spec last. Deduped so a remembered spec doesn't retry twice.
711
830
  let specs;
712
831
  if (fromFlag) specs = [fromFlag];
713
832
  else if (process.env.ORC_INSTALL_SPEC) specs = [process.env.ORC_INSTALL_SPEC];
714
833
  else {
715
834
  const remembered = readLastGoodSpec();
716
- specs = [...new Set([...(remembered ? [remembered] : []), TARBALL_SPEC, GITHUB_SPEC])];
835
+ specs = [...new Set([...(remembered ? [remembered] : []), NPM_SPEC, TARBALL_SPEC, GITHUB_SPEC])];
717
836
  }
718
837
 
719
838
  console.log("\norc upgrade — fetching the latest package, then applying it.");
720
839
  console.log(" step 1/2: refresh the global orc package");
721
840
 
841
+ // STEP 0 — evict the legacy bin owner. This runs BEFORE any source is tried,
842
+ // because the collision is on the `orc` shim and therefore fails every source
843
+ // identically: walking the whole ladder first would just spend three network
844
+ // round trips arriving at the same EEXIST. Announced, never silent — it is a
845
+ // global npm mutation on the user's machine, and the one ORC makes for them.
846
+ const legacy = detectLegacyBinOwner();
847
+ if (legacy) {
848
+ console.log(
849
+ `\n ⚠ ${legacy.name}@${legacy.version} is installed globally and owns the \`orc\` command.\n` +
850
+ ` This package is now ${PKG_NAME}, so npm cannot link \`orc\` while that one\n` +
851
+ " is there — every install source fails with the same EEXIST.\n" +
852
+ " Removing it first."
853
+ );
854
+ const ev = evictLegacyBinOwner(legacy);
855
+ if (!ev.ok) {
856
+ console.log(
857
+ " ⚠ couldn't remove it automatically — continuing anyway (the install\n" +
858
+ " below falls back to --force if npm still reports the collision)."
859
+ );
860
+ }
861
+ }
862
+
722
863
  let installed = false;
723
864
  let lastOutput = "";
865
+ let usedSpec = null;
724
866
  for (let i = 0; i < specs.length; i++) {
725
- const res = npmInstallGlobal(specs[i]);
867
+ let res = npmInstallGlobal(specs[i]);
868
+ // A SURVIVING shim collision means an ORPHANED shim — a file npm left
869
+ // behind with no package owning it, so there was nothing for step 0 to
870
+ // uninstall. --force is correct HERE and only here: the file it overwrites
871
+ // belongs to nobody, which is exactly the case --force is for.
872
+ if (!res.ok && isBinShimCollision(res.output)) {
873
+ console.log(
874
+ " ⚠ npm still reports the `orc` command file as taken, and no package\n" +
875
+ " claims it (an orphaned shim). Overwriting just that file."
876
+ );
877
+ res = npmInstallGlobal(specs[i], { force: true });
878
+ }
726
879
  if (res.ok) {
727
880
  installed = true;
881
+ usedSpec = specs[i];
728
882
  writeLastGoodSpec(specs[i]);
729
883
  break;
730
884
  }
@@ -742,14 +896,20 @@ function upgrade() {
742
896
  }
743
897
  console.error(
744
898
  "\n❌ upgrade failed at step 1 (npm install). Nothing was changed in .claude/.\n" +
745
- " Try the tarball bypass directly, then apply:\n" +
746
- ` npm i -g ${TARBALL_SPEC}\n` +
899
+ " Install directly, then apply:\n" +
900
+ ` npm i -g ${NPM_SPEC}\n` +
747
901
  " orc update" +
748
902
  (tflags.length ? " " + tflags.join(" ") : "") +
749
- "\n"
903
+ "\n" +
904
+ (isBinShimCollision(lastOutput)
905
+ ? "\n npm says the `orc` command file is already taken. Clear it, then retry:\n" +
906
+ ` npm uninstall -g ${LEGACY_BIN_OWNERS.join(" ")}\n` +
907
+ ` npm i -g ${NPM_SPEC}\n`
908
+ : "")
750
909
  );
751
910
  process.exit(1);
752
911
  }
912
+ if (usedSpec) console.log(` ✓ installed from ${usedSpec}`);
753
913
 
754
914
  const tflags = targetFlags();
755
915
  console.log(
@@ -768,7 +928,7 @@ function upgrade() {
768
928
  " orc update" +
769
929
  (tflags.length ? " " + tflags.join(" ") : "") +
770
930
  "\n If it still fails, install directly then apply:\n" +
771
- ` npm i -g ${TARBALL_SPEC} && orc update` +
931
+ ` npm i -g ${NPM_SPEC} && orc update` +
772
932
  (tflags.length ? " " + tflags.join(" ") : "") +
773
933
  "\n"
774
934
  );
@@ -830,6 +990,22 @@ const vModel = tag((raw) => {
830
990
  : "⚠ below Opus/Fable — every opus-* agent silently falls back to a smaller model (tier ladder).";
831
991
  return { value: raw, warn };
832
992
  }, { kind: "enum", choices: KNOWN_MODELS });
993
+ // The fallback target for a foreign dispatch that failed. Two WORDS and then a
994
+ // free-text agent name — deliberately open rather than a closed enum, because
995
+ // the agent roster is generated (`bin/build-agents.js`) and a closed list here
996
+ // would go stale the next time a band moves. `orc-` is required so a typo lands
997
+ // as a refusal rather than as a dispatch to a name nothing answers to.
998
+ const vFallbackAgent = tag((raw) => {
999
+ const v = String(raw || "").trim();
1000
+ if (v === "band" || v === "ask") return { value: v };
1001
+ if (!/^orc-[a-z0-9-]+$/.test(v))
1002
+ return { err: 'must be "band", "ask", or an ORC agent name (e.g. orc-executor-opus-5-med)' };
1003
+ return {
1004
+ value: v,
1005
+ warn:
1006
+ "pinned to one agent — this overrides the score table AND a slot's own pinned agent for every fallback, whatever the task. `band` is what keeps a fallback a change of WHO and not a change of tier.",
1007
+ };
1008
+ }, { kind: "text", choices: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"] });
833
1009
  const vPath = tag(
834
1010
  (raw) => (raw && raw.trim() ? { value: raw } : { err: "must be a non-empty path" }),
835
1011
  { kind: "path" }
@@ -937,6 +1113,14 @@ const CONFIG_META = [
937
1113
  // reasoning verbatim: a record you can switch off is off on the run you
938
1114
  // needed it for), and a key for the network probe.
939
1115
  { key: "extra_resume", def: "on", tier: "common", validate: vEnum("on", "off"), options: ["on", "off"], desc: "Whether a partial or crashed foreign dispatch is RESUMED rather than re-done. A worker cut off mid-write leaves a half-changed repository, and re-dispatching the SAME slice lands a fresh executor on a file that is already two-thirds written — it either discards work you paid for or improvises against a stale mental model. `on` reconciles the worktree against the journal baseline and composes a resume slice that says what is already there. Default `on`, because `off` is what is broken. INERT in /orc-quick, which asks which agent before every dispatch." },
1116
+ // --- v0.56.1 — the stall. ELEVEN keys became THIRTEEN, and both additions
1117
+ // come from the same observed failure: a foreign worker that goes quiet
1118
+ // mid-task. Deliberately NOT added: a key to nudge the child on its stdin
1119
+ // (`opencode run` is not an interactive session, so a keystroke nobody reads
1120
+ // is a fake fix), a per-profile stall budget (the number describes ORC's
1121
+ // patience, not a provider), and a key to disable the stall report.
1122
+ { key: "extra_stall_s", def: 180, tier: "common", validate: vInt(0), options: [0, 60, 120, 180, 300, 600], desc: "Seconds a foreign worker may produce NOTHING before the dispatch is stopped as `stalled`. The clock is reset by observable progress — new bytes on the worker's stream, new bytes on stderr, or a declared file that changed on disk — so it never fires on a worker that is merely slow. This is what a wall clock cannot see: `extra_timeout_s` measures the whole dispatch, and an opencode that stops mid-task and waits for someone to type `continue` burns all 900 seconds looking like a timeout. A `stalled` dispatch is RETRYABLE, so `extra_resume` continues it from what is already on disk rather than starting over. 0 turns it off and the wall clock is the only stop again. Clamped below the wall clock, because a budget that can never fire is worse than none. Engine `cli` only — engine `api` already has a per-request inactivity timeout on its own socket." },
1123
+ { key: "extra_fallback_agent", def: "band", tier: "common", validate: vFallbackAgent, options: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"], desc: "WHICH Claude agent picks up a task the foreign worker could not finish. `band` is the pre-v0.56.1 behaviour and stays the default: the exact agent the score table or the slot would have used, so a fallback changes WHO runs it and nothing else. `ask` STOPS and puts the choice to you with the failure and the position already on the table — right when a stall has just cost you fifteen minutes and you would rather pick than accept a default. Any installed agent name is accepted verbatim, for the case where you already know a stalled slice wants more (or less) than its band. It never changes the score, never widens `declared_files` and never moves `acceptance[]` — a fallback is not a re-plan." },
940
1124
  { key: "extra_resume_max", def: 2, tier: "advanced", validate: vInt(0), desc: "Resume attempts per task before the fallback procedure takes over. Bounded like `tdd_loop_max`: hitting the cap STOPS with an honest report naming the Claude agent, never a silent third loop. A resume never widens `declared_files`, never moves `acceptance[]` and never moves the score — it is a continuation, not a discount." },
941
1125
  { key: "opus5_only", def: false, tier: "common", validate: vEnum("true", "false"), options: ["true", "false"], desc: "EVERY dispatched role uses ONE model — Opus 5 — with EFFORT as the cost dial (executors: [0,40) low · [40,80) medium · [80,100] high; each fixed role its own pinned effort). Deep SWE-benchmark work on cost vs efficiency across Claude models finds a single Opus 5 agent with the effort ladder the most efficient setup. It FORCES: while on it outranks fable5_* and a hand-written rubric_bands_override. Needs an Opus 5 main session or EVERY dispatch silently downgrades. Excludes the Haiku trace writer and orc-diy (compile-owned)." },
942
1126
  // --- v0.46.0 — the six new lanes ------------------------------------------
@@ -19711,6 +19895,202 @@ function maybeStorePendingKey(claudeDir, ledger, prof, pending, pendingPass, asJ
19711
19895
  };
19712
19896
  }
19713
19897
 
19898
+ // ── orc extra health — DOES THIS MODEL STALL? (v0.56.1) ────────────────────
19899
+ //
19900
+ // `orc extra ping --live` answers "did something come back". It cannot answer
19901
+ // the question a stall raises, which is "what was it DOING for those fifteen
19902
+ // minutes" — because a synchronous probe has no view of the child while it
19903
+ // runs, exactly the blindness runCliChild was built to remove.
19904
+ //
19905
+ // So health is the live probe run through THE SAME WATCHDOG A DISPATCH USES.
19906
+ // That matters: it is not a second idea of the path (the v0.53.3 rule — a green
19907
+ // badge must be earned by the path a wave actually runs), it is the dispatch's
19908
+ // own observer pointed at a one-line prompt. What it reports is a TIMELINE:
19909
+ // when the first byte arrived, the longest silence, and whether it ended by
19910
+ // answering, by stalling, or by running out of wall clock.
19911
+ //
19912
+ // Exit codes: 0 answered · 1 stalled or failed · 2 unknown profile.
19913
+ async function extraHealth(claudeDir, name) {
19914
+ const asJson = wantsJson();
19915
+ const ledger = readExtra(claudeDir) || emptyExtra();
19916
+ const cfg = resolvedConfig(claudeDir);
19917
+ const prof = extraProfile(ledger, name);
19918
+ const fail = (reason, error, code) => {
19919
+ if (asJson)
19920
+ console.log(JSON.stringify({ ok: false, command: "extra health", profile: name || null, reason, error }, null, 2));
19921
+ else console.error(`❌ ${error}`);
19922
+ process.exit(code);
19923
+ };
19924
+ if (!prof)
19925
+ return fail(
19926
+ "unknown-profile",
19927
+ name ? `no profile called "${name}". \`orc extra list\` has the names.` : "usage: orc extra health <profile> [--model <id>]",
19928
+ 2
19929
+ );
19930
+ if (prof.engine !== "cli")
19931
+ return fail(
19932
+ "engine-unsupported",
19933
+ "`orc extra health` measures the STALL clock, and that clock only exists on engine `cli` — engine `" +
19934
+ prof.engine +
19935
+ "` has a per-request inactivity timeout on its own socket, which `orc extra ping " +
19936
+ prof.name +
19937
+ " --live` already exercises.",
19938
+ 1
19939
+ );
19940
+
19941
+ const cat = readCatalog();
19942
+ const row = catalogRow(cat, prof.provider) || {};
19943
+ const bin = (prof.cli && prof.cli.bin) || row.cli_bin || null;
19944
+ const adapter = (bin && EXTRA_CLI_ADAPTERS[bin]) || null;
19945
+ const found = bin ? whichBin(bin) : null;
19946
+ if (!adapter || !found)
19947
+ return fail(
19948
+ "engine-unavailable",
19949
+ "`" + (bin || "(no binary configured)") + "` is not on PATH, or ORC ships no adapter for it.",
19950
+ 1
19951
+ );
19952
+
19953
+ const modelArg = flag("--model");
19954
+ const model = typeof modelArg === "string" ? modelArg : prof.default_model || (prof.models_seen || [])[0] || null;
19955
+ if (!model)
19956
+ return fail(
19957
+ "no-model",
19958
+ `no model to probe. Name one: \`orc extra health ${prof.name} --model <id>\` (\`orc extra models ${prof.name}\` lists what the provider offers).`,
19959
+ 1
19960
+ );
19961
+
19962
+ // The credential goes into the CHILD'S ENVIRONMENT and nowhere else, exactly
19963
+ // as a dispatch does it. A tool that holds its own key gets nothing and still
19964
+ // authenticates.
19965
+ const cred = extraCredentialValue(claudeDir, prof, { ambientKey: process.env.ORC_EXTRA_KEY || null });
19966
+ const env = {};
19967
+ if (cred.ok && cred.value) {
19968
+ const credRow = row.credential || {};
19969
+ if (credRow.env_var) env[credRow.env_var] = cred.value;
19970
+ const kn = (prof.credential || {}).key_name;
19971
+ if (kn) env[kn] = cred.value;
19972
+ }
19973
+
19974
+ const timeouts = extraTimeouts(cfg);
19975
+ let scratch = null;
19976
+ try {
19977
+ scratch = fs.mkdtempSync(path.join(os.tmpdir(), "orc-extra-health-"));
19978
+ } catch (e) {
19979
+ return fail("scratch-failed", String((e && e.message) || e), 1);
19980
+ }
19981
+ let version = null;
19982
+ if (row.version_cmd) {
19983
+ const v = runToolCmd(found, row.version_cmd, env);
19984
+ version = parseVersion(v.stdout + "\n" + v.stderr);
19985
+ }
19986
+ const argv = adapter.probeArgv({ model, dir: scratch, prompt: EXTRA_LIVE_PROMPT, version });
19987
+ const started = Date.now();
19988
+ const r = await runCliChild(
19989
+ found,
19990
+ argv,
19991
+ { cwd: scratch, env: Object.assign({}, process.env, env), windowsHide: true, stdio: ["ignore", "pipe", "pipe"] },
19992
+ { started, wall_ms: timeouts.wall_ms, stall_ms: timeouts.stall_ms, progressFile: null, root: scratch, declared: [] }
19993
+ );
19994
+ const total = Date.now() - started;
19995
+ let parsed = null;
19996
+ try {
19997
+ parsed = adapter.parse({ stdout: r.stdout || "", stderr: r.stderr || "", lastMessage: null });
19998
+ } catch (_) {}
19999
+ try {
20000
+ fs.rmSync(scratch, { recursive: true, force: true });
20001
+ } catch (_) {}
20002
+
20003
+ const verdict =
20004
+ r.stopped_by === "stall"
20005
+ ? "stalled"
20006
+ : r.stopped_by === "wall"
20007
+ ? "timeout"
20008
+ : r.error
20009
+ ? "spawn-failed"
20010
+ : r.status === 0
20011
+ ? "answered"
20012
+ : "failed";
20013
+ const text = (parsed && parsed.text) || String(r.stdout || "").trim() || null;
20014
+ const out = {
20015
+ ok: verdict === "answered",
20016
+ command: "extra health",
20017
+ profile: prof.name,
20018
+ provider: prof.provider,
20019
+ engine: "cli",
20020
+ adapter: bin,
20021
+ model,
20022
+ verdict,
20023
+ exit_code: r.status,
20024
+ credential: { source: cred.source || null, ok: !!cred.ok },
20025
+ timeline: {
20026
+ total_ms: total,
20027
+ first_byte_ms: r.first_byte_ms,
20028
+ last_progress_ms: r.last_progress_ms,
20029
+ longest_gap_ms: r.longest_gap_ms,
20030
+ quiet_for_ms: r.stall_ms,
20031
+ stall_budget_ms: timeouts.stall_ms,
20032
+ wall_budget_ms: timeouts.wall_ms,
20033
+ stall_budget_clamped: !!timeouts.stall_clamped,
20034
+ },
20035
+ // WHAT IT WAS DOING — the bytes, capped, never interpreted. On engine `cli`
20036
+ // ORC did not compose the request and does not parse the tool's turns, so
20037
+ // this is the log and nothing more (`journal_fidelity: streamed-opaque`).
20038
+ log_excerpt: text ? String(text).slice(0, EXTRA_LIVE_EXCERPT_MAX) : null,
20039
+ log_truncated: !!(text && String(text).length > EXTRA_LIVE_EXCERPT_MAX),
20040
+ stderr_excerpt: String(r.stderr || "").trim().slice(0, EXTRA_LIVE_EXCERPT_MAX) || null,
20041
+ events: (parsed && parsed.events) || 0,
20042
+ tokens: (parsed && parsed.usage) || null,
20043
+ foreign_reply_note: EXTRA_FOREIGN_REPLY_NOTE,
20044
+ cost_note: EXTRA_LIVE_COST_CLI,
20045
+ };
20046
+ if (asJson) {
20047
+ console.log(JSON.stringify(out, null, 2));
20048
+ process.exit(out.ok ? 0 : 1);
20049
+ }
20050
+ const sec = (ms) => (ms === null || ms === undefined ? "—" : `${(ms / 1000).toFixed(1)}s`);
20051
+ console.log("");
20052
+ console.log(ui.color.bold(`ORC · extra — health · ${prof.name} · ${model}`));
20053
+ console.log("─".repeat(40));
20054
+ console.log("");
20055
+ const mark = out.ok ? ui.color.green("✔") : ui.color.red("✖");
20056
+ console.log(` ${mark} ${verdict}${out.exit_code === null ? "" : ui.color.gray(` exit ${out.exit_code}`)}`);
20057
+ console.log("");
20058
+ console.log(` first byte ${sec(out.timeline.first_byte_ms)}`);
20059
+ console.log(` last progress ${sec(out.timeline.last_progress_ms)}`);
20060
+ console.log(` longest quiet gap ${sec(out.timeline.longest_gap_ms)}`);
20061
+ console.log(` total ${sec(out.timeline.total_ms)}`);
20062
+ console.log(
20063
+ ` stall budget ${
20064
+ out.timeline.stall_budget_ms ? sec(out.timeline.stall_budget_ms) : ui.color.gray("off (extra_stall_s = 0)")
20065
+ }` + (out.timeline.stall_budget_clamped ? ui.color.yellow(" ← clamped under the wall clock") : "")
20066
+ );
20067
+ if (verdict === "stalled") {
20068
+ console.log("");
20069
+ console.log(
20070
+ ui.color.yellow(
20071
+ ` This model went quiet for ${sec(out.timeline.quiet_for_ms)} and was still running. In a wave\n` +
20072
+ " that is a `stalled` dispatch: retryable, so `extra_resume` continues it from what\n" +
20073
+ " is on disk rather than starting over."
20074
+ )
20075
+ );
20076
+ }
20077
+ if (out.log_excerpt) {
20078
+ console.log("");
20079
+ console.log(ui.color.gray(" what it said"));
20080
+ for (const line of out.log_excerpt.split(/\r?\n/).slice(0, 12)) console.log(" " + line);
20081
+ if (out.log_truncated) console.log(ui.color.gray(" …"));
20082
+ console.log("");
20083
+ console.log(ui.color.gray(" " + EXTRA_FOREIGN_REPLY_NOTE));
20084
+ } else if (out.stderr_excerpt) {
20085
+ console.log("");
20086
+ console.log(ui.color.gray(" stderr"));
20087
+ for (const line of out.stderr_excerpt.split(/\r?\n/).slice(0, 8)) console.log(" " + line);
20088
+ }
20089
+ console.log("");
20090
+ console.log(ui.color.gray(" " + EXTRA_LIVE_COST_CLI));
20091
+ process.exit(out.ok ? 0 : 1);
20092
+ }
20093
+
19714
20094
  function extraPingRender(res) {
19715
20095
  if (res.ok) {
19716
20096
  console.log(
@@ -21738,6 +22118,180 @@ const EXTRA_TOOLS_ALLOWED = new Set([
21738
22118
  //
21739
22119
  // Engine B needs exactly the same protection (`codex` is an npm shim too), which
21740
22120
  // is why this is named for what it does rather than for its first caller.
22121
+ // ── The stall watchdog (v0.56.1) ───────────────────────────────────────────
22122
+ //
22123
+ // `spawnSync` cannot have one. It blocks the event loop for the whole dispatch,
22124
+ // so nothing in this process can observe the child while it runs — which is why
22125
+ // a stalled worker could only ever be discovered by the wall clock, fifteen
22126
+ // minutes later, wearing the wrong name (`timeout`).
22127
+ //
22128
+ // So engine `cli` spawns ASYNCHRONOUSLY and something watches. What it watches
22129
+ // is deliberately not "is the process alive" — a stalled opencode is extremely
22130
+ // alive. It watches for OBSERVABLE PROGRESS, in the three places progress can
22131
+ // show up:
22132
+ //
22133
+ // 1. the child's stdout, which is redirected onto the journal's progress file
22134
+ // (so the measurement is a `stat`, not a buffer this process holds),
22135
+ // 2. the child's stderr, which stays a pipe and is counted as it arrives,
22136
+ // 3. the DECLARED FILES on disk — a worker can think for four minutes and
22137
+ // write nothing, but a worker that just wrote a file is working, whatever
22138
+ // its stream is doing.
22139
+ //
22140
+ // Any one of the three resets the clock. All three quiet for the whole stall
22141
+ // budget is the finding.
22142
+ function spawnCmdParts(bin, argv) {
22143
+ const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
22144
+ if (!needsShell) return { cmd: bin, args: argv, shell: false };
22145
+ const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
22146
+ return { cmd: q(bin) + " " + argv.map(q).join(" "), args: [], shell: true };
22147
+ }
22148
+
22149
+ // A .cmd shim on Windows is `cmd.exe` with the real tool as a GRANDCHILD, and
22150
+ // killing cmd.exe leaves the tool running against the same repository the next
22151
+ // attempt is about to resume into. `taskkill /T` is the only thing that takes
22152
+ // the tree. On POSIX the child is spawned into its own process group and the
22153
+ // group is signalled.
22154
+ function killProcessTree(child) {
22155
+ if (!child || child.pid === undefined || child.exitCode !== null) return;
22156
+ if (process.platform === "win32") {
22157
+ try {
22158
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
22159
+ return;
22160
+ } catch (_) {}
22161
+ }
22162
+ try {
22163
+ process.kill(-child.pid, "SIGTERM");
22164
+ } catch (_) {
22165
+ try {
22166
+ child.kill("SIGTERM");
22167
+ } catch (_) {}
22168
+ }
22169
+ }
22170
+
22171
+ // The size of every declared file, plus its mtime. Cheap enough to run every
22172
+ // few seconds, and it is the ONE progress signal that does not depend on the
22173
+ // worker choosing to say anything.
22174
+ function declaredFilesFingerprint(root, declared) {
22175
+ let acc = "";
22176
+ for (const rel of declared || []) {
22177
+ try {
22178
+ const st = fs.statSync(path.resolve(root, rel));
22179
+ acc += `${rel}:${st.size}:${st.mtimeMs};`;
22180
+ } catch (_) {
22181
+ acc += `${rel}:-;`;
22182
+ }
22183
+ }
22184
+ return acc;
22185
+ }
22186
+
22187
+ const EXTRA_STALL_POLL_MS = 5000;
22188
+
22189
+ function runCliChild(bin, argv, opts, watch) {
22190
+ return new Promise((resolve) => {
22191
+ const parts = spawnCmdParts(bin, argv);
22192
+ const spawnOpts = Object.assign({}, opts, { shell: parts.shell });
22193
+ if (!parts.shell && process.platform !== "win32") spawnOpts.detached = true;
22194
+ let child;
22195
+ try {
22196
+ child = require("child_process").spawn(parts.cmd, parts.args, spawnOpts);
22197
+ } catch (e) {
22198
+ return resolve({ error: e, status: null, stderr: "", stopped_by: null, stall_ms: null, last_progress_ms: null });
22199
+ }
22200
+
22201
+ let stderr = "";
22202
+ let stderrBytes = 0;
22203
+ if (child.stderr)
22204
+ child.stderr.on("data", (c) => {
22205
+ stderrBytes += c.length;
22206
+ if (stderr.length < 512 * 1024) stderr += c.toString("utf8");
22207
+ });
22208
+
22209
+ // When there is no journal to redirect stdout onto, the bytes are collected
22210
+ // HERE instead — never dropped. A journal is best effort by construction
22211
+ // (it can be null), and a dispatch that silently produced no readable output
22212
+ // because ORC could not write a log file would be a far worse failure than
22213
+ // the missing log.
22214
+ let stdout = "";
22215
+ let stdoutBytes = 0;
22216
+ if (child.stdout)
22217
+ child.stdout.on("data", (c) => {
22218
+ stdoutBytes += c.length;
22219
+ if (stdout.length < EXTRA_MAX_OUTPUT_BYTES) stdout += c.toString("utf8");
22220
+ });
22221
+
22222
+ let stoppedBy = null;
22223
+ let lastProgress = Date.now();
22224
+ let longestGap = 0;
22225
+ let firstByteAt = null;
22226
+ let seen = { out: -1, err: -1, files: null };
22227
+
22228
+ // `true` on the very first call: the baseline is what everything after it is
22229
+ // compared AGAINST, so counting it as movement would report a first byte at
22230
+ // 0ms on every dispatch — a timeline that always says the worker started
22231
+ // instantly is a timeline nobody can read a stall out of.
22232
+ const sample = (baseline) => {
22233
+ let outSize = stdoutBytes;
22234
+ if (watch.progressFile) {
22235
+ try {
22236
+ outSize = fs.statSync(watch.progressFile).size;
22237
+ } catch (_) {}
22238
+ }
22239
+ const files = declaredFilesFingerprint(watch.root, watch.declared);
22240
+ const moved =
22241
+ outSize !== seen.out || stderrBytes !== seen.err || (seen.files !== null && files !== seen.files);
22242
+ seen = { out: outSize, err: stderrBytes, files };
22243
+ if (moved && !baseline) {
22244
+ const now = Date.now();
22245
+ longestGap = Math.max(longestGap, now - lastProgress);
22246
+ if (firstByteAt === null) firstByteAt = now;
22247
+ lastProgress = now;
22248
+ }
22249
+ return moved;
22250
+ };
22251
+ sample(true); // establish the baseline WITHOUT counting it as movement
22252
+
22253
+ const wall = setTimeout(() => {
22254
+ stoppedBy = "wall";
22255
+ killProcessTree(child);
22256
+ }, watch.wall_ms);
22257
+
22258
+ let poll = null;
22259
+ if (watch.stall_ms > 0) {
22260
+ poll = setInterval(() => {
22261
+ sample();
22262
+ if (Date.now() - lastProgress >= watch.stall_ms) {
22263
+ stoppedBy = "stall";
22264
+ clearInterval(poll);
22265
+ poll = null;
22266
+ killProcessTree(child);
22267
+ }
22268
+ }, Math.min(EXTRA_STALL_POLL_MS, Math.max(1000, Math.floor(watch.stall_ms / 4))));
22269
+ }
22270
+
22271
+ const done = (status, err) => {
22272
+ clearTimeout(wall);
22273
+ if (poll) clearInterval(poll);
22274
+ sample();
22275
+ resolve({
22276
+ error: err || null,
22277
+ status,
22278
+ stdout,
22279
+ stderr,
22280
+ stopped_by: stoppedBy,
22281
+ // The timeline, for `orc extra health` and for the failure report. A
22282
+ // stall report that cannot say how long the worker was quiet is a
22283
+ // report nobody can tune a budget from.
22284
+ stall_ms: Date.now() - lastProgress,
22285
+ longest_gap_ms: longestGap,
22286
+ first_byte_ms: firstByteAt === null ? null : firstByteAt - watch.started,
22287
+ last_progress_ms: lastProgress - watch.started,
22288
+ });
22289
+ };
22290
+ child.on("error", (e) => done(null, e));
22291
+ child.on("close", (code) => done(code === null ? null : code, null));
22292
+ });
22293
+ }
22294
+
21741
22295
  function spawnCmdSafe(bin, argv, opts) {
21742
22296
  const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
21743
22297
  if (!needsShell) return spawnSync(bin, argv, opts);
@@ -21755,7 +22309,43 @@ function extraTimeouts(cfg) {
21755
22309
  const wall = Math.max(30, Number(cfg.extra_timeout_s) || 900) * 1000;
21756
22310
  const api = Math.min(wall, Math.max(60000, wall - 60000));
21757
22311
  const idle = Math.max(10000, Math.min(300000, api - 30000, 1800000));
21758
- return { wall_ms: wall, api_ms: api, idle_ms: idle };
22312
+ // v0.56.1 THE FOURTH TIMEOUT, and the only one that measures the WORKER
22313
+ // rather than a socket. A wall clock cannot tell a worker that is thinking
22314
+ // hard from one that stopped: both look like 900 seconds of nothing. The
22315
+ // stall clock is reset by OBSERVABLE PROGRESS — new bytes on the worker's own
22316
+ // stream, new bytes on its stderr, or a declared file that changed on disk —
22317
+ // so it fires only when nothing at all is happening.
22318
+ //
22319
+ // Ordered with the rest, once: stall < idle < api < wall. A stall budget at
22320
+ // or past the wall clock can never fire, so it is CLAMPED rather than
22321
+ // honoured, and the clamp is reported by `orc extra health`.
22322
+ const raw = cfg.extra_stall_s === undefined ? 180 : Number(cfg.extra_stall_s);
22323
+ const asked = !Number.isFinite(raw) || raw <= 0 ? 0 : raw * 1000;
22324
+ // STRICTLY under the wall clock, and never below the 30s floor. Those two
22325
+ // rules can disagree — a 30-second wall clock leaves no room for either — and
22326
+ // when they do the stall clock is simply OFF. Two timers that fire at the same
22327
+ // instant would report whichever won the race, which is exactly the "three
22328
+ // timeouts disagreeing about which one fires first" bug this function exists
22329
+ // to prevent; the wall clock is the one that must win a tie, because it is
22330
+ // the budget the user set.
22331
+ const fitted = Math.min(asked, wall - 15000);
22332
+ const stall = asked === 0 || fitted < 30000 ? 0 : fitted;
22333
+ return {
22334
+ wall_ms: wall,
22335
+ api_ms: api,
22336
+ idle_ms: idle,
22337
+ stall_ms: stall,
22338
+ stall_clamped: asked > 0 && stall !== asked,
22339
+ // WHY it is off, when it is off and the user asked for one. A budget that
22340
+ // silently does nothing is the failure mode; a budget that says "your wall
22341
+ // clock is too short for me" is a thing somebody can act on.
22342
+ stall_off_reason:
22343
+ asked > 0 && stall === 0
22344
+ ? `extra_stall_s (${Math.round(asked / 1000)}s) cannot fit under extra_timeout_s (${Math.round(
22345
+ wall / 1000
22346
+ )}s) with the 30s floor, so the wall clock is the only stop.`
22347
+ : null,
22348
+ };
21759
22349
  }
21760
22350
 
21761
22351
  // ── Managed settings (F4, last row) ────────────────────────────────────────
@@ -21919,6 +22509,20 @@ const EXTRA_FAILURES = {
21919
22509
  "connection-lost-local": { retry: true, label: "the connection dropped and this machine could not reach the network either" },
21920
22510
  "redirect-refused": { retry: false, label: "the endpoint redirected, and a credential must never follow one" },
21921
22511
  "response-truncated": { retry: true, label: "the reply exceeded the response ceiling" },
22512
+ // v0.56.1 — THE ROW FOR A WORKER THAT IS STILL ALIVE AND DOING NOTHING.
22513
+ //
22514
+ // `timeout` says the whole dispatch budget ran out, which is a statement
22515
+ // about ORC's patience. `stalled` says the worker went quiet: no new bytes on
22516
+ // its stream, no new bytes on stderr, and no declared file changed, for the
22517
+ // whole stall budget. The process was up the entire time — an opencode
22518
+ // session that stops mid-task and waits for a human to type "continue" looks
22519
+ // exactly like this, and reporting it as `timeout` hid that it was a
22520
+ // RESUMABLE position rather than a budget somebody should raise.
22521
+ //
22522
+ // Retryable, and that is the point: `extra_resume` turns it into a
22523
+ // continuation slice carrying what is already on disk, which is ORC's own
22524
+ // spelling of typing "continue".
22525
+ stalled: { retry: true, label: "the worker went quiet and stopped changing anything" },
21922
22526
  unknown: { retry: false, label: "the worker failed and said nothing ORC can classify" },
21923
22527
  };
21924
22528
  const classifyFailure = (k) => EXTRA_FAILURES[k] || EXTRA_FAILURES.unknown;
@@ -23105,6 +23709,27 @@ function extraJournalCmd(claudeDir, sub, arg) {
23105
23709
  // same profile" and then wait out `extra_resume_max` × a 401.
23106
23710
  const EXTRA_RESUME_TARGETS = ["extra", "claude", "hold", "off"];
23107
23711
 
23712
+ // ONE reader of "which Claude agent takes this over". A resume that goes to
23713
+ // Claude and a fallback that goes to Claude must never disagree about the
23714
+ // answer, and before v0.56.1 there was only one place it could come from. Now
23715
+ // `extra_fallback_agent` can pin one or defer to the user, so the resolved
23716
+ // choice wins where it made one and `fallback_to` is still the floor. `null`
23717
+ // under `ask` is CORRECT and not a gap: nothing has been chosen yet.
23718
+ function extraFallbackAgentOf(result) {
23719
+ const f = result && result.fallback;
23720
+ // `ask` deliberately resolves to NULL — nothing has been chosen yet, and a
23721
+ // resume report that named an agent would answer the question the setting
23722
+ // exists to ask. The renderer prints the menu instead.
23723
+ if (f && f.mode === "ask") return null;
23724
+ // ONLY a PINNED setting overrides. Under `band` the two fields agree by
23725
+ // construction (both are `res.claude`), and `fallback_to` keeps its name, its
23726
+ // position and its meaning as the promise at the payload says — so it stays
23727
+ // the authority for the default, and this function is the exception, not a
23728
+ // replacement.
23729
+ if (f && f.mode === "pinned" && f.agent) return f.agent;
23730
+ return (result && result.fallback_to && result.fallback_to.agent) || (f && f.agent) || null;
23731
+ }
23732
+
23108
23733
  // ONE idea of what "on" means. Two readers of the same key that disagree about
23109
23734
  // its default is the drift this repo lints for everywhere else.
23110
23735
  const extraResumeOn = (cfg) => String((cfg && cfg.extra_resume) === undefined ? "on" : cfg.extra_resume) !== "off";
@@ -23144,7 +23769,7 @@ function extraResumeTarget(v, result, cfg) {
23144
23769
  return {
23145
23770
  kind: "claude",
23146
23771
  profile: null,
23147
- agent: (result && result.fallback_to && result.fallback_to.agent) || null,
23772
+ agent: extraFallbackAgentOf(result),
23148
23773
  why: `\`extra_resume_max\` is ${max} and this task has already been resumed ${resumesSoFar} time(s). The cap STOPS with an honest report rather than looping a third time — bounded like \`tdd_loop_max\`.`,
23149
23774
  capped: true,
23150
23775
  resumes_so_far: resumesSoFar,
@@ -23165,7 +23790,7 @@ function extraResumeTarget(v, result, cfg) {
23165
23790
  return {
23166
23791
  kind: "claude",
23167
23792
  profile: null,
23168
- agent: (result && result.fallback_to && result.fallback_to.agent) || null,
23793
+ agent: extraFallbackAgentOf(result),
23169
23794
  // THE CASE THAT FIXES GAP 1. A Claude executor receiving a resume slice gets
23170
23795
  // the same preamble, the same `preexisting[]` table and the same instruction
23171
23796
  // not to rewrite finished work. The Claude fallback stops being a
@@ -23176,6 +23801,78 @@ function extraResumeTarget(v, result, cfg) {
23176
23801
  };
23177
23802
  }
23178
23803
 
23804
+ // ── WHICH Claude agent picks up a failed foreign task (v0.56.1) ────────────
23805
+ //
23806
+ // `fallback_to` has always carried the band's (or the slot's) own Claude agent,
23807
+ // which is the right default and the only one ORC can compute: a fallback that
23808
+ // changes tier is a re-plan nobody asked for.
23809
+ //
23810
+ // What it could not do is let a human choose. A stall costs real minutes before
23811
+ // anybody hears about it, and by the time the wave stops the user often knows
23812
+ // something ORC does not — that this slice wants more thinking than its band
23813
+ // bought, or less. So `extra_fallback_agent` adds two words to the default:
23814
+ // `ask` STOPS with the menu, and a bare agent name pins one.
23815
+ //
23816
+ // The menu is COMPUTED, never a second idea of the ladder: the band's own agent
23817
+ // always leads it (it is what happens if the user just presses enter), and the
23818
+ // three named alternates are the ones a person actually reaches for. "Any other
23819
+ // installed agent" is the honest last row — the roster is generated, and a
23820
+ // closed list here would go stale the next time a band moves.
23821
+ const EXTRA_FALLBACK_ALTERNATES = [
23822
+ { agent: "orc-executor-opus-5-med", why: "more thinking than most bands buy, without the top-tier cost." },
23823
+ { agent: "orc-executor-opus-5-low", why: "the same model on the cheapest rung — right when the slice turned out to be mechanical." },
23824
+ { agent: "orc-executor-sonnet-4-6-high", why: "a different model entirely, for a slice where Opus is not the constraint." },
23825
+ ];
23826
+
23827
+ function extraFallbackChoice(cfg, claude) {
23828
+ const bandAgent = (claude && claude.agent) || null;
23829
+ const setting = String((cfg && cfg.extra_fallback_agent) || "band").trim() || "band";
23830
+ const options = [];
23831
+ if (bandAgent)
23832
+ options.push({
23833
+ agent: bandAgent,
23834
+ label: "the agent this task would have had",
23835
+ why: "a fallback changes WHO runs the task and nothing else — the score, the declared files and the acceptance criteria are untouched.",
23836
+ is_band: true,
23837
+ });
23838
+ for (const alt of EXTRA_FALLBACK_ALTERNATES)
23839
+ if (alt.agent !== bandAgent) options.push({ agent: alt.agent, label: alt.agent, why: alt.why, is_band: false });
23840
+
23841
+ if (setting === "ask")
23842
+ return {
23843
+ mode: "ask",
23844
+ agent: null,
23845
+ band_agent: bandAgent,
23846
+ options,
23847
+ free_text: "any installed ORC agent name is also accepted",
23848
+ // The lane STOPS here. It is a config the user set on purpose, so a lane
23849
+ // that quietly picked the default for them would be answering the one
23850
+ // question the setting exists to ask.
23851
+ note: "`extra_fallback_agent` is `ask` — put these to the user and dispatch what they pick. Do not choose one for them.",
23852
+ };
23853
+ if (setting === "band")
23854
+ return {
23855
+ mode: "band",
23856
+ agent: bandAgent,
23857
+ band_agent: bandAgent,
23858
+ options,
23859
+ free_text: null,
23860
+ note: bandAgent
23861
+ ? `dispatch \`${bandAgent}\` — the agent this band or slot already resolves to.`
23862
+ : "no Claude agent could be resolved for this task, so there is nothing to fall back to. Report that rather than guessing one.",
23863
+ };
23864
+ return {
23865
+ mode: "pinned",
23866
+ agent: setting,
23867
+ band_agent: bandAgent,
23868
+ options,
23869
+ free_text: null,
23870
+ note:
23871
+ `\`extra_fallback_agent\` pins every fallback to \`${setting}\`` +
23872
+ (bandAgent && bandAgent !== setting ? `, overriding this task's own \`${bandAgent}\`.` : "."),
23873
+ };
23874
+ }
23875
+
23179
23876
  // ONE WORDING, composed here. Renderers print it; nobody writes a second one.
23180
23877
  function extraResumePreamble(v, target) {
23181
23878
  const line = (f) => {
@@ -25334,7 +26031,7 @@ const EXTRA_CLI_ADAPTERS = {
25334
26031
  // property that kept the bridge, the slot accounting, the render and every
25335
26032
  // downstream gate untouched across three engines, and it is worth more than any
25336
26033
  // one adapter.
25337
- function runCliEngine(ctx) {
26034
+ async function runCliEngine(ctx) {
25338
26035
  const { prof, route, key, cfg, slice, cwd, root, workDir } = ctx;
25339
26036
  const started = Date.now();
25340
26037
  const timeouts = extraTimeouts(cfg);
@@ -25469,14 +26166,20 @@ function runCliEngine(ctx) {
25469
26166
  const spawnOpts = {
25470
26167
  cwd,
25471
26168
  env: Object.assign({}, process.env, adapter.env(a)),
25472
- encoding: "utf8",
25473
- timeout: timeouts.wall_ms,
25474
- killSignal: "SIGTERM",
25475
- maxBuffer: EXTRA_MAX_OUTPUT_BYTES,
25476
26169
  windowsHide: true,
25477
26170
  };
25478
- if (fd !== null) spawnOpts.stdio = ["ignore", fd, "pipe"];
25479
- const r = spawnCmdSafe(bin, argv, spawnOpts);
26171
+ // stdout to the fd when there is one; a pipe we never read otherwise, so a
26172
+ // journal-less dispatch cannot fill the OS buffer and deadlock the child.
26173
+ spawnOpts.stdio = ["ignore", fd !== null ? fd : "pipe", "pipe"];
26174
+ // v0.56.1 — ASYNCHRONOUS, so something can watch. See runCliChild.
26175
+ const r = await runCliChild(bin, argv, spawnOpts, {
26176
+ started,
26177
+ wall_ms: timeouts.wall_ms,
26178
+ stall_ms: timeouts.stall_ms,
26179
+ progressFile: (ctx.journal && ctx.journal.progress) || null,
26180
+ root,
26181
+ declared,
26182
+ });
25480
26183
  // READ BACK FIRST, before any early return. A timeout is exactly the case
25481
26184
  // where the bytes matter most, and the old code discarded them.
25482
26185
  let captured = null;
@@ -25493,23 +26196,50 @@ function runCliEngine(ctx) {
25493
26196
  }
25494
26197
  const childOut = captured === null ? r.stdout || "" : captured;
25495
26198
 
25496
- if (r.error && r.error.code === "ETIMEDOUT")
25497
- return fail("timeout", `no answer in ${Math.round(timeouts.wall_ms / 1000)}s (the dispatch wall clock).`, {
25498
- adapter: cli.bin,
25499
- argv: argvUsed,
25500
- // WHERE THE BYTES ARE. The child's output up to the kill is on disk, and
25501
- // a path to a file that exists is the difference between a bare
25502
- // `timeout` and a position somebody can look at.
25503
- output_file: (ctx.journal && ctx.journal.progress) || null,
25504
- captured_bytes: captured === null ? null : Buffer.byteLength(captured, "utf8"),
25505
- });
26199
+ // WHERE THE BYTES ARE, on either stop. The child's output up to the kill is
26200
+ // on disk, and a path to a file that exists is the difference between a bare
26201
+ // verdict and a position somebody can look at.
26202
+ const stopEvidence = {
26203
+ adapter: cli.bin,
26204
+ argv: argvUsed,
26205
+ // NAMED on a stop, not only on a completion. The trace line falls back to
26206
+ // `?` without it, and `orc extra stats` dedupes on the fields that line
26207
+ // carries so a stalled dispatch would be unjoinable to a price, a
26208
+ // provider or another stall on a different model.
26209
+ model_requested: route.model,
26210
+ output_file: (ctx.journal && ctx.journal.progress) || null,
26211
+ captured_bytes: captured === null ? null : Buffer.byteLength(captured, "utf8"),
26212
+ timeline: {
26213
+ first_byte_ms: r.first_byte_ms,
26214
+ last_progress_ms: r.last_progress_ms,
26215
+ longest_gap_ms: r.longest_gap_ms,
26216
+ quiet_for_ms: r.stall_ms,
26217
+ stall_budget_ms: timeouts.stall_ms,
26218
+ wall_budget_ms: timeouts.wall_ms,
26219
+ },
26220
+ };
26221
+ // THE STALL, NAMED. Reported before the wall clock because it is the more
26222
+ // specific fact: a stall is a POSITION to resume from, a wall-clock timeout
26223
+ // is a budget to raise, and calling the first one the second is what sent
26224
+ // fifteen-minute stalls to a from-scratch Claude fallback.
26225
+ if (r.stopped_by === "stall")
26226
+ return fail(
26227
+ "stalled",
26228
+ `${cli.bin} produced nothing for ${Math.round(r.stall_ms / 1000)}s (the stall budget is ${Math.round(
26229
+ timeouts.stall_ms / 1000
26230
+ )}s) — no output, no stderr and no declared file changed. The process was still running; it had stopped working. ` +
26231
+ "`orc extra reconcile` has the position it left, and `extra_resume` continues from it instead of starting over.",
26232
+ stopEvidence
26233
+ );
26234
+ if (r.stopped_by === "wall")
26235
+ return fail("timeout", `no answer in ${Math.round(timeouts.wall_ms / 1000)}s (the dispatch wall clock).`, stopEvidence);
25506
26236
  if (r.error) return fail("spawn-failed", r.error.message, { adapter: cli.bin, argv: argvUsed });
25507
26237
 
25508
26238
  let lastMessage = null;
25509
26239
  try {
25510
26240
  lastMessage = fs.readFileSync(outFile, "utf8");
25511
26241
  } catch (_) {}
25512
- out = { stdout: childOut, stderr: r.stderr || "", status: r.status, lastMessage };
26242
+ out = { stdout: childOut, stderr: r.stderr || "", status: r.status, lastMessage, timeline: stopEvidence.timeline };
25513
26243
  } finally {
25514
26244
  try {
25515
26245
  fs.rmSync(wdir, { recursive: true, force: true });
@@ -25584,6 +26314,9 @@ function runCliEngine(ctx) {
25584
26314
  },
25585
26315
  preflight_warning: preflightWarn,
25586
26316
  preflight_remedy: preflightRemedy,
26317
+ // The same timeline the stall report carries, on EVERY outcome. A budget you
26318
+ // can only see when it fires is a budget nobody can set before it does.
26319
+ timeline: out.timeline || null,
25587
26320
  };
25588
26321
 
25589
26322
  // F14f — THE PROVIDER'S OWN ERROR OBJECT FIRST, the stderr patterns as the
@@ -25889,7 +26622,7 @@ async function extraDispatch(claudeDir) {
25889
26622
  // dispatch that named only the model would run at whatever that user's
25890
26623
  // config happens to say — a silent downgrade, the exact failure class the
25891
26624
  // `expect=<model>/<effort>` trace design exists to catch.
25892
- out = runCliEngine({
26625
+ out = await runCliEngine({
25893
26626
  prof,
25894
26627
  route: Object.assign({}, route, { effort: route.effort || extraEffortFromAgent(res.claude && res.claude.agent) }),
25895
26628
  key: cred.value,
@@ -25932,6 +26665,9 @@ async function extraDispatch(claudeDir) {
25932
26665
  engine: prof.engine,
25933
26666
  declared_files: slice.declared_files || [],
25934
26667
  fallback_to: res.claude,
26668
+ // v0.56.1 — the same agent, plus WHETHER THE USER GETS A SAY. `fallback_to`
26669
+ // keeps its name, its position and its meaning; this is beside it.
26670
+ fallback: extraFallbackChoice(cfg, res.claude),
25935
26671
  announce: res.announce,
25936
26672
  // v0.53.3 — WHAT WAS ACTUALLY SENT, not what the profile declares. The
25937
26673
  // return used to copy `credential.source: "vault"` off the profile while
@@ -26075,8 +26811,17 @@ function extraTraceExtras(p) {
26075
26811
  // The fallback line is the CALLER's to emit AFTER it re-dispatches, because
26076
26812
  // only the caller knows whether it did. What the CLI can honestly supply is
26077
26813
  // the text, pre-composed, so the two wordings cannot diverge.
26078
- if (!p.ok && p.fallback_to && p.fallback_to.agent)
26079
- out.push(`EXTRA fallback task=${p.task_id || "?"} :: ${p.reason} → ${p.fallback_to.agent}`);
26814
+ //
26815
+ // v0.56.1 — under `extra_fallback_agent: ask` NOTHING HAS BEEN CHOSEN YET, so
26816
+ // the line says `pending` rather than naming the band's agent. A trace that
26817
+ // asserts an agent the user has not picked is a decision /orc-retro would
26818
+ // aggregate as one that was made.
26819
+ if (!p.ok) {
26820
+ const chosen = extraFallbackAgentOf(p);
26821
+ if (p.fallback && p.fallback.mode === "ask")
26822
+ out.push(`EXTRA fallback task=${p.task_id || "?"} :: ${p.reason} → pending (extra_fallback_agent=ask)`);
26823
+ else if (chosen) out.push(`EXTRA fallback task=${p.task_id || "?"} :: ${p.reason} → ${chosen}`);
26824
+ }
26080
26825
  return out;
26081
26826
  }
26082
26827
 
@@ -26136,6 +26881,30 @@ function extraDispatchRender(p) {
26136
26881
  if (p.credential_hint) console.log(" " + ui.mark.warn(p.credential_hint));
26137
26882
  if (!p.ok && p.fallback_to)
26138
26883
  console.log(ui.color.gray(` fallback: ${p.fallback_to.agent} ${p.fallback_to.band}`));
26884
+ // v0.56.1 — the TIMELINE, on every engine-cli outcome. A stall budget you can
26885
+ // only see when it fires is a budget nobody can set before it does.
26886
+ if (p.timeline && p.timeline.stall_budget_ms) {
26887
+ const sec = (ms) => (ms === null || ms === undefined ? "—" : `${Math.round(ms / 1000)}s`);
26888
+ console.log(
26889
+ ui.color.gray(
26890
+ ` timeline: first byte ${sec(p.timeline.first_byte_ms)} · last progress ${sec(
26891
+ p.timeline.last_progress_ms
26892
+ )} · longest quiet gap ${sec(p.timeline.longest_gap_ms)} · stall budget ${sec(p.timeline.stall_budget_ms)}`
26893
+ )
26894
+ );
26895
+ }
26896
+ // The MENU, when the user asked to be the one who picks. Printed in full,
26897
+ // because a lane that has to go and look the options up is a lane that will
26898
+ // pick the first one.
26899
+ if (!p.ok && p.fallback && p.fallback.mode === "ask") {
26900
+ console.log("");
26901
+ console.log(ui.color.yellow(" extra_fallback_agent is `ask` — choose who picks this task up:"));
26902
+ for (const o of p.fallback.options)
26903
+ console.log(` ${ui.color.cyan(o.agent)}${o.is_band ? ui.color.gray(" (this task's own band)") : ""} ${ui.color.gray(o.why)}`);
26904
+ if (p.fallback.free_text) console.log(ui.color.gray(` …or ${p.fallback.free_text}`));
26905
+ } else if (!p.ok && p.fallback && p.fallback.mode === "pinned") {
26906
+ console.log(ui.color.gray(` ${p.fallback.note}`));
26907
+ }
26139
26908
  // Copy this into the phase packet verbatim. It is printed rather than only
26140
26909
  // returned in --json because the orchestrator reads the human output too, and
26141
26910
  // a line it has to retype is a line it will retype differently.
@@ -27335,6 +28104,12 @@ function extraUsage() {
27335
28104
  " orc extra lanes [--json] WHICH LANE each band governs. A\n" +
27336
28105
  " fixed-executor lane shows both edges of its\n" +
27337
28106
  " pinned agent's band and whether they agreed.\n" +
28107
+ " orc extra health <profile> [--model <id>] [--json]\n" +
28108
+ " DOES THIS MODEL STALL. Runs the live probe through the\n" +
28109
+ " SAME watchdog a dispatch uses and reports the timeline:\n" +
28110
+ " first byte, longest quiet gap, and whether it answered,\n" +
28111
+ " stalled or ran out of wall clock. Engine `cli` only.\n" +
28112
+ " 0 answered - 1 stalled or failed - 2 unknown profile\n" +
27338
28113
  " orc extra preflight [--json] the P0 gate before wave 1.\n" +
27339
28114
  " 0 ok · 1 STOP (an expired or missing passphrase on a\n" +
27340
28115
  " vaulted profile a route row names)\n" +
@@ -27740,6 +28515,10 @@ async function extra() {
27740
28515
  case "preflight":
27741
28516
  extraPreflight(claudeDir);
27742
28517
  break;
28518
+ // v0.56.1 - the stall clock, measurable BEFORE a wave depends on it.
28519
+ case "health":
28520
+ await extraHealth(claudeDir, pos[2]);
28521
+ break;
27743
28522
  case "route":
27744
28523
  if (pos[2] === "set") extraRouteSet(claudeDir, pos[3], pos[4]);
27745
28524
  else if (pos[2] === "rm") extraRouteRm(claudeDir, pos[3]);
@@ -28063,6 +28842,32 @@ function doctor() {
28063
28842
  else if (docList(claudeDir).length) ok(`${plural(docList(claudeDir).length, "document")}, none drifted`);
28064
28843
  } catch (_) {}
28065
28844
 
28845
+ // 6b) The legacy global package (v0.56.0). This is not a `.claude/` problem —
28846
+ // it is a problem with the TOOL ITSELF, and it is the one finding that
28847
+ // explains why `orc upgrade` cannot fix anything else in this report. It is
28848
+ // NOT fixable by `orc doctor --fix`: that command's whole blast radius is
28849
+ // this project's .claude/, and evicting a global npm package is neither
28850
+ // project-scoped nor something to do without saying so. `orc upgrade` does it
28851
+ // (announced), so the fix command points there.
28852
+ try {
28853
+ const legacyOwner = detectLegacyBinOwner();
28854
+ if (legacyOwner)
28855
+ warn(
28856
+ "legacy-global-package",
28857
+ `${legacyOwner.name}@${legacyOwner.version} is installed globally and owns the \`orc\` ` +
28858
+ `command, but this package is now ${PKG_NAME} — npm refuses to link \`orc\` over it, ` +
28859
+ "so EVERY install source fails with the same EEXIST. Run `orc upgrade` (it removes the " +
28860
+ `old package first), or by hand: \`npm uninstall -g ${legacyOwner.name} && npm i -g ${NPM_SPEC}\``,
28861
+ {
28862
+ legacy_name: legacyOwner.name,
28863
+ legacy_version: legacyOwner.version,
28864
+ legacy_dir: legacyOwner.dir,
28865
+ package_name: PKG_NAME,
28866
+ fix_command: "orc upgrade",
28867
+ }
28868
+ );
28869
+ } catch (_) {}
28870
+
28066
28871
  // 7) `orc extra` (v0.50.0). ONE line here, carrying the COUNT and the
28067
28872
  // command that itemises it — the restraint is deliberate and the same call
28068
28873
  // the two wiki findings made: a doctor that recites eleven finding ids for a