@ordewell/web 0.10.1 → 0.10.3

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.
@@ -3,6 +3,7 @@
3
3
 
4
4
  var hono = require('hono');
5
5
  var child_process = require('child_process');
6
+ var crypto = require('crypto');
6
7
  var core = require('@ordewell/core');
7
8
  var fs = require('fs');
8
9
  var path = require('path');
@@ -31,60 +32,59 @@ var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
31
32
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
32
33
 
33
34
  var WebConfig = class extends core.BaseConfig {
34
- _provider = null;
35
- _providerCacheKey = null;
36
- _providerCacheLists = void 0;
37
35
  _enabledRunnersOverride;
38
36
  _providerModelLists;
39
37
  _modelOverride;
38
+ _pickedModel;
39
+ _agentCatalog;
40
40
  constructor(opts) {
41
41
  super();
42
42
  this._enabledRunnersOverride = opts?.enabledRunners;
43
- this._providerModelLists = opts?.providerModelLists;
43
+ const lists = opts?.providerModelLists;
44
+ this._providerModelLists = typeof lists === "function" ? lists : () => lists;
44
45
  this._modelOverride = opts?.modelOverride;
46
+ this._pickedModel = opts?.pickedModel ?? (() => void 0);
47
+ this._agentCatalog = opts?.agentCatalog ?? (() => []);
48
+ }
49
+ agentCatalog(provider) {
50
+ return this._agentCatalog(provider);
45
51
  }
46
52
  setProviderModelLists(lists) {
47
- this._providerModelLists = lists;
48
- this._provider = null;
53
+ this._providerModelLists = () => lists;
54
+ }
55
+ /**
56
+ * Read live, not fixed at construction: a session's WebConfig lives for the
57
+ * whole session while /model, /planner and /key mutate process.env under
58
+ * it. `routedProvider` keeps that cheap.
59
+ */
60
+ get planner() {
61
+ return this.resolvePlanner({ ...this.configuredPlanner(), override: this._modelOverride }, this._providerModelLists());
62
+ }
63
+ /**
64
+ * What the daemon inherited at startup, or what a settings write has set
65
+ * since; the model counts as picked only while it is the one a write picked.
66
+ */
67
+ configuredPlanner() {
68
+ const model = process.env.ORCHESTRATOR_MODEL || "";
69
+ const pick = this._pickedModel();
70
+ return { model, effort: process.env.ORDEWELL_PLANNER_EFFORT || "", pickedFor: pick?.model === model ? pick.provider : void 0 };
49
71
  }
50
72
  get orchestratorModel() {
51
- return this._modelOverride || process.env.ORCHESTRATOR_MODEL || "";
73
+ return this.planner.model;
52
74
  }
53
75
  get aiProvider() {
54
- const explicitEnv = process.env.AI_PROVIDER;
55
- const model = this._modelOverride || process.env.ORCHESTRATOR_MODEL;
56
- const cacheKey = `${explicitEnv ?? ""}|${model ?? ""}`;
57
- if (this._provider && this._providerCacheKey === cacheKey && this._providerCacheLists === this._providerModelLists) {
58
- return this._provider;
59
- }
60
- const explicit = explicitEnv;
61
- if (explicit && core.isCliProvider(explicit)) {
62
- this._provider = explicit;
63
- this._providerCacheKey = cacheKey;
64
- this._providerCacheLists = this._providerModelLists;
65
- return this._provider;
66
- }
67
- if (model && this._providerModelLists) {
68
- const resolved = core.resolveProvider(model, this._providerModelLists);
69
- if (resolved) {
70
- this._provider = resolved;
71
- this._providerCacheKey = cacheKey;
72
- this._providerCacheLists = this._providerModelLists;
73
- return this._provider;
74
- }
75
- }
76
- this._provider = core.BaseConfig.detectProvider("openrouter");
77
- this._providerCacheKey = cacheKey;
78
- this._providerCacheLists = this._providerModelLists;
79
- return this._provider;
76
+ return this.planner.provider;
77
+ }
78
+ get plannerThinkingEffort() {
79
+ return this.planner.effort || void 0;
80
80
  }
81
81
  get apiKey() {
82
- return this.getProviderApiKey(this.aiProvider);
82
+ const provider = this.aiProvider;
83
+ return core.isApiProvider(provider) ? this.getProviderApiKey(provider) : "";
83
84
  }
84
85
  get planningModel() {
85
- if (this.aiProvider === "google")
86
- return core.normalizeGeminiModel(process.env.GEMINI_MODEL || process.env.ORCHESTRATOR_MODEL || super.geminiModel);
87
- return process.env.ORCHESTRATOR_MODEL || "";
86
+ const model = this.plannerChoiceFor(this.aiProvider, this.configuredPlanner()).model;
87
+ return this.aiProvider === "google" ? core.normalizeGeminiModel(process.env.GEMINI_MODEL || model) : model;
88
88
  }
89
89
  get enabledRunners() {
90
90
  if (this._enabledRunnersOverride) return this._enabledRunnersOverride;
@@ -93,6 +93,8 @@ var WebConfig = class extends core.BaseConfig {
93
93
  const parsed = envRunners.split(",").map((s) => s.trim()).filter(Boolean);
94
94
  if (parsed.length > 0) return parsed;
95
95
  }
96
+ const toggles = ["ORDEWELL_CLAUDE_CODE_ENABLED", "ORDEWELL_CODEX_ENABLED", "ORDEWELL_OPENCODE_ENABLED"];
97
+ if (toggles.every((name) => process.env[name] === void 0)) return core.defaultEnabledRunners();
96
98
  const out = [];
97
99
  if (process.env.ORDEWELL_CLAUDE_CODE_ENABLED !== "false" && process.env.ORDEWELL_CLAUDE_CODE_ENABLED !== "0") {
98
100
  out.push("claude-code");
@@ -372,12 +374,26 @@ var PoolAwareRunner = class {
372
374
  var OrchestratorPool = class {
373
375
  sessions = /* @__PURE__ */ new Map();
374
376
  clients = /* @__PURE__ */ new Map();
377
+ /**
378
+ * This process's broadcast numbering (see `StreamStamp`), per session id
379
+ * rather than per Session object: a session replaced under the same id
380
+ * keeps counting up, so a client never mistakes its first broadcast for one
381
+ * of the old session's it already showed.
382
+ */
383
+ stream = crypto.randomUUID();
384
+ sequence = /* @__PURE__ */ new Map();
375
385
  registry = new core.RunnerRegistry();
376
386
  modelResolver;
377
- runnerInstallation = new core.RunnerInstallation(this.registry);
387
+ runnerInstallation;
378
388
  cachedProviderLists;
379
389
  settingsService = new core.SettingsService();
380
390
  plannerModelMemory = new core.PlannerModelMemory(this.settingsService);
391
+ /**
392
+ * The model a settings write picked in this daemon. The environment's model
393
+ * is otherwise inherited, and an agent planning only because detection
394
+ * found it does not plan with an inherited model it cannot be shown to run.
395
+ */
396
+ pickedModel;
381
397
  runner;
382
398
  /** One probe per daemon, asked again whenever the runner list is refreshed (ADR-0033). */
383
399
  codexSandbox;
@@ -385,6 +401,7 @@ var OrchestratorPool = class {
385
401
  const pluginNotice = core.removedPluginNotice();
386
402
  if (pluginNotice) console.warn(pluginNotice);
387
403
  this.runner = deps.runner ?? new core.StructuredRunner();
404
+ this.runnerInstallation = deps.runnerInstallation ?? new core.RunnerInstallation(this.registry);
388
405
  this.modelResolver = deps.modelResolver ?? new core.ModelResolver(this.registry, new WebConfig());
389
406
  this.codexSandbox = deps.codexSandbox ?? new core.CodexSandboxAvailability(this.registry, { spawn: child_process.spawn, fetch: globalThis.fetch }, { cwd: process.cwd(), env: process.env });
390
407
  }
@@ -396,6 +413,29 @@ var OrchestratorPool = class {
396
413
  warmCodexSandbox() {
397
414
  this.codexSandbox.ensure().catch((err) => console.error(`[web] codex sandbox probe failed: ${err instanceof Error ? err.message : err}`));
398
415
  }
416
+ /**
417
+ * Find the coding agent that plans when the user has chosen no planner and
418
+ * set no vendor key (ADR-0009), for the daemon to start as it starts. Every
419
+ * planner turn waits on it, so a goal typed the instant the TUI opens
420
+ * does not plan with an openrouter key nobody set.
421
+ */
422
+ detectInstalledPlanner() {
423
+ return core.detectInstalledPlanner(this.runnerInstallation);
424
+ }
425
+ /**
426
+ * A new session's first planner turn. It waits for that probe at the
427
+ * session's own planner gate, where a reply sent meanwhile queues behind
428
+ * it, and a Stop or the session closing or being replaced ends the wait.
429
+ */
430
+ async firstTurn(sessionId, session, turn) {
431
+ try {
432
+ return await turn();
433
+ } catch (err) {
434
+ if (err instanceof core.PlannerNotReadyError && this.sessions.get(sessionId) === session) this.destroy(sessionId);
435
+ if (err instanceof core.PlannerTurnStoppedError && !session.planState) throw new core.PlannerTurnDiscardedError();
436
+ throw err;
437
+ }
438
+ }
399
439
  subscribe(sessionId, ws) {
400
440
  if (!this.clients.has(sessionId)) this.clients.set(sessionId, /* @__PURE__ */ new Set());
401
441
  this.clients.get(sessionId).add(ws);
@@ -405,7 +445,9 @@ var OrchestratorPool = class {
405
445
  if (this.clients.get(sessionId)?.size === 0) this.clients.delete(sessionId);
406
446
  }
407
447
  broadcast(sessionId, msg) {
408
- const payload = JSON.stringify(msg);
448
+ const seq = (this.sequence.get(sessionId) ?? 0) + 1;
449
+ this.sequence.set(sessionId, seq);
450
+ const payload = JSON.stringify({ ...msg, stream: this.stream, seq });
409
451
  for (const ws of this.clients.get(sessionId) ?? []) {
410
452
  if (ws.readyState === ws.OPEN) ws.send(payload);
411
453
  }
@@ -444,6 +486,14 @@ var OrchestratorPool = class {
444
486
  runSnapshot(sessionId) {
445
487
  return this.sessions.get(sessionId)?.runSnapshot() ?? null;
446
488
  }
489
+ /** The planner's approval requests still waiting, as they were announced. */
490
+ announcedApprovals(sessionId) {
491
+ return this.sessions.get(sessionId)?.announcedApprovals() ?? [];
492
+ }
493
+ /** The last broadcast of the session so far: a snapshot taken now accounts for it and everything before. */
494
+ streamPosition(sessionId) {
495
+ return { stream: this.stream, seq: this.sequence.get(sessionId) ?? 0 };
496
+ }
447
497
  runtimeSettings() {
448
498
  return core.sessionRuntimeSettings(this.settingsService.getAll());
449
499
  }
@@ -456,11 +506,7 @@ var OrchestratorPool = class {
456
506
  core.ensureStateDirIgnored(workspace);
457
507
  core.assertWorkspaceIsProject(workspace);
458
508
  }
459
- const config = new WebConfig({
460
- enabledRunners: this.enabledRunnerOverride(),
461
- modelOverride,
462
- providerModelLists: this.cachedProviderLists
463
- });
509
+ const config = this.plannerConfig(modelOverride);
464
510
  const fsAdapter = new PoolFileSystem(workspace);
465
511
  const broadcast = (msg) => this.broadcast(sessionId, msg);
466
512
  const runner = new PoolAwareRunner(sessionId, broadcast, this.runner);
@@ -484,6 +530,22 @@ var OrchestratorPool = class {
484
530
  sessionId
485
531
  });
486
532
  }
533
+ /**
534
+ * The one way the pool builds a config that answers "who plans": sessions,
535
+ * the settings response and the switch judgement all route through it, so
536
+ * what the settings route reports is what the next turn plans with. The
537
+ * lists are handed over as a reader, not a snapshot, so a credential change
538
+ * reaches a session that is already open.
539
+ */
540
+ plannerConfig(modelOverride) {
541
+ return new WebConfig({
542
+ enabledRunners: this.enabledRunnerOverride(),
543
+ modelOverride,
544
+ providerModelLists: () => this.cachedProviderLists,
545
+ pickedModel: () => this.pickedModel,
546
+ agentCatalog: (provider) => this.plannerCatalogFor(provider).map((model) => model.id)
547
+ });
548
+ }
487
549
  /**
488
550
  * The user's runner choice, or undefined when they have never made one —
489
551
  * which is what lets `WebConfig` fall back to the environment's defaults.
@@ -504,7 +566,7 @@ var OrchestratorPool = class {
504
566
  this.settingsService.setEnabledRunners(next);
505
567
  }
506
568
  getSettings() {
507
- const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride() });
569
+ const config = this.plannerConfig();
508
570
  const userSettings = this.settingsService.getAll();
509
571
  return {
510
572
  orchestratorModel: config.orchestratorModel,
@@ -555,21 +617,14 @@ var OrchestratorPool = class {
555
617
  * model id, so this is read again after a write rather than carried over.
556
618
  */
557
619
  plannerProviderInEffect() {
558
- return new WebConfig({
559
- enabledRunners: this.enabledRunnerOverride(),
560
- providerModelLists: this.cachedProviderLists
561
- }).aiProvider;
620
+ return this.plannerConfig().aiProvider;
562
621
  }
563
622
  updateSettings(changes) {
564
- const current = {
565
- provider: this.plannerProviderInEffect(),
566
- model: process.env.ORCHESTRATOR_MODEL ?? "",
567
- effort: process.env.ORDEWELL_PLANNER_EFFORT ?? ""
568
- };
623
+ const current = this.plannerConfig().planner;
569
624
  const rawEnv = changes.env && typeof changes.env === "object" && !Array.isArray(changes.env) ? changes.env : void 0;
570
625
  const admission = rawEnv ? core.admitSettingsEnv(rawEnv) : void 0;
571
626
  const envChanges = admission ? admission.accepted : void 0;
572
- const plannerChange = {
627
+ const plannerChange = changes.inherited === true ? {} : {
573
628
  provider: envChanges?.AI_PROVIDER,
574
629
  model: envChanges?.ORCHESTRATOR_MODEL ?? stringField(changes.orchestratorModel),
575
630
  effort: envChanges?.ORDEWELL_PLANNER_EFFORT ?? stringField(changes.plannerThinkingEffort)
@@ -592,11 +647,13 @@ var OrchestratorPool = class {
592
647
  if (touched) {
593
648
  this.modelResolver.invalidate();
594
649
  this.modelResolver.refreshRunnerModels();
650
+ this.dropUnkeyedProviderLists();
595
651
  }
596
652
  }
597
653
  if (selection) {
598
- process.env.ORCHESTRATOR_MODEL = selection.next.model;
654
+ if (plannerChange.provider !== void 0 || plannerChange.model !== void 0) process.env.ORCHESTRATOR_MODEL = selection.next.model;
599
655
  process.env.ORDEWELL_PLANNER_EFFORT = selection.next.effort;
656
+ if (plannerChange.model !== void 0) this.pickedModel = { model: selection.next.model, provider: this.plannerProviderInEffect() };
600
657
  }
601
658
  if (changes.modelAllowlist && typeof changes.modelAllowlist === "object") {
602
659
  const allowlist = changes.modelAllowlist;
@@ -619,6 +676,20 @@ var OrchestratorPool = class {
619
676
  if (selection?.recall) settings.switchRecall = selection.recall;
620
677
  return settings;
621
678
  }
679
+ /**
680
+ * A vendor whose credential is gone keeps no list to route a model to it,
681
+ * or the next turn would be sent to a planner the settings response no
682
+ * longer names. Pruned rather than cleared: the vendors that still hold a
683
+ * key keep routing their models until the catalog is discovered again, so
684
+ * adding a second key does not move the planner off the first.
685
+ */
686
+ dropUnkeyedProviderLists() {
687
+ if (!this.cachedProviderLists) return;
688
+ const keyed = new Set(core.configuredProviders(new WebConfig()));
689
+ this.cachedProviderLists = Object.fromEntries(
690
+ Object.entries(this.cachedProviderLists).filter(([provider]) => keyed.has(provider))
691
+ );
692
+ }
622
693
  setOrchestratorModel(model) {
623
694
  process.env.ORCHESTRATOR_MODEL = model;
624
695
  return model;
@@ -651,14 +722,14 @@ var OrchestratorPool = class {
651
722
  models: allModels,
652
723
  modelsByRunner,
653
724
  modesByRunner: core.runnerModesFrom(this.registry, runnerIds),
654
- orchestratorModel: process.env.ORCHESTRATOR_MODEL || "",
725
+ orchestratorModel: this.plannerConfig().orchestratorModel,
655
726
  providers,
656
727
  orchestratorModels,
657
728
  providerErrors
658
729
  };
659
730
  }
660
731
  getRunnerState() {
661
- const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride() });
732
+ const config = this.plannerConfig();
662
733
  return { enabledRunners: config.enabledRunners, orchestratorModel: config.orchestratorModel };
663
734
  }
664
735
  /**
@@ -681,11 +752,20 @@ var OrchestratorPool = class {
681
752
  }));
682
753
  }
683
754
  async generatePlan(sessionId, goal, runners, workspace, modelOverride, options) {
755
+ const session = this.openPlanningSession(sessionId, workspace, modelOverride, options);
756
+ return core.migratePlanState(await this.firstTurn(sessionId, session, () => session.generatePlan(goal, runners)));
757
+ }
758
+ /**
759
+ * Build the session a new planning conversation runs in and register it in
760
+ * place of any live one, for {@link firstTurn} to run its first turn on.
761
+ */
762
+ openPlanningSession(sessionId, workspace, modelOverride, options) {
684
763
  const session = this.createSessionFor(sessionId, workspace, modelOverride, options);
764
+ if (core.installedPlannerAnswered()) session.assertPlannerReady();
685
765
  this.destroy(sessionId);
686
766
  this.sessions.set(sessionId, session);
687
767
  this.warmCodexSandbox();
688
- return core.migratePlanState(await session.generatePlan(goal, runners));
768
+ return session;
689
769
  }
690
770
  /**
691
771
  * Conversational planning (ADR-0002): start the planner dialogue for a
@@ -694,13 +774,16 @@ var OrchestratorPool = class {
694
774
  * assistant message lives in conversationHistory).
695
775
  */
696
776
  async startPlanning(sessionId, goal, runners, workspace, modelOverride, options) {
697
- const session = this.createSessionFor(sessionId, workspace, modelOverride, options);
698
- this.destroy(sessionId);
699
- this.sessions.set(sessionId, session);
700
- this.warmCodexSandbox();
701
- return session.startPlanning(goal, runners);
777
+ const session = this.openPlanningSession(sessionId, workspace, modelOverride, options);
778
+ return this.firstTurn(sessionId, session, () => session.startPlanning(goal, runners));
702
779
  }
703
- /** Continue the planner dialogue with the user's reply; refused while a planner turn is in flight. */
780
+ /**
781
+ * Continue the planner dialogue with the user's reply: queued behind the
782
+ * first turn, even while it waits for the installed-planner probe, and
783
+ * refused only while a turn of another kind holds the conversation. The
784
+ * session's own planner gate waits for the probe and judges the planner, as
785
+ * it does for every turn.
786
+ */
704
787
  async continuePlanning(sessionId, message) {
705
788
  return this.session(sessionId).continueConversation(message);
706
789
  }
@@ -788,6 +871,7 @@ function classify(err) {
788
871
  if (err instanceof core.AlreadyExecutingError) return { status: 409, code: "already_executing" };
789
872
  if (err instanceof core.WorkspaceNotAProjectError) return { status: 400, code: "workspace_not_a_project" };
790
873
  if (err instanceof core.WorkspaceNotFoundError) return { status: 400, code: "workspace_not_found" };
874
+ if (err instanceof core.PlannerNotReadyError) return { status: 409, code: "planner_not_ready" };
791
875
  if (err instanceof core.PlannerTurnStoppedError) return { status: 409, code: "planner_turn_stopped" };
792
876
  if (err instanceof core.PlannerTurnDiscardedError) return { status: 409, code: "planner_turn_discarded" };
793
877
  if (err instanceof core.ConversationBusyError) return { status: 409, code: "conversation_busy" };
@@ -807,7 +891,8 @@ function failure(c, err, label, { fallback = 500, message = "Internal error" } =
807
891
  const body = {
808
892
  error: err instanceof Error && err.message || message,
809
893
  ...classified ? { code: classified.code } : {},
810
- ...err instanceof core.WorkspaceNotAProjectError ? { workspace: err.workspace } : {}
894
+ ...err instanceof core.WorkspaceNotAProjectError ? { workspace: err.workspace } : {},
895
+ ...err instanceof core.PlannerNotReadyError ? { plannerNotReady: err.reason } : {}
811
896
  };
812
897
  return c.json(body, status);
813
898
  }
@@ -860,7 +945,7 @@ function sessionsRoute(pool2) {
860
945
  });
861
946
  return router;
862
947
  }
863
- var NO_CHECKPOINT = "The task is not waiting at a checkpoint \u2014 it was answered already, withdrawn, or has no runner left to hear it.";
948
+ var NO_CHECKPOINT = "The task is not waiting at a checkpoint: it was answered already, withdrawn, or has no runner left to hear it.";
864
949
  async function runUserCommand(session, command) {
865
950
  const outcome = await session.execute(command, { kind: "user" });
866
951
  if (!outcome.ok) throw new core.TaskControlError(outcome.message);
@@ -874,7 +959,7 @@ function plansRoute(pool2) {
874
959
  if (!goal) return c.json({ error: "goal is required" }, 400);
875
960
  const ws = workspace || c.req.query("workspace") || process.cwd();
876
961
  const queryRunners = c.req.query("runners");
877
- const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : queryRunners ? queryRunners.split(",").map((s) => s.trim()).filter(Boolean) : pool2.getRunnerState().enabledRunners;
962
+ const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : queryRunners ? queryRunners.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
878
963
  const plan = await pool2.generatePlan(c.req.param("sessionId"), goal, runnerList, ws, model, { allowInit });
879
964
  const { models, modelsByRunner } = await pool2.getProviderModels();
880
965
  const notes = (pool2.session(c.req.param("sessionId")).planState?.conversationHistory ?? []).filter((e) => e.kind === "system").map((e) => e.content);
@@ -897,7 +982,9 @@ function plansRoute(pool2) {
897
982
  router.post("/:sessionId/stop", async (c) => {
898
983
  try {
899
984
  if (pool2.hasSession(c.req.param("sessionId"))) {
900
- await pool2.session(c.req.param("sessionId")).execute({ command: "stop_run" }, { kind: "user" });
985
+ const session = pool2.session(c.req.param("sessionId"));
986
+ const outcome = await session.execute({ command: "stop_run" }, { kind: "user" });
987
+ if (!outcome.ok && (session.isExecuting || session.startsPending)) throw new core.TaskControlError(outcome.message);
901
988
  }
902
989
  return c.json({ status: "stopped" });
903
990
  } catch (err) {
@@ -1181,7 +1268,7 @@ function plansRoute(pool2) {
1181
1268
  const { goal, runners, workspace, model, allowInit } = await c.req.json();
1182
1269
  if (!goal) return c.json({ error: "goal is required" }, 400);
1183
1270
  const ws = workspace || c.req.query("workspace") || process.cwd();
1184
- const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : pool2.getRunnerState().enabledRunners;
1271
+ const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : void 0;
1185
1272
  const plan = await pool2.startPlanning(c.req.param("sessionId"), goal, runnerList, ws, model, { allowInit });
1186
1273
  return c.json({ plan: core.surfacePlan(plan) });
1187
1274
  } catch (err) {
@@ -1251,6 +1338,7 @@ function plansRoute(pool2) {
1251
1338
  function runnersRoute(pool2) {
1252
1339
  const router = new hono.Hono();
1253
1340
  router.get("/", async (c) => {
1341
+ await core.installedPlannerDetected();
1254
1342
  const state = pool2.getRunnerState();
1255
1343
  const runners = await pool2.getInstalledRunners();
1256
1344
  return c.json({
@@ -1259,6 +1347,7 @@ function runnersRoute(pool2) {
1259
1347
  });
1260
1348
  });
1261
1349
  router.put("/:id", async (c) => {
1350
+ await core.installedPlannerDetected();
1262
1351
  const id = c.req.param("id");
1263
1352
  const body = await c.req.json();
1264
1353
  pool2.setRunnerEnabled(id, body.enabled);
@@ -1314,7 +1403,7 @@ function approvalsRoute(pool2) {
1314
1403
  );
1315
1404
  if (!outcome.ok) return refuse(c, 400, outcome.message, "refused");
1316
1405
  } else if (!pool2.resolveApproval(sessionId, approvalId, answer)) {
1317
- return refuse(c, 409, "Approval is no longer outstanding \u2014 it timed out or was already answered.", "approval_not_outstanding");
1406
+ return refuse(c, 409, "Approval is no longer outstanding: it timed out or was already answered.", "approval_not_outstanding");
1318
1407
  }
1319
1408
  return c.json({ ok: true, granted: core.isGranted(decided), decision: decided.decision });
1320
1409
  });
@@ -1322,11 +1411,13 @@ function approvalsRoute(pool2) {
1322
1411
  }
1323
1412
  function settingsRoute(pool2) {
1324
1413
  const router = new hono.Hono();
1325
- router.get("/", (c) => {
1414
+ router.get("/", async (c) => {
1415
+ await core.installedPlannerDetected();
1326
1416
  const settings = pool2.getSettings();
1327
1417
  return c.json(settings);
1328
1418
  });
1329
1419
  router.patch("/", async (c) => {
1420
+ await core.installedPlannerDetected();
1330
1421
  const body = await c.req.json();
1331
1422
  if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length === 0) {
1332
1423
  return refuse(c, 400, "At least one setting field is required");
@@ -1378,7 +1469,15 @@ function createWsHandler(pool2) {
1378
1469
  });
1379
1470
  ws.send(JSON.stringify({ type: "connected", sessionId }));
1380
1471
  const view = pool2.runSnapshot(sessionId);
1381
- if (view) ws.send(JSON.stringify({ type: "run_snapshot", sessionId, view }));
1472
+ if (view) {
1473
+ ws.send(JSON.stringify({
1474
+ type: "run_snapshot",
1475
+ sessionId,
1476
+ view,
1477
+ approvals: pool2.announcedApprovals(sessionId),
1478
+ ...pool2.streamPosition(sessionId)
1479
+ }));
1480
+ }
1382
1481
  pool2.subscribe(sessionId, ws);
1383
1482
  });
1384
1483
  return (request, socket, head) => {
@@ -1552,6 +1651,7 @@ if (portIdx !== -1 && args[portIdx + 1]) {
1552
1651
  var watchParentIdx = args.indexOf("--watch-parent");
1553
1652
  var watchParentPid = watchParentIdx !== -1 ? parseInt(args[watchParentIdx + 1], 10) : void 0;
1554
1653
  var pool = new OrchestratorPool({ runner: new core.StructuredRunner() });
1654
+ void pool.detectInstalledPlanner();
1555
1655
  var server = http.createServer();
1556
1656
  var ownsToken = false;
1557
1657
  server.listen(port, "127.0.0.1", () => {