@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.mjs CHANGED
@@ -1771,8 +1771,92 @@ import { parse } from "yaml";
1771
1771
  import { Ok as Ok3, Err as Err2 } from "@harness-engineering/types";
1772
1772
 
1773
1773
  // src/workflow/config.ts
1774
- import { Ok as Ok2, Err } from "@harness-engineering/types";
1774
+ import { z as z2 } from "zod";
1775
+ import {
1776
+ Ok as Ok2,
1777
+ Err
1778
+ } from "@harness-engineering/types";
1779
+
1780
+ // src/workflow/schema.ts
1781
+ import { z } from "zod";
1782
+ var ModelSchema = z.union([z.string().min(1), z.array(z.string().min(1)).nonempty()], {
1783
+ errorMap: () => ({
1784
+ message: "model must be a non-empty string or array of strings"
1785
+ })
1786
+ });
1787
+ var BackendDefSchema = z.discriminatedUnion("type", [
1788
+ z.object({ type: z.literal("mock") }).strict(),
1789
+ z.object({
1790
+ type: z.literal("claude"),
1791
+ command: z.string().optional()
1792
+ }).strict(),
1793
+ z.object({
1794
+ type: z.literal("anthropic"),
1795
+ model: z.string().min(1),
1796
+ apiKey: z.string().optional()
1797
+ }).strict(),
1798
+ z.object({
1799
+ type: z.literal("openai"),
1800
+ model: z.string().min(1),
1801
+ apiKey: z.string().optional()
1802
+ }).strict(),
1803
+ z.object({
1804
+ type: z.literal("gemini"),
1805
+ model: z.string().min(1),
1806
+ apiKey: z.string().optional()
1807
+ }).strict(),
1808
+ z.object({
1809
+ type: z.literal("local"),
1810
+ endpoint: z.string().url(),
1811
+ model: ModelSchema,
1812
+ apiKey: z.string().optional(),
1813
+ timeoutMs: z.number().int().positive().optional(),
1814
+ probeIntervalMs: z.number().int().min(1e3).optional()
1815
+ }).strict(),
1816
+ z.object({
1817
+ type: z.literal("pi"),
1818
+ endpoint: z.string().url(),
1819
+ model: ModelSchema,
1820
+ apiKey: z.string().optional(),
1821
+ timeoutMs: z.number().int().positive().optional(),
1822
+ probeIntervalMs: z.number().int().min(1e3).optional()
1823
+ }).strict()
1824
+ ]);
1825
+ var RoutingConfigSchema = z.object({
1826
+ default: z.string().min(1),
1827
+ "quick-fix": z.string().optional(),
1828
+ "guided-change": z.string().optional(),
1829
+ "full-exploration": z.string().optional(),
1830
+ diagnostic: z.string().optional(),
1831
+ intelligence: z.object({
1832
+ sel: z.string().optional(),
1833
+ pesl: z.string().optional()
1834
+ }).strict().optional()
1835
+ }).strict();
1836
+
1837
+ // src/workflow/config.ts
1775
1838
  var REQUIRED_SECTIONS = ["tracker", "polling", "workspace", "hooks", "agent", "server"];
