@ordewell/web 0.7.0 → 0.7.2

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.
@@ -103,7 +103,7 @@ var WebConfig = class extends core.BaseConfig {
103
103
  if (process.env.ORDEWELL_OPENCODE_ENABLED === "true" || process.env.ORDEWELL_OPENCODE_ENABLED === "1") {
104
104
  out.push("opencode");
105
105
  }
106
- return out.length > 0 ? out : ["claude-code"];
106
+ return out.length > 0 ? out : [...core.DEFAULT_RUNNERS];
107
107
  }
108
108
  };
109
109
  function scanWorkspacesImpl(deps) {
@@ -416,10 +416,6 @@ var AdvisingRunner = class {
416
416
  var OrchestratorPool = class {
417
417
  sessions = /* @__PURE__ */ new Map();
418
418
  clients = /* @__PURE__ */ new Map();
419
- /** The in-flight planning turn's abort controller, one per session — see `cancelPlanning`. */
420
- planningAborts = /* @__PURE__ */ new Map();
421
- /** Sessions whose planning slot a compaction holds; a reply must not take it over. */
422
- compacting = /* @__PURE__ */ new Set();
423
419
  registry = (() => {
424
420
  const r = new core.RunnerRegistry();
425
421
  r.loadUserPlugins();
@@ -619,7 +615,11 @@ var OrchestratorPool = class {
619
615
  if (core.PROVIDER_CREDENTIAL_ENV.has(key)) touched = true;
620
616
  }
621
617
  if (envChanges.ORDEWELL_MAX_PARALLEL !== void 0) {
622
- for (const session of this.sessions.values()) void session.reschedule();
618
+ for (const [id, session] of this.sessions) {
619
+ session.reschedule().catch((err) => {
620
+ console.error(`[web] reschedule failed for session ${id}: ${err instanceof Error ? err.message : err}`);
621
+ });
622
+ }
623
623
  }
624
624
  if (touched) {
625
625
  this.modelResolver.invalidate();
@@ -655,6 +655,7 @@ var OrchestratorPool = class {
655
655
  const rejectedEnvKeys = admission2?.rejected ?? [];
656
656
  const settings = this.getSettings();
657
657
  if (rejectedEnvKeys.length > 0) settings.rejectedEnvKeys = rejectedEnvKeys;
658
+ if (switchRecall) settings.switchRecall = switchRecall;
658
659
  return settings;
659
660
  }
660
661
  setOrchestratorModel(model) {
@@ -683,18 +684,7 @@ var OrchestratorPool = class {
683
684
  }
684
685
  }
685
686
  }
686
- const providers = [];
687
- for (const provider of core.PROVIDER_PRIORITY) {
688
- if (provider === "openai_compatible") {
689
- if (config.openaiCompatibleBaseUrl) providers.push(provider);
690
- } else if (provider === "openrouter") {
691
- if (config.openrouterKey) providers.push(provider);
692
- } else if (provider === "google") {
693
- if (config.geminiKey) providers.push(provider);
694
- } else if (config.getProviderApiKey(provider)) {
695
- providers.push(provider);
696
- }
697
- }
687
+ const providers = core.configuredProviders(config);
698
688
  return {
699
689
  models: allModels,
700
690
  modelsByRunner,
@@ -729,15 +719,9 @@ var OrchestratorPool = class {
729
719
  }
730
720
  async generatePlan(sessionId, goal, runners, workspace, modelOverride, options) {
731
721
  const session = this.createSessionFor(sessionId, workspace, modelOverride, options);
722
+ this.destroy(sessionId);
732
723
  this.sessions.set(sessionId, session);
733
- const controller = new AbortController();
734
- this.planningAborts.set(sessionId, controller);
735
- try {
736
- const plan = await session.generatePlan(goal, runners, { signal: controller.signal });
737
- return core.migratePlanState(plan);
738
- } finally {
739
- this.clearPlanningAbort(sessionId, controller);
740
- }
724
+ return core.migratePlanState(await session.generatePlan(goal, runners));
741
725
  }
742
726
  /**
743
727
  * Conversational planning (ADR-0002): start the planner dialogue for a
@@ -747,69 +731,27 @@ var OrchestratorPool = class {
747
731
  */
748
732
  async startPlanning(sessionId, goal, runners, workspace, modelOverride, options) {
749
733
  const session = this.createSessionFor(sessionId, workspace, modelOverride, options);
734
+ this.destroy(sessionId);
750
735
  this.sessions.set(sessionId, session);
751
- const controller = new AbortController();
752
- this.planningAborts.set(sessionId, controller);
753
- try {
754
- return await session.startPlanning(goal, runners, { signal: controller.signal });
755
- } finally {
756
- this.clearPlanningAbort(sessionId, controller);
757
- }
736
+ return session.startPlanning(goal, runners);
758
737
  }
759
- /**
760
- * Continue the planner dialogue with the user's reply. Refused during a
761
- * compaction before the abort slot is touched: the session refuses the reply
762
- * too, but by then the compaction's controller would be gone and a stop could
763
- * no longer reach its summary turn.
764
- */
738
+ /** Continue the planner dialogue with the user's reply; refused while a planner turn is in flight. */
765
739
  async continuePlanning(sessionId, message) {
766
- if (this.compacting.has(sessionId)) throw new core.ConversationBusyError("send a message");
767
- const controller = new AbortController();
768
- this.planningAborts.set(sessionId, controller);
769
- try {
770
- return await this.session(sessionId).continueConversation(message, { signal: controller.signal });
771
- } finally {
772
- this.clearPlanningAbort(sessionId, controller);
773
- }
740
+ return this.session(sessionId).continueConversation(message);
774
741
  }
775
- /**
776
- * Condense a session's conversation. The summary turn is a planning turn as
777
- * far as a surface is concerned, so it registers the same abort controller
778
- * and `cancelPlanning` stops it — but only when no turn holds that slot
779
- * already: taking it over would leave the reply in flight beyond stopping.
780
- */
742
+ /** Condense a session's conversation. The summary turn is a planner turn, so `cancelPlanning` stops it. */
781
743
  async compactConversation(sessionId) {
782
744
  const session = this.session(sessionId);
783
- if (this.planningAborts.has(sessionId)) throw new core.ConversationBusyError("condense the conversation");
784
- const controller = new AbortController();
785
- this.planningAborts.set(sessionId, controller);
786
- this.compacting.add(sessionId);
787
- try {
788
- const compaction = await session.compactConversation(controller.signal);
789
- return { ...compaction, plan: session.planState };
790
- } finally {
791
- this.compacting.delete(sessionId);
792
- this.clearPlanningAbort(sessionId, controller);
793
- }
745
+ const compaction = await session.compactConversation();
746
+ return { ...compaction, plan: session.planState };
794
747
  }
795
748
  /**
796
- * Abort the planning turn in flight for a session, if any. Answers whether
749
+ * Abort the planner turn in flight for a session, if any. Answers whether
797
750
  * there was one to cancel — the route reports that rather than 404ing a
798
751
  * session that simply is not planning right now.
799
752
  */
800
753
  cancelPlanning(sessionId) {
801
- const controller = this.planningAborts.get(sessionId);
802
- if (!controller) return false;
803
- controller.abort();
804
- return true;
805
- }
806
- /**
807
- * Only clears the map entry if it still holds the controller this turn
808
- * created — a later turn may already have installed its own by the time
809
- * this one's `finally` runs (a fresh request racing a slow abort).
810
- */
811
- clearPlanningAbort(sessionId, controller) {
812
- if (this.planningAborts.get(sessionId) === controller) this.planningAborts.delete(sessionId);
754
+ return this.sessions.get(sessionId)?.abortPlannerTurn() ?? false;
813
755
  }
814
756
  /**
815
757
  * Register a session persisted on disk so its plan becomes executable and
@@ -824,9 +766,10 @@ var OrchestratorPool = class {
824
766
  const live = this.sessions.get(sessionId);
825
767
  if (live?.planState) return live.planState;
826
768
  const saved = core.loadSession(sessionId, workspace);
827
- if (!saved) throw new Error("Session not found");
769
+ if (!saved) throw new core.SessionNotFoundError();
828
770
  const session = this.createSessionFor(sessionId, workspace);
829
771
  session.loadPlan(saved.plan, saved.meta.goal, workspace, { sessionId: saved.meta.id });
772
+ this.destroy(sessionId);
830
773
  this.sessions.set(sessionId, session);
831
774
  return session.planState ?? saved.plan;
832
775
  }
@@ -853,14 +796,10 @@ var OrchestratorPool = class {
853
796
  */
854
797
  session(sessionId) {
855
798
  const session = this.sessions.get(sessionId);
856
- if (!session) throw new Error("Session not found");
799
+ if (!session) throw new core.SessionNotFoundError();
857
800
  return session;
858
801
  }
859
- // The Session drops its planner context, but the model call in flight is
860
- // the pool's to abort: it holds the only signal that reaches it.
861
802
  destroy(sessionId) {
862
- this.planningAborts.get(sessionId)?.abort();
863
- this.planningAborts.delete(sessionId);
864
803
  this.sessions.get(sessionId)?.destroy();
865
804
  this.sessions.delete(sessionId);
866
805
  }
@@ -875,6 +814,37 @@ function getSessionList(workspace) {
875
814
  function removeSession(sessionId, workspace) {
876
815
  return core.deleteSession(sessionId, workspace);
877
816
  }
817
+ function classify(err) {
818
+ if (err instanceof core.SessionNotFoundError) return { status: 404, code: "session_not_found" };
819
+ if (err instanceof core.NoPlanError) return { status: 400, code: "no_plan" };
820
+ if (err instanceof core.AlreadyExecutingError) return { status: 409, code: "already_executing" };
821
+ if (err instanceof core.WorkspaceNotAProjectError) return { status: 400, code: "workspace_not_a_project" };
822
+ if (err instanceof core.WorkspaceNotFoundError) return { status: 400, code: "workspace_not_found" };
823
+ if (err instanceof core.PlannerTurnStoppedError) return { status: 409, code: "planner_turn_stopped" };
824
+ if (err instanceof core.PlannerTurnDiscardedError) return { status: 409, code: "planner_turn_discarded" };
825
+ if (err instanceof core.ConversationBusyError) return { status: 409, code: "conversation_busy" };
826
+ if (err instanceof core.ConversationEditError || err instanceof core.PlanEditError || err instanceof core.TaskControlError) {
827
+ return { status: 400, code: "refused" };
828
+ }
829
+ return void 0;
830
+ }
831
+ function refuse(c, status, message, code) {
832
+ const body = code ? { error: message, code } : { error: message };
833
+ return c.json(body, status);
834
+ }
835
+ function failure(c, err, label, { fallback = 500, message = "Internal error" } = {}) {
836
+ const classified = classify(err);
837
+ const status = classified?.status ?? fallback;
838
+ if (status >= 500) console.error(`[web] ${label} failed:`, err);
839
+ const body = {
840
+ error: err instanceof Error && err.message || message,
841
+ ...classified ? { code: classified.code } : {},
842
+ ...err instanceof core.WorkspaceNotAProjectError ? { workspace: err.workspace } : {}
843
+ };
844
+ return c.json(body, status);
845
+ }
846
+
847
+ // server/routes/sessions.ts
878
848
  function sessionsRoute(pool2) {
879
849
  const router = new hono.Hono();
880
850
  router.get("/", (c) => {
@@ -886,7 +856,7 @@ function sessionsRoute(pool2) {
886
856
  const ws = c.req.query("workspace") || process.cwd();
887
857
  const id = c.req.param("id");
888
858
  const saved = core.loadSessionPlanState(id, ws);
889
- if (!saved) return c.json({ error: "Session not found" }, 404);
859
+ if (!saved) return refuse(c, 404, "Session not found", "session_not_found");
890
860
  return c.json({ meta: saved.meta, plan: pool2.getPlanState(id) ?? saved.plan });
891
861
  });
892
862
  router.post("/:id/load", (c) => {
@@ -896,10 +866,7 @@ function sessionsRoute(pool2) {
896
866
  const plan = pool2.adoptSavedSession(id, ws);
897
867
  return c.json({ ok: true, plan, goal: pool2.getGoal(id) });
898
868
  } catch (err) {
899
- if (err instanceof core.WorkspaceNotFoundError) return c.json({ error: err.message }, 400);
900
- const message = err instanceof Error ? err.message : void 0;
901
- const status = message === "Session not found" ? 404 : 500;
902
- return c.json({ error: message ?? "Failed to load session" }, status);
869
+ return failure(c, err, "load session", { message: "Failed to load session" });
903
870
  }
904
871
  });
905
872
  router.get("/:id/tasks/:taskId/log", (c) => {
@@ -909,7 +876,7 @@ function sessionsRoute(pool2) {
909
876
  router.get("/:id/tasks/:taskId/log/:attempt", (c) => {
910
877
  const ws = c.req.query("workspace") || process.cwd();
911
878
  const attempt = Number(c.req.param("attempt"));
912
- if (!Number.isInteger(attempt) || attempt < 1) return c.json({ error: "attempt must be a positive integer" }, 400);
879
+ if (!Number.isInteger(attempt) || attempt < 1) return refuse(c, 400, "attempt must be a positive integer");
913
880
  return c.json({ attempt, events: core.readTaskLog({ baseDir: ws, sessionId: c.req.param("id") }, c.req.param("taskId"), attempt) });
914
881
  });
915
882
  router.delete("/:id", (c) => {
@@ -923,21 +890,7 @@ function sessionsRoute(pool2) {
923
890
  });
924
891
  return router;
925
892
  }
926
- function editFailure(c, err) {
927
- const e = err;
928
- if (e.message === "Session not found") return c.json({ error: e.message }, 404);
929
- if (e instanceof core.PlanEditError || e instanceof core.TaskControlError) return c.json({ error: e.message }, 400);
930
- console.error("[plans] task edit failed:", err);
931
- return c.json({ error: e.message || "Internal error" }, 500);
932
- }
933
- function conversationFailure(c, err) {
934
- const e = err;
935
- if (e.message === "Session not found") return c.json({ error: e.message }, 404);
936
- if (e instanceof core.ConversationBusyError) return c.json({ error: e.message }, 409);
937
- if (e instanceof core.ConversationEditError) return c.json({ error: e.message }, 400);
938
- console.error("[plans] conversation edit failed:", err);
939
- return c.json({ error: e.message || "Internal error" }, 500);
940
- }
893
+ 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.";
941
894
  function plansRoute(pool2) {
942
895
  const router = new hono.Hono();
943
896
  router.post("/:sessionId/generate", async (c) => {
@@ -951,12 +904,7 @@ function plansRoute(pool2) {
951
904
  const { models, modelsByRunner } = await pool2.getProviderModels();
952
905
  return c.json({ plan, models, modelsByRunner });
953
906
  } catch (err) {
954
- if (err instanceof core.WorkspaceNotFoundError) return c.json({ error: err.message }, 400);
955
- if (err instanceof core.WorkspaceNotAProjectError) {
956
- return c.json({ error: err.message, code: "workspace_not_a_project", workspace: err.workspace }, 400);
957
- }
958
- console.error("[plans] generate failed:", err);
959
- return c.json({ error: err instanceof Error ? err.message : "Plan generation failed" }, 500);
907
+ return failure(c, err, "generate", { message: "Plan generation failed" });
960
908
  }
961
909
  });
962
910
  router.post("/:sessionId/execute", async (c) => {
@@ -964,9 +912,7 @@ function plansRoute(pool2) {
964
912
  await pool2.session(c.req.param("sessionId")).executePlan();
965
913
  return c.json({ status: "running" });
966
914
  } catch (err) {
967
- const e = err;
968
- const status = e.message === "Session not found" ? 404 : e.message === "No plan to execute" ? 400 : e.message === "Session already executing" ? 409 : 500;
969
- return c.json({ error: e.message }, status);
915
+ return failure(c, err, "execute");
970
916
  }
971
917
  });
972
918
  router.post("/:sessionId/stop", (c) => {
@@ -982,7 +928,7 @@ function plansRoute(pool2) {
982
928
  await pool2.session(c.req.param("sessionId")).markTaskComplete(c.req.param("taskId"));
983
929
  return c.json({ ok: true });
984
930
  } catch (err) {
985
- return c.json({ error: err instanceof Error ? err.message : "Not found" }, 404);
931
+ return failure(c, err, "mark complete", { fallback: 404, message: "Not found" });
986
932
  }
987
933
  });
988
934
  router.post("/:sessionId/tasks/:taskId/uncomplete", async (c) => {
@@ -990,7 +936,7 @@ function plansRoute(pool2) {
990
936
  await pool2.session(c.req.param("sessionId")).markTaskIncomplete(c.req.param("taskId"));
991
937
  return c.json({ ok: true });
992
938
  } catch (err) {
993
- return c.json({ error: err instanceof Error ? err.message : "Not found" }, 404);
939
+ return failure(c, err, "mark incomplete", { fallback: 404, message: "Not found" });
994
940
  }
995
941
  });
996
942
  for (const [segment, run2] of [
@@ -1004,20 +950,29 @@ function plansRoute(pool2) {
1004
950
  await run2(pool2.session(c.req.param("sessionId")), c.req.param("taskId"));
1005
951
  return c.json({ ok: true });
1006
952
  } catch (err) {
1007
- const status = err.message === "Session not found" ? 404 : 500;
1008
- return c.json({ error: err.message }, status);
953
+ return failure(c, err, segment);
1009
954
  }
1010
955
  });
1011
956
  }
1012
- router.post("/:sessionId/tasks/:taskId/messages", async (c) => {
957
+ const messageHandler = (send) => async (c) => {
1013
958
  try {
1014
959
  const body = await c.req.json().catch(() => ({}));
1015
960
  const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
1016
961
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
1017
- const id = pool2.session(c.req.param("sessionId")).sendTaskMessage(c.req.param("taskId"), text);
962
+ const id = send(pool2.session(c.req.param("sessionId")), c.req.param("taskId"), text);
1018
963
  return c.json({ id });
1019
964
  } catch (err) {
1020
- return editFailure(c, err);
965
+ return failure(c, err, "task edit");
966
+ }
967
+ };
968
+ router.post("/:sessionId/tasks/:taskId/messages", messageHandler((session, taskId, text) => session.sendTaskMessage(taskId, text)));
969
+ router.post("/:sessionId/tasks/:taskId/messages/now", messageHandler((session, taskId, text) => session.forceSendTaskMessage(taskId, text)));
970
+ router.post("/:sessionId/tasks/:taskId/messages/:messageId/now", (c) => {
971
+ try {
972
+ const sent = pool2.session(c.req.param("sessionId")).forceSendQueuedTaskMessage(c.req.param("taskId"), c.req.param("messageId"));
973
+ return c.json({ sent });
974
+ } catch (err) {
975
+ return failure(c, err, "task edit");
1021
976
  }
1022
977
  });
1023
978
  router.post("/:sessionId/tasks/:taskId/continue", async (c) => {
@@ -1028,7 +983,7 @@ function plansRoute(pool2) {
1028
983
  await pool2.session(c.req.param("sessionId")).continueTask(c.req.param("taskId"), text);
1029
984
  return c.json({ ok: true });
1030
985
  } catch (err) {
1031
- return editFailure(c, err);
986
+ return failure(c, err, "task edit");
1032
987
  }
1033
988
  });
1034
989
  router.delete("/:sessionId/tasks/:taskId/messages/:messageId", (c) => {
@@ -1036,7 +991,7 @@ function plansRoute(pool2) {
1036
991
  const removed = pool2.session(c.req.param("sessionId")).removeQueuedTaskMessage(c.req.param("taskId"), c.req.param("messageId"));
1037
992
  return c.json({ removed });
1038
993
  } catch (err) {
1039
- return editFailure(c, err);
994
+ return failure(c, err, "task edit");
1040
995
  }
1041
996
  });
1042
997
  router.post("/:sessionId/tasks/:taskId/interrupt", async (c) => {
@@ -1044,7 +999,31 @@ function plansRoute(pool2) {
1044
999
  await pool2.session(c.req.param("sessionId")).interruptTask(c.req.param("taskId"));
1045
1000
  return c.json({ ok: true });
1046
1001
  } catch (err) {
1047
- return editFailure(c, err);
1002
+ return failure(c, err, "task edit");
1003
+ }
1004
+ });
1005
+ router.post("/:sessionId/tasks/:taskId/checkpoint/approve", (c) => {
1006
+ try {
1007
+ const session = pool2.session(c.req.param("sessionId"));
1008
+ const taskId = c.req.param("taskId");
1009
+ if (!session.awaitsCheckpoint(taskId)) return refuse(c, 409, NO_CHECKPOINT, "checkpoint_not_waiting");
1010
+ session.approveCheckpoint(taskId);
1011
+ return c.json({ ok: true });
1012
+ } catch (err) {
1013
+ return failure(c, err, "task edit");
1014
+ }
1015
+ });
1016
+ router.post("/:sessionId/tasks/:taskId/checkpoint/reject", async (c) => {
1017
+ try {
1018
+ const body = await c.req.json().catch(() => ({}));
1019
+ const reason = typeof body === "object" && body !== null && "reason" in body && typeof body.reason === "string" ? body.reason.trim() : "";
1020
+ const session = pool2.session(c.req.param("sessionId"));
1021
+ const taskId = c.req.param("taskId");
1022
+ if (!session.awaitsCheckpoint(taskId)) return refuse(c, 409, NO_CHECKPOINT, "checkpoint_not_waiting");
1023
+ session.rejectCheckpoint(taskId, reason || void 0);
1024
+ return c.json({ ok: true });
1025
+ } catch (err) {
1026
+ return failure(c, err, "task edit");
1048
1027
  }
1049
1028
  });
1050
1029
  router.get("/:sessionId/tasks/:taskId/merge-gate", (c) => {
@@ -1057,21 +1036,21 @@ function plansRoute(pool2) {
1057
1036
  });
1058
1037
  return c.json({ mergeGate });
1059
1038
  } catch (err) {
1060
- return editFailure(c, err);
1039
+ return failure(c, err, "task edit");
1061
1040
  }
1062
1041
  });
1063
1042
  router.get("/:sessionId/isolation/diff", async (c) => {
1064
1043
  try {
1065
1044
  return c.json({ diff: await pool2.session(c.req.param("sessionId")).reviewRunDiff() });
1066
1045
  } catch (err) {
1067
- return editFailure(c, err);
1046
+ return failure(c, err, "task edit");
1068
1047
  }
1069
1048
  });
1070
1049
  router.post("/:sessionId/isolation/merge", async (c) => {
1071
1050
  try {
1072
1051
  return c.json(await pool2.session(c.req.param("sessionId")).mergeRun());
1073
1052
  } catch (err) {
1074
- return editFailure(c, err);
1053
+ return failure(c, err, "task edit");
1075
1054
  }
1076
1055
  });
1077
1056
  for (const [segment, run2] of [
@@ -1085,7 +1064,7 @@ function plansRoute(pool2) {
1085
1064
  await run2(pool2.session(c.req.param("sessionId")));
1086
1065
  return c.json({ ok: true });
1087
1066
  } catch (err) {
1088
- return editFailure(c, err);
1067
+ return failure(c, err, "task edit");
1089
1068
  }
1090
1069
  });
1091
1070
  }
@@ -1094,7 +1073,7 @@ function plansRoute(pool2) {
1094
1073
  const plan = await pool2.session(c.req.param("sessionId")).resolveConflictAsTask(c.req.param("taskId"));
1095
1074
  return c.json({ plan });
1096
1075
  } catch (err) {
1097
- return editFailure(c, err);
1076
+ return failure(c, err, "task edit");
1098
1077
  }
1099
1078
  });
1100
1079
  router.put("/:sessionId/tasks/:taskId", async (c) => {
@@ -1110,33 +1089,33 @@ function plansRoute(pool2) {
1110
1089
  try {
1111
1090
  result = await session.setTaskDependencies(taskId, dependencies.map(String));
1112
1091
  } catch (err) {
1113
- return c.json({ error: err.message }, 400);
1092
+ return failure(c, err, "task dependencies", { fallback: 400 });
1114
1093
  }
1115
1094
  }
1116
1095
  if (Object.keys(rest).length > 0 || !hasRunner && !hasDeps) result = await session.updateTask(taskId, rest);
1117
- if (!result) return c.json({ error: "Task not found" }, 404);
1096
+ if (!result) return refuse(c, 404, "Task not found", "task_not_found");
1118
1097
  return c.json({ ok: true });
1119
1098
  } catch (err) {
1120
- return editFailure(c, err);
1099
+ return failure(c, err, "task edit");
1121
1100
  }
1122
1101
  });
1123
1102
  router.delete("/:sessionId/tasks/:taskId", async (c) => {
1124
1103
  try {
1125
1104
  const result = await pool2.session(c.req.param("sessionId")).removeTask(c.req.param("taskId"));
1126
- if (!result) return c.json({ error: "Task not found" }, 404);
1105
+ if (!result) return refuse(c, 404, "Task not found", "task_not_found");
1127
1106
  return c.json({ ok: true });
1128
1107
  } catch (err) {
1129
- return editFailure(c, err);
1108
+ return failure(c, err, "task edit");
1130
1109
  }
1131
1110
  });
1132
1111
  router.post("/:sessionId/tasks", async (c) => {
1133
1112
  try {
1134
1113
  const body = await c.req.json();
1135
1114
  const result = await pool2.session(c.req.param("sessionId")).addTask(body);
1136
- if (!result) return c.json({ error: "Session not found" }, 404);
1115
+ if (!result) return refuse(c, 404, "Session not found", "session_not_found");
1137
1116
  return c.json({ ok: true });
1138
1117
  } catch (err) {
1139
- return editFailure(c, err);
1118
+ return failure(c, err, "task edit");
1140
1119
  }
1141
1120
  });
1142
1121
  router.post("/:sessionId/review/approve", async (c) => {
@@ -1144,9 +1123,7 @@ function plansRoute(pool2) {
1144
1123
  const plan = await pool2.session(c.req.param("sessionId")).approveReview();
1145
1124
  return c.json({ plan });
1146
1125
  } catch (err) {
1147
- const e = err;
1148
- const status = e.message === "Session not found" ? 404 : e.message === "No plan to review" ? 400 : 500;
1149
- return c.json({ error: e.message }, status);
1126
+ return failure(c, err, "review approve");
1150
1127
  }
1151
1128
  });
1152
1129
  router.post("/:sessionId/tasks/merge", async (c) => {
@@ -1158,9 +1135,7 @@ function plansRoute(pool2) {
1158
1135
  const plan = await pool2.session(c.req.param("sessionId")).requestMerge(taskIds);
1159
1136
  return c.json({ plan });
1160
1137
  } catch (err) {
1161
- const e = err;
1162
- const status = e.message === "Session not found" ? 404 : 400;
1163
- return c.json({ error: e.message }, status);
1138
+ return failure(c, err, "merge", { fallback: 400 });
1164
1139
  }
1165
1140
  });
1166
1141
  router.post("/:sessionId/tasks/:taskId/split", async (c) => {
@@ -1168,9 +1143,7 @@ function plansRoute(pool2) {
1168
1143
  const plan = await pool2.session(c.req.param("sessionId")).requestSplit(c.req.param("taskId"));
1169
1144
  return c.json({ plan });
1170
1145
  } catch (err) {
1171
- const e = err;
1172
- const status = e.message === "Session not found" ? 404 : 400;
1173
- return c.json({ error: e.message }, status);
1146
+ return failure(c, err, "split", { fallback: 400 });
1174
1147
  }
1175
1148
  });
1176
1149
  router.post("/:sessionId/converse/start", async (c) => {
@@ -1182,11 +1155,7 @@ function plansRoute(pool2) {
1182
1155
  const plan = await pool2.startPlanning(c.req.param("sessionId"), goal, runnerList, ws, model, { allowInit });
1183
1156
  return c.json({ plan });
1184
1157
  } catch (err) {
1185
- if (err instanceof core.WorkspaceNotFoundError) return c.json({ error: err.message }, 400);
1186
- if (err instanceof core.WorkspaceNotAProjectError) {
1187
- return c.json({ error: err.message, code: "workspace_not_a_project", workspace: err.workspace }, 400);
1188
- }
1189
- return c.json({ error: err instanceof Error ? err.message : "Planning failed" }, 500);
1158
+ return failure(c, err, "converse start", { message: "Planning failed" });
1190
1159
  }
1191
1160
  });
1192
1161
  router.post("/:sessionId/converse/message", async (c) => {
@@ -1196,24 +1165,21 @@ function plansRoute(pool2) {
1196
1165
  const plan = await pool2.continuePlanning(c.req.param("sessionId"), message);
1197
1166
  return c.json({ plan });
1198
1167
  } catch (err) {
1199
- const e = err;
1200
- if (e instanceof core.ConversationBusyError) return c.json({ error: e.message }, 409);
1201
- const status = e.message === "Session not found" ? 404 : 500;
1202
- return c.json({ error: e.message }, status);
1168
+ return failure(c, err, "conversation message");
1203
1169
  }
1204
1170
  });
1205
1171
  router.post("/:sessionId/conversation/fork", (c) => {
1206
1172
  try {
1207
1173
  return c.json(pool2.forkConversation(c.req.param("sessionId")));
1208
1174
  } catch (err) {
1209
- return conversationFailure(c, err);
1175
+ return failure(c, err, "conversation edit");
1210
1176
  }
1211
1177
  });
1212
1178
  router.get("/:sessionId/conversation/rewind-targets", (c) => {
1213
1179
  try {
1214
1180
  return c.json({ targets: pool2.session(c.req.param("sessionId")).rewindTargets() });
1215
1181
  } catch (err) {
1216
- return conversationFailure(c, err);
1182
+ return failure(c, err, "conversation edit");
1217
1183
  }
1218
1184
  });
1219
1185
  router.post("/:sessionId/conversation/rewind", async (c) => {
@@ -1222,29 +1188,19 @@ function plansRoute(pool2) {
1222
1188
  if (!Number.isInteger(index) || index < 0) return c.json({ error: "index must be a non-negative integer" }, 400);
1223
1189
  return c.json(pool2.rewindConversation(c.req.param("sessionId"), index));
1224
1190
  } catch (err) {
1225
- return conversationFailure(c, err);
1191
+ return failure(c, err, "conversation edit");
1226
1192
  }
1227
1193
  });
1228
1194
  router.post("/:sessionId/conversation/compact", async (c) => {
1229
1195
  try {
1230
1196
  return c.json(await pool2.compactConversation(c.req.param("sessionId")));
1231
1197
  } catch (err) {
1232
- return conversationFailure(c, err);
1233
- }
1234
- });
1235
- router.post("/:sessionId/process-queued", async (c) => {
1236
- try {
1237
- await pool2.session(c.req.param("sessionId")).processQueuedMessages();
1238
- return c.json({ ok: true });
1239
- } catch (err) {
1240
- const e = err;
1241
- const status = e.message === "Session not found" ? 404 : 500;
1242
- return c.json({ error: e.message }, status);
1198
+ return failure(c, err, "conversation edit");
1243
1199
  }
1244
1200
  });
1245
1201
  router.get("/:sessionId/prd", (c) => {
1246
1202
  const plan = pool2.getPlan(c.req.param("sessionId"));
1247
- if (!plan?.prdMarkdown) return c.json({ error: "No PRD found" }, 404);
1203
+ if (!plan?.prdMarkdown) return refuse(c, 404, "No PRD found");
1248
1204
  return c.json({ prdMarkdown: plan.prdMarkdown });
1249
1205
  });
1250
1206
  return router;
@@ -1295,7 +1251,7 @@ function approvalsRoute(pool2) {
1295
1251
  const router = new hono.Hono();
1296
1252
  router.get("/:sessionId", (c) => {
1297
1253
  const sessionId = c.req.param("sessionId");
1298
- if (!pool2.hasSession(sessionId)) return c.json({ error: `Unknown session: ${sessionId}` }, 404);
1254
+ if (!pool2.hasSession(sessionId)) return refuse(c, 404, `Unknown session: ${sessionId}`, "session_not_found");
1299
1255
  return c.json({
1300
1256
  pending: pool2.outstandingApprovals(sessionId),
1301
1257
  approvedScopes: pool2.approvedScopes(sessionId)
@@ -1304,10 +1260,10 @@ function approvalsRoute(pool2) {
1304
1260
  router.post("/:sessionId/:approvalId", async (c) => {
1305
1261
  const sessionId = c.req.param("sessionId");
1306
1262
  const approvalId = c.req.param("approvalId");
1307
- if (!pool2.hasSession(sessionId)) return c.json({ error: `Unknown session: ${sessionId}` }, 404);
1263
+ if (!pool2.hasSession(sessionId)) return refuse(c, 404, `Unknown session: ${sessionId}`, "session_not_found");
1308
1264
  const answer = answerOf(await c.req.json().catch(() => null));
1309
1265
  if (!pool2.resolveApproval(sessionId, approvalId, answer)) {
1310
- return c.json({ error: "Approval is no longer outstanding \u2014 it timed out or was already answered." }, 409);
1266
+ return refuse(c, 409, "Approval is no longer outstanding \u2014 it timed out or was already answered.", "approval_not_outstanding");
1311
1267
  }
1312
1268
  const { decision } = core.toApprovalDecision(answer);
1313
1269
  return c.json({ ok: true, granted: core.isGranted({ decision }), decision });
@@ -1317,12 +1273,13 @@ function approvalsRoute(pool2) {
1317
1273
  function settingsRoute(pool2) {
1318
1274
  const router = new hono.Hono();
1319
1275
  router.get("/", (c) => {
1320
- return c.json(pool2.getSettings());
1276
+ const settings = pool2.getSettings();
1277
+ return c.json(settings);
1321
1278
  });
1322
1279
  router.patch("/", async (c) => {
1323
1280
  const body = await c.req.json();
1324
1281
  if (!body || typeof body !== "object" || Object.keys(body).length === 0) {
1325
- return c.json({ error: "At least one setting field is required" }, 400);
1282
+ return refuse(c, 400, "At least one setting field is required");
1326
1283
  }
1327
1284
  const result = pool2.updateSettings(body);
1328
1285
  return c.json(result);
@@ -1334,6 +1291,7 @@ var COMMANDS = [
1334
1291
  { name: "verify", description: "Toggle verification mode \u2014 adds a final evidence-based verification task that runs the full suite (on|off|status)" },
1335
1292
  { name: "transport", description: "Drive tasks through each runner's protocol (structured) or a terminal, from the next run (terminal|structured|status)" }
1336
1293
  ];
1294
+ var BOOLEAN_TOGGLES = { tdd: "tdd", verify: "verification" };
1337
1295
  function commandsRoute(pool2) {
1338
1296
  const router = new hono.Hono();
1339
1297
  router.get("/", (c) => {
@@ -1345,23 +1303,15 @@ function commandsRoute(pool2) {
1345
1303
  const args2 = body?.args || {};
1346
1304
  const command = COMMANDS.find((cmd) => cmd.name === name);
1347
1305
  if (!command) {
1348
- return c.json({ error: `Unknown command: ${name}` }, 404);
1306
+ return refuse(c, 404, `Unknown command: ${name}`);
1349
1307
  }
1350
- if (name === "tdd") {
1308
+ const toggle = BOOLEAN_TOGGLES[name];
1309
+ if (toggle) {
1351
1310
  const action = args2.action || "status";
1352
1311
  if (action === "on") {
1353
- pool2.updateSettings({ tdd: { enabled: true } });
1312
+ pool2.updateSettings({ [toggle]: { enabled: true } });
1354
1313
  } else if (action === "off") {
1355
- pool2.updateSettings({ tdd: { enabled: false } });
1356
- }
1357
- return c.json({ ok: true, settings: pool2.getSettings() });
1358
- }
1359
- if (name === "verify") {
1360
- const action = args2.action || "status";
1361
- if (action === "on") {
1362
- pool2.updateSettings({ verification: { enabled: true } });
1363
- } else if (action === "off") {
1364
- pool2.updateSettings({ verification: { enabled: false } });
1314
+ pool2.updateSettings({ [toggle]: { enabled: false } });
1365
1315
  }
1366
1316
  return c.json({ ok: true, settings: pool2.getSettings() });
1367
1317
  }
@@ -1370,11 +1320,11 @@ function commandsRoute(pool2) {
1370
1320
  if (action === "terminal" || action === "structured") {
1371
1321
  pool2.updateSettings({ runnerTransport: action });
1372
1322
  } else if (action !== "status") {
1373
- return c.json({ error: `Unknown transport: ${action} (terminal|structured)` }, 400);
1323
+ return refuse(c, 400, `Unknown transport: ${action} (terminal|structured)`);
1374
1324
  }
1375
1325
  return c.json({ ok: true, settings: pool2.getSettings() });
1376
1326
  }
1377
- return c.json({ error: `Unknown command: ${name}` }, 404);
1327
+ return refuse(c, 404, `Unknown command: ${name}`);
1378
1328
  });
1379
1329
  return router;
1380
1330
  }
@@ -1590,6 +1540,9 @@ async function shutdown() {
1590
1540
  server.close();
1591
1541
  process.exit(0);
1592
1542
  }
1543
+ process.on("unhandledRejection", (reason) => {
1544
+ console.error(`[web] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
1545
+ });
1593
1546
  process.on("SIGINT", () => {
1594
1547
  shutdown();
1595
1548
  });