micro-models-agent 2.15.0 → 2.16.1

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
@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - **Web UI incoming message queue** (`src/modules/webui/state.ts`, `util.ts`, `forward.ts`, `module.ts`, `server.ts`, `run-agent.ts`, `assets/js/tabs.js`): a message sent mid-turn no longer dies on `409 busy` — it queues (`202 {queued: n}`, cap `MAX_QUEUE_LENGTH = 5`, overflow `429 queue_full`) and runs as the next turn on `turn_end`, in order. Drain reuses the single-flight submit path; the queue clears on server stop; `GET /api/state` and SSE `state` carry `queueLen`. The composer stays editable during a turn with a `↳ queued · position N` receipt (`web.input.queued/queue_full`, en+ru; dead `web.input.busy` removed). Docs: `docs/agents/modules.md`. Tests: `tests/webui-server.test.ts`, `tests/webui-auth.test.ts`.
12
+
13
+ - **Web Trace: diagnostics coverage + full model text** (`src/modules/webui/log-api.ts`, `sessions-api.ts`, `forward.ts`, `state.ts`, `module.ts`, `assets/app.js`, `assets/js/sidebar.js`): Trace shows status rows (SSE `meta`), warn/error logs (SSE `log`, minus info-diagnostic chatter), draft retractions, and index start/done/error — plus critic/MoE/compaction/context-probe/usage/audit/claim-retry/output pulled incrementally from `session.jsonl` on every `turn_end` via the new `GET /api/sessions/:id/log` (`?types=&since=&limit=`, field whitelist, secret scrub, no new producers). `turn_end` carries a server `turnSeq`; text details show the full turn text (honest 20K cap instead of a 4K tail slice) with session/turn and a session-log provenance footer (assistant count, iterations, model/tokens). Git ops stay inside `bash` rows (prefix heuristics too fragile). i18n `web.sidebar.trace_*/detail_*` (en+ru). Tests: `tests/webui-log.test.ts`, e2e `web-boot` (Trace panel + index lifecycle).
14
+
15
+ ### Fixed
16
+
17
+ - **Web file list is always expanded with full root-relative links** (`src/modules/webui/assets/js/feed.js`, `styles.css`): consecutive file rows no longer collapse into an expandable `│ N files` group (its chevron toggle failed to open) — every file tool renders its own `◇ <label> · <full-path-link> [copy] <status>` line, and the link text is the full workspace-relative path (`displayPathFromRoot`, absolute paths stripped by the workspace prefix) instead of basename + dim tail. Consecutive file/action rows are compact (`~4px` via `margin-top: -7px` compensating the `11px` feed gap, `line-height: 1.35`). Group CSS/JS removed. Tests: `tests/webui-assets.test.ts`, `tests/e2e/web-filecard.e2e-test.ts`.
18
+ - **Web transcript edges and answer separation** (`src/modules/webui/assets/styles.css`): scroll edge fades are glued exactly to the transcript edges — vertical breathing moved from the scroller to `.feed` (sticky pseudos are constrained to the padding-free content box, previously leaving a gap under the header and above the composer) and hidden fades take zero flow space (`-33px` margins neutralizing height + flex gap). Finished assistant answers carry a quiet accent rail (`.feed > .md/.ans`, `border-left: 2px solid var(--accent)`, mirroring the user block) so the result reads instantly apart from the dim tool/think process. Tests: `tests/webui-assets.test.ts`.
19
+ - **Success-claim no longer vetoed by plan-bookkeeping failures** (`src/modules/hallucination/success-claim.ts`): `checkSuccessClaim` ignores `plan`/`plan_step`/`todo` evidences — their failures police the agent's own notebook (covered by the final audit), never task outcome. Lived-in case (ses_mupgenhq_75f66l, qwen3.5-9b): an honest "tests passed" was retracted over a stale `re-plan` error ("Длина checks должна совпадать"), forcing a same-substance re-answer after the task was done. Tests: `tests/hallucination-success-claim.test.ts`.
20
+
21
+ - **Web UI boot was dead on 2.15.0** (`src/modules/webui/assets/js/settings.js`): a stray `}` in `setStaleBar` pushed `return` to top level (`Uncaught SyntaxError: Illegal return statement`), killing the whole `app.js` import graph — blank page, no feed, selectors stuck at `–`. One-line brace fix.
22
+ - **Web e2e boot smoke** (`tests/e2e/web-boot.e2e-test.ts`): `mma web` boots in a real Chromium with zero console/page errors (live session tab, chrome skeleton, settings modal) and every served JS module in the live `/app.js` import graph answers 200; `web-helpers.launch` now names collected console errors when the feed never boots, so the next broken module fails with its `SyntaxError` in the message instead of a bare timeout.
23
+
24
+ ## [2.16.1] - 2026-10-01
25
+
26
+ ### Added
27
+
28
+ - **Smooth streamed-text cascade** (`src/modules/webui/assets/js/feed.js`, `md.js`, `styles.css`): live markdown chunks are buffered and flushed once per animation frame (`requestAnimationFrame`, multi-feed safe) instead of write+scroll per SSE event, and each streamed text run renders in a `span.tk` with a short opacity-only `tkIn` fade (staggered `(n%4)*20ms`, `.15s`). History render and code blocks skip the animation; killed by `prefers-reduced-motion`.
29
+
30
+ ## [2.16.0] - 2026-10-01
31
+
32
+ ### Added
33
+
34
+ - **Web dropdown search for models and sessions** (`src/modules/webui/assets/js/settings.js`, `tabs.js`, `format.js`, `index.html`, `styles.css`, `src/i18n/en.json`, `ru.json`): the composer model menu and the sessions dropdown (`☰`) gain a sticky search field — case-insensitive substring (`matchesQuery` in `format.js`; sessions match name + id) with keyboard support (`↓` into the list, `Enter` picks the first hit, `Esc` clears/closes, `↑` back to search). The sessions input lives statically in the markup so the 2s state poll never steals the caret; the model filter survives list re-reads and clears on pick. Empty sections hide with their headers, zero hits show `no matches`; `↻ refresh` / `+ new session` stay outside the filter. New keys `web.selectors.search_models` / `web.sessions.search` (en+ru). Tests: `tests/webui-assets.test.ts`.
35
+
9
36
  ## [2.15.0] - 2026-10-01
