@ordewell/web 0.7.1 → 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,22 +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
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.";
934
- function conversationFailure(c, err) {
935
- const e = err;
936
- if (e.message === "Session not found") return c.json({ error: e.message }, 404);
937
- if (e instanceof core.ConversationBusyError) return c.json({ error: e.message }, 409);
938
- if (e instanceof core.ConversationEditError) return c.json({ error: e.message }, 400);
939
- console.error("[plans] conversation edit failed:", err);
940
- return c.json({ error: e.message || "Internal error" }, 500);
941
- }
942
894
  function plansRoute(pool2) {
943
895
  const router = new hono.Hono();
944
896
  router.post("/:sessionId/generate", async (c) => {
@@ -952,12 +904,7 @@ function plansRoute(pool2) {
952
904
  const { models, modelsByRunner } = await pool2.getProviderModels();
953
905
  return c.json({ plan, models, modelsByRunner });
954
906
  } catch (err) {
955
- if (err instanceof core.WorkspaceNotFoundError) return c.json({ error: err.message }, 400);
956
- if (err instanceof core.WorkspaceNotAProjectError) {
957
- return c.json({ error: err.message, code: "workspace_not_a_project", workspace: err.workspace }, 400);
958
- }
959
- console.error("[plans] generate failed:", err);
960
- return c.json({ error: err instanceof Error ? err.message : "Plan generation failed" }, 500);
907
+ return failure(c, err, "generate", { message: "Plan generation failed" });
961
908
  }
962
909
  });
963
910
  router.post("/:sessionId/execute", async (c) => {
@@ -965,9 +912,7 @@ function plansRoute(pool2) {
965
912
  await pool2.session(c.req.param("sessionId")).executePlan();
966
913
  return c.json({ status: "running" });
967
914
  } catch (err) {
968
- const e = err;
969
- const status = e.message === "Session not found" ? 404 : e.message === "No plan to execute" ? 400 : e.message === "Session already executing" ? 409 : 500;
970
- return c.json({ error: e.message }, status);
915
+ return failure(c, err, "execute");
971
916
  }
972
917
  });
973
918
  router.post("/:sessionId/stop", (c) => {
@@ -983,7 +928,7 @@ function plansRoute(pool2) {
983
928
  await pool2.session(c.req.param("sessionId")).markTaskComplete(c.req.param("taskId"));
984
929
  return c.json({ ok: true });
985
930
  } catch (err) {
986
- return c.json({ error: err instanceof Error ? err.message : "Not found" }, 404);
931
+ return failure(c, err, "mark complete", { fallback: 404, message: "Not found" });
987
932
  }
988
933
  });
989
934
  router.post("/:sessionId/tasks/:taskId/uncomplete", async (c) => {
@@ -991,7 +936,7 @@ function plansRoute(pool2) {
991
936
  await pool2.session(c.req.param("sessionId")).markTaskIncomplete(c.req.param("taskId"));
992
937
  return c.json({ ok: true });
993
938
  } catch (err) {
994
- return c.json({ error: err instanceof Error ? err.message : "Not found" }, 404);
939
+ return failure(c, err, "mark incomplete", { fallback: 404, message: "Not found" });
995
940
  }
996
941
  });
997
942
  for (const [segment, run2] of [
@@ -1005,39 +950,29 @@ function plansRoute(pool2) {
1005
950
  await run2(pool2.session(c.req.param("sessionId")), c.req.param("taskId"));
1006
951
  return c.json({ ok: true });
1007
952
  } catch (err) {
1008
- const status = err.message === "Session not found" ? 404 : 500;
1009
- return c.json({ error: err.message }, status);
953
+ return failure(c, err, segment);
1010
954
  }
1011
955
  });
1012
956
  }
