micro-models-agent 2.4.0 → 2.5.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to Micro Models Agent (MMA) will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [2.5.0] - 2026-09-24
8
+
9
+ ### Added
10
+
11
+ - **web file viewer** (`src/modules/webui/file-api.ts`): `GET /api/file?path=<p>` serves a workspace text file for the chat file cards' Open link — resolved against the workspace (lexical + `realpath` guards, so symlinks/junctions cannot escape), protected paths (`.git/`, `.env*`, keys) refused, 512 KB cap, binary refused. UTF-16 LE/BE BOM and UTF-8 BOM are decoded; errors are human-readable `text/plain` (the endpoint opens as a browser tab).
12
+ - **web cookie auth** (`src/modules/webui/util.ts`, `server.ts`): authenticated requests set an `HttpOnly; SameSite=Strict` `mma_web_auth` cookie and `checkAuth` accepts it, so a plain `<a target="_blank">` navigation authenticates without the token in the URL.
13
+ - **web session persistence** (`assets/app.js`): the active session is mirrored into `?session=` (`history.replaceState`, token preserved) and restored on reload after the first state refresh.
14
+
15
+ ### Fixed
16
+
17
+ - **web reasoning text** (`src/modules/webui/forward.ts`, `history-api.ts`): ANSI escape sequences (`ESC[2m…`) are stripped from streamed/replayed text, meta and tool output — reasoning no longer renders as literal `\u001b[2m…` garbage on the model's own words.
18
+ - **web file card** (`assets/app.js`, `styles.css`): the card stacks vertically (path on its own line) and its `open` control is a real `<a target="_blank">` link that opens the file in a new tab.
19
+
20
+ ### Changed
21
+
22
+ - **dedicated reasoning channel** (`src/core/agent.ts`, `src/modules/webui/forward.ts`, `src/cli/repl.ts`, `src/cli/main.ts`): model reasoning now travels on its own `onReasoning` callback and SSE `reasoning` event (no longer pre-dimmed and no longer mixed into the generic `meta` channel); the terminal renders it via `renderer.reasoning`.
23
+ - **web reasoning collapsed by default** (`assets/app.js`): `thinking` blocks (live and replayed) start collapsed; generic `meta` (status, context stats, compaction, tool summaries, warnings) renders as always-visible `.metaln` lines instead of being folded into the reasoning block.
24
+
7
25
  ## [2.4.0] - 2026-09-24
8
26
 
9
27
  ### Added
@@ -53,6 +53,9 @@
53
53
  };
54
54
 
55
55
  const FILE_TOOLS = ["read_file", "write_file", "edit_file", "list_dir", "delete_file", "move_file", "file_info"];
56
+ // Tools whose `path` argument points at a readable file the viewer can open.
57
+ // `list_dir` is a directory, `delete_file`/`move_file` leave the file gone.
58
+ const OPENABLE_TOOLS = ["read_file", "write_file", "edit_file", "file_info"];
56
59
  const IMAGE_TOOLS = ["attach_image", "browser"];
57
60
  const LINK_TOOLS = ["web_fetch", "download_file"];
58
61
 
@@ -83,7 +86,7 @@
83
86
 
84
87
  let curText = null;
85
88
  let curTextNodes = [];
86
- let curMeta = null;
89
+ let curThink = null;
87
90
  let lastPlanSig = null;
88
91
  let lastUsageSig = null;
89
92
  let lastEnvSig = null;
@@ -365,6 +368,7 @@
365
368
 
366
369
  function showEmptyFeed() {
367
370
  msgs.replaceChildren(emptyView());
371
+ syncUrl();
368
372
  }
369
373
 