10
37
 
11
38
  ### Added
@@ -23,6 +23,7 @@ initRail, setPlan as railPlan, railEvent, applyRailI18n,
23
23
  import {
24
24
  initSidebar, sidebarSessionChanged, sidebarMapState,
25
25
  sidebarTraceEvent, sidebarTurnEnd, sidebarBusy, sidebarMapIndicator,
26
+ sidebarPullLog,
26
27
  applySidebarI18n,
27
28
  } from "./js/sidebar.js";
28
29
 
@@ -141,7 +142,7 @@ const handlers = [
141
142
  ["tool_end", onToolEnd],
142
143
  ["plan", onPlan],
143
144
  ["question", onQuestion],
144
- ["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(); sidebarBusy(false); }],
145
+ ["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(d.turnSeq); sidebarBusy(false); sidebarPullLog(); }],
145
146
  ["log", onLog],
146
147
  ["retract", (d) => onRetract(d.reason || "", d.sessionId)],
147
148
  ];
@@ -161,7 +162,10 @@ for (const ev of ["tool_start", "tool_end", "text", "plan"]) {
161
162
  });
162
163
  }
163
164
  // Sidebar Trace: live per-session event log. Feed handlers stay untouched.
164
- for (const ev of ["tool_start", "tool_end", "phase", "plan", "question"]) {
165
+ // Tool/text/phase/plan/question/error stream live; status/log/retract/index
166
+ // ride the same SSE bus; critic/MoE/compaction/probe/usage/audit/claim rows
167
+ // arrive via the session-log supplement Trace pulls on every turn_end.
168
+ for (const ev of ["tool_start", "tool_end", "phase", "plan", "question", "meta", "log", "retract", "index"]) {
165
169
  es.addEventListener(ev, (e) => { const d = parse(e); if (d) sidebarTraceEvent(ev, d); });
166
170
  }
167
171
  es.addEventListener("text", (e) => { const d = parse(e); if (d) sidebarTraceEvent("text", d); });
@@ -16,6 +16,7 @@
16
16
  <div class="dd" id="sessDD">
17
17
  <button class="icon-btn" id="sessBtn" title="all sessions" aria-label="all sessions" aria-haspopup="menu" aria-expanded="false">☰</button>
18
18
  <div class="dd-menu">
19
+ <input class="searchbox" id="sessSearch" type="search" autocomplete="off" spellcheck="false" placeholder="search sessions…" aria-label="search sessions">
19
20
  <div class="h" id="sessActiveHeader">active</div>
20
21
  <div id="sessActiveList"></div>
21
22
  <div class="h" id="sessRecentHeader">recent</div>
@@ -246,16 +246,20 @@ function resetRun() {
246
246
 
247
247
  // Options passed to the streaming Markdown renderer: localized code-block
248
248
  // copy labels. Highlighting is applied by `md.js` once a fence closes.
249
- function mdOpts() {
249
+ // `animate` wraps live-streamed text chunks in `span.tk` (CSS `tkIn` fade);
250
+ // history render and code blocks skip it.
251
+ function mdOpts(animate) {
250
252
  return {
251
253
  copyLabel: t("web.code.copy"),
252
254
  copiedLabel: t("web.code.copied"),
253
255
  copyFailedLabel: t("web.code.copy_failed"),
256
+ animate: animate === true,
254
257
  };
255
258
  }
256
259
 
257
260
  // Close the active Markdown segment (if any) and clear the text S.state.
258
261
  export function finishMd() {
262
+ flushText();
259
263
  if (S.curMd) {
260
264
  S.curMd.root.classList.remove("streaming");
261
265
  S.curMd.end();
@@ -264,6 +268,38 @@ export function finishMd() {
264
268
  S.curText = null;
265
269
  }
266
270
 
271
+ // Text chunks arrive in bursts (several SSE `text` events per frame); writing
272
+ // and scrolling on every one thrashes layout and stacks overlapping `tkIn`
273
+ // fades ("рвано"). Buffer them and flush once per animation frame instead.
274
+ let textBuf = "";
275
+ let textFeed = null;
276
+ let textScheduled = false;
277
+
278
+ function flushText() {
279
+ textScheduled = false;
280
+ if (!textBuf || !S.curMd) {
281
+ textBuf = "";
282
+ textFeed = null;
283
+ return;
284
+ }
285
+ const buf = textBuf;
286
+ const feed = textFeed;
287
+ textBuf = "";
288
+ textFeed = null;
289
+ S.curMd.write(buf);
290
+ if (feed && feed === activeFeed()) scroll();
291
+ }
292
+
293
+ function scheduleText() {
294
+ if (textScheduled) return;
295
+ textScheduled = true;
296
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
297
+ window.requestAnimationFrame(flushText);
298
+ } else {
299
+ flushText();
300
+ }
301
+ }
302
+
267
303
  export function onText(chunk, sid) {
268
304
  const feed = feedForEvent(sid);
269
305
  clearBusy(feed);
@@ -275,14 +311,17 @@ export function onText(chunk, sid) {
275
311
  if (S.curText == null) {
276
312
  flushRun();
277
313
  S.curText = "";
278
- S.curMd = mountMarkdown(feed, mdOpts());
314
+ S.curMd = mountMarkdown(feed, mdOpts(true));
279
315
  S.curMd.root.classList.add("streaming");
280
316
  S.turnRoots.push(S.curMd.root);
281
317
  }
282
318
  S.curText += chunk;
283
319
  S.turnMd += chunk;
284
- S.curMd.write(chunk);
285
- if (feed === activeFeed()) scroll();
320
+ // A second session streaming concurrently must not merge into another feed's buffer.
321
+ if (textFeed && textFeed !== feed) flushText();
322
+ textFeed = feed;
323
+ textBuf += chunk;
324
+ scheduleText();
286
325
  }
287
326
 
288
327
  export function finishThink() {
@@ -444,44 +483,23 @@ function safeUrl(url) {
444
483
  return null;
445
484
  }
446
485
 
447
- function maybeGroupFilerow(feed, row) {
448
- const prev = row.previousElementSibling;
449
- const group = prev && prev.classList && prev.classList.contains("filegroup") ? prev : null;
450
- const prevRow = prev && prev.classList && prev.classList.contains("filerow") ? prev : null;
451
- if (group && group.dataset.open !== "1") {
452
- group.querySelector(".items").appendChild(row);
453
- if (row.dataset.fail === "1") group.dataset.fail = "1";
454
- const n = group.querySelectorAll(".items > .filerow").length;
455
- group.querySelector(".count").textContent = pluralCount("web.card.files", n);
456
- group.querySelector(".ok").textContent = group.dataset.fail === "1" ? " ✗" : " ✓";
457
- return group;
458
- }
459
- if (prevRow) {
460
- const wrap = el("div", "filegroup");
461
- const head = el("button", "head");
462
- head.setAttribute("aria-expanded", "false");
463
- head.appendChild(el("span", "g", "│ "));
464
- head.appendChild(el("span", "count", pluralCount("web.card.files", 2)));
465
- const fail = prevRow.dataset.fail === "1" || row.dataset.fail === "1";
466
- if (fail) wrap.dataset.fail = "1";
467
- head.appendChild(el("span", "ok", fail ? " ✗" : " ✓"));
468
- const chev = icon("chevron-down");
469
- head.appendChild(chev);
470
- const items = el("div", "items hidden");
471
- feed.replaceChild(wrap, prevRow);
472
- items.appendChild(prevRow);
473
- items.appendChild(row);
474
- wrap.appendChild(head);
475
- wrap.appendChild(items);
476
- head.addEventListener("click", () => {
477
- const open = wrap.dataset.open === "1";
478
- wrap.dataset.open = open ? "" : "1";
479
- head.setAttribute("aria-expanded", open ? "false" : "true");
480
- items.classList.toggle("hidden", open);
481
- });
482
- return wrap;
486
+ // File rows are always expanded: one `filerow` per file-tool call, no
487
+ // collapsing group. Consecutive rows stay a plain expanded list (the old
488
+ // E grouping with a chevron toggle hid the list and failed to open).
489
+ function displayPathFromRoot(path) {
490
+ const raw = String(path == null ? "" : path).replace(/\\/g, "/");
491
+ try {
492
+ const ws = S && S.state && S.state.host && S.state.host.workspace
493
+ ? String(S.state.host.workspace).replace(/\\/g, "/").replace(/\/+$/, "")
494
+ : "";
495
+ if (ws && (raw === ws || raw.indexOf(ws + "/") === 0)) {
496
+ const rel = raw === ws ? "" : raw.slice(ws.length + 1);
497
+ if (rel) return rel;
498
+ }
499
+ } catch (err) {
500
+ /* fail-open: show raw path */
483
501
  }
484
- return null;
502
+ return raw;
485
503
  }
486
504
 
487
505
  function cardFor(name, args, sid, ok) {
@@ -490,22 +508,21 @@ function cardFor(name, args, sid, ok) {
490
508
  if (FILE_TOOLS.indexOf(name) !== -1) {
491
509
  const path = args.path || args.file || args.dir;
492
510
  if (!path) return null;
511
+ const shown = displayPathFromRoot(path);
493
512
  const row = el("div", "filerow " + categoryOf(name));
494
513
  row.appendChild(el("span", "g", "◇"));
495
514
  row.appendChild(el("span", "lab", labelOf(name)));
496
515
  row.appendChild(el("span", "sep", " · "));
497
516
  if (OPENABLE_TOOLS.indexOf(name) !== -1) {
498
- const openLink = el("a", "path", String(path).split(/[\\/]/).pop() || path);
499
- openLink.href = "/api/file?path=" + encodeURIComponent(path);
517
+ const openLink = el("a", "path", shown || String(path));
518
+ openLink.href = "/api/file?path=" + encodeURIComponent(String(path));
500
519
  openLink.target = "_blank";
501
520
  openLink.rel = "noopener noreferrer";
502
- openLink.title = t("web.card.open_title") + " · " + path;
521
+ openLink.title = t("web.card.open_title") + " · " + String(path);
503
522
  row.appendChild(openLink);
504
- const tail = String(path).split(/[\\/]/).slice(0, -1).join("/");
505
- if (tail) row.appendChild(el("span", "tail", " · " + tail));
506
523
  } else {
507
- const s = el("span", "tail", path);
508
- s.title = path;
524
+ const s = el("span", "tail", shown || String(path));
525
+ s.title = String(path);
509
526
  row.appendChild(s);
510
527
  }
511
528
  const copyBtn = el("button", "copy-btn");
@@ -514,7 +531,7 @@ function cardFor(name, args, sid, ok) {
514
531
  copyBtn.setAttribute("aria-label", t("web.card.copy_title"));
515
532
  copyBtn.appendChild(icon("copy"));
516
533
  copyBtn.addEventListener("click", () => {
517
- copyText(path).then((ok) => {
534
+ copyText(String(path)).then((ok) => {
518
535
  copyBtn.replaceChildren(icon(ok ? "check" : "copy"));
519
536
  if (!ok) { const st = row.querySelector(".st"); if (st) st.textContent = " ⚠"; }
520
537
  window.setTimeout(() => copyBtn.replaceChildren(icon("copy")), 1400);
@@ -523,11 +540,10 @@ function cardFor(name, args, sid, ok) {
523
540
  row.appendChild(copyBtn);
524
541
  row.appendChild(el("span", "st", ok === false ? " ✗" : ok === true ? " ✓" : ""));
525
542
  if (ok === false) row.dataset.fail = "1";
526
- if (/\.drawio(\.xml)?$/i.test(path) && verbosity() === "verbose") {
543
+ if (/\.drawio(\.xml)?$/i.test(String(path)) && verbosity() === "verbose") {
527
544
  row.appendChild(el("span", "detail", "↳ " + t("web.card.drawio_hint")));
528
545
  }
529
546
  appendTo(feed, row);
530
- try { maybeGroupFilerow(feed, row); } catch (err) { /* fail-open: plain row stays */ }
531
547
  return row;
532
548
  }
533
549
  if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
@@ -90,6 +90,14 @@ for (const raw of text.split("\n")) {
90
90
  return "";
91
91
  }
92
92
 
93
+ // Case-insensitive substring match for dropdown filters (model / session
94
+ // search). Empty query matches everything; non-strings stringify safely.
95
+ export function matchesQuery(haystack, q) {
96
+ const needle = String(q == null ? "" : q).trim().toLowerCase();
97
+ if (!needle) return true;
98
+ return String(haystack == null ? "" : haystack).toLowerCase().indexOf(needle) !== -1;
99
+ }
100
+
93
101
  export function relTime(ts) {
94
102
  if (!ts) return "";
95
103
  const diff = Date.now() - new Date(ts).getTime();
@@ -8,7 +8,7 @@ el, icon, link, inlineTextNodes, connStatus, staleBar, makeToggle,
8
8
  import { api } from "./api.js";
9
9
  import { t, loadI18n } from "./i18n.js";
10
10
  import { S, setPath, getPath, THEME_KEY, hooks } from "./store.js";
11
- import { contextLabel } from "./format.js";
11
+ import { contextLabel, matchesQuery } from "./format.js";
12
12
  import { closeAllMenus, setSessOpen } from "./tabs.js";
13
13
  import { setIndicator, closeConfirm, confirmOverlay, trapTab } from "./dialog.js";
14
14
  import { controlFor, PANEL_SECTIONS, keyToPanel } from "./settings-controls.js";
@@ -48,6 +48,11 @@ function menuAction(label, iconName, onClick) {
48
48
  return mi;
49
49
  }
50
50
 
51
+ // Live filter text of the composer model dropdown. Kept module-level so a
52
+ // `renderSelectors()` re-read (config/models reload) preserves the query and
53
+ // the input is rebuilt with it; cleared once a model is actually picked.
54
+ let modelFilter = "";
55
+
51
56
  export function renderSelectors() {
52
57
  const cfg = S.state.config || {};
53
58
  const provider = cfg.provider || {};
@@ -55,13 +60,70 @@ export function renderSelectors() {
55
60
  setSelButton(document.getElementById("selModelBtn"), t("web.selectors.model"), cfg.model || "—");
56
61
  const modelMenu = document.getElementById("selModelMenu");
57
62
  modelMenu.textContent = "";
58
- if (!S.state.models.length) {
59
- modelMenu.appendChild(el("div", "mi dim", t("web.selectors.no_models")));
60
- } else {
61
- S.state.models.forEach((m) => {
62
- modelMenu.appendChild(menuItem(m, "", m === cfg.model, () => postConfig("model", m, true)));
63
+ // Search row stays outside the filtered list: `drawModelList` only touches
64
+ // `listBox`, so typing never rebuilds (or unfocuses) the input itself.
65
+ const search = document.createElement("input");
66
+ search.id = "selModelSearch";
67
+ search.className = "searchbox";
68
+ search.type = "search";
69
+ search.autocomplete = "off";
70
+ search.spellcheck = false;
71
+ search.placeholder = t("web.selectors.search_models");
72
+ search.setAttribute("aria-label", t("web.selectors.search_models"));
73
+ search.value = modelFilter;
74
+ search.addEventListener("click", (e) => e.stopPropagation());
75
+ modelMenu.appendChild(search);
76
+ const listBox = el("div", "model-menu-list");
77
+ modelMenu.appendChild(listBox);
78
+ const drawModelList = () => {
79
+ listBox.textContent = "";
80
+ const models = S.state.models.filter((m) => matchesQuery(m, modelFilter));
81
+ if (!models.length) {
82
+ listBox.appendChild(el("div", "mi dim", S.state.models.length ? t("web.settings.no_matches") : t("web.selectors.no_models")));
83
+ return;
84
+ }
85
+ models.forEach((m) => {
86
+ const item = menuItem(m, "", m === cfg.model, () => {
87
+ modelFilter = "";
88
+ postConfig("model", m, true);
89
+ });
90
+ // ArrowUp on the first row returns focus to the search field.
91
+ item.addEventListener("keydown", (e) => {
92
+ if (e.key === "ArrowUp" && item === listBox.querySelector(".mi:not(.dim)")) {
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ search.focus();
96
+ }
97
+ });
98
+ listBox.appendChild(item);
63
99
  });
64
- }
100
+ };
101
+ search.addEventListener("input", () => {
102
+ modelFilter = search.value;
103
+ drawModelList();
104
+ });
105
+ search.addEventListener("keydown", (e) => {
106
+ e.stopPropagation();
107
+ if (e.key === "ArrowDown") {
108
+ e.preventDefault();
109
+ const first = listBox.querySelector(".mi:not(.dim)");
110
+ if (first) first.focus();
111
+ } else if (e.key === "Enter") {
112
+ e.preventDefault();
113
+ const first = listBox.querySelector(".mi:not(.dim)");
114
+ if (first) first.click();
115
+ } else if (e.key === "Escape") {
116
+ if (search.value) {
117
+ e.preventDefault();
118
+ search.value = "";
119
+ modelFilter = "";
120
+ drawModelList();
121
+ } else {
122
+ closeAllMenus();
123
+ }
124
+ }
125
+ });
126
+ drawModelList();
65
127
  modelMenu.appendChild(el("div", "sep"));
66
128
  modelMenu.appendChild(
67
129
  menuAction(t("web.selectors.refresh_list"), "refresh", () => {
@@ -196,6 +258,11 @@ document.querySelectorAll("[data-sel]").forEach((sel) => {
196
258
  });
197
259
  const open = sel.classList.toggle("open");
198
260
  btn.setAttribute("aria-expanded", open ? "true" : "false");
261
+ // Opening the model menu lands focus in its search field.
262
+ if (open && sel.dataset && sel.dataset.sel === "model") {
263
+ const search = document.getElementById("selModelSearch");
264
+ if (search) setTimeout(() => search.focus(), 0);
265
+ }
199
266
  });
200
267
  });
201
268
 
@@ -340,7 +407,6 @@ export function setStaleBar(stale, force) {
340
407
  if (!on) return;
341
408
  staleBar.appendChild(el("span", "", "⚠ " + t("web.i18n.stale") + " "));
342
409
  staleBar.appendChild(link(t("web.i18n.reload"), "", () => location.reload()));
343
- }
344
410
  return lastStale;
345
411
  }
346
412