1013
- router.post("/:sessionId/tasks/:taskId/messages", async (c) => {
1014
- try {
1015
- const body = await c.req.json().catch(() => ({}));
1016
- const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
1017
- if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
1018
- const id = pool2.session(c.req.param("sessionId")).sendTaskMessage(c.req.param("taskId"), text);
1019
- return c.json({ id });
1020
- } catch (err) {
1021
- return editFailure(c, err);
1022
- }
1023
- });
1024
- router.post("/:sessionId/tasks/:taskId/messages/now", async (c) => {
957
+ const messageHandler = (send) => async (c) => {
1025
958
  try {
1026
959
  const body = await c.req.json().catch(() => ({}));
1027
960
  const text = typeof body === "object" && body !== null && "text" in body ? body.text : void 0;
1028
961
  if (typeof text !== "string" || !text.trim()) return c.json({ error: "text is required" }, 400);
1029
- const id = pool2.session(c.req.param("sessionId")).forceSendTaskMessage(c.req.param("taskId"), text);
962
+ const id = send(pool2.session(c.req.param("sessionId")), c.req.param("taskId"), text);
1030
963
  return c.json({ id });
1031
964
  } catch (err) {
1032
- return editFailure(c, err);
965
+ return failure(c, err, "task edit");
1033
966
  }
1034
- });
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)));
1035
970
  router.post("/:sessionId/tasks/:taskId/messages/:messageId/now", (c) => {
1036
971
  try {
1037
972
  const sent = pool2.session(c.req.param("sessionId")).forceSendQueuedTaskMessage(c.req.param("taskId"), c.req.param("messageId"));
1038
973
  return c.json({ sent });
1039
974
  } catch (err) {
1040
- return editFailure(c, err);
975
+ return failure(c, err, "task edit");
1041
976
  }
1042
977
  });
1043
978
  router.post("/:sessionId/tasks/:taskId/continue", async (c) => {
@@ -1048,7 +983,7 @@ function plansRoute(pool2) {
1048
983
  await pool2.session(c.req.param("sessionId")).continueTask(c.req.param("taskId"), text);
1049
984
  return c.json({ ok: true });
1050
985
  } catch (err) {
1051
- return editFailure(c, err);
986
+ return failure(c, err, "task edit");
1052
987
  }
1053
988
  });
1054
989
  router.delete("/:sessionId/tasks/:taskId/messages/:messageId", (c) => {
@@ -1056,7 +991,7 @@ function plansRoute(pool2) {
1056
991
  const removed = pool2.session(c.req.param("sessionId")).removeQueuedTaskMessage(c.req.param("taskId"), c.req.param("messageId"));
1057
992
  return c.json({ removed });
1058
993
  } catch (err) {
1059
- return editFailure(c, err);
994
+ return failure(c, err, "task edit");
1060
995
  }
1061
996
  });
1062
997
  router.post("/:sessionId/tasks/:taskId/interrupt", async (c) => {
@@ -1064,18 +999,18 @@ function plansRoute(pool2) {
1064
999
  await pool2.session(c.req.param("sessionId")).interruptTask(c.req.param("taskId"));
1065
1000
  return c.json({ ok: true });
1066
1001
  } catch (err) {
1067
- return editFailure(c, err);
1002
+ return failure(c, err, "task edit");
1068
1003
  }
1069
1004
  });
1070
1005
  router.post("/:sessionId/tasks/:taskId/checkpoint/approve", (c) => {
1071
1006
  try {
1072
1007
  const session = pool2.session(c.req.param("sessionId"));
1073
1008
  const taskId = c.req.param("taskId");
1074
- if (!session.awaitsCheckpoint(taskId)) return c.json({ error: NO_CHECKPOINT }, 409);
1009
+ if (!session.awaitsCheckpoint(taskId)) return refuse(c, 409, NO_CHECKPOINT, "checkpoint_not_waiting");
1075
1010
  session.approveCheckpoint(taskId);
1076
1011
  return c.json({ ok: true });
1077
1012
  } catch (err) {
1078
- return editFailure(c, err);
1013
+ return failure(c, err, "task edit");
1079
1014
  }
1080
1015
  });