1839
+ var BackendsMapSchema = z2.record(z2.string(), BackendDefSchema);
1840
+ function crossFieldRoutingIssues(backends, routing) {
1841
+ const issues = [];
1842
+ const names = new Set(Object.keys(backends));
1843
+ const checkRef = (path16, name) => {
1844
+ if (name !== void 0 && !names.has(name)) {
1845
+ issues.push({
1846
+ path: path16,
1847
+ message: `routing.${path16.join(".")} references unknown backend '${name}'. Defined: [${[...names].join(", ")}].`
1848
+ });
1849
+ }
1850
+ };
1851
+ checkRef(["default"], routing.default);
1852
+ checkRef(["quick-fix"], routing["quick-fix"]);
1853
+ checkRef(["guided-change"], routing["guided-change"]);
1854
+ checkRef(["full-exploration"], routing["full-exploration"]);
1855
+ checkRef(["diagnostic"], routing.diagnostic);
1856
+ checkRef(["intelligence", "sel"], routing.intelligence?.sel);
1857
+ checkRef(["intelligence", "pesl"], routing.intelligence?.pesl);
1858
+ return issues;
1859
+ }
1776
1860
  function validateWorkflowConfig(config) {
1777
1861
  if (!config || typeof config !== "object")
1778
1862
  return Err(new Error("Config is missing or not an object"));
@@ -1783,6 +1867,35 @@ function validateWorkflowConfig(config) {
1783
1867
  if (c.intelligence !== void 0 && (typeof c.intelligence !== "object" || c.intelligence === null)) {
1784
1868
  return Err(new Error("Config intelligence section must be an object if present"));
1785
1869
  }
1870
+ const agent = c.agent ?? {};
1871
+ const hasLegacyBackend = typeof agent.backend === "string" && agent.backend.length > 0;
1872
+ const hasModernBackends = agent.backends !== void 0 && typeof agent.backends === "object" && agent.backends !== null;
1873
+ if (!hasLegacyBackend && !hasModernBackends) {
1874
+ return Err(new Error("Config must define agent.backend or agent.backends."));
1875
+ }
1876
+ if (hasModernBackends) {
1877
+ const backendsParsed = BackendsMapSchema.safeParse(agent.backends);
1878
+ if (!backendsParsed.success) {
1879
+ return Err(new Error(`agent.backends: ${backendsParsed.error.message}`));
1880
+ }
1881
+ const routingParsed = RoutingConfigSchema.optional().safeParse(agent.routing);
1882
+ if (!routingParsed.success) {
1883
+ return Err(new Error(`agent.routing: ${routingParsed.error.message}`));
1884
+ }
1885
+ if (routingParsed.data) {
1886
+ const cross = crossFieldRoutingIssues(
1887
+ backendsParsed.data,
1888
+ routingParsed.data
1889
+ );
1890
+ if (cross.length > 0) {
1891
+ return Err(
1892
+ new Error(
1893
+ `Cross-field: ${cross.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
1894
+ )
1895
+ );
1896
+ }
1897
+ }
1898
+ }
1786
1899
  return Ok2(config);
1787
1900
  }
1788
1901
  function getDefaultConfig() {
@@ -2493,9 +2606,9 @@ import { randomUUID as randomUUID5 } from "crypto";
2493
2606
  import { writeTaint } from "@harness-engineering/core";
2494
2607
  import {
2495
2608
  IntelligencePipeline,
2496
- AnthropicAnalysisProvider,
2497
- OpenAICompatibleAnalysisProvider,
2498
- ClaudeCliAnalysisProvider
2609
+ AnthropicAnalysisProvider as AnthropicAnalysisProvider2,
2610
+ OpenAICompatibleAnalysisProvider as OpenAICompatibleAnalysisProvider2,
2611
+ ClaudeCliAnalysisProvider as ClaudeCliAnalysisProvider2
2499
2612
  } from "@harness-engineering/intelligence";
2500
2613
  import { GraphStore } from "@harness-engineering/graph";
2501
2614
 
@@ -3180,6 +3293,428 @@ var AgentRunner = class {
3180
3293
  }
3181
3294
  };
3182
3295
 
3296
+ // src/agent/local-model-resolver.ts
3297
+ var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3298
+ var MIN_PROBE_INTERVAL_MS = 1e3;
3299
+ var DEFAULT_API_KEY = "lm-studio";
3300
+ var DEFAULT_FETCH_TIMEOUT_MS = 5e3;
3301
+ var noopLogger = {
3302
+ info: () => void 0,
3303
+ warn: () => void 0
3304
+ };
3305
+ async function defaultFetchModels(endpoint, apiKey, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
3306
+ const url = `${endpoint.replace(/\/$/, "")}/models`;
3307
+ let res;
3308
+ try {
3309
+ res = await fetch(url, {
3310
+ headers: { Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}` },
3311
+ signal: AbortSignal.timeout(timeoutMs)
3312
+ });
3313
+ } catch (err) {
3314
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
3315
+ throw new Error(`request timeout (${timeoutMs}ms)`, { cause: err });
3316
+ }
3317
+ throw err;
3318
+ }
3319
+ if (!res.ok) {
3320
+ throw new Error(`probe failed: ${res.status} ${res.statusText}`);
3321
+ }
3322
+ let body;
3323
+ try {
3324
+ body = await res.json();
3325
+ } catch {
3326
+ throw new Error("malformed /v1/models response");
3327
+ }
3328
+ if (!body || typeof body !== "object" || !Array.isArray(body.data)) {
3329
+ throw new Error("malformed /v1/models response");
3330
+ }
3331
+ const data = body.data;
3332
+ const ids = [];
3333
+ for (const entry of data) {
3334
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
3335
+ throw new Error("malformed /v1/models response");
3336
+ }
3337
+ ids.push(entry.id);
3338
+ }
3339
+ return ids;
3340
+ }
3341
+ var LocalModelResolver = class {
3342
+ endpoint;
3343
+ apiKey;
3344
+ configured;
3345
+ probeIntervalMs;
3346
+ fetchModels;
3347
+ logger;
3348
+ timer = null;
3349
+ listeners = /* @__PURE__ */ new Set();
3350
+ /**
3351
+ * Tracks an in-flight probe so concurrent invocations (interval tick while a
3352
+ * slow probe is running, or a manual `probe()` call mid-flight) share the
3353
+ * existing promise instead of racing to mutate `detected/resolved/lastError/
3354
+ * warnings` non-atomically across `await` points. Applies to both the timer
3355
+ * callback and direct `probe()` calls — any caller that arrives during an
3356
+ * in-flight probe gets the same promise back. Cleared in `finally` so the
3357
+ * next tick can start a fresh probe.
3358
+ */
3359
+ probeInFlight = null;
3360
+ // Mutable status fields (composed into LocalModelStatus on demand).
3361
+ resolved = null;
3362
+ detected = [];
3363
+ lastProbeAt = null;
3364
+ lastError = null;
3365
+ warnings = [];
3366
+ available = false;
3367
+ constructor(opts) {
3368
+ this.endpoint = opts.endpoint;
3369
+ if (opts.apiKey !== void 0) {
3370
+ this.apiKey = opts.apiKey;
3371
+ }
3372
+ this.configured = [...opts.configured];
3373
+ const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3374
+ this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
3375
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3376
+ this.fetchModels = opts.fetchModels ?? ((endpoint, apiKey) => defaultFetchModels(endpoint, apiKey, timeoutMs));
3377
+ this.logger = opts.logger ?? noopLogger;
3378
+ }
3379
+ resolveModel() {
3380
+ return this.resolved;
3381
+ }
3382
+ getStatus() {
3383
+ return {
3384
+ available: this.available,
3385
+ resolved: this.resolved,
3386
+ configured: [...this.configured],
3387
+ detected: [...this.detected],
3388
+ lastProbeAt: this.lastProbeAt,
3389
+ lastError: this.lastError,
3390
+ warnings: [...this.warnings]
3391
+ };
3392
+ }
3393
+ onStatusChange(handler) {
3394
+ this.listeners.add(handler);
3395
+ return () => {
3396
+ this.listeners.delete(handler);
3397
+ };
3398
+ }
3399
+ async probe() {
3400
+ if (this.probeInFlight !== null) {
3401
+ return this.probeInFlight;
3402
+ }
3403
+ const inFlight = this.runProbe().finally(() => {
3404
+ this.probeInFlight = null;
3405
+ });
3406
+ this.probeInFlight = inFlight;
3407
+ return inFlight;
3408
+ }
3409
+ async runProbe() {
3410
+ const before = this.snapshotForDiff();
3411
+ try {
3412
+ const detected = await this.fetchModels(this.endpoint, this.apiKey);
3413
+ this.detected = [...detected];
3414
+ this.lastError = null;
3415
+ this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
3416
+ const match = this.configured.find((id) => detected.includes(id)) ?? null;
3417
+ this.resolved = match;
3418
+ this.available = match !== null;
3419
+ this.warnings = match ? [] : [
3420
+ `No configured local model is loaded. Configured: [${this.configured.join(", ")}]. Detected: [${detected.join(", ")}].`
3421
+ ];
3422
+ } catch (err) {
3423
+ const message = err instanceof Error ? err.message : "probe failed";
3424
+ this.lastError = message;
3425
+ this.available = false;
3426
+ this.resolved = null;
3427
+ this.warnings = [`Local model probe failed against ${this.endpoint}: ${message}.`];
3428
+ this.logger.warn("local-model-resolver probe failed", {
3429
+ endpoint: this.endpoint,
3430
+ error: message
3431
+ });
3432
+ }
3433
+ const after = this.snapshotForDiff();
3434
+ const status = this.getStatus();
3435
+ if (before !== after) {
3436
+ for (const listener of this.listeners) {
3437
+ try {
3438
+ listener(status);
3439
+ } catch (err) {
3440
+ this.logger.warn("local-model-resolver listener threw", {
3441
+ error: err instanceof Error ? err.message : String(err)
3442
+ });
3443
+ }
3444
+ }
3445
+ }
3446
+ return status;
3447
+ }
3448
+ async start() {
3449
+ if (this.timer !== null) {
3450
+ return;
3451
+ }
3452
+ await this.probe();
3453
+ this.timer = setInterval(() => {
3454
+ void this.probe();
3455
+ }, this.probeIntervalMs);
3456
+ const handle = this.timer;
3457
+ handle.unref?.();
3458
+ }
3459
+ stop() {
3460
+ if (this.timer !== null) {
3461
+ clearInterval(this.timer);
3462
+ this.timer = null;
3463
+ }
3464
+ }
3465
+ snapshotForDiff() {
3466
+ return JSON.stringify({
3467
+ available: this.available,
3468
+ resolved: this.resolved,
3469
+ configured: this.configured,
3470
+ detected: this.detected,
3471
+ lastError: this.lastError,
3472
+ warnings: this.warnings
3473
+ });
3474
+ }
3475
+ };
3476
+
3477
+ // src/agent/config-migration.ts
3478
+ var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
3479
+ function migrateAgentConfig(agent) {
3480
+ const warnings = [];
3481
+ const legacyFields = [
3482
+ { path: "agent.backend", present: agent.backend !== void 0 && agent.backend !== "" },
3483
+ { path: "agent.command", present: agent.command !== void 0 },
3484
+ { path: "agent.model", present: agent.model !== void 0 },
3485
+ { path: "agent.apiKey", present: agent.apiKey !== void 0 },
3486
+ { path: "agent.localBackend", present: agent.localBackend !== void 0 },
3487
+ { path: "agent.localEndpoint", present: agent.localEndpoint !== void 0 },
3488
+ { path: "agent.localModel", present: agent.localModel !== void 0 },
3489
+ { path: "agent.localApiKey", present: agent.localApiKey !== void 0 },
3490
+ { path: "agent.localTimeoutMs", present: agent.localTimeoutMs !== void 0 },
3491
+ { path: "agent.localProbeIntervalMs", present: agent.localProbeIntervalMs !== void 0 }
3492
+ ];
3493
+ const presentLegacy = legacyFields.filter((f) => f.present).map((f) => f.path);
3494
+ const CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
3495
+ const CASE1_LOCAL_GROUP = /* @__PURE__ */ new Set([
3496
+ "agent.localBackend",
3497
+ "agent.localEndpoint",
3498
+ "agent.localModel",
3499
+ "agent.localApiKey",
3500
+ "agent.localTimeoutMs",
3501
+ "agent.localProbeIntervalMs"
3502
+ ]);
3503
+ const suppressLocalGroup = agent.localBackend !== void 0;
3504
+ if (agent.backends !== void 0) {
3505
+ for (const path16 of presentLegacy) {
3506
+ if (CASE1_ALWAYS_SUPPRESS.has(path16)) continue;
3507
+ if (suppressLocalGroup && CASE1_LOCAL_GROUP.has(path16)) continue;
3508
+ warnings.push(
3509
+ `Ignoring legacy field '${path16}': 'agent.backends' is set and takes precedence. See ${MIGRATION_GUIDE}.`
3510
+ );
3511
+ }
3512
+ return { config: agent, warnings };
3513
+ }
3514
+ if (presentLegacy.length === 0) {
3515
+ return { config: agent, warnings };
3516
+ }
3517
+ const backends = {};
3518
+ const routing = { default: "primary" };
3519
+ backends.primary = synthesizePrimary(agent);
3520
+ if (agent.localBackend !== void 0) {
3521
+ backends.local = synthesizeLocal(agent);
3522
+ }
3523
+ const autoExec = agent.escalation?.autoExecute ?? [];
3524
+ if (backends.local !== void 0) {
3525
+ for (const tier of autoExec) {
3526
+ routing[tier] = "local";
3527
+ }
3528
+ }
3529
+ for (const path16 of presentLegacy) {
3530
+ warnings.push(
3531
+ `Deprecated config field '${path16}' is in use. Migrate to 'agent.backends' / 'agent.routing'. See ${MIGRATION_GUIDE}.`
3532
+ );
3533
+ }
3534
+ return {
3535
+ config: {
3536
+ ...agent,
3537
+ backends,
3538
+ routing
3539
+ },
3540
+ warnings
3541
+ };
3542
+ }
3543
+ function synthesizePrimary(agent) {
3544
+ const backend = agent.backend;
3545
+ switch (backend) {
3546
+ case "mock":
3547
+ return { type: "mock" };
3548
+ case "claude": {
3549
+ const def = { type: "claude" };
3550
+ if (agent.command !== void 0) def.command = agent.command;
3551
+ return def;
3552
+ }
3553
+ case "anthropic": {
3554
+ if (agent.model === void 0) {
3555
+ throw new Error("migrateAgentConfig: agent.backend='anthropic' requires agent.model");
3556
+ }
3557
+ const def = { type: "anthropic", model: agent.model };
3558
+ if (agent.apiKey !== void 0) def.apiKey = agent.apiKey;
3559
+ return def;
3560
+ }
3561
+ case "openai": {
3562
+ if (agent.model === void 0) {
3563
+ throw new Error("migrateAgentConfig: agent.backend='openai' requires agent.model");
3564
+ }
3565
+ const def = { type: "openai", model: agent.model };
3566
+ if (agent.apiKey !== void 0) def.apiKey = agent.apiKey;
3567
+ return def;
3568
+ }
3569
+ case "gemini": {
3570
+ if (agent.model === void 0) {
3571
+ throw new Error("migrateAgentConfig: agent.backend='gemini' requires agent.model");
3572
+ }
3573
+ const def = { type: "gemini", model: agent.model };
3574
+ if (agent.apiKey !== void 0) def.apiKey = agent.apiKey;
3575
+ return def;
3576
+ }
3577
+ case "local": {
3578
+ if (agent.localEndpoint === void 0 || agent.localModel === void 0) {
3579
+ throw new Error(
3580
+ "migrateAgentConfig: agent.backend='local' requires agent.localEndpoint and agent.localModel"
3581
+ );
3582
+ }
3583
+ const def = {
3584
+ type: "local",
3585
+ endpoint: agent.localEndpoint,
3586
+ model: agent.localModel
3587
+ };
3588
+ if (agent.localApiKey !== void 0) def.apiKey = agent.localApiKey;
3589
+ if (agent.localTimeoutMs !== void 0) def.timeoutMs = agent.localTimeoutMs;
3590
+ if (agent.localProbeIntervalMs !== void 0)
3591
+ def.probeIntervalMs = agent.localProbeIntervalMs;
3592
+ return def;
3593
+ }
3594
+ case "pi": {
3595
+ if (agent.localEndpoint === void 0 || agent.localModel === void 0) {
3596
+ throw new Error(
3597
+ "migrateAgentConfig: agent.backend='pi' requires agent.localEndpoint and agent.localModel"
3598
+ );
3599
+ }
3600
+ const def = {
3601
+ type: "pi",
3602
+ endpoint: agent.localEndpoint,
3603
+ model: agent.localModel
3604
+ };
3605
+ if (agent.localApiKey !== void 0) def.apiKey = agent.localApiKey;
3606
+ if (agent.localTimeoutMs !== void 0) def.timeoutMs = agent.localTimeoutMs;
3607
+ if (agent.localProbeIntervalMs !== void 0)
3608
+ def.probeIntervalMs = agent.localProbeIntervalMs;
3609
+ return def;
3610
+ }
3611
+ default:
3612
+ throw new Error(
3613
+ `migrateAgentConfig: unknown legacy backend '${String(backend)}'. Expected one of: mock, claude, anthropic, openai, gemini, local, pi.`
3614
+ );
3615
+ }
3616
+ }
3617
+ function synthesizeLocal(agent) {
3618
+ if (agent.localBackend === void 0) {
3619
+ throw new Error("synthesizeLocal called without agent.localBackend");
3620
+ }
3621
+ if (agent.localEndpoint === void 0 || agent.localModel === void 0) {
3622
+ throw new Error(
3623
+ "migrateAgentConfig: agent.localBackend requires agent.localEndpoint and agent.localModel"
3624
+ );
3625
+ }
3626
+ if (agent.localBackend === "pi") {
3627
+ const def2 = {
3628
+ type: "pi",
3629
+ endpoint: agent.localEndpoint,
3630
+ model: agent.localModel
3631
+ };
3632
+ if (agent.localApiKey !== void 0) def2.apiKey = agent.localApiKey;
3633
+ if (agent.localTimeoutMs !== void 0) def2.timeoutMs = agent.localTimeoutMs;
3634
+ if (agent.localProbeIntervalMs !== void 0) def2.probeIntervalMs = agent.localProbeIntervalMs;
3635
+ return def2;
3636
+ }
3637
+ const def = {
3638
+ type: "local",
3639
+ endpoint: agent.localEndpoint,
3640
+ model: agent.localModel
3641
+ };
3642
+ if (agent.localApiKey !== void 0) def.apiKey = agent.localApiKey;
3643
+ if (agent.localTimeoutMs !== void 0) def.timeoutMs = agent.localTimeoutMs;
3644
+ if (agent.localProbeIntervalMs !== void 0) def.probeIntervalMs = agent.localProbeIntervalMs;
3645
+ return def;
3646
+ }
3647
+
3648
+ // src/agent/backend-router.ts
3649
+ var BackendRouter = class {
3650
+ backends;
3651
+ routing;
3652
+ constructor(opts) {
3653
+ this.backends = opts.backends;
3654
+ this.routing = opts.routing;
3655
+ this.validateReferences();
3656
+ }
3657
+ /**
3658
+ * Returns the backend name for a given use case.
3659
+ *
3660
+ * - `tier`: per-tier override, falling back to `routing.default`.
3661
+ * - `intelligence`: per-layer override under `routing.intelligence`,
3662
+ * falling back to `routing.default`.
3663
+ * - `maintenance` / `chat`: always `routing.default`.
3664
+ */
3665
+ resolve(useCase) {
3666
+ switch (useCase.kind) {
3667
+ case "tier": {
3668
+ const named = this.routing[useCase.tier];
3669
+ return named ?? this.routing.default;
3670
+ }
3671
+ case "intelligence": {
3672
+ const intel = this.routing.intelligence;
3673
+ return intel?.[useCase.layer] ?? this.routing.default;
3674
+ }
3675
+ case "maintenance":
3676
+ case "chat":
3677
+ return this.routing.default;
3678
+ }
3679
+ }
3680
+ /**
3681
+ * Returns the BackendDef reference for the resolved name. Returns the
3682
+ * exact reference held in `backends` (no copy) so identity comparisons
3683
+ * succeed (SC21).
3684
+ */
3685
+ resolveDefinition(useCase) {
3686
+ const name = this.resolve(useCase);
3687
+ const def = this.backends[name];
3688
+ if (!def) {
3689
+ throw new Error(
3690
+ `BackendRouter.resolveDefinition: routing target '${name}' is not in backends (useCase=${JSON.stringify(useCase)}).`
3691
+ );
3692
+ }
3693
+ return def;
3694
+ }
3695
+ validateReferences() {
3696
+ const known = new Set(Object.keys(this.backends));
3697
+ const missing = [];
3698
+ const check = (path16, name) => {
3699
+ if (name !== void 0 && !known.has(name)) missing.push({ path: path16, name });
3700
+ };
3701
+ check("default", this.routing.default);
3702
+ check("quick-fix", this.routing["quick-fix"]);
3703
+ check("guided-change", this.routing["guided-change"]);
3704
+ check("full-exploration", this.routing["full-exploration"]);
3705
+ check("diagnostic", this.routing.diagnostic);
3706
+ check("intelligence.sel", this.routing.intelligence?.sel);
3707
+ check("intelligence.pesl", this.routing.intelligence?.pesl);
3708
+ if (missing.length > 0) {
3709
+ const detail = missing.map(({ path: path16, name }) => `routing.${path16} -> '${name}'`).join("; ");
3710
+ const known_ = [...known].join(", ") || "(none)";
3711
+ throw new Error(
3712
+ `BackendRouter: routing references unknown backend(s): ${detail}. Defined backends: [${known_}].`
3713
+ );
3714
+ }
3715
+ }
3716
+ };
3717
+
3183
3718
  // src/agent/backends/claude.ts