370
374
  function clearFeed(feed) {
@@ -530,8 +534,8 @@
530
534
  clearBusy(feed);
531
535
  if (!feed) return;
532
536
  clearEmpty(feed);
533
- if (curMeta) {
534
- finishMeta();
537
+ if (curThink) {
538
+ finishThink();
535
539
  }
536
540
  if (curText == null) {
537
541
  flushRun();
@@ -542,39 +546,60 @@
542
546
  renderTextSegment(feed, curText);
543
547
  }
544
548
 
545
- function finishMeta() {
546
- if (curMeta && curMeta.startedAt) {
547
- const dur = curMeta.head.querySelector(".dur");
548
- if (dur) dur.textContent = "· " + ((Date.now() - curMeta.startedAt) / 1000).toFixed(1) + "s";
549
+ function finishThink() {
550
+ if (curThink && curThink.startedAt) {
551
+ const dur = curThink.head.querySelector(".dur");
552
+ if (dur) dur.textContent = "· " + ((Date.now() - curThink.startedAt) / 1000).toFixed(1) + "s";
549
553
  }
550
- curMeta = null;
554
+ curThink = null;
551
555
  }
552
556
 
553
- function onMeta(chunk, sid) {
557
+ // Build the collapsible reasoning block. The global `.think-h` click handler
558
+ // depends on this exact structure: wrap > [head, body], body carries `hidden`.
559
+ function buildThinkBlock(withDur) {
560
+ const wrap = el("div", "think");
561
+ const head = el("button", "think-h");
562
+ head.type = "button";
563
+ head.setAttribute("aria-expanded", "false");
564
+ head.appendChild(el("span", "g", "✦"));
565
+ head.appendChild(el("span", "lab", t("web.action.thinking")));
566
+ if (withDur) head.appendChild(el("span", "dur", ""));
567
+ const tog = el("span", "tog");
568
+ setIcon(tog, "chevron-right", t("web.action.show"));
569
+ head.appendChild(tog);
570
+ const body = el("div", "think-body hidden");
571
+ wrap.appendChild(head);
572
+ wrap.appendChild(body);
573
+ return { wrap, head, body };
574
+ }
575
+
576
+ function onReasoning(chunk, sid) {
554
577
  const feed = feedForEvent(sid);
555
578
  clearBusy(feed);
556
579
  if (!feed) return;
557
580
  clearEmpty(feed);
558
581
  curText = null;
559
- if (!curMeta) {
582
+ if (!curThink) {
560
583
  flushRun();
561
- const wrap = el("div", "think");
562
- const head = el("button", "think-h");
563
- head.type = "button";
564
- head.setAttribute("aria-expanded", "true");
565
- head.appendChild(el("span", "g", "✦"));
566
- head.appendChild(el("span", "lab", t("web.action.thinking")));
567
- head.appendChild(el("span", "dur", ""));
568
- const tog = el("span", "tog");
569
- setIcon(tog, "chevron-down", t("web.action.hide"));
570
- head.appendChild(tog);
571
- const body = el("div", "think-body");
572
- wrap.appendChild(head);
573
- wrap.appendChild(body);
584
+ const { wrap, head, body } = buildThinkBlock(true);
574
585
  appendTo(feed, wrap);
575
- curMeta = { wrap, head, body, startedAt: Date.now() };
586
+ curThink = { wrap, head, body, startedAt: Date.now() };
576
587
  }
577
- curMeta.body.textContent += chunk;
588
+ curThink.body.textContent += chunk;
589
+ if (feed === activeFeed()) scroll();
590
+ }
591
+
592
+ // Generic agent status (context stats, compaction, MoE status, warnings):
593
+ // always visible, one dim line per chunk, never collapsed.
594
+ function onMeta(chunk, sid) {
595
+ const feed = feedForEvent(sid);
596
+ clearBusy(feed);
597
+ const text = (chunk || "").trim();
598
+ if (!feed || !text) return;
599
+ clearEmpty(feed);
600
+ curText = null;
601
+ const warn = text.indexOf("⚠") !== -1 || text.indexOf("✗") !== -1;
602
+ appendTo(feed, el("div", warn ? "metaln warn" : "metaln", text));
578
603
  if (feed === activeFeed()) scroll();
579
604
  }
580
605
 
@@ -593,7 +618,7 @@
593
618
  if (!feed) return;
594
619
  clearEmpty(feed);
595
620
  curText = null;
596
- finishMeta();
621
+ finishThink();
597
622
  pendingArgs.set(data.toolCallId, data.args || {});
598
623
  const line = el("div", "act " + categoryOf(data.name));
599
624
  line.appendChild(el("span", "g", "◇"));
@@ -652,10 +677,22 @@
652
677
  const path = args.path || args.file || args.dir;
653
678
  if (!path) return;
654
679
  const card = el("div", "filecard");
655
- card.appendChild(el("span", "g", "◇"));
656
- card.appendChild(el("span", "name", path));
657
- card.appendChild(el("span", "", labelOf(name)));
658
- card.appendChild(el("span", "meta", t("web.card.view")));
680
+ const top = el("div", "fc-top");
681
+ top.appendChild(el("span", "g", "◇"));
682
+ const nameEl = el("span", "name", path);
683
+ nameEl.title = path;
684
+ top.appendChild(nameEl);
685
+ card.appendChild(top);
686
+ const bottom = el("div", "fc-bottom");
687
+ bottom.appendChild(el("span", "lab", labelOf(name)));
688
+ if (OPENABLE_TOOLS.indexOf(name) !== -1) {
689
+ const openLink = el("a", "fc-open", t("web.card.open"));
690
+ openLink.href = "/api/file?path=" + encodeURIComponent(path);
691
+ openLink.target = "_blank";
692
+ openLink.rel = "noopener noreferrer";
693
+ bottom.appendChild(openLink);
694
+ }
695
+ card.appendChild(bottom);
659
696
  appendTo(feed, card);
660
697
  return;
661
698
  }
@@ -711,7 +748,7 @@
711
748
  state.busy = false;
712
749
  const feed = feedForEvent(data.sessionId);
713
750
  clearBusy(feed);
714
- finishMeta();
751
+ finishThink();
715
752
  curText = null;
716
753
  flushRun();
717
754
  const owner = tabs.find((x) => x.id === data.sessionId);
@@ -782,19 +819,8 @@
782
819
  }
783
820
 
784
821
  function renderThinkBlock(feed, text) {
785
- const wrap = el("div", "think");
786
- const head = el("button", "think-h");
787
- head.type = "button";
788
- head.setAttribute("aria-expanded", "true");
789
- head.appendChild(el("span", "g", "✦"));
790
- head.appendChild(el("span", "lab", t("web.action.thinking")));
791
- const tog = el("span", "tog");
792
- setIcon(tog, "chevron-down", t("web.action.hide"));
793
- head.appendChild(tog);
794
- const body = el("div", "think-body");
822
+ const { wrap, body } = buildThinkBlock(false);
795
823
  body.textContent = text || "";
796
- wrap.appendChild(head);
797
- wrap.appendChild(body);
798
824
  feed.appendChild(wrap);
799
825
  }
800
826
 
@@ -867,7 +893,7 @@
867
893
  }
868
894
 
869
895
  function feedHasContent(feed) {
870
- return !!feed.querySelector(".u, .ans, .code, .act, .think, .errline, .sysline");
896
+ return !!feed.querySelector(".u, .ans, .code, .act, .think, .metaln, .errline, .sysline");
871
897
  }
872
898
 
873
899
  async function loadHistory(tab) {
@@ -940,6 +966,16 @@
940
966
  { passive: false }
941
967
  );
942
968
 
969
+ // Mirror the active session into `?session=` so a page refresh restores it.
970
+ // Only the active tab is persisted; the token param is preserved as-is.
971
+ function syncUrl() {
972
+ const url = new URL(location.href);
973
+ const tab = tabs.find((x) => x.id === activeTabId);
974
+ if (tab && tab.kind === "session") url.searchParams.set("session", tab.id);
975
+ else url.searchParams.delete("session");
976
+ history.replaceState(null, "", url);
977
+ }
978
+
943
979
  function openTab(id, name, kind, server) {
944
980
  let tab = tabs.find((x) => x.id === id);
945
981
  if (!tab) {
@@ -967,9 +1003,14 @@
967
1003
  tab.historyLoaded = true;
968
1004
  }
969
1005
  if (server && tab.kind === "session") {
1006
+ // A tab auto-opened as "live" (server=false) sets historyLoaded=true; when
1007
+ // it is opened from the server afterward we must actually fetch the
1008
+ // transcript, or a reload of the active session shows an empty feed.
1009
+ if (!feedHasContent(tab.feed)) tab.historyLoaded = false;
970
1010
  api("/api/sessions/activate", { method: "POST", body: { id } }).then(() => loadSessions());
971
1011
  loadHistory(tab);
972
1012
  }
1013
+ syncUrl();
973
1014
  }
974
1015
 
975
1016
  function closeTab(id) {
@@ -990,6 +1031,7 @@
990
1031
  }
991
1032
  renderTabstrip();
992
1033
  renderSessionsDropdown();
1034
+ syncUrl();
993
1035
  }
994
1036
 
995
1037
  async function newChatTab() {
@@ -1390,7 +1432,7 @@
1390
1432
  }
1391
1433
  clearEmpty(feed);
1392
1434
  curText = null;
1393
- finishMeta();
1435
+ finishThink();
1394
1436
  if (text.startsWith("/")) {
1395
1437
  appendTo(feed, el("div", "u", text));
1396
1438
  clearInput();
@@ -2106,6 +2148,10 @@
2106
2148
  const d = parse(e);
2107
2149
  if (d) onMeta(d.chunk || "", d.sessionId);
2108
2150
  });
2151
+ es.addEventListener("reasoning", (e) => {
2152
+ const d = parse(e);
2153
+ if (d) onReasoning(d.chunk || "", d.sessionId);
2154
+ });
2109
2155
  es.addEventListener("phase", (e) => {
2110
2156
  const d = parse(e);
2111
2157
  if (d) onPhase(d.phase, d.sessionId);
@@ -3027,6 +3073,8 @@
3027
3073
  }
3028
3074
 
3029
3075
  async function boot() {
3076
+ // Read the requested session before `showEmptyFeed()` → `syncUrl()` can strip it.
3077
+ const initialSession = new URLSearchParams(location.search).get("session");
3030
3078
  await loadI18n();
3031
3079
  await loadCommands();
3032
3080
  applyTheme(currentTheme());
@@ -3042,6 +3090,12 @@
3042
3090
  await loadSkills();
3043
3091
  await loadPlugins();
3044
3092
  await refresh();
3093
+ // Restore the session from the URL AFTER the first refresh: `renderState`
3094
+ // may auto-open the server's active session, and the requested one must win.
3095
+ if (initialSession) {
3096
+ const meta = state.sessions.find((s) => s.id === initialSession);
3097
+ if (meta) openTab(initialSession, meta.name || String(initialSession), "session", true);
3098
+ }
3045
3099
  connectSSE();
3046
3100
  setInterval(refresh, 2000);
3047
3101
  setInterval(loadSessions, 15000);
@@ -239,6 +239,11 @@ body {
239
239
  .think-h .tog:hover { color: var(--dim-a); }
240
240
  .think-body { color: var(--dim-c); padding-left: 18px; white-space: pre-wrap; font-size: 12.5px; overflow-wrap: anywhere; }
241
241
  .think-body.hidden { display: none; }
242
+ .metaln {
243
+ color: var(--dim-c); padding-left: 26px; white-space: pre-wrap;
244
+ font-size: 12.5px; overflow-wrap: anywhere;
245
+ }
246
+ .metaln.warn { color: var(--yellow); }
242
247
  .act { display: flex; gap: 8px; min-width: 0; }
243
248
  .act .lab { white-space: nowrap; }
244
249
  .act .args { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
@@ -298,14 +303,27 @@ body.light .code { border: 1px solid var(--border); }
298
303
  body.light .imgcard .ph { background: var(--ph-light); }
299
304
  .imgcard .cap { padding: 6px 10px; color: var(--dim-a); font-size: 12px; }
300
305
  .filecard {
301
- margin-left: 26px; padding: 7px 12px; border-radius: 4px;
306
+ margin-left: 26px; padding: 8px 12px; border-radius: 6px;
302
307
  background: var(--surface); border: 1px solid var(--border-2); color: var(--muted);
303
- display: flex; gap: 10px; align-items: baseline; font-size: 12.5px;
304
- max-width: 480px; min-width: 0;
308
+ display: flex; flex-direction: column; gap: 6px; align-items: stretch;
309
+ font-size: 12.5px; max-width: 480px; min-width: 0;
305
310
  }
311
+ .filecard .fc-top { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
306
312
  .filecard .g { color: var(--cyan); flex: 0 0 auto; }
307
- .filecard .name { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
308
- .filecard .meta { color: var(--dim-b); margin-left: auto; flex: 0 0 auto; }
313
+ .filecard .name {
314
+ color: var(--fg); min-width: 0; overflow-wrap: anywhere; word-break: break-word;
315
+ }
316
+ .filecard .fc-bottom {
317
+ display: flex; gap: 10px; align-items: center; justify-content: space-between;
318
+ }
319
+ .filecard .lab { color: var(--dim-a); min-width: 0; overflow-wrap: anywhere; }
320
+ .filecard .fc-open {
321
+ flex: 0 0 auto; font: inherit; font-size: 12px; line-height: 1.4;
322
+ color: var(--accent); background: transparent; border: 1px solid var(--border-2);
323
+ border-radius: 4px; padding: 2px 10px; cursor: pointer; text-decoration: none;
324
+ }
325
+ .filecard .fc-open:hover { border-color: var(--accent); text-decoration: none; }
326
+ .filecard .fc-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
309
327
  .linkline { margin-left: 26px; min-width: 0; overflow-wrap: anywhere; }
310
328
  .linkline a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; word-break: break-word; }
311
329
  .linkline a:hover { text-decoration: underline; }
package/dist/i18n/en.json CHANGED
@@ -1166,7 +1166,7 @@
1166
1166
  "web.tool.output_few": "output · {count} chars",
1167
1167
  "web.tool.output_many": "output · {count} chars",
1168
1168
  "web.tool.collapse": "collapse",
1169
- "web.card.view": "view ↗",
1169
+ "web.card.open": "open ↗",
1170
1170
  "web.card.screenshot": "screenshot",
1171
1171
  "web.card.attached_image": "attached image",
1172
1172
  "web.card.browser_viewport": "browser · viewport",
@@ -1174,5 +1174,12 @@
1174
1174
  "web.question.send": "send",
1175
1175
  "web.question.answered": "answered",
1176
1176
  "web.question.multi_hint": "select one or more",
1177
- "web.misc.command_unavailable": "command unavailable"
1177
+ "web.misc.command_unavailable": "command unavailable",
1178
+ "web.file.error.missing_path": "No file path was given.",
1179
+ "web.file.error.outside_workspace": "This file is outside the agent workspace and cannot be opened.",
1180
+ "web.file.error.protected_path": "This file is protected (secrets/VCS) and cannot be opened.",
1181
+ "web.file.error.too_large": "File is too large to open in the browser ({limit} bytes max).",
1182
+ "web.file.error.binary_file": "This looks like a binary file and cannot be shown as text.",
1183
+ "web.file.error.not_found": "File not found (it may be a directory or it was deleted).",
1184
+ "web.file.error.workspace_unavailable": "No workspace is open."
1178
1185
  }
package/dist/i18n/ru.json CHANGED
@@ -1148,7 +1148,7 @@
1148
1148
  "web.tool.output_few": "вывод · {count} символа",
1149
1149
  "web.tool.output_many": "вывод · {count} символов",
1150
1150
  "web.tool.collapse": "свернуть",
1151
- "web.card.view": "открыть ↗",
1151
+ "web.card.open": "открыть ↗",
1152
1152
  "web.card.screenshot": "снимок экрана",
1153
1153
  "web.card.attached_image": "прикреплённое изображение",
1154
1154
  "web.card.browser_viewport": "браузер · область просмотра",
@@ -1157,6 +1157,13 @@
1157
1157
  "web.question.answered": "отвечено",
1158
1158
  "web.question.multi_hint": "выбери один или несколько",
1159
1159
  "web.misc.command_unavailable": "команда недоступна",
1160
+ "web.file.error.missing_path": "Не указан путь к файлу.",
1161
+ "web.file.error.outside_workspace": "Файл вне рабочей папки агента — открыть нельзя.",
1162
+ "web.file.error.protected_path": "Файл защищён (секреты/VCS) — открыть нельзя.",
1163
+ "web.file.error.too_large": "Файл слишком большой для показа в браузере (максимум {limit} байт).",
1164
+ "web.file.error.binary_file": "Похоже на бинарный файл — текстом показать нельзя.",
1165
+ "web.file.error.not_found": "Файл не найден (возможно, это папка или он удалён).",
1166
+ "web.file.error.workspace_unavailable": "Рабочая папка не открыта.",
1160
1167
  "config.desc.model": "Модель основного агента.",
1161
1168
  "config.desc.provider.active": "Какой из настроенных провайдеров используется.",
1162
1169
  "config.desc.contextWindow": "Общий размер контекста в токенах (системный промпт + история + резерв).",
package/dist/main.js CHANGED
@@ -3668,7 +3668,7 @@ Updated: {updatedAt}`,
3668
3668
  "web.tool.output_few": "output · {count} chars",
3669
3669
  "web.tool.output_many": "output · {count} chars",
3670
3670
  "web.tool.collapse": "collapse",
3671
- "web.card.view": "view ↗",
3671
+ "web.card.open": "open ↗",
3672
3672
  "web.card.screenshot": "screenshot",
3673
3673
  "web.card.attached_image": "attached image",
3674
3674
  "web.card.browser_viewport": "browser · viewport",
@@ -3676,7 +3676,14 @@ Updated: {updatedAt}`,
3676
3676
  "web.question.send": "send",
3677
3677
  "web.question.answered": "answered",
3678
3678
  "web.question.multi_hint": "select one or more",
3679
- "web.misc.command_unavailable": "command unavailable"
3679
+ "web.misc.command_unavailable": "command unavailable",
3680
+ "web.file.error.missing_path": "No file path was given.",
3681
+ "web.file.error.outside_workspace": "This file is outside the agent workspace and cannot be opened.",
3682
+ "web.file.error.protected_path": "This file is protected (secrets/VCS) and cannot be opened.",
3683
+ "web.file.error.too_large": "File is too large to open in the browser ({limit} bytes max).",
3684
+ "web.file.error.binary_file": "This looks like a binary file and cannot be shown as text.",
3685
+ "web.file.error.not_found": "File not found (it may be a directory or it was deleted).",
3686
+ "web.file.error.workspace_unavailable": "No workspace is open."
3680
3687
  };
3681
3688
  });
3682
3689
 
@@ -4899,7 +4906,7 @@ var init_ru = __esm(() => {
4899
4906
  "web.tool.output_few": "вывод · {count} символа",
4900
4907
  "web.tool.output_many": "вывод · {count} символов",
4901
4908
  "web.tool.collapse": "свернуть",
4902
- "web.card.view": "открыть ↗",
4909
+ "web.card.open": "открыть ↗",
4903
4910
  "web.card.screenshot": "снимок экрана",
4904
4911
  "web.card.attached_image": "прикреплённое изображение",
4905
4912
  "web.card.browser_viewport": "браузер · область просмотра",
@@ -4908,6 +4915,13 @@ var init_ru = __esm(() => {
4908
4915
  "web.question.answered": "отвечено",
4909
4916
  "web.question.multi_hint": "выбери один или несколько",
4910
4917
  "web.misc.command_unavailable": "команда недоступна",
4918
+ "web.file.error.missing_path": "Не указан путь к файлу.",
4919
+ "web.file.error.outside_workspace": "Файл вне рабочей папки агента — открыть нельзя.",
4920
+ "web.file.error.protected_path": "Файл защищён (секреты/VCS) — открыть нельзя.",
4921
+ "web.file.error.too_large": "Файл слишком большой для показа в браузере (максимум {limit} байт).",
4922
+ "web.file.error.binary_file": "Похоже на бинарный файл — текстом показать нельзя.",
4923
+ "web.file.error.not_found": "Файл не найден (возможно, это папка или он удалён).",
4924
+ "web.file.error.workspace_unavailable": "Рабочая папка не открыта.",
4911
4925
  "config.desc.model": "Модель основного агента.",
4912
4926
  "config.desc.provider.active": "Какой из настроенных провайдеров используется.",
4913
4927
  "config.desc.contextWindow": "Общий размер контекста в токенах (системный промпт + история + резерв).",
@@ -17044,7 +17058,7 @@ class Agent {
17044
17058
  }, phase);
17045
17059
  onPhase?.(phase);
17046
17060
  }
17047
- async run(input, onChunk, onMeta, onTool, onPhase) {
17061
+ async run(input, onChunk, onMeta, onTool, onPhase, onReasoning) {
17048
17062
  this.shutdownRequested = false;
17049
17063
  this.abortController = new AbortController;
17050
17064
  this.setScope();
@@ -17130,14 +17144,14 @@ class Agent {
17130
17144
  toolExecutor,
17131
17145
  logger,
17132
17146
  baseDir: this.deps.baseDir
17133
- }, input, () => this.executeSingleAgentLoop(input, slog, onChunk, onMeta, countTool, onPhase), {
17147
+ }, input, () => this.executeSingleAgentLoop(input, slog, onChunk, onMeta, countTool, onPhase, onReasoning), {
17134
17148
  onMeta,
17135
17149
  onTool: countTool,
17136
17150
  onPhase,
17137
17151
  signal: this.abortController.signal,
17138
17152
  onEvent: (event) => slog.logMoE(event.type, event.data),
17139
17153
  sessionId: this.deps.sessionManager?.getActiveMeta()?.id
17140
- }) : await this.executeSingleAgentLoop(input, slog, onChunk, onMeta, countTool, onPhase);
17154
+ }) : await this.executeSingleAgentLoop(input, slog, onChunk, onMeta, countTool, onPhase, onReasoning);
17141
17155
  if (moeEnabled && result.moeHandled) {
17142
17156
  if (result.text) {
17143
17157
  contextManager.addMessage({ role: "assistant", content: result.text });
@@ -17161,7 +17175,7 @@ class Agent {
17161
17175
  throw err;
17162
17176
  }
17163
17177
  }
17164
- async executeSingleAgentLoop(input, slog, onChunk, onMeta, onTool, onPhase) {
17178
+ async executeSingleAgentLoop(input, slog, onChunk, onMeta, onTool, onPhase, onReasoning) {
17165
17179
  const {
17166
17180
  config,
17167
17181
  llmProvider,
@@ -17284,7 +17298,7 @@ class Agent {
17284
17298
  break;
17285
17299
  if (chunk.type === "text" && chunk.content) {
17286
17300
  if (emittedReasoning && !textContent) {
17287
- onMeta?.(`
17301
+ onReasoning?.(`
17288
17302
 
17289
17303
  `);
17290
17304
  }
@@ -17297,7 +17311,7 @@ class Agent {
17297
17311
  if (config.showReasoning) {
17298
17312
  const metaOut = pluginManager.runOnMeta({ iteration: state.iteration, logger, contextManager }, chunk.content);
17299
17313
  if (metaOut) {
17300
- onMeta?.(pc.dim(metaOut));
17314
+ onReasoning?.(metaOut);
17301
17315
  }
17302
17316
  emittedReasoning = true;
17303
17317
  }
@@ -17686,7 +17700,6 @@ var init_agent = __esm(() => {
17686
17700
  init_manager();
17687
17701
  init_factory();
17688
17702
  init_i18n();
17689
- init_colors();
17690
17703
  init_utils();
17691
17704
  init_prompt_builder();
17692
17705
  init_processes();
@@ -45601,7 +45614,7 @@ ${pc.green(t("repl.agent"))}`);
45601
45614
  } else {
45602
45615
  renderer.thinkingEnd();
45603
45616
  }
45604
- });
45617
+ }, (reasoning) => renderer.reasoning(reasoning));
45605
45618
  renderer.flush();
45606
45619
  if (result.provider && result.model) {
45607
45620
  renderer.footer(result.model, result.provider, result.llmDurationMs ?? result.durationMs ?? 0);
@@ -46431,6 +46444,25 @@ function safeEqual(a, b) {
46431
46444
  return false;
46432
46445
  return timingSafeEqual2(ab, bb);
46433
46446
  }
46447
+ var AUTH_COOKIE = "mma_web_auth";
46448
+ function cookieValue(header, name) {
46449
+ if (!header)
46450
+ return null;
46451
+ for (const part of header.split(";")) {
46452
+ const eq = part.indexOf("=");
46453
+ if (eq === -1)
46454
+ continue;
46455
+ if (part.slice(0, eq).trim() === name) {
46456
+ const raw = part.slice(eq + 1).trim();
46457
+ try {
46458
+ return decodeURIComponent(raw);
46459
+ } catch {
46460
+ return null;
46461
+ }
46462
+ }
46463
+ }
46464
+ return null;
46465
+ }
46434
46466
  function checkAuth(req, reqUrl, s) {
46435
46467
  if (!s.token)
46436
46468
  return true;
@@ -46438,7 +46470,15 @@ function checkAuth(req, reqUrl, s) {
46438
46470
  if (typeof header === "string" && safeEqual(header, s.token))
46439
46471
  return true;
46440
46472
  const query = reqUrl.searchParams.get("token");
46441
- return query !== null && safeEqual(query, s.token);
46473
+ if (query !== null && safeEqual(query, s.token))
46474
+ return true;
46475
+ const cookie = cookieValue(req.headers.cookie, AUTH_COOKIE);
46476
+ return cookie !== null && safeEqual(cookie, s.token);
46477
+ }
46478
+ function setAuthCookie(res, s) {
46479
+ if (!s.token)
46480
+ return;
46481
+ res.setHeader("Set-Cookie", `${AUTH_COOKIE}=${encodeURIComponent(s.token)}; Path=/; HttpOnly; SameSite=Strict`);
46442
46482
  }
46443
46483
  function sameOrigin(req, reqUrl) {
46444
46484
  const origin = req.headers.origin;
@@ -46816,18 +46856,22 @@ function clipToolOutput(toolName, output) {
46816
46856
  var DEFAULT_FLUSH_MS = 350;
46817
46857
  function makeForwarder(s, sink = (ev) => broadcast(ev, s), opts = {}) {
46818
46858
  const flushMs = opts.flushMs ?? DEFAULT_FLUSH_MS;
46819
- let buf = { text: "", meta: "" };
46859
+ let buf = { text: "", meta: "", reasoning: "" };
46820
46860
  let timer = null;
46821
46861
  const flush = () => {
46822
46862
  if (timer)
46823
46863
  clearTimeout(timer);
46824
46864
  timer = null;
46865
+ if (buf.reasoning) {
46866
+ sink({ type: "reasoning", ts: Date.now(), chunk: stripAnsi2(buf.reasoning) });
46867
+ buf.reasoning = "";
46868
+ }
46825
46869
  if (buf.text) {
46826
- sink({ type: "text", ts: Date.now(), chunk: buf.text });
46870
+ sink({ type: "text", ts: Date.now(), chunk: stripAnsi2(buf.text) });
46827
46871
  buf.text = "";
46828
46872
  }
46829
46873
  if (buf.meta) {
46830
- sink({ type: "meta", ts: Date.now(), chunk: buf.meta });
46874
+ sink({ type: "meta", ts: Date.now(), chunk: stripAnsi2(buf.meta) });
46831
46875
  buf.meta = "";
46832
46876
  }
46833
46877
  };
@@ -46844,6 +46888,10 @@ function makeForwarder(s, sink = (ev) => broadcast(ev, s), opts = {}) {
46844
46888
  buf.meta += chunk;
46845
46889
  schedule();
46846
46890
  },
46891
+ onReasoning(chunk) {
46892
+ buf.reasoning += chunk;
46893
+ schedule();
46894
+ },
46847
46895
  onPhase(phase) {
46848
46896
  flush();
46849
46897
  sink({ type: "phase", ts: Date.now(), phase });
@@ -46867,7 +46915,7 @@ function makeForwarder(s, sink = (ev) => broadcast(ev, s), opts = {}) {
46867
46915
  name: call.name,
46868
46916
  success: result.success,
46869
46917
  durationMs,
46870
- output: redactContextSecrets(clipToolOutput(call.name, result.output || ""))
46918
+ output: stripAnsi2(redactContextSecrets(clipToolOutput(call.name, result.output || "")))
46871
46919
  });
46872
46920
  },
46873
46921
  onPlan(plan) {
@@ -47137,7 +47185,7 @@ function handleAnswerPost(req, res, s) {
47137
47185
  // src/modules/webui/history-api.ts
47138
47186
  init_context_log();
47139
47187
  function scrub(text) {
47140
- return text ? redactContextSecrets(text) : undefined;
47188
+ return text ? stripAnsi2(redactContextSecrets(text)) : undefined;
47141
47189
  }
47142
47190
  function mapLogEntry(e) {
47143
47191
  switch (e.type) {
@@ -47766,6 +47814,106 @@ function handleProvidersApi(req, res, reqUrl, s) {
47766
47814
  return false;
47767
47815
  }
47768
47816
 
47817
+ // src/modules/webui/file-api.ts
47818
+ init_protected_paths();
47819
+ init_i18n();
47820
+ import { readFile as readFile2, realpath, stat as stat2 } from "node:fs/promises";
47821
+ import { isAbsolute as isAbsolute6, relative as relative10, resolve as resolve26, sep as sep3 } from "node:path";
47822
+ var MAX_FILE_VIEW_BYTES = 512 * 1024;
47823
+ function sendTextError(res, status2, key, vars) {
47824
+ res.writeHead(status2, {
47825
+ "Content-Type": "text/plain; charset=utf-8",
47826
+ "Cache-Control": "no-store"
47827
+ });
47828
+ res.end(t(key, vars));
47829
+ }
47830
+ function decodeText(buf) {
47831
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
47832
+ return { text: buf.subarray(2).toString("utf16le"), binary: false };
47833
+ }
47834
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
47835
+ const body2 = buf.subarray(2);
47836
+ const even = body2.length - body2.length % 2;
47837
+ const swapped = Buffer.from(body2.subarray(0, even));
47838
+ swapped.swap16();
47839
+ return { text: swapped.toString("utf16le"), binary: false };
47840
+ }
47841
+ const body = buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191 ? buf.subarray(3) : buf;
47842
+ if (body.includes(0))
47843
+ return { text: "", binary: true };
47844
+ return { text: body.toString("utf8"), binary: false };
47845
+ }
47846
+ function escapes(base, target) {
47847
+ const rel = relative10(base, target);
47848
+ return rel === ".." || rel.startsWith(".." + sep3) || isAbsolute6(rel);
47849
+ }
47850
+ function handleFileApi(req, res, reqUrl, s) {
47851
+ if (reqUrl.pathname !== "/api/file" || req.method !== "GET")
47852
+ return false;
47853
+ const base = s.workspace;
47854
+ if (!base) {
47855
+ sendTextError(res, 503, "web.file.error.workspace_unavailable");
47856
+ return true;
47857
+ }
47858
+ const raw = reqUrl.searchParams.get("path") ?? "";
47859
+ if (!raw) {
47860
+ sendTextError(res, 400, "web.file.error.missing_path");
47861
+ return true;
47862
+ }
47863
+ const abs = isAbsolute6(raw) ? resolve26(raw) : resolve26(base, raw);
47864
+ if (escapes(resolve26(base), abs)) {
47865
+ sendTextError(res, 403, "web.file.error.outside_workspace");
47866
+ return true;
47867
+ }
47868
+ serveFile(res, base, abs);
47869
+ return true;
47870
+ }
47871
+ async function serveFile(res, base, abs) {
47872
+ let baseReal;
47873
+ let absReal;
47874
+ try {
47875
+ baseReal = await realpath(resolve26(base));
47876
+ absReal = await realpath(abs);
47877
+ } catch {
47878
+ sendTextError(res, 404, "web.file.error.not_found");
47879
+ return;
47880
+ }
47881
+ if (escapes(baseReal, absReal)) {
47882
+ sendTextError(res, 403, "web.file.error.outside_workspace");
47883
+ return;
47884
+ }
47885
+ if (matchProtectedPath(baseReal, absReal)) {
47886
+ sendTextError(res, 403, "web.file.error.protected_path");
47887
+ return;
47888
+ }
47889
+ try {
47890
+ const info = await stat2(absReal);
47891
+ if (!info.isFile()) {
47892
+ sendTextError(res, 404, "web.file.error.not_found");
47893
+ return;
47894
+ }
47895
+ if (info.size > MAX_FILE_VIEW_BYTES) {
47896
+ sendTextError(res, 413, "web.file.error.too_large", {
47897
+ limit: String(MAX_FILE_VIEW_BYTES)
47898
+ });
47899
+ return;
47900
+ }
47901
+ const buf = await readFile2(absReal);
47902
+ const { text, binary } = decodeText(buf);
47903
+ if (binary) {
47904
+ sendTextError(res, 415, "web.file.error.binary_file");
47905
+ return;
47906
+ }
47907
+ res.writeHead(200, {
47908
+ "Content-Type": "text/plain; charset=utf-8",
47909
+ "Cache-Control": "no-store"
47910
+ });
47911
+ res.end(text);
47912
+ } catch {
47913
+ sendTextError(res, 404, "web.file.error.not_found");
47914
+ }
47915
+ }
47916
+
47769
47917
  // src/modules/webui/server.ts
47770
47918
  var MAX_CLIENTS = parseMaxClients(process.env.MMA_WEBUI_MAX_CLIENTS);
47771
47919
  function parseMaxClients(raw) {
@@ -47859,6 +48007,9 @@ function handleApi(req, res, reqUrl, s) {
47859
48007
  if (handleProvidersApi(req, res, reqUrl, s)) {
47860
48008
  return;
47861
48009
  }
48010
+ if (handleFileApi(req, res, reqUrl, s)) {
48011
+ return;
48012
+ }
47862
48013
  if (reqUrl.pathname === "/api/models" && req.method === "GET") {
47863
48014
  handleModelsGet(res, s);
47864
48015
  return;
@@ -47903,6 +48054,7 @@ function handleRequest(req, res, assets, s) {
47903
48054
  sendJson(res, 401, { error: "unauthorized" });
47904
48055
  return;
47905
48056
  }
48057
+ setAuthCookie(res, s);
47906
48058
  handleEvents(req, res, s);
47907
48059
  return;
47908
48060
  }
@@ -47921,6 +48073,7 @@ function handleRequest(req, res, assets, s) {
47921
48073
  sendJson(res, 403, { error: "cross-origin requests are not allowed" });
47922
48074
  return;
47923
48075
  }
48076
+ setAuthCookie(res, s);
47924
48077
  handleApi(req, res, reqUrl, s);
47925
48078
  return;
47926
48079
  }
@@ -47942,7 +48095,7 @@ function startServer(assets, s = getDefaultState()) {
47942
48095
  return Promise.reject(new Error(`refusing to bind ${s.host} without a token (non-loopback)`));
47943
48096
  }
47944
48097
  const desired = Number(process.env.MMA_WEBUI_PORT) || 0;
47945
- s.starting = new Promise((resolve26, reject) => {
48098
+ s.starting = new Promise((resolve27, reject) => {
47946
48099
  const srv = createServer((req, res) => handleRequest(req, res, assets, s));
47947
48100
  srv.on("error", (err) => {
47948
48101
  s.starting = null;
@@ -47954,7 +48107,7 @@ function startServer(assets, s = getDefaultState()) {
47954
48107
  const port = typeof addr === "object" && addr ? addr.port : desired;
47955
48108
  s.url = `http://${s.host}:${port}`;
47956
48109
  s.starting = null;
47957
- resolve26(s.url);
48110
+ resolve27(s.url);
47958
48111
  });
47959
48112
  });
47960
48113
  return s.starting;
@@ -48115,7 +48268,7 @@ async function startWebUi(deps) {
48115
48268
  if (plan)
48116
48269
  forwarder.onPlan(plan);
48117
48270
  }
48118
- }, (phase) => forwarder.onPhase(phase));
48271
+ }, (phase) => forwarder.onPhase(phase), (reasoning) => forwarder.onReasoning(reasoning));
48119
48272
  state.usage = captureUsage(result, deps.config.contextWindow);
48120
48273
  forwarder.onTurnEnd({
48121
48274
  success: result.success,
@@ -48244,13 +48397,13 @@ async function runWeb(opts) {
48244
48397
  getDefaultChannel().writeLine(t("cli.web_started", { url }));
48245
48398
  if (!opts.noOpen)
48246
48399
  tryOpenBrowser(url);
48247
- await new Promise((resolve26) => {
48400
+ await new Promise((resolve27) => {
48248
48401
  const shutdown = () => {
48249
48402
  process.off("SIGINT", shutdown);
48250
48403
  process.off("SIGTERM", shutdown);
48251
48404
  web.stop();
48252
48405
  agent.shutdown();
48253
- resolve26();
48406
+ resolve27();
48254
48407
  };
48255
48408
  process.on("SIGINT", shutdown);
48256
48409
  process.on("SIGTERM", shutdown);
@@ -48676,7 +48829,7 @@ async function main() {
48676
48829
  } else {
48677
48830
  renderer.thinkingEnd();
48678
48831
  }
48679
- });
48832
+ }, (reasoning) => renderer.reasoning(reasoning));
48680
48833
  } catch (err) {
48681
48834
  result = {
48682
48835
  success: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "micro-models-agent",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Micro Models Agent (MMA) — LLM agent harness for small models (Qwen3.5-9B, 32K-64K context)",
5
5
  "type": "module",
6
6
  "bin": {