1081
1016
  router.post("/:sessionId/tasks/:taskId/checkpoint/reject", async (c) => {
@@ -1084,11 +1019,11 @@ function plansRoute(pool2) {
1084
1019
  const reason = typeof body === "object" && body !== null && "reason" in body && typeof body.reason === "string" ? body.reason.trim() : "";
1085
1020
  const session = pool2.session(c.req.param("sessionId"));
1086
1021
  const taskId = c.req.param("taskId");
1087
- if (!session.awaitsCheckpoint(taskId)) return c.json({ error: NO_CHECKPOINT }, 409);
1022
+ if (!session.awaitsCheckpoint(taskId)) return refuse(c, 409, NO_CHECKPOINT, "checkpoint_not_waiting");
1088
1023
  session.rejectCheckpoint(taskId, reason || void 0);
1089
1024
  return c.json({ ok: true });
1090
1025
  } catch (err) {
1091
- return editFailure(c, err);
1026
+ return failure(c, err, "task edit");
1092
1027
  }
1093
1028
  });
1094
1029
  router.get("/:sessionId/tasks/:taskId/merge-gate", (c) => {
@@ -1101,21 +1036,21 @@ function plansRoute(pool2) {
1101
1036
  });
1102
1037
  return c.json({ mergeGate });
1103
1038
  } catch (err) {
1104
- return editFailure(c, err);
1039
+ return failure(c, err, "task edit");
1105
1040
  }
1106
1041
  });
1107
1042
  router.get("/:sessionId/isolation/diff", async (c) => {
1108
1043
  try {
1109
1044
  return c.json({ diff: await pool2.session(c.req.param("sessionId")).reviewRunDiff() });
1110
1045
  } catch (err) {
1111
- return editFailure(c, err);
1046
+ return failure(c, err, "task edit");
1112
1047
  }
1113
1048
  });
1114
1049
  router.post("/:sessionId/isolation/merge", async (c) => {
1115
1050
  try {
1116
1051
  return c.json(await pool2.session(c.req.param("sessionId")).mergeRun());
1117
1052
  } catch (err) {
1118
- return editFailure(c, err);
1053
+ return failure(c, err, "task edit");
1119
1054
  }
1120
1055
  });
1121
1056
  for (const [segment, run2] of [
@@ -1129,7 +1064,7 @@ function plansRoute(pool2) {
1129
1064
  await run2(pool2.session(c.req.param("sessionId")));
1130
1065
  return c.json({ ok: true });
1131
1066
  } catch (err) {
1132
- return editFailure(c, err);
1067
+ return failure(c, err, "task edit");
1133
1068
  }
1134
1069
  });
1135
1070
  }
@@ -1138,7 +1073,7 @@ function plansRoute(pool2) {
1138
1073
  const plan = await pool2.session(c.req.param("sessionId")).resolveConflictAsTask(c.req.param("taskId"));
1139
1074
  return c.json({ plan });
1140
1075
  } catch (err) {
1141
- return editFailure(c, err);
1076
+ return failure(c, err, "task edit");
1142
1077
  }
1143
1078
  });
1144
1079
  router.put("/:sessionId/tasks/:taskId", async (c) => {
@@ -1154,33 +1089,33 @@ function plansRoute(pool2) {
1154
1089
  try {
1155
1090
  result = await session.setTaskDependencies(taskId, dependencies.map(String));
1156
1091
  } catch (err) {
1157
- return c.json({ error: err.message }, 400);
1092
+ return failure(c, err, "task dependencies", { fallback: 400 });
1158
1093
  }
1159
1094
  }
1160
1095
  if (Object.keys(rest).length > 0 || !hasRunner && !hasDeps) result = await session.updateTask(taskId, rest);
1161
- if (!result) return c.json({ error: "Task not found" }, 404);
1096
+ if (!result) return refuse(c, 404, "Task not found", "task_not_found");
1162
1097
  return c.json({ ok: true });
1163
1098
  } catch (err) {
1164
- return editFailure(c, err);
1099
+ return failure(c, err, "task edit");
1165
1100
  }
1166
1101
  });
