@harness-engineering/orchestrator 0.2.16 → 0.3.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/dist/index.js CHANGED
@@ -31,12 +31,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AnalysisArchive: () => AnalysisArchive,
34
+ BackendRouter: () => BackendRouter,
34
35
  ClaimManager: () => ClaimManager,
35
36
  InteractionQueue: () => InteractionQueue,
36
37
  LinearGraphQLStub: () => LinearGraphQLStub,
37
38
  MockBackend: () => MockBackend,
38
39
  ORCHESTRATOR_IDENTITY_FILE: () => ORCHESTRATOR_IDENTITY_FILE,
39
40
  Orchestrator: () => Orchestrator,
41
+ OrchestratorBackendFactory: () => OrchestratorBackendFactory,
40
42
  PRDetector: () => PRDetector,
41
43
  PromptRenderer: () => PromptRenderer,
42
44
  RoadmapTrackerAdapter: () => RoadmapTrackerAdapter,
@@ -49,6 +51,7 @@ __export(index_exports, {
49
51
  calculateRetryDelay: () => calculateRetryDelay,
50
52
  canDispatch: () => canDispatch,
51
53
  computeRateLimitDelay: () => computeRateLimitDelay,
54
+ createBackend: () => createBackend,
52
55
  createEmptyState: () => createEmptyState,
53
56
  detectScopeTier: () => detectScopeTier,
54
57
  extractHighlights: () => extractHighlights,
@@ -59,6 +62,7 @@ __export(index_exports, {
59
62
  isEligible: () => isEligible,
60
63
  launchTUI: () => launchTUI,
61
64
  loadPublishedIndex: () => loadPublishedIndex,
65
+ migrateAgentConfig: () => migrateAgentConfig,
62
66
  reconcile: () => reconcile,
63
67
  renderAnalysisComment: () => renderAnalysisComment,
64
68
  renderPRComment: () => renderPRComment,
@@ -1846,8 +1850,89 @@ var import_yaml = require("yaml");
1846
1850
  var import_types3 = require("@harness-engineering/types");
1847
1851
 
1848
1852
  // src/workflow/config.ts
1853
+ var import_zod2 = require("zod");
1849
1854
  var import_types2 = require("@harness-engineering/types");
1855
+
1856
+ // src/workflow/schema.ts
1857
+ var import_zod = require("zod");
1858
+ var ModelSchema = import_zod.z.union([import_zod.z.string().min(1), import_zod.z.array(import_zod.z.string().min(1)).nonempty()], {
1859
+ errorMap: () => ({
1860
+ message: "model must be a non-empty string or array of strings"
1861
+ })
1862
+ });
1863
+ var BackendDefSchema = import_zod.z.discriminatedUnion("type", [
1864
+ import_zod.z.object({ type: import_zod.z.literal("mock") }).strict(),
1865
+ import_zod.z.object({
1866
+ type: import_zod.z.literal("claude"),
1867
+ command: import_zod.z.string().optional()
1868
+ }).strict(),
1869
+ import_zod.z.object({
1870
+ type: import_zod.z.literal("anthropic"),
1871
+ model: import_zod.z.string().min(1),
1872
+ apiKey: import_zod.z.string().optional()
1873
+ }).strict(),
1874
+ import_zod.z.object({
1875
+ type: import_zod.z.literal("openai"),
1876
+ model: import_zod.z.string().min(1),
1877
+ apiKey: import_zod.z.string().optional()
1878
+ }).strict(),
1879
+ import_zod.z.object({
1880
+ type: import_zod.z.literal("gemini"),
1881
+ model: import_zod.z.string().min(1),
1882
+ apiKey: import_zod.z.string().optional()
1883
+ }).strict(),
1884
+ import_zod.z.object({
1885
+ type: import_zod.z.literal("local"),
1886
+ endpoint: import_zod.z.string().url(),
1887
+ model: ModelSchema,
1888
+ apiKey: import_zod.z.string().optional(),
1889
+ timeoutMs: import_zod.z.number().int().positive().optional(),
1890
+ probeIntervalMs: import_zod.z.number().int().min(1e3).optional()
1891
+ }).strict(),
1892
+ import_zod.z.object({
1893
+ type: import_zod.z.literal("pi"),
1894
+ endpoint: import_zod.z.string().url(),
1895
+ model: ModelSchema,
1896
+ apiKey: import_zod.z.string().optional(),
1897
+ timeoutMs: import_zod.z.number().int().positive().optional(),
1898
+ probeIntervalMs: import_zod.z.number().int().min(1e3).optional()
1899
+ }).strict()
1900
+ ]);
1901
+ var RoutingConfigSchema = import_zod.z.object({
1902
+ default: import_zod.z.string().min(1),
1903
+ "quick-fix": import_zod.z.string().optional(),
1904
+ "guided-change": import_zod.z.string().optional(),
1905
+ "full-exploration": import_zod.z.string().optional(),
1906
+ diagnostic: import_zod.z.string().optional(),
1907
+ intelligence: import_zod.z.object({
1908
+ sel: import_zod.z.string().optional(),
1909
+ pesl: import_zod.z.string().optional()
1910
+ }).strict().optional()
1911
+ }).strict();
1912
+
1913
+ // src/workflow/config.ts
1850
1914
  var REQUIRED_SECTIONS = ["tracker", "polling", "workspace", "hooks", "agent", "server"];
1915
+ var BackendsMapSchema = import_zod2.z.record(import_zod2.z.string(), BackendDefSchema);
1916
+ function crossFieldRoutingIssues(backends, routing) {
1917
+ const issues = [];
1918
+ const names = new Set(Object.keys(backends));
1919
+ const checkRef = (path16, name) => {
1920
+ if (name !== void 0 && !names.has(name)) {
1921
+ issues.push({
1922
+ path: path16,
1923
+ message: `routing.${path16.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1924
+ });
1925
+ }
1926
+ };
1927
+ checkRef(["default"], routing.default);
1928
+ checkRef(["quick-fix"], routing["quick-fix"]);
1929
+ checkRef(["guided-change"], routing["guided-change"]);
1930
+ checkRef(["full-exploration"], routing["full-exploration"]);
1931
+ checkRef(["diagnostic"], routing.diagnostic);
1932
+ checkRef(["intelligence", "sel"], routing.intelligence?.sel);
1933
+ checkRef(["intelligence", "pesl"], routing.intelligence?.pesl);
1934
+ return issues;
1935
+ }
1851
1936
  function validateWorkflowConfig(config) {
1852
1937
  if (!config || typeof config !== "object")
1853
1938
  return (0, import_types2.Err)(new Error("Config is missing or not an object"));
@@ -1858,6 +1943,35 @@ function validateWorkflowConfig(config) {
1858
1943
  if (c.intelligence !== void 0 && (typeof c.intelligence !== "object" || c.intelligence === null)) {
1859
1944
  return (0, import_types2.Err)(new Error("Config intelligence section must be an object if present"));
1860
1945
  }
1946
+ const agent = c.agent ?? {};
1947
+ const hasLegacyBackend = typeof agent.backend === "string" && agent.backend.length > 0;
1948
+ const hasModernBackends = agent.backends !== void 0 && typeof agent.backends === "object" && agent.backends !== null;
1949
+ if (!hasLegacyBackend && !hasModernBackends) {
1950
+ return (0, import_types2.Err)(new Error("Config must define agent.backend or agent.backends."));
1951
+ }
1952
+ if (hasModernBackends) {
1953
+ const backendsParsed = BackendsMapSchema.safeParse(agent.backends);
1954
+ if (!backendsParsed.success) {
1955
+ return (0, import_types2.Err)(new Error(`agent.backends: ${backendsParsed.error.message}`));
1956
+ }
1957
+ const routingParsed = RoutingConfigSchema.optional().safeParse(agent.routing);
1958
+ if (!routingParsed.success) {
1959
+ return (0, import_types2.Err)(new Error(`agent.routing: ${routingParsed.error.message}`));
1960
+ }
1961
+ if (routingParsed.data) {
1962
+ const cross = crossFieldRoutingIssues(
1963
+ backendsParsed.data,
1964
+ routingParsed.data
1965
+ );
1966
+ if (cross.length > 0) {
1967
+ return (0, import_types2.Err)(
1968
+ new Error(
1969
+ `Cross-field: ${cross.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
1970
+ )
1971
+ );
1972
+ }
1973
+ }
1974
+ }
1861
1975
  return (0, import_types2.Ok)(config);
1862
1976
  }
1863
1977
  function getDefaultConfig() {
@@ -2561,7 +2675,7 @@ var import_node_events = require("events");
2561
2675
  var path15 = __toESM(require("path"));
2562
2676
  var import_node_crypto7 = require("crypto");
2563
2677
  var import_core9 = require("@harness-engineering/core");
2564
- var import_intelligence3 = require("@harness-engineering/intelligence");
2678
+ var import_intelligence4 = require("@harness-engineering/intelligence");
2565
2679
  var import_graph = require("@harness-engineering/graph");
2566
2680
 
2567
2681
  // src/intelligence/pipeline-runner.ts
@@ -3242,6 +3356,428 @@ var AgentRunner = class {
3242
3356
  }
3243
3357
  };
3244
3358
 
3359
+ // src/agent/local-model-resolver.ts
3360
+ var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3361
+ var MIN_PROBE_INTERVAL_MS = 1e3;
3362
+ var DEFAULT_API_KEY = "lm-studio";
3363
+ var DEFAULT_FETCH_TIMEOUT_MS = 5e3;
3364
+ var noopLogger = {
3365
+ info: () => void 0,
3366
+ warn: () => void 0
3367
+ };
3368
+ async function defaultFetchModels(endpoint, apiKey, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
3369
+ const url = `${endpoint.replace(/\/$/, "")}/models`;
3370
+ let res;
3371
+ try {
3372
+ res = await fetch(url, {
3373
+ headers: { Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}` },
3374
+ signal: AbortSignal.timeout(timeoutMs)
3375
+ });
3376
+ } catch (err) {
3377
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
3378
+ throw new Error(`request timeout (${timeoutMs}ms)`, { cause: err });
3379
+ }
3380
+ throw err;
3381
+ }
3382
+ if (!res.ok) {
3383
+ throw new Error(`probe failed: ${res.status} ${res.statusText}`);
3384
+ }
3385
+ let body;
3386
+ try {
3387
+ body = await res.json();
3388
+ } catch {
3389
+ throw new Error("malformed /v1/models response");
3390
+ }
3391
+ if (!body || typeof body !== "object" || !Array.isArray(body.data)) {
3392
+ throw new Error("malformed /v1/models response");
3393
+ }
3394
+ const data = body.data;
3395
+ const ids = [];
3396
+ for (const entry of data) {
3397
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
3398
+ throw new Error("malformed /v1/models response");
3399
+ }
3400
+ ids.push(entry.id);
3401
+ }
3402
+ return ids;
3403
+ }
3404
+ var LocalModelResolver = class {
3405
+ endpoint;
3406
+ apiKey;
3407
+ configured;
3408
+ probeIntervalMs;
3409
+ fetchModels;
3410
+ logger;
3411
+ timer = null;
3412
+ listeners = /* @__PURE__ */ new Set();
3413
+ /**
3414
+ * Tracks an in-flight probe so concurrent invocations (interval tick while a
3415
+ * slow probe is running, or a manual `probe()` call mid-flight) share the
3416
+ * existing promise instead of racing to mutate `detected/resolved/lastError/
3417
+ * warnings` non-atomically across `await` points. Applies to both the timer
3418
+ * callback and direct `probe()` calls — any caller that arrives during an
3419
+ * in-flight probe gets the same promise back. Cleared in `finally` so the
3420
+ * next tick can start a fresh probe.
3421
+ */
3422
+ probeInFlight = null;
3423
+ // Mutable status fields (composed into LocalModelStatus on demand).
3424
+ resolved = null;
3425
+ detected = [];
3426
+ lastProbeAt = null;
3427
+ lastError = null;
3428
+ warnings = [];
3429
+ available = false;
3430
+ constructor(opts) {
3431
+ this.endpoint = opts.endpoint;
3432
+ if (opts.apiKey !== void 0) {
3433
+ this.apiKey = opts.apiKey;
3434
+ }
3435
+ this.configured = [...opts.configured];
3436
+ const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3437
+ this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
3438
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3439
+ this.fetchModels = opts.fetchModels ?? ((endpoint, apiKey) => defaultFetchModels(endpoint, apiKey, timeoutMs));
3440
+ this.logger = opts.logger ?? noopLogger;
3441
+ }
3442
+ resolveModel() {
3443
+ return this.resolved;
3444
+ }
3445
+ getStatus() {
3446
+ return {
3447
+ available: this.available,
3448
+ resolved: this.resolved,
3449
+ configured: [...this.configured],
3450
+ detected: [...this.detected],
3451
+ lastProbeAt: this.lastProbeAt,
3452
+ lastError: this.lastError,
3453
+ warnings: [...this.warnings]
3454
+ };
3455
+ }
3456
+ onStatusChange(handler) {
3457
+ this.listeners.add(handler);
3458
+ return () => {
3459
+ this.listeners.delete(handler);
3460
+ };
3461
+ }
3462
+ async probe() {
3463
+ if (this.probeInFlight !== null) {
3464
+ return this.probeInFlight;
3465
+ }
3466
+ const inFlight = this.runProbe().finally(() => {
3467
+ this.probeInFlight = null;
3468
+ });
3469
+ this.probeInFlight = inFlight;
3470
+ return inFlight;
3471
+ }
3472
+ async runProbe() {
3473
+ const before = this.snapshotForDiff();
3474
+ try {
3475
+ const detected = await this.fetchModels(this.endpoint, this.apiKey);
3476
+ this.detected = [...detected];
3477
+ this.lastError = null;
3478
+ this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
3479
+ const match = this.configured.find((id) => detected.includes(id)) ?? null;
3480
+ this.resolved = match;
3481
+ this.available = match !== null;
3482
+ this.warnings = match ? [] : [
3483
+ `No configured local model is loaded. Configured: [${this.configured.join(", ")}]. Detected: [${detected.join(", ")}].`
3484
+ ];
3485
+ } catch (err) {
3486
+ const message = err instanceof Error ? err.message : "probe failed";
3487
+ this.lastError = message;
3488
+ this.available = false;
3489
+ this.resolved = null;
3490
+ this.warnings = [`Local model probe failed against ${this.endpoint}: ${message}.`];
3491
+ this.logger.warn("local-model-resolver probe failed", {
3492
+ endpoint: this.endpoint,
3493
+ error: message
3494
+ });
3495
+ }
3496
+ const after = this.snapshotForDiff();
3497
+ const status = this.getStatus();
3498
+ if (before !== after) {
3499
+ for (const listener of this.listeners) {
3500
+ try {
3501
+ listener(status);
3502
+ } catch (err) {
3503
+ this.logger.warn("local-model-resolver listener threw", {
3504
+ error: err instanceof Error ? err.message : String(err)
3505
+ });
3506
+ }
3507
+ }
3508
+ }
3509
+ return status;
3510
+ }
3511
+ async start() {
3512
+ if (this.timer !== null) {
3513
+ return;
3514
+ }
3515
+ await this.probe();
3516
+ this.timer = setInterval(() => {
3517
+ void this.probe();
3518
+ }, this.probeIntervalMs);
3519
+ const handle = this.timer;
3520
+ handle.unref?.();
3521
+ }
3522
+ stop() {
3523
+ if (this.timer !== null) {
3524
+ clearInterval(this.timer);
3525
+ this.timer = null;
3526
+ }
3527
+ }
3528
+ snapshotForDiff() {
3529
+ return JSON.stringify({
3530
+ available: this.available,
3531
+ resolved: this.resolved,
3532
+ configured: this.configured,
3533
+ detected: this.detected,
3534
+ lastError: this.lastError,
3535
+ warnings: this.warnings
3536
+ });
3537
+ }
3538
+ };
3539
+
3540
+ // src/agent/config-migration.ts
3541
+ var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
3542
+ function migrateAgentConfig(agent) {
3543
+ const warnings = [];
3544
+ const legacyFields = [
3545
+ { path: "agent.backend", present: agent.backend !== void 0 && agent.backend !== "" },
3546
+ { path: "agent.command", present: agent.command !== void 0 },
3547
+ { path: "agent.model", present: agent.model !== void 0 },
3548
+ { path: "agent.apiKey", present: agent.apiKey !== void 0 },
3549
+ { path: "agent.localBackend", present: agent.localBackend !== void 0 },
3550
+ { path: "agent.localEndpoint", present: agent.localEndpoint !== void 0 },
3551
+ { path: "agent.localModel", present: agent.localModel !== void 0 },
3552
+ { path: "agent.localApiKey", present: agent.localApiKey !== void 0 },
3553
+ { path: "agent.localTimeoutMs", present: agent.localTimeoutMs !== void 0 },
3554
+ { path: "agent.localProbeIntervalMs", present: agent.localProbeIntervalMs !== void 0 }
3555
+ ];
3556
+ const presentLegacy = legacyFields.filter((f) => f.present).map((f) => f.path);
3557
+ const CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
3558
+ const CASE1_LOCAL_GROUP = /* @__PURE__ */ new Set([
3559
+ "agent.localBackend",
3560
+ "agent.localEndpoint",
3561
+ "agent.localModel",
3562
+ "agent.localApiKey",
3563
+ "agent.localTimeoutMs",
3564
+ "agent.localProbeIntervalMs"
3565
+ ]);
3566
+ const suppressLocalGroup = agent.localBackend !== void 0;
3567
+ if (agent.backends !== void 0) {
3568
+ for (const path16 of presentLegacy) {
3569
+ if (CASE1_ALWAYS_SUPPRESS.has(path16)) continue;
3570
+ if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path16)) continue;
3571
+ warnings.push(
3572
+ `Ignoring legacy field '${path16}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3573
+ );
3574
+ }
3575
+ return { config: agent, warnings };
3576
+ }
3577
+ if (presentLegacy.length === 0) {
3578
+ return { config: agent, warnings };
3579
+ }
3580
+ const backends = {};
3581
+ const routing = { default: "primary" };
3582
+ backends.primary = synthesizePrimary(agent);
3583
+ if (agent.localBackend !== void 0) {
3584
+ backends.local = synthesizeLocal(agent);
3585
+ }
3586
+ const autoExec = agent.escalation?.autoExecute ?? [];
3587
+ if (backends.local !== void 0) {
3588
+ for (const tier of autoExec) {
3589
+ routing[tier] = "local";
3590
+ }
3591
+ }
3592
+ for (const path16 of presentLegacy) {
3593
+ warnings.push(
3594
+ `Deprecated config field '${path16}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3595
+ );
3596
+ }
3597
+ return {
3598
+ config: {
3599
+ ...agent,
3600
+ backends,
3601
+ routing
3602
+ },
3603
+ warnings
3604
+ };
3605
+ }
3606
+ function synthesizePrimary(agent) {
3607
+ const backend = agent.backend;
3608
+ switch (backend) {
3609
+ case "mock":
3610
+ return { type: "mock" };
3611
+ case "claude": {
3612
+ const def = { type: "claude" };
3613
+ if (agent.command !== void 0) def.command = agent.command;
3614
+ return def;
3615
+ }
3616
+ case "anthropic": {
3617
+ if (agent.model === void 0) {
3618
+ throw new Error("migrateAgentConfig: agent.backend='anthropic' requires agent.model");
3619
+ }
3620
+ const def = { type: "anthropic", model: agent.model };
3621
+ if (agent.apiKey !== void 0) def.apiKey = agent.apiKey;
3622
+ return def;
3623
+ }
3624
+ case "openai": {
3625
+ if (agent.model === void 0) {
3626
+ throw new Error("migrateAgentConfig: agent.backend='openai' requires agent.model");
3627
+ }
3628
+ const def = { type: "openai", model: agent.model };
3629
+ if (agent.apiKey !== void 0) def.apiKey = agent.apiKey;
3630
+ return def;
3631
+ }
3632
+ case "gemini": {
3633
+ if (agent.model === void 0) {
3634
+ throw new Error("migrateAgentConfig: agent.backend='gemini' requires agent.model");
3635
+ }
3636
+ const def = { type: "gemini", model: agent.model };
3637
+ if (agent.apiKey !== void 0) def.apiKey = agent.apiKey;
3638
+ return def;
3639
+ }
3640
+ case "local": {
3641
+ if (agent.localEndpoint === void 0 || agent.localModel === void 0) {
3642
+ throw new Error(
3643
+ "migrateAgentConfig: agent.backend='local' requires agent.localEndpoint and agent.localModel"
3644
+ );
3645
+ }
3646
+ const def = {
3647
+ type: "local",
3648
+ endpoint: agent.localEndpoint,
3649
+ model: agent.localModel
3650
+ };
3651
+ if (agent.localApiKey !== void 0) def.apiKey = agent.localApiKey;
3652
+ if (agent.localTimeoutMs !== void 0) def.timeoutMs = agent.localTimeoutMs;
3653
+ if (agent.localProbeIntervalMs !== void 0)
3654
+ def.probeIntervalMs = agent.localProbeIntervalMs;
3655
+ return def;
3656
+ }
3657
+ case "pi": {
3658
+ if (agent.localEndpoint === void 0 || agent.localModel === void 0) {
3659
+ throw new Error(
3660
+ "migrateAgentConfig: agent.backend='pi' requires agent.localEndpoint and agent.localModel"
3661
+ );
3662
+ }
3663
+ const def = {
3664
+ type: "pi",
3665
+ endpoint: agent.localEndpoint,
3666
+ model: agent.localModel
3667
+ };
3668
+ if (agent.localApiKey !== void 0) def.apiKey = agent.localApiKey;
3669
+ if (agent.localTimeoutMs !== void 0) def.timeoutMs = agent.localTimeoutMs;
3670
+ if (agent.localProbeIntervalMs !== void 0)
3671
+ def.probeIntervalMs = agent.localProbeIntervalMs;
3672
+ return def;
3673
+ }
3674
+ default:
3675
+ throw new Error(
3676
+ `migrateAgentConfig: unknown legacy backend '${String(backend)}'. Expected one of: mock, claude, anthropic, openai, gemini, local, pi.`
3677
+ );
3678
+ }
3679
+ }
3680
+ function synthesizeLocal(agent) {
3681
+ if (agent.localBackend === void 0) {
3682
+ throw new Error("synthesizeLocal called without agent.localBackend");
3683
+ }
3684
+ if (agent.localEndpoint === void 0 || agent.localModel === void 0) {
3685
+ throw new Error(
3686
+ "migrateAgentConfig: agent.localBackend requires agent.localEndpoint and agent.localModel"
3687
+ );
3688
+ }
3689
+ if (agent.localBackend === "pi") {
3690
+ const def2 = {
3691
+ type: "pi",
3692
+ endpoint: agent.localEndpoint,
3693
+ model: agent.localModel
3694
+ };
3695
+ if (agent.localApiKey !== void 0) def2.apiKey = agent.localApiKey;
3696
+ if (agent.localTimeoutMs !== void 0) def2.timeoutMs = agent.localTimeoutMs;
3697
+ if (agent.localProbeIntervalMs !== void 0) def2.probeIntervalMs = agent.localProbeIntervalMs;
3698
+ return def2;
3699
+ }
3700
+ const def = {
3701
+ type: "local",
3702
+ endpoint: agent.localEndpoint,
3703
+ model: agent.localModel
3704
+ };
3705
+ if (agent.localApiKey !== void 0) def.apiKey = agent.localApiKey;
3706
+ if (agent.localTimeoutMs !== void 0) def.timeoutMs = agent.localTimeoutMs;
3707
+ if (agent.localProbeIntervalMs !== void 0) def.probeIntervalMs = agent.localProbeIntervalMs;
3708
+ return def;
3709
+ }
3710
+
3711
+ // src/agent/backend-router.ts
3712
+ var BackendRouter = class {
3713
+ backends;
3714
+ routing;
3715
+ constructor(opts) {
3716
+ this.backends = opts.backends;
3717
+ this.routing = opts.routing;
3718
+ this.validateReferences();
3719
+ }
3720
+ /**
3721
+ * Returns the backend name for a given use case.
3722
+ *
3723
+ * - `tier`: per-tier override, falling back to `routing.default`.
3724
+ * - `intelligence`: per-layer override under `routing.intelligence`,
3725
+ * falling back to `routing.default`.
3726
+ * - `maintenance` / `chat`: always `routing.default`.
3727
+ */
3728
+ resolve(useCase) {
3729
+ switch (useCase.kind) {
3730
+ case "tier": {
3731
+ const named = this.routing[useCase.tier];
3732
+ return named ?? this.routing.default;
3733
+ }
3734
+ case "intelligence": {
3735
+ const intel = this.routing.intelligence;
3736
+ return intel?.[useCase.layer] ?? this.routing.default;
3737
+ }
3738
+ case "maintenance":
3739
+ case "chat":
3740
+ return this.routing.default;
3741
+ }
3742
+ }
3743
+ /**
3744
+ * Returns the BackendDef reference for the resolved name. Returns the
3745
+ * exact reference held in `backends` (no copy) so identity comparisons
3746
+ * succeed (SC21).
3747
+ */
3748
+ resolveDefinition(useCase) {
3749
+ const name = this.resolve(useCase);
3750
+ const def = this.backends[name];
3751
+ if (!def) {
3752
+ throw new Error(
3753
+ `BackendRouter.resolveDefinition: routing target '${name}' is not in backends (useCase=${JSON.stringify(useCase)}).`
3754
+ );
3755
+ }
3756
+ return def;
3757
+ }
3758
+ validateReferences() {
3759
+ const known = new Set(Object.keys(this.backends));
3760
+ const missing = [];
3761
+ const check = (path16, name) => {
3762
+ if (name !== void 0 && !known.has(name)) missing.push({ path: path16, name });
3763
+ };
3764
+ check("default", this.routing.default);
3765
+ check("quick-fix", this.routing["quick-fix"]);
3766
+ check("guided-change", this.routing["guided-change"]);
3767
+ check("full-exploration", this.routing["full-exploration"]);
3768
+ check("diagnostic", this.routing.diagnostic);
3769
+ check("intelligence.sel", this.routing.intelligence?.sel);
3770
+ check("intelligence.pesl", this.routing.intelligence?.pesl);
3771
+ if (missing.length > 0) {
3772
+ const detail = missing.map(({ path: path16, name }) => `routing.${path16} -> '${name}'`).join("; ");
3773
+ const known_ = [...known].join(", ") || "(none)";
3774
+ throw new Error(
3775
+ `BackendRouter: routing references unknown backend(s): ${detail}. Defined backends: [${known_}].`
3776
+ );
3777
+ }
3778
+ }
3779
+ };
3780
+
3245
3781
  // src/agent/backends/claude.ts
3246
3782
  var import_node_child_process4 = require("child_process");
3247
3783
  var readline = __toESM(require("readline"));
@@ -3594,10 +4130,120 @@ var ClaudeBackend = class {
3594
4130
  }
3595
4131
  };
3596
4132
 
3597
- // src/agent/backends/openai.ts
3598
- var import_openai = __toESM(require("openai"));
4133
+ // src/agent/backends/anthropic.ts
4134
+ var import_sdk = __toESM(require("@anthropic-ai/sdk"));
3599
4135
  var import_types10 = require("@harness-engineering/types");
3600
4136
  var import_core4 = require("@harness-engineering/core");
4137
+ var AnthropicBackend = class {
4138
+ name = "anthropic";
4139
+ config;
4140
+ client;
4141
+ cacheAdapter;
4142
+ constructor(config = {}) {
4143
+ this.config = {
4144
+ model: config.model ?? "claude-sonnet-4-20250514",
4145
+ apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
4146
+ maxTokens: config.maxTokens ?? 4096
4147
+ };
4148
+ this.client = new import_sdk.default({ apiKey: this.config.apiKey });
4149
+ this.cacheAdapter = new import_core4.AnthropicCacheAdapter();
4150
+ }
4151
+ async startSession(params) {
4152
+ if (!this.config.apiKey) {
4153
+ return (0, import_types10.Err)({
4154
+ category: "agent_not_found",
4155
+ message: "ANTHROPIC_API_KEY is not set"
4156
+ });
4157
+ }
4158
+ const session = {
4159
+ sessionId: `anthropic-session-${Date.now()}`,
4160
+ workspacePath: params.workspacePath,
4161
+ backendName: this.name,
4162
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4163
+ ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
4164
+ };
4165
+ return (0, import_types10.Ok)(session);
4166
+ }
4167
+ async *runTurn(session, params) {
4168
+ const anthropicSession = session;
4169
+ const systemBlocks = anthropicSession.systemPrompt ? [
4170
+ this.cacheAdapter.wrapSystemBlock(
4171
+ anthropicSession.systemPrompt,
4172
+ "session"
4173
+ )
4174
+ ] : void 0;
4175
+ try {
4176
+ const stream = this.client.messages.stream({
4177
+ model: this.config.model,
4178
+ max_tokens: this.config.maxTokens,
4179
+ ...systemBlocks && { system: systemBlocks },
4180
+ messages: [{ role: "user", content: params.prompt }]
4181
+ });
4182
+ for await (const event of stream) {
4183
+ if (event.type === "content_block_delta" && "text" in event.delta) {
4184
+ yield {
4185
+ type: "text",
4186
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4187
+ content: event.delta.text,
4188
+ sessionId: session.sessionId
4189
+ };
4190
+ }
4191
+ }
4192
+ const finalMessage = await stream.finalMessage();
4193
+ const { input_tokens, output_tokens } = finalMessage.usage;
4194
+ const cacheUsage = this.cacheAdapter.parseCacheUsage(finalMessage);
4195
+ const usage = {
4196
+ inputTokens: input_tokens,
4197
+ outputTokens: output_tokens,
4198
+ totalTokens: input_tokens + output_tokens,
4199
+ cacheCreationTokens: cacheUsage.cacheCreationTokens,
4200
+ cacheReadTokens: cacheUsage.cacheReadTokens
4201
+ };
4202
+ yield {
4203
+ type: "usage",
4204
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4205
+ sessionId: session.sessionId,
4206
+ usage
4207
+ };
4208
+ return {
4209
+ success: true,
4210
+ sessionId: session.sessionId,
4211
+ usage
4212
+ };
4213
+ } catch (err) {
4214
+ const errorMessage = err instanceof Error ? err.message : "Anthropic request failed";
4215
+ yield {
4216
+ type: "error",
4217
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4218
+ content: errorMessage,
4219
+ sessionId: session.sessionId
4220
+ };
4221
+ return {
4222
+ success: false,
4223
+ sessionId: session.sessionId,
4224
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
4225
+ error: errorMessage
4226
+ };
4227
+ }
4228
+ }
4229
+ async stopSession(_session) {
4230
+ return (0, import_types10.Ok)(void 0);
4231
+ }
4232
+ async healthCheck() {
4233
+ if (!this.config.apiKey) {
4234
+ return (0, import_types10.Err)({
4235
+ category: "response_error",
4236
+ message: "ANTHROPIC_API_KEY is not set"
4237
+ });
4238
+ }
4239
+ return (0, import_types10.Ok)(void 0);
4240
+ }
4241
+ };
4242
+
4243
+ // src/agent/backends/openai.ts
4244
+ var import_openai = __toESM(require("openai"));
4245
+ var import_types11 = require("@harness-engineering/types");
4246
+ var import_core5 = require("@harness-engineering/core");
3601
4247
  var OpenAIBackend = class {
3602
4248
  name = "openai";
3603
4249
  config;
@@ -3609,11 +4255,11 @@ var OpenAIBackend = class {
3609
4255
  apiKey: config.apiKey ?? process.env.OPENAI_API_KEY ?? ""
3610
4256
  };
3611
4257
  this.client = new import_openai.default({ apiKey: this.config.apiKey });
3612
- this.cacheAdapter = new import_core4.OpenAICacheAdapter();
4258
+ this.cacheAdapter = new import_core5.OpenAICacheAdapter();
3613
4259
  }
3614
4260
  async startSession(params) {
3615
4261
  if (!this.config.apiKey) {
3616
- return (0, import_types10.Err)({
4262
+ return (0, import_types11.Err)({
3617
4263
  category: "agent_not_found",
3618
4264
  message: "OPENAI_API_KEY is not set"
3619
4265
  });
@@ -3625,7 +4271,7 @@ var OpenAIBackend = class {
3625
4271
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3626
4272
  ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3627
4273
  };
3628
- return (0, import_types10.Ok)(session);
4274
+ return (0, import_types11.Ok)(session);
3629
4275
  }
3630
4276
  async *runTurn(session, params) {
3631
4277
  const openAISession = session;
@@ -3701,14 +4347,14 @@ var OpenAIBackend = class {
3701
4347
  };
3702
4348
  }
3703
4349
  async stopSession(_session) {
3704
- return (0, import_types10.Ok)(void 0);
4350
+ return (0, import_types11.Ok)(void 0);
3705
4351
  }
3706
4352
  async healthCheck() {
3707
4353
  try {
3708
4354
  await this.client.models.list();
3709
- return (0, import_types10.Ok)(void 0);
4355
+ return (0, import_types11.Ok)(void 0);
3710
4356
  } catch (err) {
3711
- return (0, import_types10.Err)({
4357
+ return (0, import_types11.Err)({
3712
4358
  category: "response_error",
3713
4359
  message: err instanceof Error ? err.message : "OpenAI health check failed"
3714
4360
  });
@@ -3718,8 +4364,8 @@ var OpenAIBackend = class {
3718
4364
 
3719
4365
  // src/agent/backends/gemini.ts
3720
4366
  var import_generative_ai = require("@google/generative-ai");
3721
- var import_types11 = require("@harness-engineering/types");
3722
- var import_core5 = require("@harness-engineering/core");
4367
+ var import_types12 = require("@harness-engineering/types");
4368
+ var import_core6 = require("@harness-engineering/core");
3723
4369
  var GeminiBackend = class {
3724
4370
  name = "gemini";
3725
4371
  config;
@@ -3729,11 +4375,11 @@ var GeminiBackend = class {
3729
4375
  model: config.model ?? "gemini-2.0-flash",
3730
4376
  apiKey: config.apiKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? ""
3731
4377
  };
3732
- this.cacheAdapter = new import_core5.GeminiCacheAdapter();
4378
+ this.cacheAdapter = new import_core6.GeminiCacheAdapter();
3733
4379
  }
3734
4380
  async startSession(params) {
3735
4381
  if (!this.config.apiKey) {
3736
- return (0, import_types11.Err)({
4382
+ return (0, import_types12.Err)({
3737
4383
  category: "agent_not_found",
3738
4384
  message: "GEMINI_API_KEY is not set"
3739
4385
  });
@@ -3745,7 +4391,7 @@ var GeminiBackend = class {
3745
4391
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3746
4392
  ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3747
4393
  };
3748
- return (0, import_types11.Ok)(session);
4394
+ return (0, import_types12.Ok)(session);
3749
4395
  }
3750
4396
  async *runTurn(session, params) {
3751
4397
  const geminiSession = session;
@@ -3815,132 +4461,22 @@ var GeminiBackend = class {
3815
4461
  success: true,
3816
4462
  sessionId: session.sessionId,
3817
4463
  usage
3818
- };
3819
- }
3820
- async stopSession(_session) {
3821
- return (0, import_types11.Ok)(void 0);
3822
- }
3823
- async healthCheck() {
3824
- try {
3825
- const genAI = new import_generative_ai.GoogleGenerativeAI(this.config.apiKey);
3826
- genAI.getGenerativeModel({ model: this.config.model });
3827
- return (0, import_types11.Ok)(void 0);
3828
- } catch (err) {
3829
- return (0, import_types11.Err)({
3830
- category: "response_error",
3831
- message: err instanceof Error ? err.message : "Gemini health check failed"
3832
- });
3833
- }
3834
- }
3835
- };
3836
-
3837
- // src/agent/backends/anthropic.ts
3838
- var import_sdk = __toESM(require("@anthropic-ai/sdk"));
3839
- var import_types12 = require("@harness-engineering/types");
3840
- var import_core6 = require("@harness-engineering/core");
3841
- var AnthropicBackend = class {
3842
- name = "anthropic";
3843
- config;
3844
- client;
3845
- cacheAdapter;
3846
- constructor(config = {}) {
3847
- this.config = {
3848
- model: config.model ?? "claude-sonnet-4-20250514",
3849
- apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
3850
- maxTokens: config.maxTokens ?? 4096
3851
- };
3852
- this.client = new import_sdk.default({ apiKey: this.config.apiKey });
3853
- this.cacheAdapter = new import_core6.AnthropicCacheAdapter();
3854
- }
3855
- async startSession(params) {
3856
- if (!this.config.apiKey) {
3857
- return (0, import_types12.Err)({
3858
- category: "agent_not_found",
3859
- message: "ANTHROPIC_API_KEY is not set"
3860
- });
3861
- }
3862
- const session = {
3863
- sessionId: `anthropic-session-${Date.now()}`,
3864
- workspacePath: params.workspacePath,
3865
- backendName: this.name,
3866
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3867
- ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3868
- };
3869
- return (0, import_types12.Ok)(session);
3870
- }
3871
- async *runTurn(session, params) {
3872
- const anthropicSession = session;
3873
- const systemBlocks = anthropicSession.systemPrompt ? [
3874
- this.cacheAdapter.wrapSystemBlock(
3875
- anthropicSession.systemPrompt,
3876
- "session"
3877
- )
3878
- ] : void 0;
3879
- try {
3880
- const stream = this.client.messages.stream({
3881
- model: this.config.model,
3882
- max_tokens: this.config.maxTokens,
3883
- ...systemBlocks && { system: systemBlocks },
3884
- messages: [{ role: "user", content: params.prompt }]
3885
- });
3886
- for await (const event of stream) {
3887
- if (event.type === "content_block_delta" && "text" in event.delta) {
3888
- yield {
3889
- type: "text",
3890
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3891
- content: event.delta.text,
3892
- sessionId: session.sessionId
3893
- };
3894
- }
3895
- }
3896
- const finalMessage = await stream.finalMessage();
3897
- const { input_tokens, output_tokens } = finalMessage.usage;
3898
- const cacheUsage = this.cacheAdapter.parseCacheUsage(finalMessage);
3899
- const usage = {
3900
- inputTokens: input_tokens,
3901
- outputTokens: output_tokens,
3902
- totalTokens: input_tokens + output_tokens,
3903
- cacheCreationTokens: cacheUsage.cacheCreationTokens,
3904
- cacheReadTokens: cacheUsage.cacheReadTokens
3905
- };
3906
- yield {
3907
- type: "usage",
3908
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3909
- sessionId: session.sessionId,
3910
- usage
3911
- };
3912
- return {
3913
- success: true,
3914
- sessionId: session.sessionId,
3915
- usage
3916
- };
3917
- } catch (err) {
3918
- const errorMessage = err instanceof Error ? err.message : "Anthropic request failed";
3919
- yield {
3920
- type: "error",
3921
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3922
- content: errorMessage,
3923
- sessionId: session.sessionId
3924
- };
3925
- return {
3926
- success: false,
3927
- sessionId: session.sessionId,
3928
- usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
3929
- error: errorMessage
3930
- };
3931
- }
4464
+ };
3932
4465
  }
3933
4466
  async stopSession(_session) {
3934
4467
  return (0, import_types12.Ok)(void 0);
3935
4468
  }
3936
4469
  async healthCheck() {
3937
- if (!this.config.apiKey) {
4470
+ try {
4471
+ const genAI = new import_generative_ai.GoogleGenerativeAI(this.config.apiKey);
4472
+ genAI.getGenerativeModel({ model: this.config.model });
4473
+ return (0, import_types12.Ok)(void 0);
4474
+ } catch (err) {
3938
4475
  return (0, import_types12.Err)({
3939
4476
  category: "response_error",
3940
- message: "ANTHROPIC_API_KEY is not set"
4477
+ message: err instanceof Error ? err.message : "Gemini health check failed"
3941
4478
  });
3942
4479
  }
3943
- return (0, import_types12.Ok)(void 0);
3944
4480
  }
3945
4481
  };
3946
4482
 
@@ -3951,6 +4487,7 @@ var DEFAULT_TIMEOUT_MS = 9e4;
3951
4487
  var LocalBackend = class {
3952
4488
  name = "local";
3953
4489
  config;
4490
+ getModel;
3954
4491
  client;
3955
4492
  constructor(config = {}) {
3956
4493
  this.config = {
@@ -3959,6 +4496,7 @@ var LocalBackend = class {
3959
4496
  apiKey: config.apiKey ?? "ollama",
3960
4497
  timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
3961
4498
  };
4499
+ this.getModel = config.getModel;
3962
4500
  this.client = new import_openai2.default({
3963
4501
  apiKey: this.config.apiKey,
3964
4502
  baseURL: this.config.endpoint,
@@ -3966,11 +4504,25 @@ var LocalBackend = class {
3966
4504
  });
3967
4505
  }
3968
4506
  async startSession(params) {
4507
+ let resolvedModel;
4508
+ if (this.getModel) {
4509
+ const candidate = this.getModel();
4510
+ if (candidate === null) {
4511
+ return (0, import_types13.Err)({
4512
+ category: "agent_not_found",
4513
+ message: "No local model available; check dashboard for details."
4514
+ });
4515
+ }
4516
+ resolvedModel = candidate;
4517
+ } else {
4518
+ resolvedModel = this.config.model;
4519
+ }
3969
4520
  const session = {
3970
4521
  sessionId: `local-session-${Date.now()}`,
3971
4522
  workspacePath: params.workspacePath,
3972
4523
  backendName: this.name,
3973
4524
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4525
+ resolvedModel,
3974
4526
  ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3975
4527
  };
3976
4528
  return (0, import_types13.Ok)(session);
@@ -3987,7 +4539,7 @@ var LocalBackend = class {
3987
4539
  let totalTokens = 0;
3988
4540
  try {
3989
4541
  const stream = await this.client.chat.completions.create({
3990
- model: this.config.model,
4542
+ model: localSession.resolvedModel,
3991
4543
  messages,
3992
4544
  stream: true,
3993
4545
  stream_options: { include_usage: true }
@@ -4152,13 +4704,41 @@ function buildLocalModel(config) {
4152
4704
  var PiBackend = class {
4153
4705
  name = "pi";
4154
4706
  config;
4707
+ /**
4708
+ * Per-request timeout in ms (default 90_000). Spec 2 P2-I1: enforced at
4709
+ * the request boundary by `runTurn` racing `piSession.prompt()` against
4710
+ * an `AbortController + setTimeout(timeoutMs)`. On timeout the
4711
+ * underlying pi session is aborted and the turn returns a failed
4712
+ * `TurnResult` carrying a timeout-tagged error message. Setting
4713
+ * `timeoutMs: 0` disables the watchdog (preserves the pre-fix-up
4714
+ * "no enforcement" behavior for callers that want the SDK default).
4715
+ */
4716
+ timeoutMs;
4155
4717
  constructor(config = {}) {
4156
4718
  this.config = config;
4719
+ this.timeoutMs = config.timeoutMs ?? 9e4;
4157
4720
  }
4158
4721
  async startSession(params) {
4159
4722
  try {
4723
+ let resolvedModelName;
4724
+ if (this.config.getModel) {
4725
+ const candidate = this.config.getModel();
4726
+ if (candidate === null) {
4727
+ return (0, import_types14.Err)({
4728
+ category: "agent_not_found",
4729
+ message: "No local model available; check dashboard for details."
4730
+ });
4731
+ }
4732
+ resolvedModelName = candidate;
4733
+ } else {
4734
+ resolvedModelName = this.config.model;
4735
+ }
4160
4736
  const piSdk = await import("@mariozechner/pi-coding-agent");
4161
- const model = buildLocalModel(this.config);
4737
+ const model = buildLocalModel({
4738
+ model: resolvedModelName,
4739
+ endpoint: this.config.endpoint,
4740
+ apiKey: this.config.apiKey
4741
+ });
4162
4742
  const { session: piSession } = await piSdk.createAgentSession({
4163
4743
  cwd: params.workspacePath,
4164
4744
  ...model !== void 0 && { model },
@@ -4198,15 +4778,45 @@ var PiBackend = class {
4198
4778
  signal();
4199
4779
  });
4200
4780
  session.unsubscribe = unsubscribe;
4201
- const promptPromise = piSession.prompt(params.prompt).then(
4202
- () => {
4781
+ let timeoutHandle = null;
4782
+ let timedOut = false;
4783
+ if (this.timeoutMs > 0) {
4784
+ timeoutHandle = setTimeout(() => {
4785
+ timedOut = true;
4786
+ promptErrorMsg = `Pi backend request timed out after ${this.timeoutMs}ms`;
4203
4787
  promptDone = true;
4788
+ try {
4789
+ const maybeAbort = piSession.abort?.();
4790
+ if (maybeAbort && typeof maybeAbort.catch === "function") {
4791
+ maybeAbort.catch(() => {
4792
+ });
4793
+ }
4794
+ } catch {
4795
+ }
4204
4796
  signal();
4797
+ }, this.timeoutMs);
4798
+ }
4799
+ const clearTimeoutHandle = () => {
4800
+ if (timeoutHandle !== null) {
4801
+ clearTimeout(timeoutHandle);
4802
+ timeoutHandle = null;
4803
+ }
4804
+ };
4805
+ const promptPromise = piSession.prompt(params.prompt).then(
4806
+ () => {
4807
+ if (!timedOut) {
4808
+ clearTimeoutHandle();
4809
+ promptDone = true;
4810
+ signal();
4811
+ }
4205
4812
  },
4206
4813
  (err) => {
4207
- promptErrorMsg = err.message;
4208
- promptDone = true;
4209
- signal();
4814
+ if (!timedOut) {
4815
+ clearTimeoutHandle();
4816
+ promptErrorMsg = err.message;
4817
+ promptDone = true;
4818
+ signal();
4819
+ }
4210
4820
  }
4211
4821
  );
4212
4822
  let inputTokens = 0;
@@ -4222,12 +4832,15 @@ var PiBackend = class {
4222
4832
  })
4223
4833
  });
4224
4834
  } finally {
4835
+ clearTimeoutHandle();
4225
4836
  resolveWait?.();
4226
4837
  resolveWait = null;
4227
4838
  unsubscribe();
4228
4839
  session.unsubscribe = null;
4229
- await promptPromise.catch(() => {
4230
- });
4840
+ if (!timedOut) {
4841
+ await promptPromise.catch(() => {
4842
+ });
4843
+ }
4231
4844
  }
4232
4845
  const totalTokens = inputTokens + outputTokens;
4233
4846
  if (promptErrorMsg) {
@@ -4287,6 +4900,60 @@ var PiBackend = class {
4287
4900
  }
4288
4901
  };
4289
4902
 
4903
+ // src/agent/backend-factory.ts
4904
+ function makeGetModel(model) {
4905
+ if (typeof model === "string") return () => model;
4906
+ if (Array.isArray(model) && model.length > 0) return () => model[0] ?? null;
4907
+ return () => null;
4908
+ }
4909
+ function createBackend(def) {
4910
+ switch (def.type) {
4911
+ case "mock":
4912
+ return new MockBackend();
4913
+ case "claude":
4914
+ return new ClaudeBackend(def.command ?? "claude");
4915
+ case "anthropic":
4916
+ return new AnthropicBackend({
4917
+ model: def.model,
4918
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {}
4919
+ });
4920
+ case "openai":
4921
+ return new OpenAIBackend({
4922
+ model: def.model,
4923
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {}
4924
+ });
4925
+ case "gemini":
4926
+ return new GeminiBackend({
4927
+ model: def.model,
4928
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {}
4929
+ });
4930
+ case "local": {
4931
+ const isArray = Array.isArray(def.model);
4932
+ return new LocalBackend({
4933
+ endpoint: def.endpoint,
4934
+ ...typeof def.model === "string" ? { model: def.model } : {},
4935
+ ...isArray ? { getModel: makeGetModel(def.model) } : {},
4936
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
4937
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
4938
+ });
4939
+ }
4940
+ case "pi": {
4941
+ const isArray = Array.isArray(def.model);
4942
+ return new PiBackend({
4943
+ endpoint: def.endpoint,
4944
+ ...typeof def.model === "string" ? { model: def.model } : {},
4945
+ ...isArray ? { getModel: makeGetModel(def.model) } : {},
4946
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
4947
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
4948
+ });
4949
+ }
4950
+ default: {
4951
+ const exhaustive = def;
4952
+ throw new Error(`createBackend: unknown backend type ${JSON.stringify(exhaustive)}`);
4953
+ }
4954
+ }
4955
+ }
4956
+
4290
4957
  // src/agent/backends/container.ts
4291
4958
  var import_types15 = require("@harness-engineering/types");
4292
4959
  function toAgentError(message, details) {
@@ -4648,6 +5315,191 @@ function createSecretBackend(config) {
4648
5315
  }
4649
5316
  }
4650
5317
 
5318
+ // src/agent/orchestrator-backend-factory.ts
5319
+ var OrchestratorBackendFactory = class {
5320
+ router;
5321
+ opts;
5322
+ constructor(opts) {
5323
+ this.opts = opts;
5324
+ this.router = new BackendRouter({ backends: opts.backends, routing: opts.routing });
5325
+ }
5326
+ /**
5327
+ * Resolve `useCase` to a backend name, materialize a fresh
5328
+ * `AgentBackend`, optionally rebind its model resolver, and apply
5329
+ * sandbox wrapping. Idempotent across calls (no caching) — the AgentRunner
5330
+ * holds the per-dispatch reference and discards it when the run ends.
5331
+ */
5332
+ /**
5333
+ * Resolve `useCase` to its routed backend name, exposing the
5334
+ * router lookup without materializing a backend. Used by callers
5335
+ * (e.g., the orchestrator's dispatch site) that need to label
5336
+ * telemetry with the routed name BEFORE constructing the backend.
5337
+ *
5338
+ * Spec 2 P2-I2: previously the orchestrator labelled `LiveSession`
5339
+ * + `StreamRecorder` with the legacy `agent.backend` field, which
5340
+ * is `undefined` for pure-modern configs. Threading the routed name
5341
+ * through dispatch eliminates that gap.
5342
+ */
5343
+ resolveName(useCase) {
5344
+ return this.router.resolve(useCase);
5345
+ }
5346
+ forUseCase(useCase) {
5347
+ const def = this.router.resolveDefinition(useCase);
5348
+ const name = this.router.resolve(useCase);
5349
+ let backend;
5350
+ if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
5351
+ const getModel = this.opts.getResolverModelFor(name);
5352
+ backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def);
5353
+ } else {
5354
+ backend = createBackend(def);
5355
+ }
5356
+ if (this.opts.sandboxPolicy === "docker" && this.opts.container) {
5357
+ backend = this.wrapInContainer(backend);
5358
+ }
5359
+ return backend;
5360
+ }
5361
+ /**
5362
+ * Rebuild a `local`/`pi` backend with a resolver-bound `getModel`,
5363
+ * mirroring `createBackend`'s local/pi branches but substituting the
5364
+ * head-of-array placeholder with the orchestrator-owned resolver.
5365
+ */
5366
+ buildLocalLikeWithResolver(def, getModel) {
5367
+ if (def.type === "local") {
5368
+ return new LocalBackend({
5369
+ endpoint: def.endpoint,
5370
+ getModel,
5371
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
5372
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
5373
+ });
5374
+ }
5375
+ if (def.type === "pi") {
5376
+ return new PiBackend({
5377
+ endpoint: def.endpoint,
5378
+ getModel,
5379
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
5380
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
5381
+ });
5382
+ }
5383
+ throw new Error(
5384
+ `OrchestratorBackendFactory.buildLocalLikeWithResolver called with non-local def.type='${def.type}'`
5385
+ );
5386
+ }
5387
+ /**
5388
+ * Apply ContainerBackend wrapping (PFC-3). Pulls the runtime + secret
5389
+ * backend per call so each dispatch sees a fresh container handle map
5390
+ * (ContainerBackend keeps its own per-instance Map<sessionId, handle>).
5391
+ */
5392
+ wrapInContainer(inner) {
5393
+ const runtime = new DockerRuntime();
5394
+ const secretBackend = this.opts.secrets ? createSecretBackend(this.opts.secrets) : null;
5395
+ const secretKeys = this.opts.secrets?.keys ?? [];
5396
+ return new ContainerBackend(
5397
+ inner,
5398
+ runtime,
5399
+ secretBackend,
5400
+ this.opts.container,
5401
+ secretKeys
5402
+ );
5403
+ }
5404
+ };
5405
+
5406
+ // src/agent/analysis-provider-factory.ts
5407
+ var import_intelligence2 = require("@harness-engineering/intelligence");
5408
+ function buildAnalysisProvider(args) {
5409
+ const { def, backendName, layer, intelligence, logger } = args;
5410
+ const layerModel = layer === "sel" ? intelligence?.models?.sel : intelligence?.models?.pesl;
5411
+ switch (def.type) {
5412
+ case "local":
5413
+ case "pi":
5414
+ return buildLocalLikeProvider(def, args, layerModel);
5415
+ case "anthropic":
5416
+ return buildAnthropicProvider(def, args, layerModel);
5417
+ case "openai":
5418
+ return buildOpenAIProvider(def, args, layerModel);
5419
+ case "claude":
5420
+ return buildClaudeCliProvider(def, args, layerModel);
5421
+ case "mock":
5422
+ case "gemini":
5423
+ logger.warn(
5424
+ `Intelligence pipeline disabled for layer '${layer}': routed backend '${backendName}' has type '${def.type}' which has no AnalysisProvider implementation.`
5425
+ );
5426
+ return null;
5427
+ }
5428
+ }
5429
+ function buildLocalLikeProvider(def, args, layerModel) {
5430
+ const { backendName, getResolverStatusSnapshot, intelligence, logger } = args;
5431
+ const snapshot = getResolverStatusSnapshot();
5432
+ if (!snapshot || !snapshot.available) {
5433
+ const configured = snapshot?.configured ?? [];
5434
+ const detected = snapshot?.detected ?? [];
5435
+ logger.warn(
5436
+ `Intelligence pipeline disabled for backend '${backendName}' at ${def.endpoint}: no configured local model loaded. Configured: [${configured.join(", ")}]. Detected: [${detected.join(", ")}].`
5437
+ );
5438
+ return null;
5439
+ }
5440
+ const model = layerModel ?? snapshot.resolved ?? void 0;
5441
+ const apiKey = def.apiKey ?? "ollama";
5442
+ logger.info(
5443
+ `Intelligence pipeline using backend '${backendName}' (${def.type}) at ${def.endpoint} (model: ${model ?? "(default)"})`
5444
+ );
5445
+ return new import_intelligence2.OpenAICompatibleAnalysisProvider({
5446
+ apiKey,
5447
+ baseUrl: def.endpoint,
5448
+ ...model !== void 0 && { defaultModel: model },
5449
+ ...intelligence?.requestTimeoutMs !== void 0 && {
5450
+ timeoutMs: intelligence.requestTimeoutMs
5451
+ },
5452
+ ...intelligence?.promptSuffix !== void 0 && { promptSuffix: intelligence.promptSuffix },
5453
+ ...intelligence?.jsonMode !== void 0 && { jsonMode: intelligence.jsonMode }
5454
+ });
5455
+ }
5456
+ function buildAnthropicProvider(def, args, layerModel) {
5457
+ const apiKey = def.apiKey ?? process.env.ANTHROPIC_API_KEY;
5458
+ const model = layerModel ?? def.model;
5459
+ if (apiKey) {
5460
+ return new import_intelligence2.AnthropicAnalysisProvider({
5461
+ apiKey,
5462
+ ...model !== void 0 && { defaultModel: model }
5463
+ });
5464
+ }
5465
+ args.logger.info(
5466
+ `Intelligence pipeline routed to '${args.backendName}' (anthropic) without API key \u2014 using Claude CLI fallback.`
5467
+ );
5468
+ return new import_intelligence2.ClaudeCliAnalysisProvider({
5469
+ ...model !== void 0 && { defaultModel: model },
5470
+ ...args.intelligence?.requestTimeoutMs !== void 0 && {
5471
+ timeoutMs: args.intelligence.requestTimeoutMs
5472
+ }
5473
+ });
5474
+ }
5475
+ function buildOpenAIProvider(def, args, layerModel) {
5476
+ const apiKey = def.apiKey ?? process.env.OPENAI_API_KEY;
5477
+ if (!apiKey) {
5478
+ args.logger.warn(
5479
+ `Intelligence pipeline disabled for backend '${args.backendName}' (openai): no API key configured.`
5480
+ );
5481
+ return null;
5482
+ }
5483
+ const model = layerModel ?? def.model;
5484
+ return new import_intelligence2.OpenAICompatibleAnalysisProvider({
5485
+ apiKey,
5486
+ baseUrl: "https://api.openai.com/v1",
5487
+ ...model !== void 0 && { defaultModel: model },
5488
+ ...args.intelligence?.requestTimeoutMs !== void 0 && {
5489
+ timeoutMs: args.intelligence.requestTimeoutMs
5490
+ }
5491
+ });
5492
+ }
5493
+ function buildClaudeCliProvider(def, args, layerModel) {
5494
+ return new import_intelligence2.ClaudeCliAnalysisProvider({
5495
+ ...def.command !== void 0 && { command: def.command },
5496
+ ...layerModel !== void 0 && { defaultModel: layerModel },
5497
+ ...args.intelligence?.requestTimeoutMs !== void 0 && {
5498
+ timeoutMs: args.intelligence.requestTimeoutMs
5499
+ }
5500
+ });
5501
+ }
5502
+
4651
5503
  // src/server/http.ts
4652
5504
  var http = __toESM(require("http"));
4653
5505
  var path13 = __toESM(require("path"));
@@ -4707,7 +5559,7 @@ var WebSocketBroadcaster = class {
4707
5559
  };
4708
5560
 
4709
5561
  // src/server/routes/interactions.ts
4710
- var import_zod = require("zod");
5562
+ var import_zod3 = require("zod");
4711
5563
 
4712
5564
  // src/server/utils.ts
4713
5565
  var DEFAULT_MAX_BYTES = 1048576;
@@ -4730,8 +5582,8 @@ function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
4730
5582
  }
4731
5583
 
4732
5584
  // src/server/routes/interactions.ts
4733
- var InteractionUpdateSchema = import_zod.z.object({
4734
- status: import_zod.z.enum(["pending", "claimed", "resolved"])
5585
+ var InteractionUpdateSchema = import_zod3.z.object({
5586
+ status: import_zod3.z.enum(["pending", "claimed", "resolved"])
4735
5587
  });
4736
5588
  var SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
4737
5589
  function sendJson(res, status, body) {
@@ -4782,12 +5634,12 @@ function handleInteractionsRoute(req, res, queue) {
4782
5634
  }
4783
5635
 
4784
5636
  // src/server/routes/plans.ts
4785
- var import_zod2 = require("zod");
5637
+ var import_zod4 = require("zod");
4786
5638
  var fs9 = __toESM(require("fs/promises"));
4787
5639
  var path9 = __toESM(require("path"));
4788
- var PlanWriteSchema = import_zod2.z.object({
4789
- filename: import_zod2.z.string().min(1),
4790
- content: import_zod2.z.string().min(1)
5640
+ var PlanWriteSchema = import_zod4.z.object({
5641
+ filename: import_zod4.z.string().min(1),
5642
+ content: import_zod4.z.string().min(1)
4791
5643
  });
4792
5644
  function handlePlansRoute(req, res, plansDir) {
4793
5645
  const { method, url } = req;
@@ -4831,7 +5683,7 @@ function handlePlansRoute(req, res, plansDir) {
4831
5683
  var import_node_child_process8 = require("child_process");
4832
5684
  var import_node_crypto5 = require("crypto");
4833
5685
  var readline2 = __toESM(require("readline"));
4834
- var import_zod3 = require("zod");
5686
+ var import_zod5 = require("zod");
4835
5687
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4836
5688
  var SAFE_ENV_PREFIXES = [
4837
5689
  "PATH",
@@ -4866,10 +5718,10 @@ function buildChildEnv() {
4866
5718
  }
4867
5719
  return env;
4868
5720
  }
4869
- var ChatRequestSchema = import_zod3.z.object({
4870
- prompt: import_zod3.z.string().min(1),
4871
- system: import_zod3.z.string().optional(),
4872
- sessionId: import_zod3.z.string().regex(UUID_RE).optional()
5721
+ var ChatRequestSchema = import_zod5.z.object({
5722
+ prompt: import_zod5.z.string().min(1),
5723
+ system: import_zod5.z.string().optional(),
5724
+ sessionId: import_zod5.z.string().regex(UUID_RE).optional()
4873
5725
  });
4874
5726
  function handleChatProxyRoute(req, res, command = "claude") {
4875
5727
  const { method, url } = req;
@@ -5078,12 +5930,12 @@ function extractChunks(event) {
5078
5930
  }
5079
5931
 
5080
5932
  // src/server/routes/analyze.ts
5081
- var import_intelligence2 = require("@harness-engineering/intelligence");
5082
- var import_zod4 = require("zod");
5083
- var AnalyzeRequestSchema = import_zod4.z.object({
5084
- title: import_zod4.z.string().min(1),
5085
- description: import_zod4.z.string().optional(),
5086
- labels: import_zod4.z.array(import_zod4.z.string()).optional()
5933
+ var import_intelligence3 = require("@harness-engineering/intelligence");
5934
+ var import_zod6 = require("zod");
5935
+ var AnalyzeRequestSchema = import_zod6.z.object({
5936
+ title: import_zod6.z.string().min(1),
5937
+ description: import_zod6.z.string().optional(),
5938
+ labels: import_zod6.z.array(import_zod6.z.string()).optional()
5087
5939
  });
5088
5940
  function emit2(res, event) {
5089
5941
  res.write(`data: ${JSON.stringify(event)}
@@ -5108,7 +5960,7 @@ async function runPipeline(res, pipeline, parsed) {
5108
5960
  disconnected = true;
5109
5961
  });
5110
5962
  emit2(res, { type: "status", text: "Converting to work item..." });
5111
- const rawItem = (0, import_intelligence2.manualToRawWorkItem)({
5963
+ const rawItem = (0, import_intelligence3.manualToRawWorkItem)({
5112
5964
  title: parsed.title,
5113
5965
  description: parsed.description ?? "",
5114
5966
  labels: parsed.labels ?? []
@@ -5151,7 +6003,7 @@ async function runPipeline(res, pipeline, parsed) {
5151
6003
  }
5152
6004
  }
5153
6005
  if (disconnected) return;
5154
- const signals = (0, import_intelligence2.scoreToConcernSignals)(score);
6006
+ const signals = (0, import_intelligence3.scoreToConcernSignals)(score);
5155
6007
  if (signals.length > 0) {
5156
6008
  emit2(res, { type: "signals", data: signals });
5157
6009
  }
@@ -5191,19 +6043,19 @@ function handleAnalyzeRoute(req, res, pipeline) {
5191
6043
  // src/server/routes/roadmap-actions.ts
5192
6044
  var fs10 = __toESM(require("fs/promises"));
5193
6045
  var import_core7 = require("@harness-engineering/core");
5194
- var import_zod5 = require("zod");
5195
- var AppendRoadmapRequestSchema = import_zod5.z.object({
5196
- title: import_zod5.z.string().min(1),
5197
- summary: import_zod5.z.string().optional(),
5198
- labels: import_zod5.z.array(import_zod5.z.string()).optional(),
5199
- enrichedSpec: import_zod5.z.object({
5200
- intent: import_zod5.z.string(),
5201
- unknowns: import_zod5.z.array(import_zod5.z.string()),
5202
- ambiguities: import_zod5.z.array(import_zod5.z.string()),
5203
- riskSignals: import_zod5.z.array(import_zod5.z.string()),
5204
- affectedSystems: import_zod5.z.array(import_zod5.z.object({ name: import_zod5.z.string() }))
6046
+ var import_zod7 = require("zod");
6047
+ var AppendRoadmapRequestSchema = import_zod7.z.object({
6048
+ title: import_zod7.z.string().min(1),
6049
+ summary: import_zod7.z.string().optional(),
6050
+ labels: import_zod7.z.array(import_zod7.z.string()).optional(),
6051
+ enrichedSpec: import_zod7.z.object({
6052
+ intent: import_zod7.z.string(),
6053
+ unknowns: import_zod7.z.array(import_zod7.z.string()),
6054
+ ambiguities: import_zod7.z.array(import_zod7.z.string()),
6055
+ riskSignals: import_zod7.z.array(import_zod7.z.string()),
6056
+ affectedSystems: import_zod7.z.array(import_zod7.z.object({ name: import_zod7.z.string() }))
5205
6057
  }).optional(),
5206
- cmlRecommendedRoute: import_zod5.z.enum(["local", "human", "simulation-required"]).optional()
6058
+ cmlRecommendedRoute: import_zod7.z.enum(["local", "human", "simulation-required"]).optional()
5207
6059
  });
5208
6060
  function sendJSON(res, status, body) {
5209
6061
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -5274,11 +6126,11 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
5274
6126
 
5275
6127
  // src/server/routes/dispatch-actions.ts
5276
6128
  var import_node_crypto6 = require("crypto");
5277
- var import_zod6 = require("zod");
5278
- var DispatchAdHocRequestSchema = import_zod6.z.object({
5279
- title: import_zod6.z.string().min(1),
5280
- description: import_zod6.z.string().optional(),
5281
- labels: import_zod6.z.array(import_zod6.z.string()).optional()
6129
+ var import_zod8 = require("zod");
6130
+ var DispatchAdHocRequestSchema = import_zod8.z.object({
6131
+ title: import_zod8.z.string().min(1),
6132
+ description: import_zod8.z.string().optional(),
6133
+ labels: import_zod8.z.array(import_zod8.z.string()).optional()
5282
6134
  });
5283
6135
  function sendJSON2(res, status, body) {
5284
6136
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -5375,9 +6227,9 @@ function handleAnalysesRoute(req, res, archive) {
5375
6227
  }
5376
6228
 
5377
6229
  // src/server/routes/maintenance.ts
5378
- var import_zod7 = require("zod");
5379
- var TriggerRequestSchema = import_zod7.z.object({
5380
- taskId: import_zod7.z.string().min(1)
6230
+ var import_zod9 = require("zod");
6231
+ var TriggerRequestSchema = import_zod9.z.object({
6232
+ taskId: import_zod9.z.string().min(1)
5381
6233
  });
5382
6234
  function sendJSON3(res, status, body) {
5383
6235
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -5456,9 +6308,9 @@ function handleMaintenanceRoute(req, res, deps) {
5456
6308
  // src/server/routes/sessions.ts
5457
6309
  var fs11 = __toESM(require("fs/promises"));
5458
6310
  var path10 = __toESM(require("path"));
5459
- var import_zod8 = require("zod");
5460
- var SessionCreateSchema = import_zod8.z.object({
5461
- sessionId: import_zod8.z.string().min(1)
6311
+ var import_zod10 = require("zod");
6312
+ var SessionCreateSchema = import_zod10.z.object({
6313
+ sessionId: import_zod10.z.string().min(1)
5462
6314
  }).passthrough();
5463
6315
  var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5464
6316
  function isSafeId(id) {
@@ -5545,7 +6397,7 @@ async function handleUpdate(req, res, url, sessionsDir) {
5545
6397
  return;
5546
6398
  }
5547
6399
  const body = await readBody(req);
5548
- const updates = import_zod8.z.record(import_zod8.z.unknown()).parse(JSON.parse(body));
6400
+ const updates = import_zod10.z.record(import_zod10.z.unknown()).parse(JSON.parse(body));
5549
6401
  const sessionFilePath = path10.join(sessionsDir, id, "session.json");
5550
6402
  const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
5551
6403
  await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
@@ -5664,6 +6516,42 @@ function handleStreamsRoute(req, res, recorder) {
5664
6516
  return true;
5665
6517
  }
5666
6518
 
6519
+ // src/server/routes/local-model.ts
6520
+ function sendJSON4(res, status, body) {
6521
+ res.writeHead(status, { "Content-Type": "application/json" });
6522
+ res.end(JSON.stringify(body));
6523
+ }
6524
+ function handleLocalModelRoute(req, res, getStatus) {
6525
+ const { method, url } = req;
6526
+ if (url !== "/api/v1/local-model/status") return false;
6527
+ if (method !== "GET") {
6528
+ sendJSON4(res, 405, { error: "Method not allowed" });
6529
+ return true;
6530
+ }
6531
+ if (!getStatus) {
6532
+ sendJSON4(res, 503, { error: "Local backend not configured" });
6533
+ return true;
6534
+ }
6535
+ const status = getStatus();
6536
+ if (!status) {
6537
+ sendJSON4(res, 503, { error: "Local backend not configured" });
6538
+ return true;
6539
+ }
6540
+ sendJSON4(res, 200, status);
6541
+ return true;
6542
+ }
6543
+ function handleLocalModelsRoute(req, res, getStatuses) {
6544
+ const { method, url } = req;
6545
+ if (url !== "/api/v1/local-models/status") return false;
6546
+ if (method !== "GET") {
6547
+ sendJSON4(res, 405, { error: "Method not allowed" });
6548
+ return true;
6549
+ }
6550
+ const statuses = getStatuses ? getStatuses() : [];
6551
+ sendJSON4(res, 200, statuses);
6552
+ return true;
6553
+ }
6554
+
5667
6555
  // src/server/static.ts
5668
6556
  var fs12 = __toESM(require("fs"));
5669
6557
  var path11 = __toESM(require("path"));
@@ -5817,6 +6705,8 @@ var OrchestratorServer = class {
5817
6705
  dispatchAdHoc;
5818
6706
  sessionsDir;
5819
6707
  maintenanceDeps = null;
6708
+ getLocalModelStatus = null;
6709
+ getLocalModelStatuses = null;
5820
6710
  recorder = null;
5821
6711
  planWatcher = null;
5822
6712
  stateChangeListener;
@@ -5843,6 +6733,8 @@ var OrchestratorServer = class {
5843
6733
  this.dispatchAdHoc = deps?.dispatchAdHoc ?? null;
5844
6734
  this.sessionsDir = deps?.sessionsDir ?? path13.resolve(".harness", "sessions");
5845
6735
  this.maintenanceDeps = deps?.maintenanceDeps ?? null;
6736
+ this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
6737
+ this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
5846
6738
  }
5847
6739
  wireEvents() {
5848
6740
  this.stateChangeListener = (snapshot) => {
@@ -5869,6 +6761,31 @@ var OrchestratorServer = class {
5869
6761
  broadcastMaintenance(type, data) {
5870
6762
  this.broadcaster.broadcast(type, data);
5871
6763
  }
6764
+ /**
6765
+ * Broadcast a local-model status change to dashboard clients.
6766
+ *
6767
+ * Phase 3 routes status events through the existing WebSocket broadcaster
6768
+ * on topic `local-model:status` so test fixtures and dashboard consumers
6769
+ * observe payloads immediately. The project broadcasts via WebSocket; the
6770
+ * spec's "SSE topic" wording is approximate. Phase 5 widens the payload
6771
+ * to `NamedLocalModelStatus` (with `backendName` + `endpoint`); the channel
6772
+ * and bind-before-probe ordering are unchanged.
6773
+ */
6774
+ broadcastLocalModelStatus(status) {
6775
+ this.broadcaster.broadcast("local-model:status", status);
6776
+ }
6777
+ /**
6778
+ * Update the intelligence pipeline reference after construction.
6779
+ *
6780
+ * The orchestrator constructs the pipeline lazily inside `start()` (the
6781
+ * resolver must observe the server before pipeline construction). The
6782
+ * server is built in the orchestrator constructor with `pipeline: null`,
6783
+ * so it must be told the real pipeline once it's been created — otherwise
6784
+ * `/api/analyze` would always see a null pipeline and return 503.
6785
+ */
6786
+ setPipeline(pipeline) {
6787
+ this.pipeline = pipeline;
6788
+ }
5872
6789
  /**
5873
6790
  * Set (or update) the maintenance route dependencies after construction.
5874
6791
  * Called by the Orchestrator once the scheduler and reporter are ready.
@@ -5945,6 +6862,12 @@ var OrchestratorServer = class {
5945
6862
  if (handleDispatchActionsRoute(req, res, this.dispatchAdHoc)) {
5946
6863
  return true;
5947
6864
  }
6865
+ if (handleLocalModelRoute(req, res, this.getLocalModelStatus)) {
6866
+ return true;
6867
+ }
6868
+ if (handleLocalModelsRoute(req, res, this.getLocalModelStatuses)) {
6869
+ return true;
6870
+ }
5948
6871
  if (handleMaintenanceRoute(req, res, this.maintenanceDeps)) {
5949
6872
  return true;
5950
6873
  }
@@ -6509,17 +7432,17 @@ var MaintenanceScheduler = class {
6509
7432
  // src/maintenance/reporter.ts
6510
7433
  var fs14 = __toESM(require("fs"));
6511
7434
  var path14 = __toESM(require("path"));
6512
- var import_zod9 = require("zod");
6513
- var RunResultSchema = import_zod9.z.object({
6514
- taskId: import_zod9.z.string(),
6515
- startedAt: import_zod9.z.string(),
6516
- completedAt: import_zod9.z.string(),
6517
- status: import_zod9.z.enum(["success", "failure", "skipped", "no-issues"]),
6518
- findings: import_zod9.z.number(),
6519
- fixed: import_zod9.z.number(),
6520
- prUrl: import_zod9.z.string().nullable(),
6521
- prUpdated: import_zod9.z.boolean(),
6522
- error: import_zod9.z.string().optional()
7435
+ var import_zod11 = require("zod");
7436
+ var RunResultSchema = import_zod11.z.object({
7437
+ taskId: import_zod11.z.string(),
7438
+ startedAt: import_zod11.z.string(),
7439
+ completedAt: import_zod11.z.string(),
7440
+ status: import_zod11.z.enum(["success", "failure", "skipped", "no-issues"]),
7441
+ findings: import_zod11.z.number(),
7442
+ fixed: import_zod11.z.number(),
7443
+ prUrl: import_zod11.z.string().nullable(),
7444
+ prUpdated: import_zod11.z.boolean(),
7445
+ error: import_zod11.z.string().optional()
6523
7446
  });
6524
7447
  var MAX_HISTORY = 500;
6525
7448
  var fallbackLogger = {
@@ -6546,7 +7469,7 @@ var MaintenanceReporter = class {
6546
7469
  await fs14.promises.mkdir(this.persistDir, { recursive: true });
6547
7470
  const filePath = path14.join(this.persistDir, "history.json");
6548
7471
  const data = await fs14.promises.readFile(filePath, "utf-8");
6549
- const parsed = import_zod9.z.array(RunResultSchema).safeParse(JSON.parse(data));
7472
+ const parsed = import_zod11.z.array(RunResultSchema).safeParse(JSON.parse(data));
6550
7473
  if (parsed.success) {
6551
7474
  this.history = parsed.data.slice(0, MAX_HISTORY);
6552
7475
  }
@@ -6829,13 +7752,43 @@ var TaskRunner = class {
6829
7752
  };
6830
7753
 
6831
7754
  // src/orchestrator.ts
7755
+ function useCaseForBackendParam(issue, backendParam) {
7756
+ if (backendParam === "local") return { kind: "tier", tier: "quick-fix" };
7757
+ const tier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
7758
+ return { kind: "tier", tier };
7759
+ }
6832
7760
  var Orchestrator = class extends import_node_events.EventEmitter {
6833
7761
  state;
6834
7762
  config;
6835
7763
  tracker;
6836
7764
  workspace;
6837
7765
  hooks;
6838
- runner;
7766
+ /**
7767
+ * Spec 2 SC30 / Task 11: per-dispatch backend factory replaces the
7768
+ * Phase 1 `runner` / `localRunner` two-runner split. Each
7769
+ * `dispatchIssue()` call asks the factory for a `RoutingUseCase`-routed
7770
+ * `AgentBackend`, then wraps it in a fresh `AgentRunner`.
7771
+ *
7772
+ * `AgentRunner` is stateless (just `{ backend, options }`), so
7773
+ * per-dispatch construction is safe and avoids the cross-call state
7774
+ * the old two-runner split had to coordinate.
7775
+ *
7776
+ * Null only in the legacy fallback path: when `migrateAgentConfig`
7777
+ * throws (legacy configs missing supplemental fields, e.g.
7778
+ * `agent.backend='anthropic'` with no `agent.model`) AND no
7779
+ * `overrides.backend` is supplied, factory construction is skipped to
7780
+ * preserve the prior behavior of failing at dispatch time rather than
7781
+ * construction time. Eliminating this fallback is autopilot Phase 4+.
7782
+ */
7783
+ backendFactory;
7784
+ /**
7785
+ * Test-only: when overrides.backend is provided, dispatch uses this
7786
+ * instance directly (bypassing the factory). Mirrors Phase 1
7787
+ * `overrides.backend → this.runner.backend` behavior so existing
7788
+ * MockBackend-injection tests keep working without touching the
7789
+ * factory's routing path.
7790
+ */
7791
+ overrideBackend;
6839
7792
  renderer;
6840
7793
  promptTemplate;
6841
7794
  server;
@@ -6843,7 +7796,22 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6843
7796
  heartbeatInterval;
6844
7797
  logger;
6845
7798
  interactionQueue;
6846
- localRunner;
7799
+ /**
7800
+ * Per-named-backend resolver map (Spec 2 SC37). Each `local`/`pi` entry
7801
+ * in `agent.backends` spawns one `LocalModelResolver`. Legacy
7802
+ * single-backend configs converge here via `migrateAgentConfig` (Task 9),
7803
+ * so this map is the single source of truth post-migration.
7804
+ */
7805
+ localResolvers = /* @__PURE__ */ new Map();
7806
+ /**
7807
+ * Per-resolver `onStatusChange` unsubscribe callbacks. Spec 2 Phase 5
7808
+ * (SC39): each local/pi resolver gets its own listener emitting a
7809
+ * `NamedLocalModelStatus` event tagged with `backendName` + `endpoint`.
7810
+ * The previous single-resolver field (`localModelStatusUnsubscribe`)
7811
+ * is replaced by this list so multi-local configs can teardown all
7812
+ * listeners on `stop()` without a Map mutation.
7813
+ */
7814
+ localModelStatusUnsubscribes = [];
6847
7815
  pipeline;
6848
7816
  analysisArchive;
6849
7817
  graphStore = null;
@@ -6885,20 +7853,60 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6885
7853
  this.promptTemplate = promptTemplate;
6886
7854
  this.state = createEmptyState(config);
6887
7855
  this.logger = new StructuredLogger();
7856
+ try {
7857
+ const migrationResult = migrateAgentConfig(this.config.agent);
7858
+ if (migrationResult.warnings.length > 0) {
7859
+ for (const w of migrationResult.warnings) this.logger.warn(w);
7860
+ }
7861
+ this.config = { ...this.config, agent: migrationResult.config };
7862
+ } catch (err) {
7863
+ this.logger.warn(
7864
+ `migrateAgentConfig failed; continuing with legacy fields. Error: ${err instanceof Error ? err.message : String(err)}`
7865
+ );
7866
+ }
6888
7867
  this.tracker = overrides?.tracker || this.createTracker();
6889
7868
  this.workspace = new WorkspaceManager(config.workspace);
6890
7869
  this.hooks = new WorkspaceHooks(config.hooks);
6891
7870
  this.renderer = new PromptRenderer();
6892
- this.runner = new AgentRunner(overrides?.backend || this.createBackend(), {
6893
- maxTurns: config.agent.maxTurns
6894
- });
7871
+ this.overrideBackend = overrides?.backend ?? null;
6895
7872
  this.interactionQueue = new InteractionQueue(
6896
7873
  path15.join(config.workspace.root, "..", "interactions")
6897
7874
  );
6898
7875
  this.analysisArchive = new AnalysisArchive(path15.join(config.workspace.root, "..", "analyses"));
6899
- const localBackend = this.createLocalBackend();
6900
- this.localRunner = localBackend ? new AgentRunner(localBackend, { maxTurns: config.agent.maxTurns }) : null;
6901
- this.pipeline = this.createIntelligencePipeline();
7876
+ const backendsMap = this.config.agent.backends ?? {};
7877
+ for (const [name, def] of Object.entries(backendsMap)) {
7878
+ if (def.type === "local" || def.type === "pi") {
7879
+ const resolverOpts = {
7880
+ endpoint: def.endpoint,
7881
+ configured: typeof def.model === "string" ? [def.model] : def.model,
7882
+ logger: this.logger
7883
+ };
7884
+ if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
7885
+ if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
7886
+ this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
7887
+ }
7888
+ }
7889
+ if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
7890
+ const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
7891
+ const firstBackendName = Object.keys(this.config.agent.backends)[0];
7892
+ const routing = this.config.agent.routing ?? {
7893
+ default: firstBackendName ?? "primary"
7894
+ };
7895
+ this.backendFactory = new OrchestratorBackendFactory({
7896
+ backends: this.config.agent.backends,
7897
+ routing,
7898
+ sandboxPolicy,
7899
+ ...this.config.agent.container !== void 0 ? { container: this.config.agent.container } : {},
7900
+ ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
7901
+ getResolverModelFor: (name) => {
7902
+ const resolver = this.localResolvers.get(name);
7903
+ return resolver ? () => resolver.resolveModel() : void 0;
7904
+ }
7905
+ });
7906
+ } else {
7907
+ this.backendFactory = null;
7908
+ }
7909
+ this.pipeline = null;
6902
7910
  this.orchestratorIdPromise = resolveOrchestratorId(config.orchestratorId);
6903
7911
  this.prDetector = new PRDetector({
6904
7912
  logger: this.logger,
@@ -6942,7 +7950,25 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6942
7950
  pipeline: this.pipeline,
6943
7951
  analysisArchive: this.analysisArchive,
6944
7952
  roadmapPath: config.tracker.filePath ?? null,
6945
- dispatchAdHoc: this.dispatchAdHoc.bind(this)
7953
+ dispatchAdHoc: this.dispatchAdHoc.bind(this),
7954
+ getLocalModelStatus: () => {
7955
+ const first = this.localResolvers.values().next();
7956
+ return first.done ? null : first.value.getStatus();
7957
+ },
7958
+ getLocalModelStatuses: () => {
7959
+ const backends = this.config.agent.backends ?? {};
7960
+ const out = [];
7961
+ for (const [name, resolver] of this.localResolvers) {
7962
+ const def = backends[name];
7963
+ if (!def || def.type !== "local" && def.type !== "pi") continue;
7964
+ out.push({
7965
+ ...resolver.getStatus(),
7966
+ backendName: name,
7967
+ endpoint: def.endpoint
7968
+ });
7969
+ }
7970
+ return out;
7971
+ }
6946
7972
  });
6947
7973
  this.server.setRecorder(this.recorder);
6948
7974
  this.interactionQueue.onPush((interaction) => {
@@ -6956,44 +7982,6 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6956
7982
  }
6957
7983
  throw new Error(`Unsupported tracker kind: ${this.config.tracker.kind}`);
6958
7984
  }
6959
- createBackend() {
6960
- let backend;
6961
- if (this.config.agent.backend === "mock") {
6962
- backend = new MockBackend();
6963
- } else if (this.config.agent.backend === "claude") {
6964
- backend = new ClaudeBackend(this.config.agent.command);
6965
- } else if (this.config.agent.backend === "openai") {
6966
- backend = new OpenAIBackend({
6967
- ...this.config.agent.model !== void 0 && { model: this.config.agent.model },
6968
- ...this.config.agent.apiKey !== void 0 && { apiKey: this.config.agent.apiKey }
6969
- });
6970
- } else if (this.config.agent.backend === "gemini") {
6971
- backend = new GeminiBackend({
6972
- ...this.config.agent.model !== void 0 && { model: this.config.agent.model },
6973
- ...this.config.agent.apiKey !== void 0 && { apiKey: this.config.agent.apiKey }
6974
- });
6975
- } else if (this.config.agent.backend === "anthropic") {
6976
- backend = new AnthropicBackend({
6977
- ...this.config.agent.model !== void 0 && { model: this.config.agent.model },
6978
- ...this.config.agent.apiKey !== void 0 && { apiKey: this.config.agent.apiKey }
6979
- });
6980
- } else {
6981
- throw new Error(`Unsupported agent backend: ${this.config.agent.backend}`);
6982
- }
6983
- if (this.config.agent.sandboxPolicy === "docker" && this.config.agent.container) {
6984
- const runtime = new DockerRuntime();
6985
- const secretBackend = this.config.agent.secrets ? createSecretBackend(this.config.agent.secrets) : null;
6986
- const secretKeys = this.config.agent.secrets?.keys ?? [];
6987
- backend = new ContainerBackend(
6988
- backend,
6989
- runtime,
6990
- secretBackend,
6991
- this.config.agent.container,
6992
- secretKeys
6993
- );
6994
- }
6995
- return backend;
6996
- }
6997
7985
  /**
6998
7986
  * Creates a TaskRunner for the maintenance scheduler.
6999
7987
  * CheckCommandRunner and CommandExecutor use real child_process execution.
@@ -7119,97 +8107,98 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7119
8107
  });
7120
8108
  }
7121
8109
  }
7122
- createLocalBackend() {
7123
- if (this.config.agent.localBackend === "openai-compatible") {
7124
- const localConfig = {};
7125
- if (this.config.agent.localEndpoint) localConfig.endpoint = this.config.agent.localEndpoint;
7126
- if (this.config.agent.localModel) localConfig.model = this.config.agent.localModel;
7127
- if (this.config.agent.localApiKey) localConfig.apiKey = this.config.agent.localApiKey;
7128
- if (this.config.agent.localTimeoutMs)
7129
- localConfig.timeoutMs = this.config.agent.localTimeoutMs;
7130
- return new LocalBackend(localConfig);
7131
- }
7132
- if (this.config.agent.localBackend === "pi") {
7133
- return new PiBackend({
7134
- model: this.config.agent.localModel,
7135
- endpoint: this.config.agent.localEndpoint,
7136
- apiKey: this.config.agent.localApiKey
7137
- });
7138
- }
7139
- return null;
7140
- }
7141
8110
  createIntelligencePipeline() {
7142
8111
  const intel = this.config.intelligence;
7143
8112
  if (!intel?.enabled) return null;
7144
- const provider = this.createAnalysisProvider();
7145
- if (!provider) return null;
8113
+ const selProvider = this.createAnalysisProvider("sel");
8114
+ if (!selProvider) return null;
8115
+ const routing = this.config.agent.routing;
8116
+ const peslName = routing?.intelligence?.pesl;
8117
+ const selName = routing?.intelligence?.sel ?? routing?.default;
8118
+ const peslProvider = peslName !== void 0 && peslName !== selName ? this.createAnalysisProvider("pesl") : null;
7146
8119
  const peslModel = intel.models?.pesl ?? this.config.agent.model;
7147
8120
  const store = new import_graph.GraphStore();
7148
8121
  this.graphStore = store;
7149
- return new import_intelligence3.IntelligencePipeline(provider, store, {
7150
- ...peslModel !== void 0 && { peslModel }
8122
+ return new import_intelligence4.IntelligencePipeline(selProvider, store, {
8123
+ ...peslModel !== void 0 && { peslModel },
8124
+ ...peslProvider !== null && peslProvider !== void 0 && { peslProvider }
7151
8125
  });
7152
8126
  }
7153
8127
  /**
7154
- * Create the AnalysisProvider for the intelligence pipeline.
8128
+ * Create the AnalysisProvider for an intelligence-pipeline layer
8129
+ * (`sel` by default; `pesl` when constructing a distinct PESL
8130
+ * provider per Spec 2 SC35).
8131
+ *
8132
+ * Spec 2 Phase 4 (SC31–SC36) — resolution order:
8133
+ * 1. Explicit `intelligence.provider` config wins (preserves Phase 0–3 behavior; SC33).
8134
+ * 2. Otherwise, consult `agent.routing.intelligence.<layer>` (or
8135
+ * `routing.default`) to pick a `BackendDef` from `agent.backends`,
8136
+ * then translate via `buildAnalysisProvider` (the per-type factory).
7155
8137
  *
7156
- * Resolution order:
7157
- * 1. Explicit `intelligence.provider` config (separate key/endpoint)
7158
- * 2. Local backend config (agent.localBackend + localEndpoint/localModel)
7159
- * 3. Primary agent backend config (agent.apiKey + agent.backend)
8138
+ * Closes the Phase 2 deferral (P2-DEF-638): the legacy
8139
+ * `this.config.agent.backend` read at the bottom of this method is
8140
+ * removed; routing is the sole source for non-explicit configs.
8141
+ *
8142
+ * Cyclomatic complexity: pre-Phase-4 was 33 (factory dispatch was
8143
+ * inlined here). Phase 4 extracts the per-type tree into
8144
+ * `buildAnalysisProvider`, dropping this method to ≤ 5 branches
8145
+ * (under the 15 threshold).
7160
8146
  */
7161
- createAnalysisProvider() {
8147
+ createAnalysisProvider(layer = "sel") {
7162
8148
  const intel = this.config.intelligence;
7163
- const selModel = intel?.models?.sel ?? this.config.agent.model;
7164
- if (intel?.provider) {
7165
- return this.createProviderFromExplicitConfig(intel.provider, selModel);
7166
- }
7167
- if (this.config.agent.localBackend === "openai-compatible" || this.config.agent.localBackend === "pi") {
7168
- const endpoint = this.config.agent.localEndpoint ?? "http://localhost:11434/v1";
7169
- const apiKey = this.config.agent.localApiKey ?? "ollama";
7170
- const model = selModel ?? this.config.agent.localModel;
7171
- this.logger.info(`Intelligence pipeline using local backend at ${endpoint}`);
7172
- return new import_intelligence3.OpenAICompatibleAnalysisProvider({
7173
- apiKey,
7174
- baseUrl: endpoint,
7175
- ...model !== void 0 && { defaultModel: model },
7176
- ...intel?.requestTimeoutMs !== void 0 && { timeoutMs: intel.requestTimeoutMs },
7177
- ...intel?.promptSuffix !== void 0 && { promptSuffix: intel.promptSuffix },
7178
- ...intel?.jsonMode !== void 0 && { jsonMode: intel.jsonMode }
7179
- });
7180
- }
7181
- const backend = this.config.agent.backend;
7182
- if (backend === "anthropic" || backend === "claude") {
7183
- const apiKey = this.config.agent.apiKey ?? process.env.ANTHROPIC_API_KEY;
7184
- if (apiKey) {
7185
- return new import_intelligence3.AnthropicAnalysisProvider({
7186
- apiKey,
7187
- ...selModel !== void 0 && { defaultModel: selModel }
7188
- });
7189
- }
7190
- }
7191
- if (backend === "openai") {
7192
- const apiKey = this.config.agent.apiKey ?? process.env.OPENAI_API_KEY;
7193
- if (apiKey) {
7194
- return new import_intelligence3.OpenAICompatibleAnalysisProvider({
7195
- apiKey,
7196
- baseUrl: "https://api.openai.com/v1",
7197
- ...selModel !== void 0 && { defaultModel: selModel }
7198
- });
7199
- }
8149
+ if (!intel?.enabled) return null;
8150
+ if (intel.provider) {
8151
+ const layerModel = layer === "sel" ? intel.models?.sel : intel.models?.pesl;
8152
+ return this.createProviderFromExplicitConfig(
8153
+ intel.provider,
8154
+ layerModel ?? this.config.agent.model
8155
+ );
7200
8156
  }
7201
- if (backend === "claude" || backend === "anthropic") {
7202
- this.logger.info("Intelligence pipeline using Claude CLI (no API key configured)");
7203
- return new import_intelligence3.ClaudeCliAnalysisProvider({
7204
- command: this.config.agent.command,
7205
- ...selModel !== void 0 && { defaultModel: selModel },
7206
- ...intel?.requestTimeoutMs !== void 0 && { timeoutMs: intel.requestTimeoutMs }
7207
- });
8157
+ const routed = this.resolveRoutedBackendForIntelligence(layer);
8158
+ if (!routed) return null;
8159
+ const { name, def } = routed;
8160
+ const resolver = this.localResolvers.get(name);
8161
+ return buildAnalysisProvider({
8162
+ def,
8163
+ backendName: name,
8164
+ layer,
8165
+ // Spec 2 P3-IMP-1: a single snapshot read feeds the factory's
8166
+ // unavailable-warn diagnostic (Configured/Detected lists) and
8167
+ // collapses the two `getStatus()` calls flagged by P3-SUG-2.
8168
+ getResolverStatusSnapshot: () => {
8169
+ if (!resolver) return null;
8170
+ const status = resolver.getStatus();
8171
+ return {
8172
+ available: status.available,
8173
+ resolved: status.resolved,
8174
+ configured: status.configured,
8175
+ detected: status.detected
8176
+ };
8177
+ },
8178
+ intelligence: intel,
8179
+ logger: this.logger
8180
+ });
8181
+ }
8182
+ /**
8183
+ * Look up the routed BackendDef for an intelligence layer, falling
8184
+ * back through `routing.intelligence.<layer>` → `routing.default`
8185
+ * → null. Returns the resolved name alongside the def so callers can
8186
+ * key into the per-name resolver map.
8187
+ */
8188
+ resolveRoutedBackendForIntelligence(layer) {
8189
+ const routing = this.config.agent.routing;
8190
+ const backends = this.config.agent.backends;
8191
+ if (!routing || !backends) return null;
8192
+ const layerName = routing.intelligence?.[layer];
8193
+ const name = layerName ?? routing.default;
8194
+ const def = backends[name];
8195
+ if (!def) {
8196
+ this.logger.warn(
8197
+ `Intelligence pipeline: routed backend '${name}' for layer '${layer}' is not in agent.backends.`
8198
+ );
8199
+ return null;
7208
8200
  }
7209
- this.logger.warn(
7210
- `Intelligence pipeline: unsupported backend "${backend}". Supported: anthropic, claude, openai, or localBackend: openai-compatible / pi.`
7211
- );
7212
- return null;
8201
+ return { name, def };
7213
8202
  }
7214
8203
  createProviderFromExplicitConfig(provider, selModel) {
7215
8204
  if (provider.kind === "anthropic") {
@@ -7217,13 +8206,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7217
8206
  if (!apiKey2) {
7218
8207
  throw new Error("Intelligence pipeline: no Anthropic API key found.");
7219
8208
  }
7220
- return new import_intelligence3.AnthropicAnalysisProvider({
8209
+ return new import_intelligence4.AnthropicAnalysisProvider({
7221
8210
  apiKey: apiKey2,
7222
8211
  ...selModel !== void 0 && { defaultModel: selModel }
7223
8212
  });
7224
8213
  }
7225
8214
  if (provider.kind === "claude-cli") {
7226
- return new import_intelligence3.ClaudeCliAnalysisProvider({
8215
+ return new import_intelligence4.ClaudeCliAnalysisProvider({
7227
8216
  command: this.config.agent.command,
7228
8217
  ...selModel !== void 0 && { defaultModel: selModel },
7229
8218
  ...this.config.intelligence?.requestTimeoutMs !== void 0 && {
@@ -7234,7 +8223,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7234
8223
  const apiKey = provider.apiKey ?? this.config.agent.apiKey ?? "ollama";
7235
8224
  const baseUrl = provider.baseUrl ?? "http://localhost:11434/v1";
7236
8225
  const intel = this.config.intelligence;
7237
- return new import_intelligence3.OpenAICompatibleAnalysisProvider({
8226
+ return new import_intelligence4.OpenAICompatibleAnalysisProvider({
7238
8227
  apiKey,
7239
8228
  baseUrl,
7240
8229
  ...selModel !== void 0 && { defaultModel: selModel },
@@ -7716,9 +8705,18 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7716
8705
  issue,
7717
8706
  attempt: attempt || 1
7718
8707
  });
8708
+ const useCase = useCaseForBackendParam(issue, backend);
8709
+ let routedBackendName;
8710
+ if (this.overrideBackend !== null) {
8711
+ routedBackendName = this.overrideBackend.name;
8712
+ } else if (this.backendFactory !== null) {
8713
+ routedBackendName = this.backendFactory.resolveName(useCase);
8714
+ } else {
8715
+ routedBackendName = this.config.agent.routing?.default ?? this.config.agent.backend ?? "unknown";
8716
+ }
7719
8717
  const session = {
7720
8718
  sessionId: `pending-${Date.now()}`,
7721
- backendName: this.config.agent.backend,
8719
+ backendName: routedBackendName,
7722
8720
  agentPid: null,
7723
8721
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
7724
8722
  lastEvent: "Dispatching",
@@ -7745,11 +8743,23 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7745
8743
  issue.id,
7746
8744
  issue.externalId ?? null,
7747
8745
  issue.identifier,
7748
- this.config.agent.backend,
8746
+ routedBackendName,
7749
8747
  attempt ?? 1,
7750
8748
  issue.title
7751
8749
  );
7752
- const activeRunner = backend === "local" && this.localRunner ? this.localRunner : this.runner;
8750
+ let agentBackend;
8751
+ if (this.overrideBackend !== null) {
8752
+ agentBackend = this.overrideBackend;
8753
+ } else if (this.backendFactory !== null) {
8754
+ agentBackend = this.backendFactory.forUseCase(useCase);
8755
+ } else {
8756
+ throw new Error(
8757
+ `Cannot dispatch ${issue.identifier}: agent.backends not synthesized (migration failed) and no override backend supplied. Migrate to agent.backends/agent.routing per docs/guides/multi-backend-routing.md.`
8758
+ );
8759
+ }
8760
+ const activeRunner = new AgentRunner(agentBackend, {
8761
+ maxTurns: this.config.agent.maxTurns
8762
+ });
7753
8763
  this.runAgentInBackgroundTask(issue, workspacePath, prompt, attempt, activeRunner);
7754
8764
  } catch (error) {
7755
8765
  this.logger.error(`Dispatch failed for ${issue.identifier}`, { error: String(error) });
@@ -7782,7 +8792,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7782
8792
  }
7783
8793
  }
7784
8794
  runAgentInBackgroundTask(issue, workspacePath, prompt, attempt, runner) {
7785
- const activeRunner = runner ?? this.runner;
8795
+ const activeRunner = runner;
7786
8796
  this.logger.info(`Starting background task for ${issue.identifier}`);
7787
8797
  const abortController = new AbortController();
7788
8798
  this.abortControllers.set(issue.id, { controller: abortController, pid: null });
@@ -7903,6 +8913,42 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7903
8913
  this.emit("state_change", this.getSnapshot());
7904
8914
  await this.dispatchIssue(issue, 1, "local");
7905
8915
  }
8916
+ /**
8917
+ * Initialize the LocalModelResolver and intelligence pipeline.
8918
+ *
8919
+ * Runs the initial probe (so resolver state reflects server availability)
8920
+ * before constructing the intelligence pipeline. Subscribes the dashboard
8921
+ * broadcast stub to status changes. Called exactly once from start().
8922
+ */
8923
+ async initLocalModelAndPipeline() {
8924
+ if (this.localResolvers.size > 0) {
8925
+ const backends = this.config.agent.backends ?? {};
8926
+ for (const [name, resolver] of this.localResolvers) {
8927
+ const def = backends[name];
8928
+ if (!def || def.type !== "local" && def.type !== "pi") {
8929
+ this.logger.warn("Resolver without matching backend def \u2014 broadcast skipped", {
8930
+ name
8931
+ });
8932
+ continue;
8933
+ }
8934
+ const endpoint = def.endpoint;
8935
+ const unsubscribe = resolver.onStatusChange((status) => {
8936
+ const named = {
8937
+ ...status,
8938
+ backendName: name,
8939
+ endpoint
8940
+ };
8941
+ this.server?.broadcastLocalModelStatus(named);
8942
+ });
8943
+ this.localModelStatusUnsubscribes.push(unsubscribe);
8944
+ }
8945
+ for (const resolver of this.localResolvers.values()) {
8946
+ await resolver.start();
8947
+ }
8948
+ }
8949
+ this.pipeline = this.createIntelligencePipeline();
8950
+ this.server?.setPipeline(this.pipeline);
8951
+ }
7906
8952
  /**
7907
8953
  * Starts the polling loop and the internal HTTP server.
7908
8954
  * Runs startup reconciliation to release orphaned claims before the first tick.
@@ -7911,6 +8957,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7911
8957
  if (this.server) {
7912
8958
  void this.server.start();
7913
8959
  }
8960
+ await this.initLocalModelAndPipeline();
7914
8961
  await this.ensureClaimManager();
7915
8962
  const runningIssueIds = new Set(this.state.running.keys());
7916
8963
  const reconcileResult = await this.claimManager.reconcileOnStartup(runningIssueIds);
@@ -7962,6 +9009,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7962
9009
  clearInterval(this.heartbeatInterval);
7963
9010
  this.heartbeatInterval = void 0;
7964
9011
  }
9012
+ for (const unsub of this.localModelStatusUnsubscribes) {
9013
+ unsub();
9014
+ }
9015
+ this.localModelStatusUnsubscribes = [];
9016
+ for (const resolver of this.localResolvers.values()) {
9017
+ resolver.stop();
9018
+ }
7965
9019
  if (this.maintenanceScheduler) {
7966
9020
  this.maintenanceScheduler.stop();
7967
9021
  this.maintenanceScheduler = null;
@@ -8239,12 +9293,14 @@ function launchTUI(orchestrator) {
8239
9293
  // Annotate the CommonJS export names for ESM import in node:
8240
9294
  0 && (module.exports = {
8241
9295
  AnalysisArchive,
9296
+ BackendRouter,
8242
9297
  ClaimManager,
8243
9298
  InteractionQueue,
8244
9299
  LinearGraphQLStub,
8245
9300
  MockBackend,
8246
9301
  ORCHESTRATOR_IDENTITY_FILE,
8247
9302
  Orchestrator,
9303
+ OrchestratorBackendFactory,
8248
9304
  PRDetector,
8249
9305
  PromptRenderer,
8250
9306
  RoadmapTrackerAdapter,
@@ -8257,6 +9313,7 @@ function launchTUI(orchestrator) {
8257
9313
  calculateRetryDelay,
8258
9314
  canDispatch,
8259
9315
  computeRateLimitDelay,
9316
+ createBackend,
8260
9317
  createEmptyState,
8261
9318
  detectScopeTier,
8262
9319
  extractHighlights,
@@ -8267,6 +9324,7 @@ function launchTUI(orchestrator) {
8267
9324
  isEligible,
8268
9325
  launchTUI,
8269
9326
  loadPublishedIndex,
9327
+ migrateAgentConfig,
8270
9328
  reconcile,
8271
9329
  renderAnalysisComment,
8272
9330
  renderPRComment,