@lazyingart/agintiflow 0.20.164 → 0.20.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.164",
3
+ "version": "0.20.165",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -76,6 +76,7 @@
76
76
  "scripts/smoke-toolchain-docker.js",
77
77
  "scripts/smoke-web-autostart.js",
78
78
  "scripts/smoke-web-api.js",
79
+ "scripts/smoke-web-ui.js",
79
80
  "scripts/smoke-webapp-command.js",
80
81
  "scripts/smoke-web-port-fallback.js",
81
82
  "src/",
@@ -90,7 +91,7 @@
90
91
  "scripts": {
91
92
  "start": "node run.js",
92
93
  "web": "node web.js",
93
- "check": "node --check run.js && node --check web.js && node --check bin/aginti-cli.js && node --check src/*.js && node --check src/mcp/*.js && node --check public/app.js && node --check scripts/seed-supervised-homework.js && node --check scripts/smoke-mcp.js && node --check scripts/fixtures/mcp-stdio-smoke-server.mjs",
94
+ "check": "node --check run.js && node --check web.js && node --check bin/aginti-cli.js && node --check src/*.js && node --check src/mcp/*.js && node --check public/app.js && node --check scripts/seed-supervised-homework.js && node --check scripts/smoke-mcp.js && node --check scripts/smoke-web-ui.js && node --check scripts/fixtures/mcp-stdio-smoke-server.mjs",
94
95
  "setup:toolchain-docker": "scripts/setup-agent-toolchain-docker.sh",
95
96
  "smoke:coding-tools": "node scripts/smoke-coding-tools.js",
96
97
  "smoke:dynamic-step-budget": "node scripts/smoke-dynamic-step-budget.js",
@@ -112,6 +113,7 @@
112
113
  "smoke:runtime-compat": "node scripts/smoke-runtime-compat.js",
113
114
  "smoke:tmux-tools": "node scripts/smoke-tmux-tools.js",
114
115
  "smoke:web-api": "node scripts/smoke-web-api.js",
116
+ "smoke:web-ui": "node scripts/smoke-web-ui.js",
115
117
  "smoke:web-autostart": "node scripts/smoke-web-autostart.js",
116
118
  "smoke:webapp-command": "node scripts/smoke-webapp-command.js",
117
119
  "smoke:web-port-fallback": "node scripts/smoke-web-port-fallback.js",
@@ -122,7 +124,7 @@
122
124
  "storage:migrate": "node bin/aginti-cli.js storage migrate",
123
125
  "publish:env": "node scripts/npm-publish-from-env.js publish --access public",
124
126
  "publish:env:whoami": "node scripts/npm-publish-from-env.js whoami",
125
- "test": "npm run check && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
127
+ "test": "npm run check && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
126
128
  "pack:dry-run": "npm pack --dry-run",
127
129
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
128
130
  },