1167
1102
  router.delete("/:sessionId/tasks/:taskId", async (c) => {
1168
1103
  try {
1169
1104
  const result = await pool2.session(c.req.param("sessionId")).removeTask(c.req.param("taskId"));
1170
- if (!result) return c.json({ error: "Task not found" }, 404);
1105
+ if (!result) return refuse(c, 404, "Task not found", "task_not_found");
1171
1106
  return c.json({ ok: true });
1172
1107
  } catch (err) {
1173
- return editFailure(c, err);
1108
+ return failure(c, err, "task edit");
1174
1109
  }
1175
1110
  });
1176
1111
  router.post("/:sessionId/tasks", async (c) => {
1177
1112
  try {
1178
1113
  const body = await c.req.json();
1179
1114
  const result = await pool2.session(c.req.param("sessionId")).addTask(body);
1180
- if (!result) return c.json({ error: "Session not found" }, 404);
1115
+ if (!result) return refuse(c, 404, "Session not found", "session_not_found");
1181
1116
  return c.json({ ok: true });
1182
1117
  } catch (err) {
1183
- return editFailure(c, err);
1118
+ return failure(c, err, "task edit");
1184
1119
  }
1185
1120
  });
1186
1121
  router.post("/:sessionId/review/approve", async (c) => {
@@ -1188,9 +1123,7 @@ function plansRoute(pool2) {
1188
1123
  const plan = await pool2.session(c.req.param("sessionId")).approveReview();
1189
1124
  return c.json({ plan });
1190
1125
  } catch (err) {
1191
- const e = err;
1192
- const status = e.message === "Session not found" ? 404 : e.message === "No plan to review" ? 400 : 500;
1193
- return c.json({ error: e.message }, status);
1126
+ return failure(c, err, "review approve");
1194
1127
  }
1195
1128
  });
1196
1129
  router.post("/:sessionId/tasks/merge", async (c) => {
@@ -1202,9 +1135,7 @@ function plansRoute(pool2) {
1202
1135
  const plan = await pool2.session(c.req.param("sessionId")).requestMerge(taskIds);
1203
1136
  return c.json({ plan });
1204
1137
  } catch (err) {
1205
- const e = err;
1206
- const status = e.message === "Session not found" ? 404 : 400;
1207
- return c.json({ error: e.message }, status);
1138
+ return failure(c, err, "merge", { fallback: 400 });
1208
1139
  }
1209
1140
  });
1210
1141
  router.post("/:sessionId/tasks/:taskId/split", async (c) => {
@@ -1212,9 +1143,7 @@ function plansRoute(pool2) {
1212
1143
  const plan = await pool2.session(c.req.param("sessionId")).requestSplit(c.req.param("taskId"));
1213
1144
  return c.json({ plan });
1214
1145
  } catch (err) {
1215
- const e = err;
1216
- const status = e.message === "Session not found" ? 404 : 400;
1217
- return c.json({ error: e.message }, status);
1146
+ return failure(c, err, "split", { fallback: 400 });
1218
1147
  }
1219
1148
  });
1220
1149
  router.post("/:sessionId/converse/start", async (c) => {
@@ -1226,11 +1155,7 @@ function plansRoute(pool2) {
1226
1155
  const plan = await pool2.startPlanning(c.req.param("sessionId"), goal, runnerList, ws, model, { allowInit });
1227
1156
  return c.json({ plan });
1228
1157
  } catch (err) {
1229
- if (err instanceof core.WorkspaceNotFoundError) return c.json({ error: err.message }, 400);
1230
- if (err instanceof core.WorkspaceNotAProjectError) {
1231
- return c.json({ error: err.message, code: "workspace_not_a_project", workspace: err.workspace }, 400);
1232
- }
1233
- return c.json({ error: err instanceof Error ? err.message : "Planning failed" }, 500);
1158
+ return failure(c, err, "converse start", { message: "Planning failed" });
1234
1159
  }
1235
1160
  });
1236
1161
  router.post("/:sessionId/converse/message", async (c) => {
@@ -1240,24 +1165,21 @@ function plansRoute(pool2) {
1240
1165
  const plan = await pool2.continuePlanning(c.req.param("sessionId"), message);
1241
1166
  return c.json({ plan });
1242
1167
  } catch (err) {
1243
- const e = err;
1244
- if (e instanceof core.ConversationBusyError) return c.json({ error: e.message }, 409);
1245
- const status = e.message === "Session not found" ? 404 : 500;
1246
- return c.json({ error: e.message }, status);
1168
+ return failure(c, err, "conversation message");
1247
1169
  }
1248
1170
  });