3184
3719
  import { spawn as spawn2 } from "child_process";
3185
3720
  import * as readline from "readline";
@@ -3535,12 +4070,125 @@ var ClaudeBackend = class {
3535
4070
  }
3536
4071
  };
3537
4072
 
3538
- // src/agent/backends/openai.ts
3539
- import OpenAI from "openai";
4073
+ // src/agent/backends/anthropic.ts
4074
+ import Anthropic from "@anthropic-ai/sdk";
3540
4075
  import {
3541
4076
  Ok as Ok10,
3542
4077
  Err as Err7
3543
4078
  } from "@harness-engineering/types";
4079
+ import { AnthropicCacheAdapter } from "@harness-engineering/core";
4080
+ var AnthropicBackend = class {
4081
+ name = "anthropic";
4082
+ config;
4083
+ client;
4084
+ cacheAdapter;
4085
+ constructor(config = {}) {
4086
+ this.config = {
4087
+ model: config.model ?? "claude-sonnet-4-20250514",
4088
+ apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
4089
+ maxTokens: config.maxTokens ?? 4096
4090
+ };
4091
+ this.client = new Anthropic({ apiKey: this.config.apiKey });
4092
+ this.cacheAdapter = new AnthropicCacheAdapter();
4093
+ }
4094
+ async startSession(params) {
4095
+ if (!this.config.apiKey) {
4096
+ return Err7({
4097
+ category: "agent_not_found",
4098
+ message: "ANTHROPIC_API_KEY is not set"
4099
+ });
4100
+ }
4101
+ const session = {
4102
+ sessionId: `anthropic-session-${Date.now()}`,
4103
+ workspacePath: params.workspacePath,
4104
+ backendName: this.name,
4105
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4106
+ ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
4107
+ };
4108
+ return Ok10(session);
4109
+ }
4110
+ async *runTurn(session, params) {
4111
+ const anthropicSession = session;
4112
+ const systemBlocks = anthropicSession.systemPrompt ? [
4113
+ this.cacheAdapter.wrapSystemBlock(
4114
+ anthropicSession.systemPrompt,
4115
+ "session"
4116
+ )
4117
+ ] : void 0;
4118
+ try {
4119
+ const stream = this.client.messages.stream({
4120
+ model: this.config.model,
4121
+ max_tokens: this.config.maxTokens,
4122
+ ...systemBlocks && { system: systemBlocks },
4123
+ messages: [{ role: "user", content: params.prompt }]
4124
+ });
4125
+ for await (const event of stream) {
4126
+ if (event.type === "content_block_delta" && "text" in event.delta) {
4127
+ yield {
4128
+ type: "text",
4129
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4130
+ content: event.delta.text,
4131
+ sessionId: session.sessionId
4132
+ };
4133
+ }
4134
+ }
4135
+ const finalMessage = await stream.finalMessage();
4136
+ const { input_tokens, output_tokens } = finalMessage.usage;
4137
+ const cacheUsage = this.cacheAdapter.parseCacheUsage(finalMessage);
4138
+ const usage = {
4139
+ inputTokens: input_tokens,
4140
+ outputTokens: output_tokens,
4141
+ totalTokens: input_tokens + output_tokens,
4142
+ cacheCreationTokens: cacheUsage.cacheCreationTokens,
4143
+ cacheReadTokens: cacheUsage.cacheReadTokens
4144
+ };
4145
+ yield {
4146
+ type: "usage",
4147
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4148
+ sessionId: session.sessionId,
4149
+ usage
4150
+ };
4151
+ return {
4152
+ success: true,
4153
+ sessionId: session.sessionId,
4154
+ usage
4155
+ };
4156
+ } catch (err) {
4157
+ const errorMessage = err instanceof Error ? err.message : "Anthropic request failed";
4158
+ yield {
4159
+ type: "error",
4160
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4161
+ content: errorMessage,
4162
+ sessionId: session.sessionId
4163
+ };
4164
+ return {
4165
+ success: false,
4166
+ sessionId: session.sessionId,
4167
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
4168
+ error: errorMessage
4169
+ };
4170
+ }
4171
+ }
4172
+ async stopSession(_session) {
4173
+ return Ok10(void 0);
4174
+ }
4175
+ async healthCheck() {
4176
+ if (!this.config.apiKey) {
4177
+ return Err7({
4178
+ category: "response_error",
4179
+ message: "ANTHROPIC_API_KEY is not set"
4180
+ });
4181
+ }
4182
+ return Ok10(void 0);
4183
+ }
4184
+ };
4185
+
4186
+ // src/agent/backends/openai.ts
4187
+ import OpenAI from "openai";
4188
+ import {
4189
+ Ok as Ok11,
4190
+ Err as Err8
4191
+ } from "@harness-engineering/types";
3544
4192
  import { OpenAICacheAdapter } from "@harness-engineering/core";
