@lazyingart/agintiflow 0.20.163 → 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.163",
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
  }
@@ -408,6 +408,25 @@ try {
408
408
  if (promptLayout.cursorRow < 0 || promptLayout.cursorColumn < 0) {
409
409
  throw new Error("terminal prompt layout returned an invalid cursor location");
410
410
  }
411
+ const cjkPrompt = "你好日本語";
412
+ const cjkPromptLayout = buildPromptLayout(cjkPrompt, cjkPrompt.length, 80, 24, { inputPadding: false });
413
+ if (cjkPromptLayout.cursorColumn !== cjkPromptLayout.rows[0].prefix.length + cellWidth(cjkPrompt)) {
414
+ throw new Error("terminal prompt layout did not place CJK/IME cursor at the visual end of input");
415
+ }
416
+ const mixedPrompt = "abc你好";
417
+ const mixedPromptLayout = buildPromptLayout(mixedPrompt, mixedPrompt.length, 80, 24, { inputPadding: false });
418
+ if (mixedPromptLayout.cursorColumn !== mixedPromptLayout.rows[0].prefix.length + cellWidth(mixedPrompt)) {
419
+ throw new Error("terminal prompt layout did not combine ASCII and CJK cursor cell widths correctly");
420
+ }
421
+ const wrappedCjkPrompt = "你".repeat(30);
422
+ const wrappedCjkLayout = buildPromptLayout(wrappedCjkPrompt, wrappedCjkPrompt.length, 50, 24, { inputPadding: false });
423
+ if (
424
+ wrappedCjkLayout.rows.length < 2 ||
425
+ wrappedCjkLayout.rows.some((row) => cellWidth(row.text) > row.innerWidth) ||
426
+ wrappedCjkLayout.cursorColumn !== wrappedCjkLayout.rows.at(-1).prefix.length + cellWidth(wrappedCjkLayout.rows.at(-1).text)
427
+ ) {
428
+ throw new Error("terminal prompt layout did not wrap CJK text by terminal cell width");
429
+ }
411
430
  const promptCursorMoveBefore = buildPromptLayout("smooth typing", 13, 90, 24, { commandCwd: "/tmp/aginti-project" });
412
431
  const promptCursorMoveAfter = buildPromptLayout("smooth typing", 4, 90, 24, { commandCwd: "/tmp/aginti-project" });
413
432
  const cursorOnlySequence = buildPromptRenderSequence(promptCursorMoveAfter, {
@@ -860,6 +879,8 @@ try {
860
879
  "workspace-patch-event-render",
861
880
  "large-launch-header",
862
881
  "prompt-layout",
882
+ "prompt-cjk-ime-cursor-column",
883
+ "prompt-cjk-cell-width-wrap",
863
884
  "prompt-redraw-fast-path",
864
885
  "committed-user-no-cwd-footer",
865
886
  "cli-i18n",
@@ -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
+ }
@@ -321,6 +321,7 @@ function charCellWidth(char = "") {
321
321
  const code = char.codePointAt(0);
322
322
  if (!code) return 0;
323
323
  if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0;
324
+ if (code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f)) return 0;
324
325
  if (
325
326
  (code >= 0x0300 && code <= 0x036f) ||
326
327
  (code >= 0x1ab0 && code <= 0x1aff) ||
@@ -355,13 +356,116 @@ function stringCellWidth(value = "") {
355
356
  return [...String(value || "")].reduce((sum, char) => sum + charCellWidth(char), 0);
356
357
  }
357
358
 
359
+ const graphemeSegmenter = (() => {
360
+ try {
361
+ return typeof Intl?.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
362
+ } catch {
363
+ return null;
364
+ }
365
+ })();
366
+
367
+ function graphemeSegments(value = "", baseOffset = 0) {
368
+ const text = String(value || "");
369
+ if (!text) return [];
370
+ if (graphemeSegmenter) {
371
+ return [...graphemeSegmenter.segment(text)].map((item) => ({
372
+ text: item.segment,
373
+ start: baseOffset + item.index,
374
+ end: baseOffset + item.index + item.segment.length,
375
+ width: stringCellWidth(item.segment),
376
+ }));
377
+ }
378
+ const result = [];
379
+ let offset = 0;
380
+ for (const char of text) {
381
+ result.push({
382
+ text: char,
383
+ start: baseOffset + offset,
384
+ end: baseOffset + offset + char.length,
385
+ width: stringCellWidth(char),
386
+ });
387
+ offset += char.length;
388
+ }
389
+ return result;
390
+ }
391
+
392
+ function normalizeCursorBoundary(buffer = "", cursor = 0) {
393
+ const safeBuffer = String(buffer || "");
394
+ const safeCursor = clamp(Number(cursor) || 0, 0, safeBuffer.length);
395
+ for (const segment of graphemeSegments(safeBuffer)) {
396
+ if (safeCursor <= segment.start) return segment.start;
397
+ if (safeCursor < segment.end) return segment.start;
398
+ }
399
+ return safeCursor;
400
+ }
401
+
402
+ function previousGraphemeBoundary(buffer = "", cursor = 0) {
403
+ const safeBuffer = String(buffer || "");
404
+ const safeCursor = clamp(Number(cursor) || 0, 0, safeBuffer.length);
405
+ let previous = 0;
406
+ for (const segment of graphemeSegments(safeBuffer)) {
407
+ if (segment.end >= safeCursor) return segment.start;
408
+ previous = segment.start;
409
+ }
410
+ return safeCursor > 0 ? previous : 0;
411
+ }
412
+
413
+ function nextGraphemeBoundary(buffer = "", cursor = 0) {
414
+ const safeBuffer = String(buffer || "");
415
+ const safeCursor = clamp(Number(cursor) || 0, 0, safeBuffer.length);
416
+ for (const segment of graphemeSegments(safeBuffer)) {
417
+ if (segment.end > safeCursor) return segment.end;
418
+ }
419
+ return safeBuffer.length;
420
+ }
421
+
422
+ function takeCellWidthChunk(text = "", startOffset = 0, maxCells = 1) {
423
+ let used = 0;
424
+ let chunk = "";
425
+ let endOffset = startOffset;
426
+ for (const segment of graphemeSegments(String(text || "").slice(startOffset), startOffset)) {
427
+ const next = used + segment.width;
428
+ if (chunk && next > maxCells) break;
429
+ if (!chunk && segment.width > maxCells && maxCells > 0) {
430
+ chunk += segment.text;
431
+ endOffset = segment.end;
432
+ used = next;
433
+ break;
434
+ }
435
+ if (next > maxCells) break;
436
+ chunk += segment.text;
437
+ endOffset = segment.end;
438
+ used = next;
439
+ }
440
+ return { text: chunk, endOffset, cellWidth: used };
441
+ }
442
+
443
+ function rowCellOffset(buffer = "", row = {}, cursor = 0) {
444
+ const safeCursor = clamp(Number(cursor) || 0, row.start || 0, row.end || 0);
445
+ return stringCellWidth(String(buffer || "").slice(row.start || 0, safeCursor));
446
+ }
447
+
448
+ function cursorForRowCellColumn(buffer = "", row = {}, column = 0) {
449
+ const target = Math.max(Number(column) || 0, 0);
450
+ let used = 0;
451
+ let best = row.start || 0;
452
+ for (const segment of graphemeSegments(String(buffer || "").slice(row.start || 0, row.end || 0), row.start || 0)) {
453
+ const next = used + segment.width;
454
+ if (next > target) return segment.start;
455
+ best = segment.end;
456
+ if (next === target) return best;
457
+ used = next;
458
+ }
459
+ return best;
460
+ }
461
+
358
462
  function sliceToVisualWidth(value = "", width = 0) {
359
463
  let used = 0;
360
464
  let result = "";
361
- for (const char of String(value || "")) {
362
- const next = used + charCellWidth(char);
465
+ for (const segment of graphemeSegments(value)) {
466
+ const next = used + segment.width;
363
467
  if (next > width) break;
364
- result += char;
468
+ result += segment.text;
365
469
  used = next;
366
470
  }
367
471
  return result;
@@ -944,7 +1048,7 @@ function promptVisibleWindow(rows, cursorRow, height = terminalHeight()) {
944
1048
 
945
1049
  export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth(), height = terminalHeight(), options = {}) {
946
1050
  const safeBuffer = String(buffer || "");
947
- const safeCursor = clamp(Number(cursor) || 0, 0, safeBuffer.length);
1051
+ const safeCursor = normalizeCursorBoundary(safeBuffer, cursor);
948
1052
  const lineWidth = editorWidth(width);
949
1053
  const firstPrefix = labelText("user>", { prompt: true });
950
1054
  const nextPrefix = labelText("...", { prompt: true });
@@ -972,18 +1076,19 @@ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth
972
1076
  while (localOffset < text.length) {
973
1077
  const prefix = lineIndex === 0 && localOffset === 0 ? firstPrefix : nextPrefix;
974
1078
  const innerWidth = prefix === firstPrefix ? firstInnerWidth : nextInnerWidth;
975
- const chunk = text.slice(localOffset, localOffset + innerWidth);
1079
+ const chunk = takeCellWidthChunk(text, localOffset, innerWidth);
976
1080
  rows.push({
977
1081
  prefix,
978
- text: chunk,
1082
+ text: chunk.text,
979
1083
  start: line.start + localOffset,
980
- end: line.start + localOffset + chunk.length,
1084
+ end: line.start + chunk.endOffset,
1085
+ textCellWidth: chunk.cellWidth,
981
1086
  innerWidth,
982
1087
  lineStart: line.start,
983
1088
  lineEnd: line.end,
984
1089
  hasNewline: line.hasNewline,
985
1090
  });
986
- localOffset += chunk.length;
1091
+ localOffset = chunk.endOffset;
987
1092
  }
988
1093
  }
989
1094
 
@@ -1001,7 +1106,7 @@ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth
1001
1106
  }
1002
1107
 
1003
1108
  const last = rows[rows.length - 1];
1004
- if (safeCursor === safeBuffer.length && last.end === safeCursor && last.text.length >= last.innerWidth) {
1109
+ if (safeCursor === safeBuffer.length && last.end === safeCursor && (last.textCellWidth || stringCellWidth(last.text)) >= last.innerWidth) {
1005
1110
  rows.push({
1006
1111
  prefix: nextPrefix,
1007
1112
  text: "",
@@ -1021,13 +1126,13 @@ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth
1021
1126
  const next = rows[index + 1];
1022
1127
  if (safeCursor < row.end) {
1023
1128
  cursorRow = index;
1024
- cursorColumn = row.prefix.length + safeCursor - row.start;
1129
+ cursorColumn = stringCellWidth(row.prefix) + rowCellOffset(safeBuffer, row, safeCursor);
1025
1130
  break;
1026
1131
  }
1027
1132
  if (safeCursor === row.end) {
1028
- if (next && next.start === safeCursor && row.text.length >= row.innerWidth) continue;
1133
+ if (next && next.start === safeCursor && (row.textCellWidth || stringCellWidth(row.text)) >= row.innerWidth) continue;
1029
1134
  cursorRow = index;
1030
- cursorColumn = row.prefix.length + safeCursor - row.start;
1135
+ cursorColumn = stringCellWidth(row.prefix) + rowCellOffset(safeBuffer, row, safeCursor);
1031
1136
  break;
1032
1137
  }
1033
1138
  }
@@ -1102,15 +1207,18 @@ function cursorLocation(layout, cursor) {
1102
1207
  for (let index = 0; index < layout.rows.length; index += 1) {
1103
1208
  const row = layout.rows[index];
1104
1209
  const next = layout.rows[index + 1];
1105
- if (cursor < row.end) return { rowIndex: index, column: cursor - row.start };
1210
+ if (cursor < row.end) {
1211
+ const localCursor = clamp(cursor - row.start, 0, row.text.length);
1212
+ return { rowIndex: index, column: stringCellWidth(row.text.slice(0, localCursor)) };
1213
+ }
1106
1214
  if (cursor === row.end) {
1107
- if (next && next.start === cursor && row.text.length >= row.innerWidth) continue;
1108
- return { rowIndex: index, column: cursor - row.start };
1215
+ if (next && next.start === cursor && (row.textCellWidth || stringCellWidth(row.text)) >= row.innerWidth) continue;
1216
+ return { rowIndex: index, column: row.textCellWidth || stringCellWidth(row.text) };
1109
1217
  }
1110
1218
  }
1111
1219
  const rowIndex = Math.max(layout.rows.length - 1, 0);
1112
1220
  const row = layout.rows[rowIndex];
1113
- return { rowIndex, column: Math.max(row.end - row.start, 0) };
1221
+ return { rowIndex, column: row.textCellWidth || stringCellWidth(row.text) };
1114
1222
  }
1115
1223
 
1116
1224
  function clearRenderedPrompt(previous) {
@@ -1217,17 +1325,21 @@ function insertAt(buffer, cursor, text) {
1217
1325
 
1218
1326
  function removeBefore(buffer, cursor) {
1219
1327
  if (cursor <= 0) return { buffer, cursor };
1328
+ const safeCursor = normalizeCursorBoundary(buffer, cursor);
1329
+ const start = previousGraphemeBoundary(buffer, safeCursor);
1220
1330
  return {
1221
- buffer: `${buffer.slice(0, cursor - 1)}${buffer.slice(cursor)}`,
1222
- cursor: cursor - 1,
1331
+ buffer: `${buffer.slice(0, start)}${buffer.slice(safeCursor)}`,
1332
+ cursor: start,
1223
1333
  };
1224
1334
  }
1225
1335
 
1226
1336
  function removeAt(buffer, cursor) {
1227
1337
  if (cursor >= buffer.length) return { buffer, cursor };
1338
+ const safeCursor = normalizeCursorBoundary(buffer, cursor);
1339
+ const end = nextGraphemeBoundary(buffer, safeCursor);
1228
1340
  return {
1229
- buffer: `${buffer.slice(0, cursor)}${buffer.slice(cursor + 1)}`,
1230
- cursor,
1341
+ buffer: `${buffer.slice(0, safeCursor)}${buffer.slice(end)}`,
1342
+ cursor: safeCursor,
1231
1343
  };
1232
1344
  }
1233
1345
 
@@ -1384,7 +1496,7 @@ function readTtyPrompt(options = {}) {
1384
1496
 
1385
1497
  const setBuffer = (nextBuffer, nextCursor = nextBuffer.length, { keepSuggestionAnchor = false, keepHistoryMode = false } = {}) => {
1386
1498
  buffer = nextBuffer;
1387
- cursor = clamp(nextCursor, 0, buffer.length);
1499
+ cursor = normalizeCursorBoundary(buffer, nextCursor);
1388
1500
  preferredColumn = null;
1389
1501
  if (!keepHistoryMode) promptHistory.resetBrowsing();
1390
1502
  if (!keepSuggestionAnchor) {
@@ -1422,7 +1534,7 @@ function readTtyPrompt(options = {}) {
1422
1534
  if (targetRowIndex < 0 || targetRowIndex >= layout.rows.length) return;
1423
1535
  const currentColumn = preferredColumn ?? location.column;
1424
1536
  const targetRow = layout.rows[targetRowIndex];
1425
- cursor = targetRow.start + Math.min(currentColumn, targetRow.end - targetRow.start);
1537
+ cursor = cursorForRowCellColumn(buffer, targetRow, currentColumn);
1426
1538
  promptHistory.resetBrowsing();
1427
1539
  preferredColumn = currentColumn;
1428
1540
  redraw();
@@ -1467,7 +1579,7 @@ function readTtyPrompt(options = {}) {
1467
1579
  if (key.name === "left") {
1468
1580
  if (suggestionModeActive() && cycleSuggestion(-1)) return;
1469
1581
  promptHistory.resetBrowsing();
1470
- cursor = Math.max(cursor - 1, 0);
1582
+ cursor = previousGraphemeBoundary(buffer, cursor);
1471
1583
  preferredColumn = null;
1472
1584
  redraw();
1473
1585
  return;
@@ -1475,7 +1587,7 @@ function readTtyPrompt(options = {}) {
1475
1587
  if (key.name === "right") {
1476
1588
  if (suggestionModeActive() && cycleSuggestion(1)) return;
1477
1589
  promptHistory.resetBrowsing();
1478
- cursor = Math.min(cursor + 1, buffer.length);
1590
+ cursor = nextGraphemeBoundary(buffer, cursor);
1479
1591
  preferredColumn = null;
1480
1592
  redraw();
1481
1593
  return;
@@ -1681,7 +1793,7 @@ class LiveRunInput {
1681
1793
 
1682
1794
  setBuffer(nextBuffer, nextCursor = nextBuffer.length) {
1683
1795
  this.buffer = String(nextBuffer || "");
1684
- this.cursor = clamp(nextCursor, 0, this.buffer.length);
1796
+ this.cursor = normalizeCursorBoundary(this.buffer, nextCursor);
1685
1797
  this.preferredColumn = null;
1686
1798
  this.redraw();
1687
1799
  }
@@ -1707,7 +1819,7 @@ class LiveRunInput {
1707
1819
  if (targetRowIndex < 0 || targetRowIndex >= layout.rows.length) return;
1708
1820
  const currentColumn = this.preferredColumn ?? location.column;
1709
1821
  const targetRow = layout.rows[targetRowIndex];
1710
- this.cursor = targetRow.start + Math.min(currentColumn, targetRow.end - targetRow.start);
1822
+ this.cursor = cursorForRowCellColumn(this.buffer, targetRow, currentColumn);
1711
1823
  this.preferredColumn = currentColumn;
1712
1824
  this.redraw();
1713
1825
  }
@@ -1854,13 +1966,13 @@ class LiveRunInput {
1854
1966
  return;
1855
1967
  }
1856
1968
  if (key.name === "left") {
1857
- this.cursor = Math.max(this.cursor - 1, 0);
1969
+ this.cursor = previousGraphemeBoundary(this.buffer, this.cursor);
1858
1970
  this.preferredColumn = null;
1859
1971
  this.redraw();
1860
1972
  return;
1861
1973
  }
1862
1974
  if (key.name === "right") {
1863
- this.cursor = Math.min(this.cursor + 1, this.buffer.length);
1975
+ this.cursor = nextGraphemeBoundary(this.buffer, this.cursor);
1864
1976
  this.preferredColumn = null;
1865
1977
  this.redraw();
1866
1978
  return;
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
  });