1249
1171
  router.post("/:sessionId/conversation/fork", (c) => {
1250
1172
  try {
1251
1173
  return c.json(pool2.forkConversation(c.req.param("sessionId")));
1252
1174
  } catch (err) {
1253
- return conversationFailure(c, err);
1175
+ return failure(c, err, "conversation edit");
1254
1176
  }
1255
1177
  });
1256
1178
  router.get("/:sessionId/conversation/rewind-targets", (c) => {
1257
1179
  try {
1258
1180
  return c.json({ targets: pool2.session(c.req.param("sessionId")).rewindTargets() });
1259
1181
  } catch (err) {
1260
- return conversationFailure(c, err);
1182
+ return failure(c, err, "conversation edit");
1261
1183
  }
1262
1184
  });
1263
1185
  router.post("/:sessionId/conversation/rewind", async (c) => {
@@ -1266,29 +1188,19 @@ function plansRoute(pool2) {
1266
1188
  if (!Number.isInteger(index) || index < 0) return c.json({ error: "index must be a non-negative integer" }, 400);
1267
1189
  return c.json(pool2.rewindConversation(c.req.param("sessionId"), index));
1268
1190
  } catch (err) {
1269
- return conversationFailure(c, err);
1191
+ return failure(c, err, "conversation edit");
1270
1192
  }
1271
1193
  });
1272
1194
  router.post("/:sessionId/conversation/compact", async (c) => {
1273
1195
  try {
1274
1196
  return c.json(await pool2.compactConversation(c.req.param("sessionId")));
1275
1197
  } catch (err) {
1276
- return conversationFailure(c, err);
1277
- }
1278
- });
1279
- router.post("/:sessionId/process-queued", async (c) => {
1280
- try {
1281
- await pool2.session(c.req.param("sessionId")).processQueuedMessages();
1282
- return c.json({ ok: true });
1283
- } catch (err) {
1284
- const e = err;
1285
- const status = e.message === "Session not found" ? 404 : 500;
1286
- return c.json({ error: e.message }, status);
1198
+ return failure(c, err, "conversation edit");
1287
1199
  }
1288
1200
  });
1289
1201
  router.get("/:sessionId/prd", (c) => {
1290
1202
  const plan = pool2.getPlan(c.req.param("sessionId"));
1291
- if (!plan?.prdMarkdown) return c.json({ error: "No PRD found" }, 404);
1203
+ if (!plan?.prdMarkdown) return refuse(c, 404, "No PRD found");
1292
1204
  return c.json({ prdMarkdown: plan.prdMarkdown });
1293
1205
  });
1294
1206
  return router;