package/public/app.js CHANGED
@@ -99,6 +99,20 @@ const translations = {
99
99
  stopRunButton: "Stop",
100
100
  stoppingRun: "Stopping...",
101
101
  stopRunFailed: "Failed to stop run.",
102
+ newSessionButton: "New",
103
+ runStateIdle: "Ready",
104
+ runStateIdleDetail: "Select a conversation or type a new goal below.",
105
+ runStateRunning: "Running",
106
+ runStateFinished: "Finished",
107
+ runStateStopped: "Stopped",
108
+ runStateFailed: "Failed",
109
+ composerStartButton: "Start new run",
110
+ composerPlaceholderNoSession: "Type a goal to start a new agent run.",
111
+ composerPlaceholderSession: "Continue the selected conversation here.",
112
+ toastRunStarted: "Run started.",
113
+ toastRunFinished: "Run finished.",
114
+ toastRunStopped: "Run stopped.",
115
+ toastRunFailed: "Run failed.",
102
116
  runOutputTitle: "Run output",
103
117
  noRunStarted: "No run started.",
104
118
  conversationTitle: "Conversation",
@@ -716,9 +730,15 @@ const sandboxLogsEl = document.querySelector("#sandbox-logs");
716
730
  const checkSandboxButton = document.querySelector("#check-sandbox");
717
731
  const preflightSandboxButton = document.querySelector("#preflight-sandbox");
718
732
  const sessionSelectEl = document.querySelector("#session-select");
733
+ const newSessionButton = document.querySelector("#new-session");
734
+ const runStateEl = document.querySelector("#run-state");
735
+ const runStateTextEl = document.querySelector("#run-state-text");
736
+ const runStateDetailEl = document.querySelector("#run-state-detail");
737
+ const toastRegionEl = document.querySelector("#toast-region");
719
738
  const chatThreadEl = document.querySelector("#chat-thread");
720
739
  const chatFormEl = document.querySelector("#chat-form");
721
740
  const chatInputEl = document.querySelector("#chat-input");
741
+ const chatSubmitButton = document.querySelector("#chat-submit");
722
742
  const chatPendingEl = document.querySelector("#chat-pending");
723
743
  const chatStatusEl = document.querySelector("#chat-status");
724
744
  const pipeMessageButton = document.querySelector("#pipe-message");
@@ -789,6 +809,7 @@ let artifactItems = [];
789
809
  let selectedArtifactId = "";
790
810
  let currentArtifactTab = "canvas";
791
811
  let artifactUnreadCount = 0;
812
+ let lastAnnouncedRunKey = "";
792
813
 
793
814
  function normalizeLanguage(language) {
794
815
  const lower = String(language || "").toLowerCase();
@@ -806,6 +827,36 @@ function setLogs(text, mode = "active") {
806
827
  logsEl.textContent = text;
807
828
  }
808
829
 
830
+ function isRunningStatus(status = currentRunStatus) {
831
+ return status === "running";
832
+ }
833
+
834
+ function isTerminalStatus(status = currentRunStatus) {
835
+ return ["finished", "failed", "stopped"].includes(status);
836
+ }
837
+
838
+ function runStatusLabel(status = currentRunStatus) {
839
+ if (status === "running") return t("runStateRunning");
840
+ if (status === "finished") return t("runStateFinished");
841
+ if (status === "failed") return t("runStateFailed");
842
+ if (status === "stopped") return t("runStateStopped");
843
+ return t("runStateIdle");
844
+ }
845
+
846
+ function showToast(message, kind = "info") {
847
+ if (!toastRegionEl || !message) return;
848
+ const toast = document.createElement("div");
849
+ toast.className = "toast";
850
+ toast.dataset.kind = kind;
851
+ toast.textContent = message;
852
+ toastRegionEl.append(toast);
853
+ setTimeout(() => {
854
+ toast.style.opacity = "0";
855
+ toast.style.transform = "translateY(6px)";
856
+ }, 2600);
857
+ setTimeout(() => toast.remove(), 3200);
858
+ }
859
+
809
860
  function outputLineCount(value = "") {
810
861
  const text = String(value || "");
811
862
  return text ? text.split(/\r?\n/).length : 0;
@@ -823,8 +874,37 @@ function outputPreviewText(value = "", maxLines = 18) {
823
874
 
824
875
  function updateStopRunButton() {
825
876
  if (!stopRunButton) return;
826
- stopRunButton.hidden = currentRunStatus !== "running";
827
- stopRunButton.disabled = currentRunStatus !== "running";
877
+ stopRunButton.hidden = !isRunningStatus();
878
+ stopRunButton.disabled = !isRunningStatus();
879
+ if (pipeMessageButton) pipeMessageButton.disabled = !currentSessionId;
880
+ if (queueAfterFinishButton) queueAfterFinishButton.disabled = !currentSessionId;
881
+ if (chatSubmitButton) chatSubmitButton.textContent = currentSessionId ? t("sendMessageButton") : t("composerStartButton");
882
+ if (chatInputEl) {
883
+ chatInputEl.placeholder = currentSessionId ? t("composerPlaceholderSession") : t("composerPlaceholderNoSession");
884
+ }
885
+ if (runStateEl) {
886
+ const status = currentRunStatus || "idle";
887
+ runStateEl.dataset.status = status;
888
+ runStateTextEl.textContent = runStatusLabel(status);
889
+ const selected = currentSessionId ? `session=${currentSessionId}` : t("runStateIdleDetail");
890
+ runStateDetailEl.textContent = isRunningStatus(status) || isTerminalStatus(status) ? selected : t("runStateIdleDetail");
891
+ }
892
+ }
893
+
894
+ function setRunStatus(status = "", { sessionId, detail = "", announce = false } = {}) {
895
+ const previous = currentRunStatus;
896
+ currentRunStatus = String(status || "");
897
+ if (sessionId !== undefined) currentSessionId = sessionId;
898
+ updateStopRunButton();
899
+ if (detail && runStateDetailEl) runStateDetailEl.textContent = detail;
900
+
901
+ const key = `${currentSessionId}:${currentRunStatus}`;
902
+ if (!announce || key === lastAnnouncedRunKey || previous === currentRunStatus) return;
903
+ lastAnnouncedRunKey = key;
904
+ if (currentRunStatus === "running") showToast(t("toastRunStarted"));
905
+ if (currentRunStatus === "finished") showToast(t("toastRunFinished"));
906
+ if (currentRunStatus === "stopped") showToast(t("toastRunStopped"));
907
+ if (currentRunStatus === "failed") showToast(t("toastRunFailed"), "error");
828
908
  }
829
909
 
830
910
  function renderKeyStatus(status = lastKeyStatus) {
@@ -880,10 +960,10 @@ function recommendedMaxStepsForProfile(profile = "auto", goal = "") {
880
960
  return 24;
881
961
  }
882
962
 
883
- function ensureRecommendedMaxStepsForCurrentTask() {
963
+ function ensureRecommendedMaxStepsForCurrentTask(goalOverride = "") {
884
964
  const maxStepsField = document.querySelector("#maxSteps");
885
965
  const goalField = document.querySelector("#goal");
886
- const recommended = recommendedMaxStepsForProfile(taskProfileField?.value || "auto", goalField?.value || "");
966
+ const recommended = recommendedMaxStepsForProfile(taskProfileField?.value || "auto", goalOverride || goalField?.value || "");
887
967
  if (maxStepsField && Number(maxStepsField.value || 0) < recommended) {
888
968
  maxStepsField.value = String(recommended);
889
969
  }
@@ -891,6 +971,7 @@ function ensureRecommendedMaxStepsForCurrentTask() {
891
971
 
892
972
  function renderWrapperStatus(wrappers = lastWrappers) {
893
973
  lastWrappers = wrappers || [];
974
+ refreshProviderDropdowns();
894
975
  if (lastWrappers.length === 0) {
895
976
  wrapperStatusEl.textContent = "";
896
977
  wrapperGridEl.innerHTML = "";
@@ -1045,6 +1126,51 @@ function fieldValue(field) {
1045
1126
  return String(field?.value || "").trim();
1046
1127
  }
1047
1128
 
1129
+ const providerLabels = {
1130
+ deepseek: "DeepSeek",
1131
+ openai: "OpenAI",
1132
+ qwen: "Qwen",
1133
+ venice: "Venice",
1134
+ mock: "Mock local",
1135
+ grsai: "GRS AI image",
1136
+ };
1137
+
1138
+ function setChoiceOptions(select, options, selectedValue = "", fallbackValue = "") {
1139
+ if (!select) return "";
1140
+ const normalized = (options || [])
1141
+ .map((item) => (typeof item === "string" ? { id: item, label: providerLabels[item] || item } : item))
1142
+ .filter((item) => item?.id);
1143
+ const selected = String(selectedValue || fallbackValue || "").trim();
1144
+ if (selected && !normalized.some((item) => item.id === selected)) {
1145
+ normalized.unshift({ id: selected, label: providerLabels[selected] || `Custom: ${selected}` });
1146
+ }
1147
+ select.innerHTML = normalized
1148
+ .map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.label || item.id)}</option>`)
1149
+ .join("");
1150
+ select.value = selected && normalized.some((item) => item.id === selected) ? selected : normalized[0]?.id || "";
1151
+ return select.value;
1152
+ }
1153
+
1154
+ function configuredTextProviders({ includeMock = true } = {}) {
1155
+ const base = ["deepseek", "openai", "qwen", "venice"];
1156
+ if (includeMock) base.push("mock");
1157
+ if (Object.keys(modelCatalog || {}).length === 0) return base;
1158
+ return base.filter((provider) => provider === "mock" || Array.isArray(modelCatalog[provider]));
1159
+ }
1160
+
1161
+ function refreshProviderDropdowns() {
1162
+ setChoiceOptions(setupProviderField, ["deepseek", "openai", "qwen", "venice", "grsai"], setupProviderField?.value || "deepseek", "deepseek");
1163
+ setChoiceOptions(providerField, configuredTextProviders({ includeMock: true }), providerField?.value || "deepseek", "deepseek");
1164
+ setChoiceOptions(routeProviderField, configuredTextProviders({ includeMock: true }), routeProviderField?.value || "deepseek", "deepseek");
1165
+ setChoiceOptions(mainProviderField, configuredTextProviders({ includeMock: false }), mainProviderField?.value || "deepseek", "deepseek");
1166
+ setChoiceOptions(spareProviderField, configuredTextProviders({ includeMock: false }), spareProviderField?.value || "openai", "openai");
1167
+ setChoiceOptions(auxiliaryProviderField, ["grsai", "venice"], auxiliaryProviderField?.value || "grsai", "grsai");
1168
+ const wrappers = lastWrappers.length
1169
+ ? lastWrappers.map((wrapper) => ({ id: wrapper.name, label: wrapper.label || wrapper.name }))
1170
+ : ["codex", "claude", "gemini", "copilot", "qwen"].map((name) => ({ id: name, label: providerLabels[name] || name }));
1171
+ setChoiceOptions(preferredWrapperField, wrappers, preferredWrapperField?.value || "codex", "codex");
1172
+ }
1173
+
1048
1174
  function providerModelOptions(provider = providerField.value) {
1049
1175
  return (modelCatalog[provider] || []).filter((item) => !item.hidden);
1050
1176
  }
@@ -1108,6 +1234,7 @@ function setSelectOptions(select, options, selectedValue = "", fallbackValue = "
1108
1234
  }
1109
1235
 
1110
1236
  function refreshModelDropdowns() {
1237
+ refreshProviderDropdowns();
1111
1238
  const provider = providerField?.value || "deepseek";
1112
1239
  setSelectOptions(modelField, providerModelOptions(provider), fieldValue(modelField), defaults[provider]);
1113
1240
  setSelectOptions(
@@ -1926,7 +2053,7 @@ async function pipeMessageFromInput() {
1926
2053
  }
1927
2054
 
1928
2055
  async function flushAfterFinishQueue() {
1929
- if (!currentSessionId || flushingAfterFinish || currentRunStatus === "running" || pendingAfterFinishItems.length === 0) return;
2056
+ if (!currentSessionId || flushingAfterFinish || isRunningStatus() || pendingAfterFinishItems.length === 0) return;
1930
2057
  flushingAfterFinish = true;
1931
2058
  const next = pendingAfterFinishItems[0];
1932
2059
  pendingAfterFinishItems = pendingAfterFinishItems.slice(1);
@@ -1944,9 +2071,11 @@ async function flushAfterFinishQueue() {
1944
2071
  });
1945
2072
  const data = await response.json().catch(() => ({}));
1946
2073
  if (!response.ok) throw new Error(data.error || t("failedContinue"));
1947
- currentSessionId = data.sessionId;
1948
- currentRunStatus = data.queued ? currentRunStatus : "running";
1949
- updateStopRunButton();
2074
+ if (!data.queued) setRunStatus("running", { sessionId: data.sessionId, announce: true });
2075
+ else {
2076
+ currentSessionId = data.sessionId;
2077
+ updateStopRunButton();
2078
+ }
1950
2079
  chatStatusEl.textContent = data.queued ? t("queuedStatus") : t("runningStatus");
1951
2080
  await refreshSessions();
1952
2081
  await refreshChat();
@@ -2408,20 +2537,26 @@ async function refreshWorkspaceChanges() {
2408
2537
  }
2409
2538
 
2410
2539
  async function refreshRun() {
2411
- if (!currentSessionId) return;
2540
+ if (!currentSessionId) {
2541
+ setRunStatus("", { sessionId: "" });
2542
+ return;
2543
+ }
2412
2544
  const response = await fetch(`/api/runs/${encodeURIComponent(currentSessionId)}`);
2413
- if (!response.ok) return;
2545
+ if (!response.ok) {
2546
+ setRunStatus("", { detail: t("noRunSelected") });
2547
+ return;
2548
+ }
2414
2549
  const run = await response.json();
2415
- currentRunStatus = run.status || "";
2416
- updateStopRunButton();
2550
+ setRunStatus(run.status || "", { sessionId: run.sessionId, announce: true });
2417
2551
  runMetaEl.textContent = `${run.status} · ${run.sessionId}`;
2418
2552
  renderLogs(run);
2419
2553
  await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
2420
2554
  await refreshInbox();
2421
2555
 
2422
- if (run.status === "finished" || run.status === "failed" || run.status === "stopped") {
2556
+ if (isTerminalStatus(run.status)) {
2423
2557
  clearInterval(pollTimer);
2424
2558
  pollTimer = null;
2559
+ chatStatusEl.textContent = run.status === "finished" ? t("runStateFinished") : run.error || runStatusLabel(run.status);
2425
2560
  await refreshChat();
2426
2561
  await refreshSessions();
2427
2562
  await refreshWorkspaceChanges();
@@ -2431,7 +2566,7 @@ async function refreshRun() {
2431
2566
  }
2432
2567
 
2433
2568
  async function stopCurrentRun() {
2434
- if (!currentSessionId || currentRunStatus !== "running") return;
2569
+ if (!currentSessionId || !isRunningStatus()) return;
2435
2570
  stopRunButton.disabled = true;
2436
2571
  chatStatusEl.textContent = t("stoppingRun");
2437
2572
  const response = await fetch(`/api/runs/${encodeURIComponent(currentSessionId)}/stop`, {
@@ -2440,6 +2575,7 @@ async function stopCurrentRun() {
2440
2575
  if (!response.ok) {
2441
2576
  const data = await response.json().catch(() => ({}));
2442
2577
  chatStatusEl.textContent = data.error || t("stopRunFailed");
2578
+ if (data.status) setRunStatus(data.status, { announce: true });
2443
2579
  updateStopRunButton();
2444
2580
  return;
2445
2581
  }
@@ -2466,7 +2602,7 @@ async function approvePermission(action) {
2466
2602
  permissionModeField.value = data.permissionMode;
2467
2603
  applyPermissionModeToForm(data.permissionMode);
2468
2604
  }
2469
- currentRunStatus = action === "no" ? currentRunStatus : "running";
2605
+ if (action !== "no") setRunStatus("running", { announce: true });
2470
2606
  chatStatusEl.textContent = action === "no" ? t("permissionApprovalNo") : t("runningStatus");
2471
2607
  updateStopRunButton();
2472
2608
  await refreshRun();
@@ -2608,20 +2744,80 @@ async function deleteManagedSession() {
2608
2744
  }
2609
2745
 
2610
2746
  if (currentSessionId === selected.sessionId) {
2611
- currentSessionId = "";
2612
- sessionSelectEl.value = "";
2613
- runMetaEl.textContent = "";
2614
- setLogs(t("noRunSelected"), "empty");
2615
- renderChat([]);
2616
- artifactItems = [];
2617
- selectedArtifactId = "";
2618
- renderArtifactShell();
2747
+ clearCurrentSessionScope({ focus: false });
2619
2748
  }
2620
2749
  managedSessionId = "";
2621
2750
  await refreshSessions();
2622
2751
  renderSessionManager(t("sessionDeleted"));
2623
2752
  }
2624
2753
 
2754
+ function clearCurrentSessionScope({ focus = true } = {}) {
2755
+ currentSessionId = "";
2756
+ setRunStatus("", { sessionId: "" });
2757
+ pendingInboxItems = [];
2758
+ pendingAfterFinishItems = [];
2759
+ sessionSelectEl.value = "";
2760
+ runMetaEl.textContent = "";
2761
+ setLogs(t("noRunSelected"), "empty");
2762
+ renderChat([]);
2763
+ renderPendingMessages();
2764
+ artifactItems = [];
2765
+ selectedArtifactId = "";
2766
+ renderArtifactShell();
2767
+ chatStatusEl.textContent = "";
2768
+ if (focus) chatInputEl.focus();
2769
+ }
2770
+
2771
+ async function startRunFromGoal(goal, { clearComposer = false } = {}) {
2772
+ const normalizedGoal = String(goal || "").trim();
2773
+ if (!normalizedGoal) {
2774
+ setLogs(t("goalRequired"), "empty");
2775
+ chatStatusEl.textContent = t("goalRequired");
2776
+ return false;
2777
+ }
2778
+ ensureRecommendedMaxStepsForCurrentTask(normalizedGoal);
2779
+
2780
+ const payload = {
2781
+ ...formPayload(),
2782
+ goal: normalizedGoal,
2783
+ };
2784
+
2785
+ setLogs(t("startingRun"));
2786
+ runMetaEl.textContent = "";
2787
+ chatStatusEl.textContent = t("startingRun");
2788
+ showToast(t("startingRun"));
2789
+
2790
+ const response = await fetch("/api/runs", {
2791
+ method: "POST",
2792
+ headers: { "Content-Type": "application/json" },
2793
+ body: JSON.stringify(payload),
2794
+ });
2795
+
2796
+ const data = await response.json().catch(() => ({}));
2797
+ if (!response.ok) {
2798
+ setLogs(data.error || t("failedStartRun"));
2799
+ chatStatusEl.textContent = data.error || t("failedStartRun");
2800
+ showToast(data.error || t("failedStartRun"), "error");
2801
+ return false;
2802
+ }
2803
+
2804
+ currentSessionId = data.sessionId;
2805
+ setRunStatus("running", { sessionId: data.sessionId, announce: true });
2806
+ sessionSelectEl.value = currentSessionId;
2807
+ runMetaEl.textContent = `${runStatusLabel("running")} · ${currentSessionId}`;
2808
+ if (clearComposer) chatInputEl.value = "";
2809
+ await refreshSessions();
2810
+ sessionSelectEl.value = currentSessionId;
2811
+ await refreshChat();
2812
+ await refreshWorkspaceChanges();
2813
+ await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
2814
+
2815
+ if (pollTimer) clearInterval(pollTimer);
2816
+ pollTimer = setInterval(refreshRun, 1500);
2817
+ await refreshRun();
2818
+ return true;
2819
+ }
2820
+
2625
2821
  function schedulePreferenceSave() {
2626
2822
  clearTimeout(saveTimer);
2627
2823
  saveTimer = setTimeout(() => {
@@ -2656,6 +2852,10 @@ manageSessionsButton.addEventListener("click", () => {
2656
2852
  });
2657
2853
  });
2658
2854
 
2855
+ newSessionButton?.addEventListener("click", () => {
2856
+ clearCurrentSessionScope();
2857
+ });
2858
+
2659
2859
  openArtifactsButton.addEventListener("click", () => {
2660
2860
  openArtifactTunnel().catch((error) => {
2661
2861
  chatStatusEl.textContent = String(error);
@@ -2906,7 +3106,7 @@ logsEl?.addEventListener("click", (event) => {
2906
3106
  });
2907
3107
 
2908
3108
  window.addEventListener("keydown", (event) => {
2909
- if (event.key !== "Escape" || currentRunStatus !== "running") return;
3109
+ if (event.key !== "Escape" || !isRunningStatus()) return;
2910
3110
  const tag = event.target?.tagName?.toLowerCase();
2911
3111
  if (tag === "select" || tag === "button") return;
2912
3112
  event.preventDefault();
@@ -2920,62 +3120,13 @@ form.addEventListener("submit", async (event) => {
2920
3120
  event.preventDefault();
2921
3121
 
2922
3122
  const goal = document.querySelector("#goal").value.trim();
2923
- if (!goal) {
2924
- setLogs(t("goalRequired"), "empty");
2925
- return;
2926
- }
2927
- ensureRecommendedMaxStepsForCurrentTask();
2928
-
2929
- const payload = {
2930
- ...formPayload(),
2931
- goal,
2932
- };
2933
-
2934
- setLogs(t("startingRun"));
2935
- runMetaEl.textContent = "";
2936
- chatStatusEl.textContent = "";
2937
-
2938
- const response = await fetch("/api/runs", {
2939
- method: "POST",
2940
- headers: { "Content-Type": "application/json" },
2941
- body: JSON.stringify(payload),
2942
- });
2943
-
2944
- const data = await response.json();
2945
- if (!response.ok) {
2946
- setLogs(data.error || t("failedStartRun"));
2947
- return;
2948
- }
2949
-
2950
- currentSessionId = data.sessionId;
2951
- currentRunStatus = "running";
2952
- updateStopRunButton();
2953
- sessionSelectEl.value = currentSessionId;
2954
- runMetaEl.textContent = `${t("runningStatus").replace("...", "")} · ${currentSessionId}`;
2955
- await refreshSessions();
2956
- await refreshChat();
2957
- await refreshWorkspaceChanges();
2958
- await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
2959
-
2960
- if (pollTimer) clearInterval(pollTimer);
2961
- pollTimer = setInterval(refreshRun, 1500);
2962
- await refreshRun();
3123
+ await startRunFromGoal(goal);
2963
3124
  });
2964
3125
 
2965
3126
  sessionSelectEl.addEventListener("change", async () => {
2966
3127
  currentSessionId = sessionSelectEl.value;
2967
3128
  if (!currentSessionId) {
2968
- currentRunStatus = "";
2969
- pendingInboxItems = [];
2970
- pendingAfterFinishItems = [];
2971
- updateStopRunButton();
2972
- runMetaEl.textContent = "";
2973
- setLogs(t("noRunSelected"), "empty");
2974
- renderChat([]);
2975
- renderPendingMessages();
2976
- artifactItems = [];
2977
- selectedArtifactId = "";
2978
- renderArtifactShell();
3129
+ clearCurrentSessionScope({ focus: false });
2979
3130
  return;
2980
3131
  }
2981
3132
  await refreshRun();
@@ -2986,17 +3137,17 @@ sessionSelectEl.addEventListener("change", async () => {
2986
3137
  chatFormEl.addEventListener("submit", async (event) => {
2987
3138
  event.preventDefault();
2988
3139
 
2989
- if (!currentSessionId) {
2990
- chatStatusEl.textContent = t("selectSessionFirst");
2991
- return;
2992
- }
2993
-
2994
3140
  const content = chatInputEl.value.trim();
2995
3141
  if (!content) {
2996
3142
  chatStatusEl.textContent = t("messageRequired");
2997
3143
  return;
2998
3144
  }
2999
3145
 
3146
+ if (!currentSessionId) {
3147
+ await startRunFromGoal(content, { clearComposer: true });
3148
+ return;
3149
+ }
3150
+
3000
3151
  chatStatusEl.textContent = t("sendingStatus");
3001
3152
 
3002
3153
  const response = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/messages`, {
@@ -3015,8 +3166,8 @@ chatFormEl.addEventListener("submit", async (event) => {
3015
3166
  }
3016
3167
 
3017
3168
  currentSessionId = data.sessionId;
3018
- currentRunStatus = data.queued ? currentRunStatus : "running";
3019
- updateStopRunButton();
3169
+ if (!data.queued) setRunStatus("running", { sessionId: data.sessionId, announce: true });
3170
+ else updateStopRunButton();
3020
3171
  chatInputEl.value = "";
3021
3172
  chatStatusEl.textContent = data.queued ? t("queuedStatus") : t("runningStatus");
3022
3173
  await refreshSessions();
@@ -3047,6 +3198,7 @@ async function loadConfig() {
3047
3198
  defaults.mock = data.defaults?.mock?.model || defaults.mock;
3048
3199
 
3049
3200
  applyLanguage(prefs.language || normalizeLanguage(navigator.language || "en"), { persist: false });
3201
+ refreshProviderDropdowns();
3050
3202
 
3051
3203
  routingModeField.value = prefs.routingMode || "smart";
3052
3204
  providerField.value = prefs.provider || "deepseek";
@@ -3107,6 +3259,7 @@ async function loadConfig() {
3107
3259
  renderKeyStatus(data.keyStatus);
3108
3260
  renderProjectStatus(data.project);
3109
3261
  renderWrapperStatus(data.wrappers || []);
3262
+ refreshProviderDropdowns();
3110
3263
  renderWorkspacePanel(data.workspace, []);
3111
3264
  renderMcpPanel(data.mcp || null);
3112
3265
  await refreshWorkspaceChanges();
package/public/index.html CHANGED
@@ -213,6 +213,7 @@
213
213
  <select id="session-select" class="session-select">
214
214
  <option value="" data-i18n="noSessionSelected">No session selected</option>
215
215
  </select>
216
+ <button id="new-session" class="icon-button" type="button" data-i18n="newSessionButton">New</button>
216
217
  <button
217
218
  id="open-artifacts"
218
219
  class="icon-button artifact-button"
@@ -234,6 +235,11 @@
234
235
  </button>
235
236
  </div>
236
237
  </div>
238
+ <div id="run-state" class="run-state" data-status="idle" role="status" aria-live="polite">
239
+ <span id="run-state-dot" class="run-state-dot" aria-hidden="true"></span>
240
+ <strong id="run-state-text" data-i18n="runStateIdle">Ready</strong>
241
+ <span id="run-state-detail" class="subtle" data-i18n="runStateIdleDetail">Select a conversation or type a new goal below.</span>
242
+ </div>
237
243
  <form id="chat-form" class="chat-form">
238
244
  <textarea
239
245
  id="chat-input"
@@ -245,7 +251,7 @@
245
251
  Enter sends · Shift+Enter adds a newline · use buttons for pipe/queue control
246
252
  </p>
247
253
  <div class="actions">
248
- <button type="submit" data-i18n="sendMessageButton">Send message</button>
254
+ <button id="chat-submit" type="submit" data-i18n="sendMessageButton">Send message</button>
249
255
  <button id="pipe-message" type="button" class="secondary" data-i18n="pipeMessageButton">Pipe to run</button>
250
256
  <button id="queue-after-finish" type="button" class="secondary" data-i18n="queueAfterFinishButton">
251
257
  Queue after finish
@@ -311,6 +317,7 @@
311
317
  </section>
312
318
  </section>
313
319
  </main>
320
+ <div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="true"></div>
314
321
 
315
322
  <dialog id="settings-modal" class="modal settings-modal">
316
323
  <section class="modal-card settings-modal-card" aria-labelledby="settings-modal-title">
package/public/styles.css CHANGED
@@ -242,6 +242,8 @@ h1 {
242
242
  .role-editor-grid {
243
243
  display: grid;
244
244
  gap: 10px;
245
+ grid-template-columns: repeat(5, minmax(150px, 1fr));
246
+ align-items: start;
245
247
  }
246
248
 
247
249
  .role-editor-grid label {
@@ -806,6 +808,83 @@ button.danger {
806
808
  max-width: none;
807
809
  }
808
810
 
811
+ .run-state {
812
+ display: grid;
813
+ grid-template-columns: auto auto minmax(0, 1fr);
814
+ gap: 8px;
815
+ align-items: center;
816
+ padding: 10px 12px;
817
+ border: 1px solid rgba(15, 118, 110, 0.16);
818
+ border-radius: 14px;
819
+ background: linear-gradient(135deg, rgba(255, 253, 250, 0.9), rgba(240, 253, 250, 0.76));
820
+ }
821
+
822
+ .run-state[data-status="running"] {
823
+ border-color: rgba(15, 118, 110, 0.34);
824
+ background: linear-gradient(135deg, rgba(204, 251, 241, 0.9), rgba(254, 243, 199, 0.62));
825
+ }
826
+
827
+ .run-state[data-status="failed"],
828
+ .run-state[data-status="stopped"] {
829
+ border-color: rgba(185, 28, 28, 0.22);
830
+ background: linear-gradient(135deg, rgba(254, 242, 242, 0.92), rgba(255, 251, 235, 0.7));
831
+ }
832
+
833
+ .run-state-dot {
834
+ width: 10px;
835
+ height: 10px;
836
+ border-radius: 999px;
837
+ background: #94a3b8;
838
+ box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.14);
839
+ }
840
+
841
+ .run-state[data-status="running"] .run-state-dot {
842
+ background: #0f766e;
843
+ box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.16);
844
+ }
845
+
846
+ .run-state[data-status="finished"] .run-state-dot {
847
+ background: #15803d;
848
+ box-shadow: 0 0 0 4px rgba(21, 128, 61, 0.15);
849
+ }
850
+
851
+ .run-state[data-status="failed"] .run-state-dot,
852
+ .run-state[data-status="stopped"] .run-state-dot {
853
+ background: #b91c1c;
854
+ box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.13);
855
+ }
856
+
857
+ .run-state strong {
858
+ color: var(--ink);
859
+ }
860
+
861
+ .toast-region {
862
+ position: fixed;
863
+ right: 18px;
864
+ bottom: 18px;
865
+ z-index: 60;
866
+ display: grid;
867
+ width: min(360px, calc(100vw - 36px));
868
+ gap: 10px;
869
+ pointer-events: none;
870
+ }
871
+
872
+ .toast {
873
+ padding: 12px 14px;
874
+ border: 1px solid rgba(15, 118, 110, 0.22);
875
+ border-radius: 16px;
876
+ background: rgba(255, 253, 250, 0.96);
877
+ box-shadow: 0 18px 48px rgba(36, 28, 15, 0.16);
878
+ color: var(--ink);
879
+ font-size: 0.9rem;
880
+ pointer-events: auto;
881
+ }
882
+
883
+ .toast[data-kind="error"] {
884
+ border-color: rgba(185, 28, 28, 0.25);
885
+ background: rgba(254, 242, 242, 0.96);
886
+ }
887
+
809
888
  .logs {
810
889
  width: 100%;
811
890
  min-width: 0;
@@ -1313,6 +1392,8 @@ button.danger {
1313
1392
  .settings-modal-card {
1314
1393
  max-height: min(860px, calc(100vh - 28px));
1315
1394
  overflow: auto;
1395
+ display: grid;
1396
+ gap: 14px;
1316
1397
  }
1317
1398
 
1318
1399
  .settings-section {
@@ -1699,7 +1780,8 @@ button.danger {
1699
1780
  }
1700
1781
 
1701
1782
  .settings-grid,
1702
- .settings-checks {
1783
+ .settings-checks,
1784
+ .role-editor-grid {
1703
1785
  grid-template-columns: 1fr;
1704
1786
  }
1705
1787
  }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { SessionStore } from "../src/session-store.js";
8
8
  import { projectPaths, sessionStoreOptions } from "../src/project.js";
9
+ import { WebDatabase } from "../src/web-db.js";
9
10
 
10
11
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
12
  const fixtureMcpServer = path.join(repoRoot, "scripts", "fixtures", "mcp-stdio-smoke-server.mjs");
@@ -95,6 +96,9 @@ try {
95
96
  if (chatThreadIndex < 0 || chatPendingIndex < 0 || chatPendingIndex < chatThreadIndex) {
96
97
  throw new Error("pending message panel must render below the chat thread");
97
98
  }
99
+ for (const marker of ['id="new-session"', 'id="run-state"', 'id="toast-region"', 'id="chat-submit"']) {
100
+ if (!webAppHtml.includes(marker)) throw new Error(`web UI is missing ${marker}`);
101
+ }
98
102
 
99
103
  const config = await fetchJson("/api/config");
100
104
  if (!config.keyStatus?.mock) throw new Error("mock provider is not advertised by /api/config");
@@ -337,6 +341,35 @@ try {
337
341
  const changes = await fetchJson("/api/workspace/changes");
338
342
  if (!Array.isArray(changes.activity)) throw new Error("workspace changes endpoint returned an invalid payload");
339
343
 
344
+ const staleSessionId = "stale-running-smoke";
345
+ const staleStore = new SessionStore(paths.globalSessionsDir, staleSessionId, sessionStoreOptions(runtimeDir, staleSessionId));
346
+ await staleStore.ensure();
347
+ await staleStore.appendEvent("session.finished", {
348
+ result: "stale running session reconciled",
349
+ mode: "smoke",
350
+ });
351
+ const staleDb = new WebDatabase(runtimeDir);
352
+ staleDb.upsertSession({
353
+ sessionId: staleSessionId,
354
+ provider: "mock",
355
+ model: "mock-agent",
356
+ goal: "Synthetic stale running session.",
357
+ status: "running",
358
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
359
+ updatedAt: new Date(Date.now() - 30_000).toISOString(),
360
+ endedAt: "",
361
+ result: "",
362
+ error: "",
363
+ projectRoot: runtimeDir,
364
+ commandCwd: runtimeDir,
365
+ projectSessionsDir: paths.sessionsDir,
366
+ sessionDir: staleStore.sessionDir,
367
+ });
368
+ const reconciledStaleRun = await fetchJson(`/api/runs/${encodeURIComponent(staleSessionId)}`);
369
+ if (reconciledStaleRun.status !== "finished" || !reconciledStaleRun.result.includes("reconciled")) {
370
+ throw new Error(`orphaned running session was not reconciled: ${JSON.stringify(reconciledStaleRun)}`);
371
+ }
372
+
340
373
  const canvasRunStart = await fetchJson("/api/runs", {
341
374
  method: "POST",
342
375
  headers: { "Content-Type": "application/json" },
@@ -443,6 +476,7 @@ try {
443
476
  "POST /api/sessions/:id/auto-title",
444
477
  "DELETE /api/sessions/:id",
445
478
  "/api/workspace/changes",
479
+ "stale running status reconciliation",
446
480
  "/api/sessions/:id/artifacts",
447
481
  "/api/sessions/:id/artifacts/:artifactId",
448
482
  "/api/sessions/:id/artifacts/:artifactId/raw",
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { chromium } from "playwright";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-web-ui-"));
11
+ const agintiflowHome = path.join(runtimeDir, ".agintiflow-home");
12
+ const port = 47000 + Math.floor(Math.random() * 1000);
13
+ const baseUrl = `http://127.0.0.1:${port}`;
14
+ const server = spawn(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "web", "--port", String(port), "--host", "127.0.0.1"], {
15
+ cwd: runtimeDir,
16
+ env: {
17
+ ...process.env,
18
+ AGINTIFLOW_HOME: agintiflowHome,
19
+ AGINTIFLOW_RUNTIME_DIR: runtimeDir,
20
+ },
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ });
23
+
24
+ let stdout = "";
25
+ let stderr = "";
26
+ server.stdout.on("data", (chunk) => {
27
+ stdout += chunk.toString();
28
+ });
29
+ server.stderr.on("data", (chunk) => {
30
+ stderr += chunk.toString();
31
+ });
32
+
33
+ function delay(ms) {
34
+ return new Promise((resolve) => setTimeout(resolve, ms));
35
+ }
36
+
37
+ async function waitForHealth() {
38
+ const deadline = Date.now() + 15000;
39
+ while (Date.now() < deadline) {
40
+ if (server.exitCode !== null) break;
41
+ try {
42
+ const health = await fetch(`${baseUrl}/health`).then((response) => response.json());
43
+ if (health.ok) return health;
44
+ } catch {
45
+ await delay(250);
46
+ }
47
+ }
48
+ throw new Error(`web server did not become healthy. stdout=${stdout.slice(-500)} stderr=${stderr.slice(-500)}`);
49
+ }
50
+
51
+ async function waitForRunState(page, status, timeout = 20000) {
52
+ await page.waitForFunction(
53
+ (expected) => document.querySelector("#run-state")?.dataset.status === expected,
54
+ status,
55
+ { timeout }
56
+ );
57
+ }
58
+
59
+ async function waitForTerminalRunState(page, timeout = 30000) {
60
+ await page.waitForFunction(
61
+ () => ["finished", "failed", "stopped"].includes(document.querySelector("#run-state")?.dataset.status || ""),
62
+ null,
63
+ { timeout }
64
+ );
65
+ return page.locator("#run-state").evaluate((node) => node.dataset.status || "");
66
+ }
67
+
68
+ async function optionValues(page, selector) {
69
+ return page.locator(selector).evaluate((node) => [...node.options].map((option) => option.value));
70
+ }
71
+
72
+ let browser;
73
+
74
+ try {
75
+ await waitForHealth();
76
+ browser = await chromium.launch({ headless: true });
77
+ const page = await browser.newPage();
78
+ const runPayloads = [];
79
+ page.on("request", (request) => {
80
+ if (request.url().endsWith("/api/runs") && request.method() === "POST") {
81
+ runPayloads.push(request.postData() || "");
82
+ }
83
+ });
84
+ await page.goto(baseUrl, { waitUntil: "networkidle" });
85
+ await page.waitForSelector("#chat-input");
86
+
87
+ const initialSubmit = await page.locator("#chat-submit").innerText();
88
+ if (!/start new run/i.test(initialSubmit)) throw new Error(`composer did not default to new-run mode: ${initialSubmit}`);
89
+ if (await page.locator("#stop-run").isVisible()) throw new Error("stop button is visible before a run starts");
90
+
91
+ await page.selectOption("#routingMode", "manual");
92
+ await page.selectOption("#provider", "mock");
93
+ await page.selectOption("#model", "mock-agent");
94
+ await page.fill("#maxSteps", "4");
95
+ await page.fill("#chat-input", "Say hello from the web UI composer in one concise sentence.");
96
+ await page.click("#chat-submit");
97
+ await waitForRunState(page, "running");
98
+ if (!(await page.locator("#stop-run").isVisible())) throw new Error("stop button did not appear while run was active");
99
+ await page.click("#stop-run");
100
+ await waitForTerminalRunState(page);
101
+ if (await page.locator("#stop-run").isVisible()) throw new Error("stop button stayed visible after terminal run state");
102
+ if (!(await page.locator(".toast").first().isVisible().catch(() => false))) {
103
+ throw new Error("running status toast did not render");
104
+ }
105
+
106
+ await page.click("#new-session");
107
+ await waitForRunState(page, "idle");
108
+ const resetSubmit = await page.locator("#chat-submit").innerText();
109
+ if (!/start new run/i.test(resetSubmit)) throw new Error("new session did not reset composer to start mode");
110
+
111
+ await page.click("#open-settings");
112
+ await page.waitForSelector("#settings-modal[open]");
113
+ const routeProviders = await optionValues(page, "#routeProvider");
114
+ for (const provider of ["deepseek", "openai", "qwen", "venice", "mock"]) {
115
+ if (!routeProviders.includes(provider)) throw new Error(`settings route provider dropdown is missing ${provider}`);
116
+ }
117
+ const wrapperOptions = await optionValues(page, "#preferredWrapper");
118
+ for (const wrapper of ["codex", "claude", "gemini", "copilot", "qwen"]) {
119
+ if (!wrapperOptions.includes(wrapper)) throw new Error(`preferred wrapper dropdown is missing ${wrapper}`);
120
+ }
121
+ await page.click("#done-settings");
122
+
123
+ console.log(
124
+ JSON.stringify(
125
+ {
126
+ ok: true,
127
+ runtimeDir,
128
+ checks: [
129
+ "composer-starts-new-run",
130
+ "running-stop-button-visible",
131
+ "running-status-toast",
132
+ "terminal-stop-button-hidden",
133
+ "new-session-resets-scope",
134
+ "settings-provider-dropdowns",
135
+ "settings-wrapper-dropdowns",
136
+ ],
137
+ },
138
+ null,
139
+ 2
140
+ )
141
+ );
142
+ } finally {
143
+ await browser?.close().catch(() => {});
144
+ server.kill("SIGTERM");
145
+ await delay(150);
146
+ await fs.rm(runtimeDir, { recursive: true, force: true });
147
+ }
package/web.js CHANGED
@@ -105,18 +105,47 @@ async function loadStoredRun(sessionId) {
105
105
 
106
106
  const store = sessionStore(sessionId);
107
107
  const events = await store.loadEvents();
108
+ const terminalEvent = [...events]
109
+ .reverse()
110
+ .find((event) => ["session.finished", "session.failed", "session.stopped"].includes(event.type));
111
+ const orphanedRunning = meta.status === "running" && !runs.has(sessionId);
112
+ const reconciled = { ...meta };
113
+
114
+ if (orphanedRunning && terminalEvent) {
115
+ reconciled.status =
116
+ terminalEvent.type === "session.finished" ? "finished" : terminalEvent.type === "session.failed" ? "failed" : "stopped";
117
+ reconciled.endedAt = reconciled.endedAt || terminalEvent.timestamp || new Date().toISOString();
118
+ reconciled.updatedAt = terminalEvent.timestamp || reconciled.updatedAt || reconciled.endedAt;
119
+ reconciled.result =
120
+ reconciled.status === "finished" ? String(terminalEvent.data?.result || reconciled.result || "") : reconciled.result || "";
121
+ reconciled.error =
122
+ reconciled.status === "failed"
123
+ ? String(terminalEvent.data?.error || terminalEvent.data?.reason || reconciled.error || "Run failed.")
124
+ : reconciled.status === "stopped"
125
+ ? String(terminalEvent.data?.reason || reconciled.error || "Run stopped.")
126
+ : reconciled.error || "";
127
+ db.upsertSession(reconciled);
128
+ } else if (orphanedRunning) {
129
+ reconciled.status = "stopped";
130
+ reconciled.endedAt = reconciled.endedAt || new Date().toISOString();
131
+ reconciled.updatedAt = reconciled.endedAt;
132
+ reconciled.error =
133
+ reconciled.error ||
134
+ "Run is not active in this web process. It may have ended before the web server restarted.";
135
+ db.upsertSession(reconciled);
136
+ }
108
137
 
109
138
  return {
110
- sessionId: meta.sessionId,
111
- status: meta.status,
112
- provider: meta.provider,
113
- model: meta.model,
114
- goal: meta.goal,
115
- title: meta.title || "",
116
- startedAt: meta.startedAt,
117
- endedAt: meta.endedAt || null,
118
- result: meta.result || "",
119
- error: meta.error || "",
139
+ sessionId: reconciled.sessionId,
140
+ status: reconciled.status,
141
+ provider: reconciled.provider,
142
+ model: reconciled.model,
143
+ goal: reconciled.goal,
144
+ title: reconciled.title || "",
145
+ startedAt: reconciled.startedAt,
146
+ endedAt: reconciled.endedAt || null,
147
+ result: reconciled.result || "",
148
+ error: reconciled.error || "",
120
149
  logs: mapEventLogs(events).slice(-300),
121
150
  };
122
151
  }
@@ -562,8 +591,8 @@ async function ensureNotRunning(sessionId) {
562
591
  throw new Error("This session is already running.");
563
592
  }
564
593
 
565
- const meta = db.getSession(sessionId);
566
- if (meta?.status === "running") {
594
+ const stored = await loadStoredRun(sessionId);
595
+ if (stored?.status === "running") {
567
596
  throw new Error("This session is already running.");
568
597
  }
569
598
  }
@@ -717,12 +746,16 @@ function wireRun(record, config) {
717
746
  onEvent: (type, data = {}) => push("event", type, data),
718
747
  })
719
748
  .then((result) => {
720
- record.status = result?.stopped ? "stopped" : "finished";
749
+ record.status = result?.failed ? "failed" : result?.stopped ? "stopped" : "finished";
721
750
  record.result = result?.result || "";
722
- record.error = result?.stopped ? result.reason || "Run stopped before finish." : "";
751
+ record.error = result?.failed
752
+ ? result.reason || result.result || "Run failed."
753
+ : result?.stopped
754
+ ? result.reason || "Run stopped before finish."
755
+ : "";
723
756
  record.updatedAt = new Date().toISOString();
724
757
  record.endedAt = new Date().toISOString();
725
- push("session", result?.stopped ? "Run stopped" : "Run finished", {
758
+ push("session", result?.failed ? "Run failed" : result?.stopped ? "Run stopped" : "Run finished", {
726
759
  result: record.result,
727
760
  reason: result?.reason || "",
728
761
  });