3545
4193
  var OpenAIBackend = class {
3546
4194
  name = "openai";
@@ -3557,7 +4205,7 @@ var OpenAIBackend = class {
3557
4205
  }
3558
4206
  async startSession(params) {
3559
4207
  if (!this.config.apiKey) {
3560
- return Err7({
4208
+ return Err8({
3561
4209
  category: "agent_not_found",
3562
4210
  message: "OPENAI_API_KEY is not set"
3563
4211
  });
@@ -3569,7 +4217,7 @@ var OpenAIBackend = class {
3569
4217
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3570
4218
  ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3571
4219
  };
3572
- return Ok10(session);
4220
+ return Ok11(session);
3573
4221
  }
3574
4222
  async *runTurn(session, params) {
3575
4223
  const openAISession = session;
@@ -3645,14 +4293,14 @@ var OpenAIBackend = class {
3645
4293
  };
3646
4294
  }
3647
4295
  async stopSession(_session) {
3648
- return Ok10(void 0);
4296
+ return Ok11(void 0);
3649
4297
  }
3650
4298
  async healthCheck() {
3651
4299
  try {
3652
4300
  await this.client.models.list();
3653
- return Ok10(void 0);
4301
+ return Ok11(void 0);
3654
4302
  } catch (err) {
3655
- return Err7({
4303
+ return Err8({
3656
4304
  category: "response_error",
3657
4305
  message: err instanceof Error ? err.message : "OpenAI health check failed"
3658
4306
  });
@@ -3663,8 +4311,8 @@ var OpenAIBackend = class {
3663
4311
  // src/agent/backends/gemini.ts
3664
4312
  import { GoogleGenerativeAI } from "@google/generative-ai";
3665
4313
  import {
3666
- Ok as Ok11,
3667
- Err as Err8
4314
+ Ok as Ok12,
4315
+ Err as Err9
3668
4316
  } from "@harness-engineering/types";
3669
4317
  import { GeminiCacheAdapter } from "@harness-engineering/core";
3670
4318
  var GeminiBackend = class {
@@ -3680,7 +4328,7 @@ var GeminiBackend = class {
3680
4328
  }
3681
4329
  async startSession(params) {
3682
4330
  if (!this.config.apiKey) {
3683
- return Err8({
4331
+ return Err9({
3684
4332
  category: "agent_not_found",
3685
4333
  message: "GEMINI_API_KEY is not set"
3686
4334
  });
@@ -3692,7 +4340,7 @@ var GeminiBackend = class {
3692
4340
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3693
4341
  ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3694
4342
  };
3695
- return Ok11(session);
4343
+ return Ok12(session);
3696
4344
  }
3697
4345
  async *runTurn(session, params) {
3698
4346
  const geminiSession = session;
@@ -3762,135 +4410,22 @@ var GeminiBackend = class {
3762
4410
  success: true,
3763
4411
  sessionId: session.sessionId,
3764
4412
  usage
3765
- };
3766
- }
3767
- async stopSession(_session) {
3768
- return Ok11(void 0);
3769
- }
3770
- async healthCheck() {
3771
- try {
3772
- const genAI = new GoogleGenerativeAI(this.config.apiKey);
3773
- genAI.getGenerativeModel({ model: this.config.model });
3774
- return Ok11(void 0);
3775
- } catch (err) {
3776
- return Err8({
3777
- category: "response_error",
3778
- message: err instanceof Error ? err.message : "Gemini health check failed"
3779
- });
3780
- }
3781
- }
3782
- };
3783
-
3784
- // src/agent/backends/anthropic.ts
3785
- import Anthropic from "@anthropic-ai/sdk";
3786
- import {
3787
- Ok as Ok12,
3788
- Err as Err9
3789
- } from "@harness-engineering/types";
3790
- import { AnthropicCacheAdapter } from "@harness-engineering/core";
3791
- var AnthropicBackend = class {
3792
- name = "anthropic";
3793
- config;
3794
- client;
3795
- cacheAdapter;
3796
- constructor(config = {}) {
3797
- this.config = {
3798
- model: config.model ?? "claude-sonnet-4-20250514",
3799
- apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "",
3800
- maxTokens: config.maxTokens ?? 4096
3801
- };
3802
- this.client = new Anthropic({ apiKey: this.config.apiKey });
3803
- this.cacheAdapter = new AnthropicCacheAdapter();
3804
- }
3805
- async startSession(params) {
3806
- if (!this.config.apiKey) {
3807
- return Err9({
3808
- category: "agent_not_found",
3809
- message: "ANTHROPIC_API_KEY is not set"
3810
- });
3811
- }
3812
- const session = {
3813
- sessionId: `anthropic-session-${Date.now()}`,
3814
- workspacePath: params.workspacePath,
3815
- backendName: this.name,
3816
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3817
- ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3818
- };
3819
- return Ok12(session);
3820
- }
3821
- async *runTurn(session, params) {
3822
- const anthropicSession = session;
3823
- const systemBlocks = anthropicSession.systemPrompt ? [
3824
- this.cacheAdapter.wrapSystemBlock(
3825
- anthropicSession.systemPrompt,
3826
- "session"
3827
- )
3828
- ] : void 0;
3829
- try {
3830
- const stream = this.client.messages.stream({
3831
- model: this.config.model,
3832
- max_tokens: this.config.maxTokens,
3833
- ...systemBlocks && { system: systemBlocks },
3834
- messages: [{ role: "user", content: params.prompt }]
3835
- });
3836
- for await (const event of stream) {
3837
- if (event.type === "content_block_delta" && "text" in event.delta) {
3838
- yield {
3839
- type: "text",
3840
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3841
- content: event.delta.text,
3842
- sessionId: session.sessionId
3843
- };
3844
- }
3845
- }
3846
- const finalMessage = await stream.finalMessage();
3847
- const { input_tokens, output_tokens } = finalMessage.usage;
3848
- const cacheUsage = this.cacheAdapter.parseCacheUsage(finalMessage);
3849
- const usage = {
3850
- inputTokens: input_tokens,
3851
- outputTokens: output_tokens,
3852
- totalTokens: input_tokens + output_tokens,
3853
- cacheCreationTokens: cacheUsage.cacheCreationTokens,
3854
- cacheReadTokens: cacheUsage.cacheReadTokens
3855
- };
3856
- yield {
3857
- type: "usage",
3858
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3859
- sessionId: session.sessionId,
3860
- usage
3861
- };
3862
- return {
3863
- success: true,
3864
- sessionId: session.sessionId,
3865
- usage
3866
- };
3867
- } catch (err) {
3868
- const errorMessage = err instanceof Error ? err.message : "Anthropic request failed";
3869
- yield {
3870
- type: "error",
3871
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3872
- content: errorMessage,
3873
- sessionId: session.sessionId
3874
- };
3875
- return {
3876
- success: false,
3877
- sessionId: session.sessionId,
3878
- usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
3879
- error: errorMessage
3880
- };
3881
- }
4413
+ };
3882
4414
  }
3883
4415
  async stopSession(_session) {
3884
4416
  return Ok12(void 0);
3885
4417
  }
3886
4418
  async healthCheck() {
3887
- if (!this.config.apiKey) {
4419
+ try {
4420
+ const genAI = new GoogleGenerativeAI(this.config.apiKey);
4421
+ genAI.getGenerativeModel({ model: this.config.model });
4422
+ return Ok12(void 0);
4423
+ } catch (err) {
3888
4424
  return Err9({
3889
4425
  category: "response_error",
3890
- message: "ANTHROPIC_API_KEY is not set"
4426
+ message: err instanceof Error ? err.message : "Gemini health check failed"
3891
4427
  });
3892
4428
  }
3893
- return Ok12(void 0);
3894
4429
  }
3895
4430
  };
3896
4431
 
@@ -3904,6 +4439,7 @@ var DEFAULT_TIMEOUT_MS = 9e4;
3904
4439
  var LocalBackend = class {
3905
4440
  name = "local";
3906
4441
  config;
4442
+ getModel;
3907
4443
  client;
3908
4444
  constructor(config = {}) {
3909
4445
  this.config = {
@@ -3912,6 +4448,7 @@ var LocalBackend = class {
3912
4448
  apiKey: config.apiKey ?? "ollama",
3913
4449
  timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
3914
4450
  };
4451
+ this.getModel = config.getModel;
3915
4452
  this.client = new OpenAI2({
3916
4453
  apiKey: this.config.apiKey,
3917
4454
  baseURL: this.config.endpoint,
@@ -3919,11 +4456,25 @@ var LocalBackend = class {
3919
4456
  });
3920
4457
  }
3921
4458
  async startSession(params) {
4459
+ let resolvedModel;
4460
+ if (this.getModel) {
4461
+ const candidate = this.getModel();
4462
+ if (candidate === null) {
4463
+ return Err10({
4464
+ category: "agent_not_found",
4465
+ message: "No local model available; check dashboard for details."
4466
+ });
4467
+ }
4468
+ resolvedModel = candidate;
4469
+ } else {
4470
+ resolvedModel = this.config.model;
4471
+ }
3922
4472
  const session = {
3923
4473
  sessionId: `local-session-${Date.now()}`,
3924
4474
  workspacePath: params.workspacePath,
3925
4475
  backendName: this.name,
3926
4476
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4477
+ resolvedModel,
3927
4478
  ...params.systemPrompt !== void 0 && { systemPrompt: params.systemPrompt }
3928
4479
  };
3929
4480
  return Ok13(session);
@@ -3940,7 +4491,7 @@ var LocalBackend = class {
3940
4491
  let totalTokens = 0;
3941
4492
  try {
3942
4493
  const stream = await this.client.chat.completions.create({
3943
- model: this.config.model,
4494
+ model: localSession.resolvedModel,
3944
4495
  messages,
3945
4496
  stream: true,
3946
4497
  stream_options: { include_usage: true }
@@ -4108,13 +4659,41 @@ function buildLocalModel(config) {
4108
4659
  var PiBackend = class {
4109
4660
  name = "pi";
4110
4661
  config;
4662
+ /**
4663
+ * Per-request timeout in ms (default 90_000). Spec 2 P2-I1: enforced at
4664
+ * the request boundary by `runTurn` racing `piSession.prompt()` against
4665
+ * an `AbortController + setTimeout(timeoutMs)`. On timeout the
4666
+ * underlying pi session is aborted and the turn returns a failed
4667
+ * `TurnResult` carrying a timeout-tagged error message. Setting
4668
+ * `timeoutMs: 0` disables the watchdog (preserves the pre-fix-up
4669
+ * "no enforcement" behavior for callers that want the SDK default).
4670
+ */
4671
+ timeoutMs;
4111
4672
  constructor(config = {}) {
4112
4673
  this.config = config;
4674
+ this.timeoutMs = config.timeoutMs ?? 9e4;
4113
4675
  }
4114
4676
  async startSession(params) {
4115
4677
  try {
4678
+ let resolvedModelName;
4679
+ if (this.config.getModel) {
4680
+ const candidate = this.config.getModel();
4681
+ if (candidate === null) {
4682
+ return Err11({
4683
+ category: "agent_not_found",
4684
+ message: "No local model available; check dashboard for details."
4685
+ });
4686
+ }
4687
+ resolvedModelName = candidate;
4688
+ } else {
4689
+ resolvedModelName = this.config.model;
4690
+ }
4116
4691
  const piSdk = await import("@mariozechner/pi-coding-agent");
4117
- const model = buildLocalModel(this.config);
4692
+ const model = buildLocalModel({
4693
+ model: resolvedModelName,
4694
+ endpoint: this.config.endpoint,
4695
+ apiKey: this.config.apiKey
4696
+ });
4118
4697
  const { session: piSession } = await piSdk.createAgentSession({
4119
4698
  cwd: params.workspacePath,
4120
4699
  ...model !== void 0 && { model },
@@ -4154,15 +4733,45 @@ var PiBackend = class {
4154
4733
  signal();
4155
4734
  });
4156
4735
  session.unsubscribe = unsubscribe;
4157
- const promptPromise = piSession.prompt(params.prompt).then(
4158
- () => {
4736
+ let timeoutHandle = null;
4737
+ let timedOut = false;
4738
+ if (this.timeoutMs > 0) {
4739
+ timeoutHandle = setTimeout(() => {
4740
+ timedOut = true;
4741
+ promptErrorMsg = `Pi backend request timed out after ${this.timeoutMs}ms`;
4159
4742
  promptDone = true;
4743
+ try {
4744
+ const maybeAbort = piSession.abort?.();
4745
+ if (maybeAbort && typeof maybeAbort.catch === "function") {
4746
+ maybeAbort.catch(() => {
4747
+ });
4748
+ }
4749
+ } catch {
4750
+ }
4160
4751
  signal();
4752
+ }, this.timeoutMs);
4753
+ }
4754
+ const clearTimeoutHandle = () => {
4755
+ if (timeoutHandle !== null) {
4756
+ clearTimeout(timeoutHandle);
4757
+ timeoutHandle = null;
4758
+ }
4759
+ };
4760
+ const promptPromise = piSession.prompt(params.prompt).then(
4761
+ () => {
4762
+ if (!timedOut) {
4763
+ clearTimeoutHandle();
4764
+ promptDone = true;
4765
+ signal();
4766
+ }
4161
4767
  },
4162
4768
  (err) => {
4163
- promptErrorMsg = err.message;
4164
- promptDone = true;
4165
- signal();
4769
+ if (!timedOut) {
4770
+ clearTimeoutHandle();
4771
+ promptErrorMsg = err.message;
4772
+ promptDone = true;
4773
+ signal();
4774
+ }
4166
4775
  }
4167
4776
  );
4168
4777
  let inputTokens = 0;
@@ -4178,12 +4787,15 @@ var PiBackend = class {
4178
4787
  })
4179
4788
  });
4180
4789
  } finally {
4790
+ clearTimeoutHandle();
4181
4791
  resolveWait?.();
4182
4792
  resolveWait = null;
4183
4793
  unsubscribe();
4184
4794
  session.unsubscribe = null;
4185
- await promptPromise.catch(() => {
4186
- });
4795
+ if (!timedOut) {
4796
+ await promptPromise.catch(() => {
4797
+ });
4798
+ }
4187
4799
  }
4188
4800
  const totalTokens = inputTokens + outputTokens;
4189
4801
  if (promptErrorMsg) {
@@ -4243,6 +4855,60 @@ var PiBackend = class {
4243
4855
  }
4244
4856
  };
4245
4857
 
4858
+ // src/agent/backend-factory.ts
4859
+ function makeGetModel(model) {
4860
+ if (typeof model === "string") return () => model;
4861
+ if (Array.isArray(model) && model.length > 0) return () => model[0] ?? null;
4862
+ return () => null;
4863
+ }
4864
+ function createBackend(def) {
4865
+ switch (def.type) {
4866
+ case "mock":
4867
+ return new MockBackend();
4868
+ case "claude":
4869
+ return new ClaudeBackend(def.command ?? "claude");
4870
+ case "anthropic":
4871
+ return new AnthropicBackend({
4872
+ model: def.model,
4873
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {}
4874
+ });
4875
+ case "openai":
4876
+ return new OpenAIBackend({
4877
+ model: def.model,
4878
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {}
4879
+ });
4880
+ case "gemini":
4881
+ return new GeminiBackend({
4882
+ model: def.model,
4883
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {}
4884
+ });
4885
+ case "local": {
4886
+ const isArray = Array.isArray(def.model);
4887
+ return new LocalBackend({
4888
+ endpoint: def.endpoint,
4889
+ ...typeof def.model === "string" ? { model: def.model } : {},
4890
+ ...isArray ? { getModel: makeGetModel(def.model) } : {},
4891
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
4892
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
4893
+ });
4894
+ }
4895
+ case "pi": {
4896
+ const isArray = Array.isArray(def.model);
4897
+ return new PiBackend({
4898
+ endpoint: def.endpoint,
4899
+ ...typeof def.model === "string" ? { model: def.model } : {},
4900
+ ...isArray ? { getModel: makeGetModel(def.model) } : {},
4901
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
4902
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
4903
+ });
4904
+ }
4905
+ default: {
4906
+ const exhaustive = def;
4907
+ throw new Error(`createBackend: unknown backend type ${JSON.stringify(exhaustive)}`);
4908
+ }
4909
+ }
4910
+ }
4911
+
4246
4912
  // src/agent/backends/container.ts
4247
4913
  import {
4248
4914
  Err as Err12
@@ -4606,6 +5272,195 @@ function createSecretBackend(config) {
4606
5272
  }
4607
5273
  }
4608
5274
 
5275
+ // src/agent/orchestrator-backend-factory.ts
5276
+ var OrchestratorBackendFactory = class {
5277
+ router;
5278
+ opts;
5279
+ constructor(opts) {
5280
+ this.opts = opts;
5281
+ this.router = new BackendRouter({ backends: opts.backends, routing: opts.routing });
5282
+ }
5283
+ /**
5284
+ * Resolve `useCase` to a backend name, materialize a fresh
5285
+ * `AgentBackend`, optionally rebind its model resolver, and apply
5286
+ * sandbox wrapping. Idempotent across calls (no caching) — the AgentRunner
5287
+ * holds the per-dispatch reference and discards it when the run ends.
5288
+ */
5289
+ /**
5290
+ * Resolve `useCase` to its routed backend name, exposing the
5291
+ * router lookup without materializing a backend. Used by callers
5292
+ * (e.g., the orchestrator's dispatch site) that need to label
5293
+ * telemetry with the routed name BEFORE constructing the backend.
5294
+ *
5295
+ * Spec 2 P2-I2: previously the orchestrator labelled `LiveSession`
5296
+ * + `StreamRecorder` with the legacy `agent.backend` field, which
5297
+ * is `undefined` for pure-modern configs. Threading the routed name
5298
+ * through dispatch eliminates that gap.
5299
+ */
5300
+ resolveName(useCase) {
5301
+ return this.router.resolve(useCase);
5302
+ }
5303
+ forUseCase(useCase) {
5304
+ const def = this.router.resolveDefinition(useCase);
5305
+ const name = this.router.resolve(useCase);
5306
+ let backend;
5307
+ if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
5308
+ const getModel = this.opts.getResolverModelFor(name);
5309
+ backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def);
5310
+ } else {
5311
+ backend = createBackend(def);
5312
+ }
5313
+ if (this.opts.sandboxPolicy === "docker" && this.opts.container) {
5314
+ backend = this.wrapInContainer(backend);
5315
+ }
5316
+ return backend;
5317
+ }
5318
+ /**
5319
+ * Rebuild a `local`/`pi` backend with a resolver-bound `getModel`,
5320
+ * mirroring `createBackend`'s local/pi branches but substituting the
5321
+ * head-of-array placeholder with the orchestrator-owned resolver.
5322
+ */
5323
+ buildLocalLikeWithResolver(def, getModel) {
5324
+ if (def.type === "local") {
5325
+ return new LocalBackend({
5326
+ endpoint: def.endpoint,
5327
+ getModel,
5328
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
5329
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
5330
+ });
5331
+ }
5332
+ if (def.type === "pi") {
5333
+ return new PiBackend({
5334
+ endpoint: def.endpoint,
5335
+ getModel,
5336
+ ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
5337
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
5338
+ });
5339
+ }
5340
+ throw new Error(
5341
+ `OrchestratorBackendFactory.buildLocalLikeWithResolver called with non-local def.type='${def.type}'`
5342
+ );
5343
+ }
5344
+ /**
5345
+ * Apply ContainerBackend wrapping (PFC-3). Pulls the runtime + secret
5346
+ * backend per call so each dispatch sees a fresh container handle map
5347
+ * (ContainerBackend keeps its own per-instance Map<sessionId, handle>).
5348
+ */
5349
+ wrapInContainer(inner) {
5350
+ const runtime = new DockerRuntime();
5351
+ const secretBackend = this.opts.secrets ? createSecretBackend(this.opts.secrets) : null;
5352
+ const secretKeys = this.opts.secrets?.keys ?? [];
5353
+ return new ContainerBackend(
5354
+ inner,
5355
+ runtime,
5356
+ secretBackend,
5357
+ this.opts.container,
5358
+ secretKeys
5359
+ );
5360
+ }
5361
+ };
5362
+
5363
+ // src/agent/analysis-provider-factory.ts
5364
+ import {
5365
+ AnthropicAnalysisProvider,
5366
+ ClaudeCliAnalysisProvider,
5367
+ OpenAICompatibleAnalysisProvider
5368
+ } from "@harness-engineering/intelligence";
5369
+ function buildAnalysisProvider(args) {
5370
+ const { def, backendName, layer, intelligence, logger } = args;
5371
+ const layerModel = layer === "sel" ? intelligence?.models?.sel : intelligence?.models?.pesl;
5372
+ switch (def.type) {
5373
+ case "local":
5374
+ case "pi":
5375
+ return buildLocalLikeProvider(def, args, layerModel);
5376
+ case "anthropic":
5377
+ return buildAnthropicProvider(def, args, layerModel);
5378
+ case "openai":
5379
+ return buildOpenAIProvider(def, args, layerModel);
5380
+ case "claude":
5381
+ return buildClaudeCliProvider(def, args, layerModel);
5382
+ case "mock":
5383
+ case "gemini":
5384
+ logger.warn(
5385
+ `Intelligence pipeline disabled for layer '${layer}': routed backend '${backendName}' has type '${def.type}' which has no AnalysisProvider implementation.`
5386
+ );
5387
+ return null;
5388
+ }
5389
+ }
5390
+ function buildLocalLikeProvider(def, args, layerModel) {
5391
+ const { backendName, getResolverStatusSnapshot, intelligence, logger } = args;
5392
+ const snapshot = getResolverStatusSnapshot();
5393
+ if (!snapshot || !snapshot.available) {
5394
+ const configured = snapshot?.configured ?? [];
5395
+ const detected = snapshot?.detected ?? [];
5396
+ logger.warn(
5397
+ `Intelligence pipeline disabled for backend '${backendName}' at ${def.endpoint}: no configured local model loaded. Configured: [${configured.join(", ")}]. Detected: [${detected.join(", ")}].`
5398
+ );
5399
+ return null;
5400
+ }
5401
+ const model = layerModel ?? snapshot.resolved ?? void 0;
5402
+ const apiKey = def.apiKey ?? "ollama";
5403
+ logger.info(
5404
+ `Intelligence pipeline using backend '${backendName}' (${def.type}) at ${def.endpoint} (model: ${model ?? "(default)"})`
5405
+ );
5406
+ return new OpenAICompatibleAnalysisProvider({
5407
+ apiKey,
5408
+ baseUrl: def.endpoint,
5409
+ ...model !== void 0 && { defaultModel: model },
5410
+ ...intelligence?.requestTimeoutMs !== void 0 && {
5411
+ timeoutMs: intelligence.requestTimeoutMs
5412
+ },
5413
+ ...intelligence?.promptSuffix !== void 0 && { promptSuffix: intelligence.promptSuffix },
5414
+ ...intelligence?.jsonMode !== void 0 && { jsonMode: intelligence.jsonMode }
5415
+ });
5416
+ }
5417
+ function buildAnthropicProvider(def, args, layerModel) {
5418
+ const apiKey = def.apiKey ?? process.env.ANTHROPIC_API_KEY;
5419
+ const model = layerModel ?? def.model;
5420
+ if (apiKey) {
5421
+ return new AnthropicAnalysisProvider({
5422
+ apiKey,
5423
+ ...model !== void 0 && { defaultModel: model }
5424
+ });
5425
+ }
5426
+ args.logger.info(
5427
+ `Intelligence pipeline routed to '${args.backendName}' (anthropic) without API key \u2014 using Claude CLI fallback.`
5428
+ );
5429
+ return new ClaudeCliAnalysisProvider({
5430
+ ...model !== void 0 && { defaultModel: model },
5431
+ ...args.intelligence?.requestTimeoutMs !== void 0 && {
5432
+ timeoutMs: args.intelligence.requestTimeoutMs
5433
+ }
5434
+ });
5435
+ }
5436
+ function buildOpenAIProvider(def, args, layerModel) {
5437
+ const apiKey = def.apiKey ?? process.env.OPENAI_API_KEY;
5438
+ if (!apiKey) {
5439
+ args.logger.warn(
5440
+ `Intelligence pipeline disabled for backend '${args.backendName}' (openai): no API key configured.`
5441
+ );
5442
+ return null;
5443
+ }
5444
+ const model = layerModel ?? def.model;
5445
+ return new OpenAICompatibleAnalysisProvider({
5446
+ apiKey,
5447
+ baseUrl: "https://api.openai.com/v1",
5448
+ ...model !== void 0 && { defaultModel: model },
5449
+ ...args.intelligence?.requestTimeoutMs !== void 0 && {
5450
+ timeoutMs: args.intelligence.requestTimeoutMs
5451
+ }
5452
+ });
5453
+ }
5454
+ function buildClaudeCliProvider(def, args, layerModel) {
5455
+ return new ClaudeCliAnalysisProvider({
5456
+ ...def.command !== void 0 && { command: def.command },
5457
+ ...layerModel !== void 0 && { defaultModel: layerModel },
5458
+ ...args.intelligence?.requestTimeoutMs !== void 0 && {
5459
+ timeoutMs: args.intelligence.requestTimeoutMs
5460
+ }
5461
+ });
5462
+ }
5463
+
4609
5464
  // src/server/http.ts
4610
5465
  import * as http from "http";
4611
5466
  import * as path13 from "path";
@@ -4665,7 +5520,7 @@ var WebSocketBroadcaster = class {
4665
5520
  };
4666
5521
 
4667
5522
  // src/server/routes/interactions.ts
4668
- import { z } from "zod";
5523
+ import { z as z3 } from "zod";
4669
5524
 
4670
5525
  // src/server/utils.ts
4671
5526
  var DEFAULT_MAX_BYTES = 1048576;
@@ -4688,8 +5543,8 @@ function readBody(req, maxBytes = DEFAULT_MAX_BYTES) {
4688
5543
  }
4689
5544
 
4690
5545
  // src/server/routes/interactions.ts
4691
- var InteractionUpdateSchema = z.object({
4692
- status: z.enum(["pending", "claimed", "resolved"])
5546
+ var InteractionUpdateSchema = z3.object({
5547
+ status: z3.enum(["pending", "claimed", "resolved"])
4693
5548
  });
4694
5549
  var SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
4695
5550
  function sendJson(res, status, body) {
@@ -4740,12 +5595,12 @@ function handleInteractionsRoute(req, res, queue) {
4740
5595
  }
4741
5596
 
4742
5597
  // src/server/routes/plans.ts
4743
- import { z as z2 } from "zod";
5598
+ import { z as z4 } from "zod";
4744
5599
  import * as fs9 from "fs/promises";
4745
5600
  import * as path9 from "path";
4746
- var PlanWriteSchema = z2.object({
4747
- filename: z2.string().min(1),
4748
- content: z2.string().min(1)
5601
+ var PlanWriteSchema = z4.object({
5602
+ filename: z4.string().min(1),
5603
+ content: z4.string().min(1)
4749
5604
  });
4750
5605
  function handlePlansRoute(req, res, plansDir) {
4751
5606
  const { method, url } = req;
@@ -4789,7 +5644,7 @@ function handlePlansRoute(req, res, plansDir) {
4789
5644
  import { spawn as spawn4 } from "child_process";
4790
5645
  import { randomUUID as randomUUID4 } from "crypto";
4791
5646
  import * as readline2 from "readline";
4792
- import { z as z3 } from "zod";
5647
+ import { z as z5 } from "zod";
4793
5648
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4794
5649
  var SAFE_ENV_PREFIXES = [
4795
5650
  "PATH",
@@ -4824,10 +5679,10 @@ function buildChildEnv() {
4824
5679
  }
4825
5680
  return env;
4826
5681
  }
4827
- var ChatRequestSchema = z3.object({
4828
- prompt: z3.string().min(1),
4829
- system: z3.string().optional(),
4830
- sessionId: z3.string().regex(UUID_RE).optional()
5682
+ var ChatRequestSchema = z5.object({
5683
+ prompt: z5.string().min(1),
5684
+ system: z5.string().optional(),
5685
+ sessionId: z5.string().regex(UUID_RE).optional()
4831
5686
  });
4832
5687
  function handleChatProxyRoute(req, res, command = "claude") {
4833
5688
  const { method, url } = req;
@@ -5037,11 +5892,11 @@ function extractChunks(event) {
5037
5892
 
5038
5893
  // src/server/routes/analyze.ts
5039
5894
  import { manualToRawWorkItem, scoreToConcernSignals } from "@harness-engineering/intelligence";
5040
- import { z as z4 } from "zod";
5041
- var AnalyzeRequestSchema = z4.object({
5042
- title: z4.string().min(1),
5043
- description: z4.string().optional(),
5044
- labels: z4.array(z4.string()).optional()
5895
+ import { z as z6 } from "zod";
5896
+ var AnalyzeRequestSchema = z6.object({
5897
+ title: z6.string().min(1),
5898
+ description: z6.string().optional(),
5899
+ labels: z6.array(z6.string()).optional()
5045
5900
  });
5046
5901
  function emit2(res, event) {
5047
5902
  res.write(`data: ${JSON.stringify(event)}
@@ -5149,19 +6004,19 @@ function handleAnalyzeRoute(req, res, pipeline) {
5149
6004
  // src/server/routes/roadmap-actions.ts
5150
6005
  import * as fs10 from "fs/promises";
5151
6006
  import { parseRoadmap as parseRoadmap2, serializeRoadmap as serializeRoadmap2 } from "@harness-engineering/core";
5152
- import { z as z5 } from "zod";
5153
- var AppendRoadmapRequestSchema = z5.object({
5154
- title: z5.string().min(1),
5155
- summary: z5.string().optional(),
5156
- labels: z5.array(z5.string()).optional(),
5157
- enrichedSpec: z5.object({
5158
- intent: z5.string(),
5159
- unknowns: z5.array(z5.string()),
5160
- ambiguities: z5.array(z5.string()),
5161
- riskSignals: z5.array(z5.string()),
5162
- affectedSystems: z5.array(z5.object({ name: z5.string() }))
6007
+ import { z as z7 } from "zod";
6008
+ var AppendRoadmapRequestSchema = z7.object({
6009
+ title: z7.string().min(1),
6010
+ summary: z7.string().optional(),
6011
+ labels: z7.array(z7.string()).optional(),
6012
+ enrichedSpec: z7.object({
6013
+ intent: z7.string(),
6014
+ unknowns: z7.array(z7.string()),
6015
+ ambiguities: z7.array(z7.string()),
6016
+ riskSignals: z7.array(z7.string()),
6017
+ affectedSystems: z7.array(z7.object({ name: z7.string() }))
5163
6018
  }).optional(),
5164
- cmlRecommendedRoute: z5.enum(["local", "human", "simulation-required"]).optional()
6019
+ cmlRecommendedRoute: z7.enum(["local", "human", "simulation-required"]).optional()
5165
6020
  });
5166
6021
  function sendJSON(res, status, body) {
5167
6022
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -5232,11 +6087,11 @@ function handleRoadmapActionsRoute(req, res, roadmapPath) {
5232
6087
 
5233
6088
  // src/server/routes/dispatch-actions.ts
5234
6089
  import { createHash as createHash3 } from "crypto";
5235
- import { z as z6 } from "zod";
5236
- var DispatchAdHocRequestSchema = z6.object({
5237
- title: z6.string().min(1),
5238
- description: z6.string().optional(),
5239
- labels: z6.array(z6.string()).optional()
6090
+ import { z as z8 } from "zod";
6091
+ var DispatchAdHocRequestSchema = z8.object({
6092
+ title: z8.string().min(1),
6093
+ description: z8.string().optional(),
6094
+ labels: z8.array(z8.string()).optional()
5240
6095
  });
5241
6096
  function sendJSON2(res, status, body) {
5242
6097
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -5333,9 +6188,9 @@ function handleAnalysesRoute(req, res, archive) {
5333
6188
  }
5334
6189
 
5335
6190
  // src/server/routes/maintenance.ts
5336
- import { z as z7 } from "zod";
5337
- var TriggerRequestSchema = z7.object({
5338
- taskId: z7.string().min(1)
6191
+ import { z as z9 } from "zod";
6192
+ var TriggerRequestSchema = z9.object({
6193
+ taskId: z9.string().min(1)
5339
6194
  });
5340
6195
  function sendJSON3(res, status, body) {
5341
6196
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -5414,9 +6269,9 @@ function handleMaintenanceRoute(req, res, deps) {
5414
6269
  // src/server/routes/sessions.ts
5415
6270
  import * as fs11 from "fs/promises";
5416
6271
  import * as path10 from "path";
5417
- import { z as z8 } from "zod";
5418
- var SessionCreateSchema = z8.object({
5419
- sessionId: z8.string().min(1)
6272
+ import { z as z10 } from "zod";
6273
+ var SessionCreateSchema = z10.object({
6274
+ sessionId: z10.string().min(1)
5420
6275
  }).passthrough();
5421
6276
  var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5422
6277
  function isSafeId(id) {
@@ -5503,7 +6358,7 @@ async function handleUpdate(req, res, url, sessionsDir) {
5503
6358
  return;
5504
6359
  }
5505
6360
  const body = await readBody(req);
5506
- const updates = z8.record(z8.unknown()).parse(JSON.parse(body));
6361
+ const updates = z10.record(z10.unknown()).parse(JSON.parse(body));
5507
6362
  const sessionFilePath = path10.join(sessionsDir, id, "session.json");
5508
6363
  const current = JSON.parse(await fs11.readFile(sessionFilePath, "utf-8"));
5509
6364
  await fs11.writeFile(sessionFilePath, JSON.stringify({ ...current, ...updates }, null, 2));
@@ -5622,6 +6477,42 @@ function handleStreamsRoute(req, res, recorder) {
5622
6477
  return true;
5623
6478
  }
5624
6479
 
6480
+ // src/server/routes/local-model.ts
6481
+ function sendJSON4(res, status, body) {
6482
+ res.writeHead(status, { "Content-Type": "application/json" });
6483
+ res.end(JSON.stringify(body));
6484
+ }
6485
+ function handleLocalModelRoute(req, res, getStatus) {
6486
+ const { method, url } = req;
6487
+ if (url !== "/api/v1/local-model/status") return false;
6488
+ if (method !== "GET") {
6489
+ sendJSON4(res, 405, { error: "Method not allowed" });
6490
+ return true;
6491
+ }
6492
+ if (!getStatus) {
6493
+ sendJSON4(res, 503, { error: "Local backend not configured" });
6494
+ return true;
6495
+ }
6496
+ const status = getStatus();
6497
+ if (!status) {
6498
+ sendJSON4(res, 503, { error: "Local backend not configured" });
6499
+ return true;
6500
+ }
6501
+ sendJSON4(res, 200, status);
6502
+ return true;
6503
+ }
6504
+ function handleLocalModelsRoute(req, res, getStatuses) {
6505
+ const { method, url } = req;
6506
+ if (url !== "/api/v1/local-models/status") return false;
6507
+ if (method !== "GET") {
6508
+ sendJSON4(res, 405, { error: "Method not allowed" });
6509
+ return true;
6510
+ }
6511
+ const statuses = getStatuses ? getStatuses() : [];
6512
+ sendJSON4(res, 200, statuses);
6513
+ return true;
6514
+ }
6515
+
5625
6516
  // src/server/static.ts
5626
6517
  import * as fs12 from "fs";
5627
6518
  import * as path11 from "path";
@@ -5775,6 +6666,8 @@ var OrchestratorServer = class {
5775
6666
  dispatchAdHoc;
5776
6667
  sessionsDir;
5777
6668
  maintenanceDeps = null;
6669
+ getLocalModelStatus = null;
6670
+ getLocalModelStatuses = null;
5778
6671
  recorder = null;
5779
6672
  planWatcher = null;
5780
6673
  stateChangeListener;
@@ -5801,6 +6694,8 @@ var OrchestratorServer = class {
5801
6694
  this.dispatchAdHoc = deps?.dispatchAdHoc ?? null;
5802
6695
  this.sessionsDir = deps?.sessionsDir ?? path13.resolve(".harness", "sessions");
5803
6696
  this.maintenanceDeps = deps?.maintenanceDeps ?? null;
6697
+ this.getLocalModelStatus = deps?.getLocalModelStatus ?? null;
6698
+ this.getLocalModelStatuses = deps?.getLocalModelStatuses ?? null;
5804
6699
  }
5805
6700
  wireEvents() {
5806
6701
  this.stateChangeListener = (snapshot) => {
@@ -5827,6 +6722,31 @@ var OrchestratorServer = class {
5827
6722
  broadcastMaintenance(type, data) {
5828
6723
  this.broadcaster.broadcast(type, data);
5829
6724
  }
6725
+ /**
6726
+ * Broadcast a local-model status change to dashboard clients.
6727
+ *
6728
+ * Phase 3 routes status events through the existing WebSocket broadcaster
6729
+ * on topic `local-model:status` so test fixtures and dashboard consumers
6730
+ * observe payloads immediately. The project broadcasts via WebSocket; the
6731
+ * spec's "SSE topic" wording is approximate. Phase 5 widens the payload
6732
+ * to `NamedLocalModelStatus` (with `backendName` + `endpoint`); the channel
6733
+ * and bind-before-probe ordering are unchanged.
6734
+ */
6735
+ broadcastLocalModelStatus(status) {
6736
+ this.broadcaster.broadcast("local-model:status", status);
6737
+ }
6738
+ /**
6739
+ * Update the intelligence pipeline reference after construction.
6740
+ *
6741
+ * The orchestrator constructs the pipeline lazily inside `start()` (the
6742
+ * resolver must observe the server before pipeline construction). The
6743
+ * server is built in the orchestrator constructor with `pipeline: null`,
6744
+ * so it must be told the real pipeline once it's been created — otherwise
6745
+ * `/api/analyze` would always see a null pipeline and return 503.
6746
+ */
6747
+ setPipeline(pipeline) {
6748
+ this.pipeline = pipeline;
6749
+ }
5830
6750
  /**
5831
6751
  * Set (or update) the maintenance route dependencies after construction.
5832
6752
  * Called by the Orchestrator once the scheduler and reporter are ready.
@@ -5903,6 +6823,12 @@ var OrchestratorServer = class {
5903
6823
  if (handleDispatchActionsRoute(req, res, this.dispatchAdHoc)) {
5904
6824
  return true;
5905
6825
  }
6826
+ if (handleLocalModelRoute(req, res, this.getLocalModelStatus)) {
6827
+ return true;
6828
+ }
6829
+ if (handleLocalModelsRoute(req, res, this.getLocalModelStatuses)) {
6830
+ return true;
6831
+ }
5906
6832
  if (handleMaintenanceRoute(req, res, this.maintenanceDeps)) {
5907
6833
  return true;
5908
6834
  }
@@ -6475,17 +7401,17 @@ var MaintenanceScheduler = class {
6475
7401
  // src/maintenance/reporter.ts
6476
7402
  import * as fs14 from "fs";
6477
7403
  import * as path14 from "path";
6478
- import { z as z9 } from "zod";
6479
- var RunResultSchema = z9.object({
6480
- taskId: z9.string(),
6481
- startedAt: z9.string(),
6482
- completedAt: z9.string(),
6483
- status: z9.enum(["success", "failure", "skipped", "no-issues"]),
6484
- findings: z9.number(),
6485
- fixed: z9.number(),
6486
- prUrl: z9.string().nullable(),
6487
- prUpdated: z9.boolean(),
6488
- error: z9.string().optional()
7404
+ import { z as z11 } from "zod";
7405
+ var RunResultSchema = z11.object({
7406
+ taskId: z11.string(),
7407
+ startedAt: z11.string(),
7408
+ completedAt: z11.string(),
7409
+ status: z11.enum(["success", "failure", "skipped", "no-issues"]),
7410
+ findings: z11.number(),
7411
+ fixed: z11.number(),
7412
+ prUrl: z11.string().nullable(),
7413
+ prUpdated: z11.boolean(),
7414
+ error: z11.string().optional()
6489
7415
  });
6490
7416
  var MAX_HISTORY = 500;
6491
7417
  var fallbackLogger = {
@@ -6512,7 +7438,7 @@ var MaintenanceReporter = class {
6512
7438
  await fs14.promises.mkdir(this.persistDir, { recursive: true });
6513
7439
  const filePath = path14.join(this.persistDir, "history.json");
6514
7440
  const data = await fs14.promises.readFile(filePath, "utf-8");
6515
- const parsed = z9.array(RunResultSchema).safeParse(JSON.parse(data));
7441
+ const parsed = z11.array(RunResultSchema).safeParse(JSON.parse(data));
6516
7442
  if (parsed.success) {
6517
7443
  this.history = parsed.data.slice(0, MAX_HISTORY);
6518
7444
  }
@@ -6795,13 +7721,43 @@ var TaskRunner = class {
6795
7721
  };
6796
7722
 
6797
7723
  // src/orchestrator.ts
7724
+ function useCaseForBackendParam(issue, backendParam) {
7725
+ if (backendParam === "local") return { kind: "tier", tier: "quick-fix" };
7726
+ const tier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
7727
+ return { kind: "tier", tier };
7728
+ }
6798
7729
  var Orchestrator = class extends EventEmitter {
6799
7730
  state;
6800
7731
  config;
6801
7732
  tracker;
6802
7733
  workspace;
6803
7734
  hooks;
6804
- runner;
7735
+ /**
7736
+ * Spec 2 SC30 / Task 11: per-dispatch backend factory replaces the
7737
+ * Phase 1 `runner` / `localRunner` two-runner split. Each
7738
+ * `dispatchIssue()` call asks the factory for a `RoutingUseCase`-routed
7739
+ * `AgentBackend`, then wraps it in a fresh `AgentRunner`.
7740
+ *
7741
+ * `AgentRunner` is stateless (just `{ backend, options }`), so
7742
+ * per-dispatch construction is safe and avoids the cross-call state
7743
+ * the old two-runner split had to coordinate.
7744
+ *
7745
+ * Null only in the legacy fallback path: when `migrateAgentConfig`
7746
+ * throws (legacy configs missing supplemental fields, e.g.
7747
+ * `agent.backend='anthropic'` with no `agent.model`) AND no
7748
+ * `overrides.backend` is supplied, factory construction is skipped to
7749
+ * preserve the prior behavior of failing at dispatch time rather than
7750
+ * construction time. Eliminating this fallback is autopilot Phase 4+.
7751
+ */
7752
+ backendFactory;
7753
+ /**
7754
+ * Test-only: when overrides.backend is provided, dispatch uses this
7755
+ * instance directly (bypassing the factory). Mirrors Phase 1
7756
+ * `overrides.backend → this.runner.backend` behavior so existing
7757
+ * MockBackend-injection tests keep working without touching the
7758
+ * factory's routing path.
7759
+ */
7760
+ overrideBackend;
6805
7761
  renderer;
6806
7762
  promptTemplate;
6807
7763
  server;
@@ -6809,7 +7765,22 @@ var Orchestrator = class extends EventEmitter {
6809
7765
  heartbeatInterval;
6810
7766
  logger;
6811
7767
  interactionQueue;
6812
- localRunner;
7768
+ /**
7769
+ * Per-named-backend resolver map (Spec 2 SC37). Each `local`/`pi` entry
7770
+ * in `agent.backends` spawns one `LocalModelResolver`. Legacy
7771
+ * single-backend configs converge here via `migrateAgentConfig` (Task 9),
7772
+ * so this map is the single source of truth post-migration.
7773
+ */
7774
+ localResolvers = /* @__PURE__ */ new Map();
7775
+ /**
7776
+ * Per-resolver `onStatusChange` unsubscribe callbacks. Spec 2 Phase 5
7777
+ * (SC39): each local/pi resolver gets its own listener emitting a
7778
+ * `NamedLocalModelStatus` event tagged with `backendName` + `endpoint`.
7779
+ * The previous single-resolver field (`localModelStatusUnsubscribe`)
7780
+ * is replaced by this list so multi-local configs can teardown all
7781
+ * listeners on `stop()` without a Map mutation.
7782
+ */
7783
+ localModelStatusUnsubscribes = [];
6813
7784
  pipeline;
6814
7785
  analysisArchive;
6815
7786
  graphStore = null;
@@ -6851,20 +7822,60 @@ var Orchestrator = class extends EventEmitter {
6851
7822
  this.promptTemplate = promptTemplate;
6852
7823
  this.state = createEmptyState(config);
6853
7824
  this.logger = new StructuredLogger();
7825
+ try {
7826
+ const migrationResult = migrateAgentConfig(this.config.agent);
7827
+ if (migrationResult.warnings.length > 0) {
7828
+ for (const w of migrationResult.warnings) this.logger.warn(w);
7829
+ }
7830
+ this.config = { ...this.config, agent: migrationResult.config };
7831
+ } catch (err) {
7832
+ this.logger.warn(
7833
+ `migrateAgentConfig failed; continuing with legacy fields. Error: ${err instanceof Error ? err.message : String(err)}`
7834
+ );
7835
+ }
6854
7836
  this.tracker = overrides?.tracker || this.createTracker();
6855
7837
  this.workspace = new WorkspaceManager(config.workspace);
6856
7838
  this.hooks = new WorkspaceHooks(config.hooks);
6857
7839
  this.renderer = new PromptRenderer();
6858
- this.runner = new AgentRunner(overrides?.backend || this.createBackend(), {
6859
- maxTurns: config.agent.maxTurns
6860
- });
7840
+ this.overrideBackend = overrides?.backend ?? null;
6861
7841
  this.interactionQueue = new InteractionQueue(
6862
7842
  path15.join(config.workspace.root, "..", "interactions")
6863
7843
  );
6864
7844
  this.analysisArchive = new AnalysisArchive(path15.join(config.workspace.root, "..", "analyses"));
6865
- const localBackend = this.createLocalBackend();
6866
- this.localRunner = localBackend ? new AgentRunner(localBackend, { maxTurns: config.agent.maxTurns }) : null;
6867
- this.pipeline = this.createIntelligencePipeline();
7845
+ const backendsMap = this.config.agent.backends ?? {};
7846
+ for (const [name, def] of Object.entries(backendsMap)) {
7847
+ if (def.type === "local" || def.type === "pi") {
7848
+ const resolverOpts = {
7849
+ endpoint: def.endpoint,
7850
+ configured: typeof def.model === "string" ? [def.model] : def.model,
7851
+ logger: this.logger
7852
+ };
7853
+ if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
7854
+ if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
7855
+ this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
7856
+ }
7857
+ }
7858
+ if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
7859
+ const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
7860
+ const firstBackendName = Object.keys(this.config.agent.backends)[0];
7861
+ const routing = this.config.agent.routing ?? {
7862
+ default: firstBackendName ?? "primary"
7863
+ };
7864
+ this.backendFactory = new OrchestratorBackendFactory({
7865
+ backends: this.config.agent.backends,
7866
+ routing,
7867
+ sandboxPolicy,
7868
+ ...this.config.agent.container !== void 0 ? { container: this.config.agent.container } : {},
7869
+ ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
7870
+ getResolverModelFor: (name) => {
7871
+ const resolver = this.localResolvers.get(name);
7872
+ return resolver ? () => resolver.resolveModel() : void 0;
7873
+ }
7874
+ });
7875
+ } else {
7876
+ this.backendFactory = null;
7877
+ }
7878
+ this.pipeline = null;
6868
7879
  this.orchestratorIdPromise = resolveOrchestratorId(config.orchestratorId);
6869
7880
  this.prDetector = new PRDetector({
6870
7881
  logger: this.logger,
@@ -6908,7 +7919,25 @@ var Orchestrator = class extends EventEmitter {
6908
7919
  pipeline: this.pipeline,
6909
7920
  analysisArchive: this.analysisArchive,
6910
7921
  roadmapPath: config.tracker.filePath ?? null,
6911
- dispatchAdHoc: this.dispatchAdHoc.bind(this)
7922
+ dispatchAdHoc: this.dispatchAdHoc.bind(this),
7923
+ getLocalModelStatus: () => {
7924
+ const first = this.localResolvers.values().next();
7925
+ return first.done ? null : first.value.getStatus();
7926
+ },
7927
+ getLocalModelStatuses: () => {
7928
+ const backends = this.config.agent.backends ?? {};
7929
+ const out = [];
7930
+ for (const [name, resolver] of this.localResolvers) {
7931
+ const def = backends[name];
7932
+ if (!def || def.type !== "local" && def.type !== "pi") continue;
7933
+ out.push({
7934
+ ...resolver.getStatus(),
7935
+ backendName: name,
7936
+ endpoint: def.endpoint
7937
+ });
7938
+ }
7939
+ return out;
7940
+ }
6912
7941
  });
6913
7942
  this.server.setRecorder(this.recorder);
6914
7943
  this.interactionQueue.onPush((interaction) => {
@@ -6922,44 +7951,6 @@ var Orchestrator = class extends EventEmitter {
6922
7951
  }
6923
7952
  throw new Error(`Unsupported tracker kind: ${this.config.tracker.kind}`);
6924
7953
  }
6925
- createBackend() {
6926
- let backend;
6927
- if (this.config.agent.backend === "mock") {
6928
- backend = new MockBackend();
6929
- } else if (this.config.agent.backend === "claude") {
6930
- backend = new ClaudeBackend(this.config.agent.command);
6931
- } else if (this.config.agent.backend === "openai") {
6932
- backend = new OpenAIBackend({
6933
- ...this.config.agent.model !== void 0 && { model: this.config.agent.model },
6934
- ...this.config.agent.apiKey !== void 0 && { apiKey: this.config.agent.apiKey }
6935
- });
6936
- } else if (this.config.agent.backend === "gemini") {
6937
- backend = new GeminiBackend({
6938
- ...this.config.agent.model !== void 0 && { model: this.config.agent.model },
6939
- ...this.config.agent.apiKey !== void 0 && { apiKey: this.config.agent.apiKey }
6940
- });
6941
- } else if (this.config.agent.backend === "anthropic") {
6942
- backend = new AnthropicBackend({
6943
- ...this.config.agent.model !== void 0 && { model: this.config.agent.model },
6944
- ...this.config.agent.apiKey !== void 0 && { apiKey: this.config.agent.apiKey }
6945
- });
6946
- } else {
6947
- throw new Error(`Unsupported agent backend: ${this.config.agent.backend}`);
6948
- }
6949
- if (this.config.agent.sandboxPolicy === "docker" && this.config.agent.container) {
6950
- const runtime = new DockerRuntime();
6951
- const secretBackend = this.config.agent.secrets ? createSecretBackend(this.config.agent.secrets) : null;
6952
- const secretKeys = this.config.agent.secrets?.keys ?? [];
6953
- backend = new ContainerBackend(
6954
- backend,
6955
- runtime,
6956
- secretBackend,
6957
- this.config.agent.container,
6958
- secretKeys
6959
- );
6960
- }
6961
- return backend;
6962
- }
6963
7954
  /**
6964
7955
  * Creates a TaskRunner for the maintenance scheduler.
6965
7956
  * CheckCommandRunner and CommandExecutor use real child_process execution.
@@ -7085,97 +8076,98 @@ var Orchestrator = class extends EventEmitter {
7085
8076
  });
7086
8077
  }
7087
8078
  }
7088
- createLocalBackend() {
7089
- if (this.config.agent.localBackend === "openai-compatible") {
7090
- const localConfig = {};
7091
- if (this.config.agent.localEndpoint) localConfig.endpoint = this.config.agent.localEndpoint;
7092
- if (this.config.agent.localModel) localConfig.model = this.config.agent.localModel;
7093
- if (this.config.agent.localApiKey) localConfig.apiKey = this.config.agent.localApiKey;
7094
- if (this.config.agent.localTimeoutMs)
7095
- localConfig.timeoutMs = this.config.agent.localTimeoutMs;
7096
- return new LocalBackend(localConfig);
7097
- }
7098
- if (this.config.agent.localBackend === "pi") {
7099
- return new PiBackend({
7100
- model: this.config.agent.localModel,
7101
- endpoint: this.config.agent.localEndpoint,
7102
- apiKey: this.config.agent.localApiKey
7103
- });
7104
- }
7105
- return null;
7106
- }
7107
8079
  createIntelligencePipeline() {
7108
8080
  const intel = this.config.intelligence;
7109
8081
  if (!intel?.enabled) return null;
7110
- const provider = this.createAnalysisProvider();
7111
- if (!provider) return null;
8082
+ const selProvider = this.createAnalysisProvider("sel");
8083
+ if (!selProvider) return null;
8084
+ const routing = this.config.agent.routing;
8085
+ const peslName = routing?.intelligence?.pesl;
8086
+ const selName = routing?.intelligence?.sel ?? routing?.default;
8087
+ const peslProvider = peslName !== void 0 && peslName !== selName ? this.createAnalysisProvider("pesl") : null;
7112
8088
  const peslModel = intel.models?.pesl ?? this.config.agent.model;
7113
8089
  const store = new GraphStore();
7114
8090
  this.graphStore = store;
7115
- return new IntelligencePipeline(provider, store, {
7116
- ...peslModel !== void 0 && { peslModel }
8091
+ return new IntelligencePipeline(selProvider, store, {
8092
+ ...peslModel !== void 0 && { peslModel },
8093
+ ...peslProvider !== null && peslProvider !== void 0 && { peslProvider }
7117
8094
  });
7118
8095
  }
7119
8096
  /**
7120
- * Create the AnalysisProvider for the intelligence pipeline.
8097
+ * Create the AnalysisProvider for an intelligence-pipeline layer
8098
+ * (`sel` by default; `pesl` when constructing a distinct PESL
8099
+ * provider per Spec 2 SC35).
8100
+ *
8101
+ * Spec 2 Phase 4 (SC31–SC36) — resolution order:
8102
+ * 1. Explicit `intelligence.provider` config wins (preserves Phase 0–3 behavior; SC33).
8103
+ * 2. Otherwise, consult `agent.routing.intelligence.<layer>` (or
8104
+ * `routing.default`) to pick a `BackendDef` from `agent.backends`,
8105
+ * then translate via `buildAnalysisProvider` (the per-type factory).
7121
8106
  *
7122
- * Resolution order:
7123
- * 1. Explicit `intelligence.provider` config (separate key/endpoint)
7124
- * 2. Local backend config (agent.localBackend + localEndpoint/localModel)
7125
- * 3. Primary agent backend config (agent.apiKey + agent.backend)
8107
+ * Closes the Phase 2 deferral (P2-DEF-638): the legacy
8108
+ * `this.config.agent.backend` read at the bottom of this method is
8109
+ * removed; routing is the sole source for non-explicit configs.
8110
+ *
8111
+ * Cyclomatic complexity: pre-Phase-4 was 33 (factory dispatch was
8112
+ * inlined here). Phase 4 extracts the per-type tree into
8113
+ * `buildAnalysisProvider`, dropping this method to ≤ 5 branches
8114
+ * (under the 15 threshold).
7126
8115
  */
7127
- createAnalysisProvider() {
8116
+ createAnalysisProvider(layer = "sel") {
7128
8117
  const intel = this.config.intelligence;
7129
- const selModel = intel?.models?.sel ?? this.config.agent.model;
7130
- if (intel?.provider) {
7131
- return this.createProviderFromExplicitConfig(intel.provider, selModel);
7132
- }
7133
- if (this.config.agent.localBackend === "openai-compatible" || this.config.agent.localBackend === "pi") {
7134
- const endpoint = this.config.agent.localEndpoint ?? "http://localhost:11434/v1";
7135
- const apiKey = this.config.agent.localApiKey ?? "ollama";
7136
- const model = selModel ?? this.config.agent.localModel;
7137
- this.logger.info(`Intelligence pipeline using local backend at ${endpoint}`);
7138
- return new OpenAICompatibleAnalysisProvider({
7139
- apiKey,
7140
- baseUrl: endpoint,
7141
- ...model !== void 0 && { defaultModel: model },
7142
- ...intel?.requestTimeoutMs !== void 0 && { timeoutMs: intel.requestTimeoutMs },
7143
- ...intel?.promptSuffix !== void 0 && { promptSuffix: intel.promptSuffix },
7144
- ...intel?.jsonMode !== void 0 && { jsonMode: intel.jsonMode }
7145
- });
7146
- }
7147
- const backend = this.config.agent.backend;
7148
- if (backend === "anthropic" || backend === "claude") {
7149
- const apiKey = this.config.agent.apiKey ?? process.env.ANTHROPIC_API_KEY;
7150
- if (apiKey) {
7151
- return new AnthropicAnalysisProvider({
7152
- apiKey,
7153
- ...selModel !== void 0 && { defaultModel: selModel }
7154
- });
7155
- }
7156
- }
7157
- if (backend === "openai") {
7158
- const apiKey = this.config.agent.apiKey ?? process.env.OPENAI_API_KEY;
7159
- if (apiKey) {
7160
- return new OpenAICompatibleAnalysisProvider({
7161
- apiKey,
7162
- baseUrl: "https://api.openai.com/v1",
7163
- ...selModel !== void 0 && { defaultModel: selModel }
7164
- });
7165
- }
8118
+ if (!intel?.enabled) return null;
8119
+ if (intel.provider) {
8120
+ const layerModel = layer === "sel" ? intel.models?.sel : intel.models?.pesl;
8121
+ return this.createProviderFromExplicitConfig(
8122
+ intel.provider,
8123
+ layerModel ?? this.config.agent.model
8124
+ );
7166
8125
  }
7167
- if (backend === "claude" || backend === "anthropic") {
7168
- this.logger.info("Intelligence pipeline using Claude CLI (no API key configured)");
7169
- return new ClaudeCliAnalysisProvider({
7170
- command: this.config.agent.command,
7171
- ...selModel !== void 0 && { defaultModel: selModel },
7172
- ...intel?.requestTimeoutMs !== void 0 && { timeoutMs: intel.requestTimeoutMs }
7173
- });
8126
+ const routed = this.resolveRoutedBackendForIntelligence(layer);
8127
+ if (!routed) return null;
8128
+ const { name, def } = routed;
8129
+ const resolver = this.localResolvers.get(name);
8130
+ return buildAnalysisProvider({
8131
+ def,
8132
+ backendName: name,
8133
+ layer,
8134
+ // Spec 2 P3-IMP-1: a single snapshot read feeds the factory's
8135
+ // unavailable-warn diagnostic (Configured/Detected lists) and
8136
+ // collapses the two `getStatus()` calls flagged by P3-SUG-2.
8137
+ getResolverStatusSnapshot: () => {
8138
+ if (!resolver) return null;
8139
+ const status = resolver.getStatus();
8140
+ return {
8141
+ available: status.available,
8142
+ resolved: status.resolved,
8143
+ configured: status.configured,
8144
+ detected: status.detected
8145
+ };
8146
+ },
8147
+ intelligence: intel,
8148
+ logger: this.logger
8149
+ });
8150
+ }
8151
+ /**
8152
+ * Look up the routed BackendDef for an intelligence layer, falling
8153
+ * back through `routing.intelligence.<layer>` → `routing.default`
8154
+ * → null. Returns the resolved name alongside the def so callers can
8155
+ * key into the per-name resolver map.
8156
+ */
8157
+ resolveRoutedBackendForIntelligence(layer) {
8158
+ const routing = this.config.agent.routing;
8159
+ const backends = this.config.agent.backends;
8160
+ if (!routing || !backends) return null;
8161
+ const layerName = routing.intelligence?.[layer];
8162
+ const name = layerName ?? routing.default;
8163
+ const def = backends[name];
8164
+ if (!def) {
8165
+ this.logger.warn(
8166
+ `Intelligence pipeline: routed backend '${name}' for layer '${layer}' is not in agent.backends.`
8167
+ );
8168
+ return null;
7174
8169
  }
7175
- this.logger.warn(
7176
- `Intelligence pipeline: unsupported backend "${backend}". Supported: anthropic, claude, openai, or localBackend: openai-compatible / pi.`
7177
- );
7178
- return null;
8170
+ return { name, def };
7179
8171
  }
7180
8172
  createProviderFromExplicitConfig(provider, selModel) {
7181
8173
  if (provider.kind === "anthropic") {
@@ -7183,13 +8175,13 @@ var Orchestrator = class extends EventEmitter {
7183
8175
  if (!apiKey2) {
7184
8176
  throw new Error("Intelligence pipeline: no Anthropic API key found.");
7185
8177
  }
7186
- return new AnthropicAnalysisProvider({
8178
+ return new AnthropicAnalysisProvider2({
7187
8179
  apiKey: apiKey2,
7188
8180
  ...selModel !== void 0 && { defaultModel: selModel }
7189
8181
  });
7190
8182
  }
7191
8183
  if (provider.kind === "claude-cli") {
7192
- return new ClaudeCliAnalysisProvider({
8184
+ return new ClaudeCliAnalysisProvider2({
7193
8185
  command: this.config.agent.command,
7194
8186
  ...selModel !== void 0 && { defaultModel: selModel },
7195
8187
  ...this.config.intelligence?.requestTimeoutMs !== void 0 && {
@@ -7200,7 +8192,7 @@ var Orchestrator = class extends EventEmitter {
7200
8192
  const apiKey = provider.apiKey ?? this.config.agent.apiKey ?? "ollama";
7201
8193
  const baseUrl = provider.baseUrl ?? "http://localhost:11434/v1";
7202
8194
  const intel = this.config.intelligence;
7203
- return new OpenAICompatibleAnalysisProvider({
8195
+ return new OpenAICompatibleAnalysisProvider2({
7204
8196
  apiKey,
7205
8197
  baseUrl,
7206
8198
  ...selModel !== void 0 && { defaultModel: selModel },
@@ -7682,9 +8674,18 @@ var Orchestrator = class extends EventEmitter {
7682
8674
  issue,
7683
8675
  attempt: attempt || 1
7684
8676
  });
8677
+ const useCase = useCaseForBackendParam(issue, backend);
8678
+ let routedBackendName;
8679
+ if (this.overrideBackend !== null) {
8680
+ routedBackendName = this.overrideBackend.name;
8681
+ } else if (this.backendFactory !== null) {
8682
+ routedBackendName = this.backendFactory.resolveName(useCase);
8683
+ } else {
8684
+ routedBackendName = this.config.agent.routing?.default ?? this.config.agent.backend ?? "unknown";
8685
+ }
7685
8686
  const session = {
7686
8687
  sessionId: `pending-${Date.now()}`,
7687
- backendName: this.config.agent.backend,
8688
+ backendName: routedBackendName,
7688
8689
  agentPid: null,
7689
8690
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
7690
8691
  lastEvent: "Dispatching",
@@ -7711,11 +8712,23 @@ var Orchestrator = class extends EventEmitter {
7711
8712
  issue.id,
7712
8713
  issue.externalId ?? null,
7713
8714
  issue.identifier,
7714
- this.config.agent.backend,
8715
+ routedBackendName,
7715
8716
  attempt ?? 1,
7716
8717
  issue.title
7717
8718
  );
7718
- const activeRunner = backend === "local" && this.localRunner ? this.localRunner : this.runner;
8719
+ let agentBackend;
8720
+ if (this.overrideBackend !== null) {
8721
+ agentBackend = this.overrideBackend;
8722
+ } else if (this.backendFactory !== null) {
8723
+ agentBackend = this.backendFactory.forUseCase(useCase);
8724
+ } else {
8725
+ throw new Error(
8726
+ `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.`
8727
+ );
8728
+ }
8729
+ const activeRunner = new AgentRunner(agentBackend, {
8730
+ maxTurns: this.config.agent.maxTurns
8731
+ });
7719
8732
  this.runAgentInBackgroundTask(issue, workspacePath, prompt, attempt, activeRunner);
7720
8733
  } catch (error) {
7721
8734
  this.logger.error(`Dispatch failed for ${issue.identifier}`, { error: String(error) });
@@ -7748,7 +8761,7 @@ var Orchestrator = class extends EventEmitter {
7748
8761
  }
7749
8762
  }
7750
8763
  runAgentInBackgroundTask(issue, workspacePath, prompt, attempt, runner) {
7751
- const activeRunner = runner ?? this.runner;
8764
+ const activeRunner = runner;
7752
8765
  this.logger.info(`Starting background task for ${issue.identifier}`);
7753
8766
  const abortController = new AbortController();
7754
8767
  this.abortControllers.set(issue.id, { controller: abortController, pid: null });
@@ -7869,6 +8882,42 @@ var Orchestrator = class extends EventEmitter {
7869
8882
  this.emit("state_change", this.getSnapshot());
7870
8883
  await this.dispatchIssue(issue, 1, "local");
7871
8884
  }
8885
+ /**
8886
+ * Initialize the LocalModelResolver and intelligence pipeline.
8887
+ *
8888
+ * Runs the initial probe (so resolver state reflects server availability)
8889
+ * before constructing the intelligence pipeline. Subscribes the dashboard
8890
+ * broadcast stub to status changes. Called exactly once from start().
8891
+ */
8892
+ async initLocalModelAndPipeline() {
8893
+ if (this.localResolvers.size > 0) {
8894
+ const backends = this.config.agent.backends ?? {};
8895
+ for (const [name, resolver] of this.localResolvers) {
8896
+ const def = backends[name];
8897
+ if (!def || def.type !== "local" && def.type !== "pi") {
8898
+ this.logger.warn("Resolver without matching backend def \u2014 broadcast skipped", {
8899
+ name
8900
+ });
8901
+ continue;
8902
+ }
8903
+ const endpoint = def.endpoint;
8904
+ const unsubscribe = resolver.onStatusChange((status) => {
8905
+ const named = {
8906
+ ...status,
8907
+ backendName: name,
8908
+ endpoint
8909
+ };
8910
+ this.server?.broadcastLocalModelStatus(named);
8911
+ });
8912
+ this.localModelStatusUnsubscribes.push(unsubscribe);
8913
+ }
8914
+ for (const resolver of this.localResolvers.values()) {
8915
+ await resolver.start();
8916
+ }
8917
+ }
8918
+ this.pipeline = this.createIntelligencePipeline();
8919
+ this.server?.setPipeline(this.pipeline);
8920
+ }
7872
8921
  /**
7873
8922
  * Starts the polling loop and the internal HTTP server.
7874
8923
  * Runs startup reconciliation to release orphaned claims before the first tick.
@@ -7877,6 +8926,7 @@ var Orchestrator = class extends EventEmitter {
7877
8926
  if (this.server) {
7878
8927
  void this.server.start();
7879
8928
  }
8929
+ await this.initLocalModelAndPipeline();
7880
8930
  await this.ensureClaimManager();
7881
8931
  const runningIssueIds = new Set(this.state.running.keys());
7882
8932
  const reconcileResult = await this.claimManager.reconcileOnStartup(runningIssueIds);
@@ -7928,6 +8978,13 @@ var Orchestrator = class extends EventEmitter {
7928
8978
  clearInterval(this.heartbeatInterval);
7929
8979
  this.heartbeatInterval = void 0;
7930
8980
  }
8981
+ for (const unsub of this.localModelStatusUnsubscribes) {
8982
+ unsub();
8983
+ }
8984
+ this.localModelStatusUnsubscribes = [];
8985
+ for (const resolver of this.localResolvers.values()) {
8986
+ resolver.stop();
8987
+ }
7931
8988
  if (this.maintenanceScheduler) {
7932
8989
  this.maintenanceScheduler.stop();
7933
8990
  this.maintenanceScheduler = null;
@@ -8204,12 +9261,14 @@ function launchTUI(orchestrator) {
8204
9261
  }
8205
9262
  export {
8206
9263
  AnalysisArchive,
9264
+ BackendRouter,
8207
9265
  ClaimManager,
8208
9266
  InteractionQueue,
8209
9267
  LinearGraphQLStub,
8210
9268
  MockBackend,
8211
9269
  ORCHESTRATOR_IDENTITY_FILE,
8212
9270
  Orchestrator,
9271
+ OrchestratorBackendFactory,
8213
9272
  PRDetector,
8214
9273
  PromptRenderer,
8215
9274
  RoadmapTrackerAdapter,
@@ -8222,6 +9281,7 @@ export {
8222
9281
  calculateRetryDelay,
8223
9282
  canDispatch,
8224
9283
  computeRateLimitDelay,
9284
+ createBackend,
8225
9285
  createEmptyState,
8226
9286
  detectScopeTier,
8227
9287
  extractHighlights,
@@ -8232,6 +9292,7 @@ export {
8232
9292
  isEligible,
8233
9293
  launchTUI,
8234
9294
  loadPublishedIndex,
9295
+ migrateAgentConfig,
8235
9296
  reconcile,
8236
9297
  renderAnalysisComment,
8237
9298
  renderPRComment,