@@ -1339,7 +1251,7 @@ function approvalsRoute(pool2) {
1339
1251
  const router = new hono.Hono();
1340
1252
  router.get("/:sessionId", (c) => {
1341
1253
  const sessionId = c.req.param("sessionId");
1342
- 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");
1343
1255
  return c.json({
1344
1256
  pending: pool2.outstandingApprovals(sessionId),
1345
1257
  approvedScopes: pool2.approvedScopes(sessionId)
@@ -1348,10 +1260,10 @@ function approvalsRoute(pool2) {
1348
1260
  router.post("/:sessionId/:approvalId", async (c) => {
1349
1261
  const sessionId = c.req.param("sessionId");
1350
1262
  const approvalId = c.req.param("approvalId");
1351
- 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");
1352
1264
  const answer = answerOf(await c.req.json().catch(() => null));
1353
1265
  if (!pool2.resolveApproval(sessionId, approvalId, answer)) {
1354
- 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");
1355
1267
  }
1356
1268
  const { decision } = core.toApprovalDecision(answer);
1357
1269
  return c.json({ ok: true, granted: core.isGranted({ decision }), decision });
@@ -1361,12 +1273,13 @@ function approvalsRoute(pool2) {
1361
1273
  function settingsRoute(pool2) {
1362
1274
  const router = new hono.Hono();
1363
1275
  router.get("/", (c) => {
1364
- return c.json(pool2.getSettings());
1276
+ const settings = pool2.getSettings();
1277
+ return c.json(settings);
1365
1278
  });
1366
1279
  router.patch("/", async (c) => {
1367
1280
  const body = await c.req.json();
1368
1281
  if (!body || typeof body !== "object" || Object.keys(body).length === 0) {
1369
- return c.json({ error: "At least one setting field is required" }, 400);
1282
+ return refuse(c, 400, "At least one setting field is required");
1370
1283
  }
1371
1284
  const result = pool2.updateSettings(body);
1372
1285
  return c.json(result);
@@ -1378,6 +1291,7 @@ var COMMANDS = [
1378
1291
  { name: "verify", description: "Toggle verification mode \u2014 adds a final evidence-based verification task that runs the full suite (on|off|status)" },
1379
1292
  { name: "transport", description: "Drive tasks through each runner's protocol (structured) or a terminal, from the next run (terminal|structured|status)" }
1380
1293
  ];
1294
+ var BOOLEAN_TOGGLES = { tdd: "tdd", verify: "verification" };
1381
1295
  function commandsRoute(pool2) {
1382
1296
  const router = new hono.Hono();
1383
1297
  router.get("/", (c) => {
@@ -1389,23 +1303,15 @@ function commandsRoute(pool2) {
1389
1303
  const args2 = body?.args || {};
1390
1304
  const command = COMMANDS.find((cmd) => cmd.name === name);
1391
1305
  if (!command) {
1392
- return c.json({ error: `Unknown command: ${name}` }, 404);
1393
- }
1394
- if (name === "tdd") {
1395
- const action = args2.action || "status";
1396
- if (action === "on") {
1397
- pool2.updateSettings({ tdd: { enabled: true } });
1398
- } else if (action === "off") {
1399
- pool2.updateSettings({ tdd: { enabled: false } });
1400
- }
1401
- return c.json({ ok: true, settings: pool2.getSettings() });
1306
+ return refuse(c, 404, `Unknown command: ${name}`);
1402
1307
  }
1403
- if (name === "verify") {
1308
+ const toggle = BOOLEAN_TOGGLES[name];
1309
+ if (toggle) {
1404
1310
  const action = args2.action || "status";
1405
1311
  if (action === "on") {
1406
- pool2.updateSettings({ verification: { enabled: true } });
1312
+ pool2.updateSettings({ [toggle]: { enabled: true } });
1407
1313
  } else if (action === "off") {
1408
- pool2.updateSettings({ verification: { enabled: false } });
1314
+ pool2.updateSettings({ [toggle]: { enabled: false } });
1409
1315
  }
1410
1316
  return c.json({ ok: true, settings: pool2.getSettings() });
1411
1317
  }
@@ -1414,11 +1320,11 @@ function commandsRoute(pool2) {
1414
1320
  if (action === "terminal" || action === "structured") {
1415
1321
  pool2.updateSettings({ runnerTransport: action });
1416
1322
  } else if (action !== "status") {
1417
- return c.json({ error: `Unknown transport: ${action} (terminal|structured)` }, 400);
1323
+ return refuse(c, 400, `Unknown transport: ${action} (terminal|structured)`);
1418
1324
  }
1419
1325
  return c.json({ ok: true, settings: pool2.getSettings() });
1420
1326
  }
1421
- return c.json({ error: `Unknown command: ${name}` }, 404);
1327
+ return refuse(c, 404, `Unknown command: ${name}`);
1422
1328
  });
1423
1329
  return router;
1424
1330
  }
@@ -1634,6 +1540,9 @@ async function shutdown() {
1634
1540
  server.close();
1635
1541
  process.exit(0);
1636
1542
  }
1543
+ process.on("unhandledRejection", (reason) => {
1544
+ console.error(`[web] unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
1545
+ });
1637
1546
  process.on("SIGINT", () => {
1638
1547
  shutdown();
1639
1548
  });