@allansantos-dev/smart-tool 0.9.31 → 0.9.33

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.
@@ -0,0 +1,140 @@
1
+ """Tells who updated that this version recommends other models than the ones the config holds, and applies them only on
2
+ consent. `model_defaults.ensure_recommended_models` fills empty fields only, so a user who updated keeps the previous
3
+ version's defaults forever; this module is the visible, reversible way out.
4
+
5
+ State lives in `config.json` under `defaults_notice`, tied to DEFAULTS_VERSION: pending (announced once a day until the
6
+ user answers), declined (silent for this version), applied (the replaced models are kept for `revert`). Raising
7
+ DEFAULTS_VERSION when RECOMMENDED_MODELS changes starts a new notice for everyone. Embedding and rerank are never
8
+ offered: changing them invalidates every index.
9
+ """
10
+ import secrets
11
+ import time
12
+
13
+ import config
14
+ import model_catalog
15
+ import model_defaults
16
+
17
+ DEFAULTS_VERSION = 2
18
+ ROLES = ("router_model", "scope_model", "scout_model", "research_model")
19
+ REMIND_EVERY_S = 24 * 3600
20
+
21
+
22
+ def _current(cfg):
23
+ """The notice of this defaults version, or a blank one."""
24
+ notice = cfg.get("defaults_notice") or {}
25
+ return notice if notice.get("version") == DEFAULTS_VERSION else {"version": DEFAULTS_VERSION, "state": "none"}
26
+
27
+
28
+ def _differences(cfg, chat):
29
+ """{role: {"current", "new"}} for each role the user has set to something other than the recommended model that
30
+ this gateway's catalog has."""
31
+ found = {}
32
+ for role in ROLES:
33
+ wanted = model_defaults._matching(chat, model_defaults.RECOMMENDED_MODELS[role][1])
34
+ if wanted and cfg.get(role) and cfg[role] != wanted:
35
+ found[role] = {"current": cfg[role], "new": wanted}
36
+ return found
37
+
38
+
39
+ def check():
40
+ """Recomputes what is on offer against the gateway catalog and stores it. Raises when the catalog is unreachable
41
+ (the caller shows that; no answer is invented). Returns `status()`."""
42
+ cfg = config.load_config()
43
+ notice = _current(cfg)
44
+ if notice["state"] in ("declined", "applied"):
45
+ return status()
46
+ found = _differences(cfg, set(model_catalog.list_models("chat")))
47
+
48
+ def store(current):
49
+ notice = dict(_current(current))
50
+ if not found:
51
+ notice.update(state="none", changes={}, nonce="")
52
+ else:
53
+ notice.update(state="pending", changes=found, nonce=notice.get("nonce") or secrets.token_hex(8))
54
+ current["defaults_notice"] = notice
55
+
56
+ config.update_config(store)
57
+ return status()
58
+
59
+
60
+ def status(cfg=None):
61
+ """What the setup screen and the tray read: state, the model changes on offer, and what `revert` would restore."""
62
+ notice = _current(cfg if cfg is not None else config.load_config())
63
+ return {"version": DEFAULTS_VERSION, "state": notice["state"], "changes": notice.get("changes") or {},
64
+ "previous": notice.get("previous") or {}, "nonce": notice.get("nonce", "")}
65
+
66
+
67
+ def _change(mutate_notice, nonce):
68
+ """Runs `mutate_notice(cfg, notice)` under the config lock on a pending notice; `nonce` None (the setup screen,
69
+ which has its own token) skips the link check, a toast button must carry the nonce it was shown with."""
70
+ result = {}
71
+
72
+ def apply(cfg):
73
+ notice = dict(_current(cfg))
74
+ if notice["state"] != "pending":
75
+ raise ValueError(f"No new default models are waiting (state: {notice['state']}).")
76
+ if nonce is not None and not secrets.compare_digest(str(nonce), str(notice.get("nonce", ""))):
77
+ raise ValueError("This notification is out of date; open the settings to see the current offer.")
78
+ result["text"] = mutate_notice(cfg, notice)
79
+ cfg["defaults_notice"] = notice
80
+
81
+ config.update_config(apply)
82
+ return result["text"]
83
+
84
+
85
+ def apply(nonce=None):
86
+ """Switches the offered roles to the recommended models and keeps the previous ones for `revert`."""
87
+ def mutate(cfg, notice):
88
+ # A role the user changed since the offer was made is theirs: it is skipped, never overwritten.
89
+ todo = {role: change for role, change in notice["changes"].items() if cfg.get(role) == change["current"]}
90
+ if not todo:
91
+ raise ValueError("The offered roles were changed since the notice; open the settings to see the current state.")
92
+ notice["previous"] = {role: change["current"] for role, change in todo.items()}
93
+ for role, change in todo.items():
94
+ cfg[role] = change["new"]
95
+ notice.update(state="applied", applied_at=time.time())
96
+ return "applied: " + ", ".join(f"{role} {change['current']} -> {change['new']}" for role, change in todo.items())
97
+ return _change(mutate, nonce)
98
+
99
+
100
+ def decline(nonce=None):
101
+ """Stops announcing this defaults version; the user's models stay as they are."""
102
+ def mutate(cfg, notice):
103
+ notice["state"] = "declined"
104
+ return "declined: no more notices for this version of the defaults"
105
+ return _change(mutate, nonce)
106
+
107
+
108
+ def revert():
109
+ """Puts back the models `apply` replaced. Raises when nothing was applied."""
110
+ done = {}
111
+
112
+ def put_back(cfg):
113
+ notice = dict(_current(cfg))
114
+ if notice["state"] != "applied" or not notice.get("previous"):
115
+ raise ValueError("Nothing to revert: the recommended models were not applied.")
116
+ for role, model in notice["previous"].items():
117
+ cfg[role] = model
118
+ done["text"] = "reverted: " + ", ".join(f"{role} -> {model}" for role, model in notice["previous"].items())
119
+ notice.update(state="declined", previous={})
120
+ cfg["defaults_notice"] = notice
121
+
122
+ config.update_config(put_back)
123
+ return done["text"]
124
+
125
+
126
+ def due(now=None):
127
+ """The offer the tray should announce now (pending and not announced in the last 24 h), or None. Marks it
128
+ announced, so a second call in the same day returns None; closing the toast without choosing brings it back."""
129
+ now = now or time.time()
130
+ taken = {}
131
+
132
+ def mark(cfg):
133
+ notice = dict(_current(cfg))
134
+ if notice["state"] == "pending" and notice.get("changes") and now - notice.get("last_notified", 0) >= REMIND_EVERY_S:
135
+ notice["last_notified"] = now
136
+ cfg["defaults_notice"] = notice
137
+ taken["offer"] = {"changes": notice["changes"], "nonce": notice["nonce"]}
138
+
139
+ config.update_config(mark)
140
+ return taken.get("offer")
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Runs a toast button: `pythonw notice_action.py "smart-tool-action:<action>?<query>"`.
3
+
4
+ Started by Windows through the protocol registered in `toast_notice.register`, with no window. Every outcome (done or
5
+ refused) is appended to `notice-actions.log` in the data folder, because nothing else can show an error here.
6
+ """
7
+ import os
8
+ import sys
9
+ import time
10
+ import urllib.parse
11
+ import webbrowser
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+ import model_upgrade
15
+ import paths
16
+ import toast_notice
17
+
18
+ LOG_PATH = os.path.join(paths.DATA_DIR, "notice-actions.log")
19
+
20
+
21
+ def run(uri):
22
+ """Returns a short result line. Raises ValueError for a link that is not ours or an action that does not exist."""
23
+ prefix = toast_notice.PROTOCOL + ":"
24
+ if not uri.startswith(prefix):
25
+ raise ValueError(f"Not a Smart Tool link: {uri[:80]!r}")
26
+ action, _, query = uri[len(prefix):].partition("?")
27
+ params = dict(urllib.parse.parse_qsl(query))
28
+ if action == "models-apply":
29
+ return model_upgrade.apply(params.get("n", ""))
30
+ if action == "models-decline":
31
+ return model_upgrade.decline(params.get("n", ""))
32
+ if action == "open-setup":
33
+ import daemon_launcher
34
+ webbrowser.open(daemon_launcher.ensure_daemon_running().rstrip("/") + "/setup#" + params.get("section", "models"))
35
+ return "setup opened"
36
+ if action == "mute-model":
37
+ import model_health
38
+ return model_health.mute(params.get("model", ""))
39
+ raise ValueError(f"Unknown action {action!r}")
40
+
41
+
42
+ def main(argv):
43
+ """Runs the link in `argv[1]` and logs the outcome; returns the process exit code (0 done, 1 refused)."""
44
+ os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
45
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
46
+ try:
47
+ line = f"{stamp} ok {argv[1] if len(argv) > 1 else ''} -> {run(argv[1])}"
48
+ code = 0
49
+ except Exception as exc:
50
+ line = f"{stamp} FAIL {argv[1] if len(argv) > 1 else ''} -> {type(exc).__name__}: {exc}"
51
+ code = 1
52
+ with open(LOG_PATH, "a", encoding="utf-8") as log:
53
+ log.write(line + "\n")
54
+ return code
55
+
56
+
57
+ if __name__ == "__main__":
58
+ sys.exit(main(sys.argv))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
package/setup_audit.py CHANGED
@@ -11,6 +11,7 @@ Usage: python setup_audit.py [--project DIR] [--no-probe] [--json]
11
11
  """
12
12
  import argparse
13
13
  import concurrent.futures
14
+ import glob
14
15
  import hashlib
15
16
  import json
16
17
  import os
@@ -41,18 +42,22 @@ PROBE_MAX_AGE_S = 6 * 3600
41
42
  SKILL_DESCRIPTION_MAX = 1536
42
43
  SKILL_BUDGET_CHARS = 8000
43
44
  CODEX_PROJECT_DOC_MAX = 32 * 1024
45
+ SMART_TOOL_SERVER = "smart-tool"
44
46
  CLAUDE_TOOLS = ("Bash", "Read", "Grep", "Glob", "LS", "Edit", "MultiEdit", "Write", "NotebookEdit", "WebFetch",
45
47
  "WebSearch", "Task")
46
48
  CODEX_TOOLS = ("Bash", "apply_patch")
47
49
  DECIDING_EVENTS = ("PreToolUse",)
48
50
  DOCS = {"claude_hooks": "https://code.claude.com/docs/en/hooks.md", "codex_hooks": "https://learn.chatgpt.com/docs/hooks.md",
49
51
  "claude_skills": "https://code.claude.com/docs/en/skills.md",
52
+ "claude_subagents": "https://code.claude.com/docs/en/sub-agents",
50
53
  "codex_skills": "https://learn.chatgpt.com/docs/build-skills.md",
51
54
  "claude_memory": "https://code.claude.com/docs/en/memory.md",
52
55
  "codex_agents_md": "https://developers.openai.com/codex/guides/agents-md",
53
56
  "claude_mcp": "https://code.claude.com/docs/en/mcp.md", "mcp_lifecycle":
54
57
  "https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle"}
55
58
  _SCRIPT_RE = re.compile(r"""["']?([^\s"'|&;<>]+\.(?:js|cjs|mjs|py|ps1|sh|bat|cmd|exe))["']?""", re.IGNORECASE)
59
+ # The quoted program text after -e / -c / -p / -Command / --eval: code, not a file the hook runs.
60
+ _INLINE_CODE_RE = re.compile(r"""\s(?:-e|-c|-p|-Command|--eval)\s+(?:"(?:[^"\\]|\\.)*"|'[^']*')""", re.IGNORECASE)
56
61
  _SILENCED_RE = re.compile(r"\|\|\s*(?:true|exit\s+0)\b|2>\s*(?:/dev/null|nul)\b|;\s*exit\s+0\s*$", re.IGNORECASE)
57
62
  _IMPORT_RE = re.compile(r"(?<![\w`])@((?:~|\.{1,2})?/?[\w.\-/\\]+\.\w+)")
58
63
 
@@ -204,6 +209,20 @@ def _approvals(data, approval):
204
209
  approval["off"].update(data.get("disabledMcpjsonServers") or [])
205
210
 
206
211
 
212
+ def _agents(snapshot, folder, scope):
213
+ """Subagent definitions (*.md with frontmatter) in folder: name, scope, path and `tools` (None when the field is
214
+ absent, which makes the agent inherit every tool, MCP included)."""
215
+ found = []
216
+ for path in sorted(glob.glob(os.path.join(folder, "*.md"))):
217
+ meta = _frontmatter(snapshot.read(path))
218
+ tools = meta.get("tools")
219
+ if isinstance(tools, str):
220
+ tools = [item.strip() for item in tools.split(",") if item.strip()]
221
+ found.append({"name": str(meta.get("name") or os.path.basename(path)[:-3]), "scope": scope, "path": path,
222
+ "tools": [str(item) for item in tools] if isinstance(tools, list) else None})
223
+ return found
224
+
225
+
207
226
  def collect_claude(project, snapshot):
208
227
  """Claude Code's effective set for project: settings layers, enabled plugins, hooks, MCP, skills, CLAUDE.md."""
209
228
  layers = [(path, "managed") for path in _managed_settings()] + [
@@ -234,6 +253,8 @@ def collect_claude(project, snapshot):
234
253
  mcp += [e for e in shared if e["name"] not in unapproved]
235
254
  skills = _skills(snapshot, os.path.join(endpoint_sync.claude_dir(), "skills"), "user", disabled=disabled_skills)
236
255
  skills += _skills(snapshot, os.path.join(project, ".claude", "skills"), "project", disabled=disabled_skills)
256
+ agents = _agents(snapshot, os.path.join(endpoint_sync.claude_dir(), "agents"), "user")
257
+ agents += _agents(snapshot, os.path.join(project, ".claude", "agents"), "project")
237
258
  plugins = []
238
259
  installed = snapshot.json(os.path.join(endpoint_sync.claude_dir(), "plugins", "installed_plugins.json")) or {}
239
260
  for plugin_id, installs in (installed.get("plugins") or {}).items():
@@ -261,10 +282,64 @@ def collect_claude(project, snapshot):
261
282
  text = snapshot.read(path)
262
283
  if text is not None:
263
284
  instructions.append({"path": path, "kind": kind, "bytes": len(text.encode("utf-8")), "_text": text})
264
- return {"settings": settings, "plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills,
285
+ return {"settings": settings, "plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills, "agents": agents,
265
286
  "instructions": instructions, "unapproved_mcp": unapproved}
266
287
 
267
288
 
289
+ def _codex_plugin_root(market, name):
290
+ """Newest installed version folder of a Codex plugin (`plugins/cache/<marketplace>/<name>/<version>/`), skipping
291
+ the `latest` alias unless it is all there is; None when the plugin is not in the cache."""
292
+ base = os.path.join(codex_dir(), "plugins", "cache", market, name)
293
+ if not os.path.isdir(base):
294
+ return None
295
+ versions = [os.path.join(base, entry) for entry in os.listdir(base) if os.path.isdir(os.path.join(base, entry))]
296
+ real = [path for path in versions if os.path.basename(path) != "latest"]
297
+ return max(real or versions, key=os.path.getmtime) if versions else None
298
+
299
+
300
+ def _codex_plugin_path(manifest, key, root, defaults):
301
+ """Path a Codex plugin manifest names for `key` (relative to the plugin root), else the first default that exists."""
302
+ named = manifest.get(key)
303
+ if isinstance(named, str):
304
+ return os.path.join(root, named)
305
+ return next((os.path.join(root, d) for d in defaults if os.path.exists(os.path.join(root, d))), None)
306
+
307
+
308
+ def _collect_codex_plugins(config, snapshot):
309
+ """Skills, hooks and MCP servers of the Codex plugins enabled in config.toml (`[plugins."<name>@<marketplace>"]`
310
+ unless `enabled = false`), read from the plugin cache: manifest `.codex-plugin/plugin.json`, `skills/`,
311
+ `hooks/hooks.json`, `.mcp.json`. A plugin whose folder or manifest is missing is reported as not verified."""
312
+ plugins, hooks, mcp, skills = [], [], [], []
313
+ for plugin_id, options in (config.get("plugins") or {}).items():
314
+ if isinstance(options, dict) and options.get("enabled") is False:
315
+ continue
316
+ name, _, market = plugin_id.rpartition("@")
317
+ root = _codex_plugin_root(market, name) if name else None
318
+ if not root:
319
+ snapshot.unverified.append(f"Codex plugin {plugin_id}: not found under {os.path.join(codex_dir(), 'plugins', 'cache')}")
320
+ continue
321
+ manifest_path = next((p for p in (os.path.join(root, ".codex-plugin", "plugin.json"),
322
+ os.path.join(root, "plugin.json")) if os.path.exists(p)), None)
323
+ manifest = snapshot.json(manifest_path) if manifest_path else None
324
+ if manifest is None:
325
+ snapshot.unverified.append(f"Codex plugin {plugin_id}: no readable manifest (.codex-plugin/plugin.json) in {root}")
326
+ continue
327
+ plugins.append({"id": plugin_id, "path": root})
328
+ hook_file = _codex_plugin_path(manifest, "hooks", root, (os.path.join("hooks", "hooks.json"),))
329
+ hooks += _hooks_from(snapshot.json(hook_file) if hook_file else None, f"plugin:{name}", root)
330
+ mcp_file = _codex_plugin_path(manifest, "mcpServers", root, (".mcp.json", "mcp.json"))
331
+ plugin_mcp = snapshot.json(mcp_file) if mcp_file else None
332
+ if plugin_mcp is None and isinstance(manifest.get("mcpServers"), dict):
333
+ plugin_mcp = manifest["mcpServers"]
334
+ plugin_mcp = plugin_mcp or {}
335
+ mcp += _mcp_entries(plugin_mcp.get("mcpServers", plugin_mcp), f"plugin:{name}", mcp_file or manifest_path,
336
+ f"plugin:{name}:", {"PLUGIN_ROOT": root})
337
+ skill_dir = _codex_plugin_path(manifest, "skills", root, ("skills",))
338
+ skills += [{**s, "name": f"{name}:{s['name']}"} for s in
339
+ _skills(snapshot, skill_dir, f"plugin:{name}", recursive=True)] if skill_dir else []
340
+ return plugins, hooks, mcp, skills
341
+
342
+
268
343
  def collect_codex(project, snapshot):
269
344
  """Codex's effective set for project: config.toml (MCP, hooks), hooks.json, skills, AGENTS.md chain."""
270
345
  config = snapshot.toml(os.path.join(codex_dir(), "config.toml")) or {}
@@ -274,6 +349,10 @@ def collect_codex(project, snapshot):
274
349
  skills = _skills(snapshot, os.path.join(codex_dir(), "skills"), "user", recursive=True)
275
350
  skills += _skills(snapshot, _home(".agents", "skills"), "user-agents")
276
351
  skills += _skills(snapshot, os.path.join(project, ".agents", "skills"), "project")
352
+ plugins, plugin_hooks, plugin_mcp, plugin_skills = _collect_codex_plugins(config, snapshot)
353
+ hooks += plugin_hooks
354
+ mcp += plugin_mcp
355
+ skills += plugin_skills
277
356
  instructions = []
278
357
  for name in ("AGENTS.override.md", "AGENTS.md"):
279
358
  text = snapshot.read(os.path.join(codex_dir(), name))
@@ -292,8 +371,7 @@ def collect_codex(project, snapshot):
292
371
  instructions.append({"path": os.path.join(folder, name), "kind": "project",
293
372
  "bytes": len(text.encode("utf-8")), "_text": text})
294
373
  break
295
- return {"hooks": hooks, "mcp": mcp, "skills": skills, "instructions": instructions,
296
- "plugins_unverified": bool(config.get("plugins"))}
374
+ return {"plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills, "instructions": instructions}
297
375
 
298
376
 
299
377
  def _finding(check, severity, harness, subject, detail, source, evidence=()):
@@ -336,8 +414,9 @@ def check_hook_overlap(harness, hooks, tools):
336
414
 
337
415
 
338
416
  def _expand(token, root):
339
- """A hook script path with ${CLAUDE_PLUGIN_ROOT}, ~ and environment variables expanded."""
340
- token = token.replace("${CLAUDE_PLUGIN_ROOT}", root or "${CLAUDE_PLUGIN_ROOT}")
417
+ """A hook script path with ${CLAUDE_PLUGIN_ROOT} / ${PLUGIN_ROOT}, ~ and environment variables expanded."""
418
+ for variable in ("CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT"):
419
+ token = token.replace("${%s}" % variable, root or "${%s}" % variable)
341
420
  return os.path.expandvars(os.path.expanduser(token))
342
421
 
343
422
 
@@ -348,7 +427,9 @@ def check_hook_targets(harness, hooks, mcp_names, probe):
348
427
  for hook in hooks:
349
428
  where = f"{hook['source']} {hook['event']} {hook['matcher'] or '*'}"
350
429
  if hook["type"] == "command" and hook["command"]:
351
- for token in _SCRIPT_RE.findall(hook["command"]):
430
+ for token in _SCRIPT_RE.findall(_INLINE_CODE_RE.sub(" ", hook["command"])):
431
+ if not re.search(r"[\\/]", token) and shutil.which(token):
432
+ continue
352
433
  path = _expand(token, hook.get("root"))
353
434
  if "$" in path or "%" in path:
354
435
  continue
@@ -384,6 +465,24 @@ def _reachable(url):
384
465
  return str(exc)
385
466
 
386
467
 
468
+ def check_agents(claude):
469
+ """Subagents whose `tools` list names no Smart Tool tool while the smart-tool server is registered: the redirect
470
+ leaves them alone (nothing to redirect to), so they search folders with Grep and Read at full cost. A subagent
471
+ with no `tools` field inherits every tool, MCP included; with the field, only what it lists."""
472
+ if not any(entry["name"] == SMART_TOOL_SERVER for entry in claude["mcp"]):
473
+ return []
474
+ blind = [a for a in claude.get("agents", []) if a["tools"] is not None
475
+ and not any(tool == "*" or tool.startswith(f"mcp__{SMART_TOOL_SERVER}") for tool in a["tools"])]
476
+ if not blind:
477
+ return []
478
+ names = ", ".join(sorted(a["name"] for a in blind)[:8]) + (" ..." if len(blind) > 8 else "")
479
+ return [_finding("agent_without_smart_tool", "warn", "claude", "subagents without Smart Tool",
480
+ f"{len(blind)} subagent(s) list tools but none of mcp__{SMART_TOOL_SERVER}__*: {names}. Add "
481
+ f"mcp__{SMART_TOOL_SERVER}__smart_search and mcp__{SMART_TOOL_SERVER}__smart_search_result to "
482
+ "their `tools`, or drop the `tools` line to inherit every tool.", DOCS["claude_subagents"],
483
+ [a["path"] for a in blind[:8]])]
484
+
485
+
387
486
  def check_skills(harness, skills):
388
487
  """Skill name collisions, descriptions over Claude's per-skill cut, and the total over the list budget."""
389
488
  findings, by_name = [], {}
@@ -635,6 +734,7 @@ def run(project, probe=True, save=True, probe_max_age=0, trigger="manual"):
635
734
  results = (probe_mcp(data["mcp"], project, max_age=probe_max_age) if probe
636
735
  else [None] * len(data["mcp"]))
637
736
  findings += check_mcp(harness, data["mcp"], results)
737
+ findings += check_agents(claude)
638
738
  findings += check_instructions(project, claude, codex)
639
739
  unverified = list(snapshot.unverified)
640
740
  if not probe:
@@ -642,8 +742,6 @@ def run(project, probe=True, save=True, probe_max_age=0, trigger="manual"):
642
742
  for name in claude["unapproved_mcp"]:
643
743
  unverified.append(f".mcp.json server '{name}' is not approved in Claude Code, so it is neither loaded nor "
644
744
  "started here")
645
- if codex.get("plugins_unverified"):
646
- unverified.append("Codex plugins (their skills and hooks) are not collected yet")
647
745
  unverified += ["skill trigger overlap and contradictions between instructions need a model (not run)",
648
746
  "the order between two parallel rewrites is undocumented and cannot be checked"]
649
747
  key = hashlib.sha256(json.dumps(sorted(snapshot.files.items())).encode()).hexdigest()[:16]
package/setup_ui.py CHANGED
@@ -18,6 +18,7 @@ import json
18
18
  import os
19
19
  import re
20
20
  import secrets
21
+ import time
21
22
 
22
23
  import autostart
23
24
  import block_patterns
@@ -29,6 +30,8 @@ import endpoint_sync
29
30
  import gateway
30
31
  import model_catalog
31
32
  import model_defaults
33
+ import model_health
34
+ import model_upgrade
32
35
  import local_embedder
33
36
  import project_store
34
37
  import secret_store
@@ -155,6 +158,56 @@ def handle_models_get():
155
158
  }
156
159
 
157
160
 
161
+ _UPGRADE_CHECK_EVERY_S = 3600
162
+ _upgrade_checked = {"at": 0.0, "error": ""}
163
+
164
+
165
+ def _checked_upgrade(force=False):
166
+ """The model offer, rechecked against the catalog at most once an hour (or when forced). A catalog that cannot be
167
+ read is reported in `check_error`, with the last stored offer, never as 'nothing to offer'."""
168
+ now = time.time()
169
+ if force or now - _upgrade_checked["at"] >= _UPGRADE_CHECK_EVERY_S:
170
+ try:
171
+ model_upgrade.check()
172
+ _upgrade_checked["error"] = ""
173
+ except Exception as exc:
174
+ _upgrade_checked["error"] = str(exc)[:300]
175
+ _upgrade_checked["at"] = now
176
+ return {**model_upgrade.status(), "check_error": _upgrade_checked["error"]}
177
+
178
+
179
+ def handle_upgrade_get():
180
+ return _checked_upgrade(force=True)
181
+
182
+
183
+ def handle_upgrade_action(body):
184
+ action = body.get("action") if isinstance(body, dict) else None
185
+ try:
186
+ if action == "apply":
187
+ text = model_upgrade.apply()
188
+ elif action == "decline":
189
+ text = model_upgrade.decline()
190
+ elif action == "revert":
191
+ text = model_upgrade.revert()
192
+ else:
193
+ raise SetupError("Invalid action: use apply, decline or revert.")
194
+ except (ValueError, config.ConfigCorruptedError, OSError) as exc:
195
+ raise SetupError(str(exc))
196
+ return {"ok": True, "message": text, "upgrade": model_upgrade.status()}
197
+
198
+
199
+ def handle_health_get():
200
+ return model_health.summary()
201
+
202
+
203
+ def handle_notices():
204
+ """What the tray should show now: the model offer (once a day until answered) and models over the failure limit
205
+ (once a day each). Fetching marks them announced, so each toast is shown once per reminder period."""
206
+ upgrade = _checked_upgrade()
207
+ offer = model_upgrade.due() if upgrade["state"] == "pending" else None
208
+ return {"upgrade": offer, "failing": model_health.alerts_due(), "check_error": upgrade["check_error"]}
209
+
210
+
158
211
  def handle_local_models_get():
159
212
  available = local_embedder.info() if local_embedder.configured() else None
160
213
  return {"mode": config.local_models_mode(), "modes": list(config.LOCAL_MODEL_MODES),
@@ -671,6 +724,24 @@ _PAGE_TEMPLATE = """<!doctype html>
671
724
  <div class="msg" id="gateway-msg" role="status"></div>
672
725
  </div>
673
726
 
727
+ <div class="card" id="upgrade-card" hidden>
728
+ <div class="card-head"><h3>New recommended models</h3><span class="pill" id="upgrade-pill" data-tone="warn">Available</span></div>
729
+ <p class="lede">This version recommends other models than the ones you use. Nothing changes until you apply; the models you have now are kept so you can switch back.</p>
730
+ <ul id="upgrade-list"></ul>
731
+ <div class="actions">
732
+ <button type="button" class="primary" id="btn-upgrade-apply">Apply recommended models</button>
733
+ <button type="button" id="btn-upgrade-decline">Keep mine, don't ask again</button>
734
+ <button type="button" id="btn-upgrade-revert" hidden>Switch back to my previous models</button>
735
+ </div>
736
+ <div class="msg" id="upgrade-msg" role="status"></div>
737
+ </div>
738
+
739
+ <div class="card" id="health-card">
740
+ <div class="card-head"><h3>Model failures (last 24 h)</h3><span class="pill" id="health-pill" data-tone="busy">Checking</span></div>
741
+ <p class="lede">Every failed call to a gateway model, by role and tool. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
742
+ <div id="health-table"></div>
743
+ </div>
744
+
674
745
  <div class="card" id="models-card">
675
746
  <div class="card-head"><h3>2. Models</h3><span class="pill" id="models-pill" data-tone="busy">Checking</span></div>
676
747
  <p class="lede">Any model id your gateway accepts. The gateway's catalog shows up as suggestions while you type.</p>
@@ -1048,6 +1119,52 @@ async function loadModels() {
1048
1119
  setMsg("models-msg", "error", "Could not read the gateway catalog: " + error.message);
1049
1120
  }
1050
1121
  }
1122
+
1123
+ function esc(text) { const el = document.createElement("span"); el.textContent = text; return el.innerHTML; }
1124
+ async function loadUpgrade() {
1125
+ try {
1126
+ const data = await getJSON("/setup/models/upgrade");
1127
+ const rows = Object.entries(data.changes || {}).map(([role, change]) => `<li><b>${esc(role)}</b>: ${esc(change.current)} → ${esc(change.new)}</li>`);
1128
+ const offer = data.state === "pending" && rows.length;
1129
+ const applied = data.state === "applied" && Object.keys(data.previous || {}).length;
1130
+ $("upgrade-card").hidden = !(offer || applied);
1131
+ $("upgrade-list").innerHTML = offer ? rows.join("") : Object.entries(data.previous).map(([role, model]) => `<li><b>${esc(role)}</b> was ${esc(model)}</li>`).join("");
1132
+ setPill("upgrade-pill", offer ? "warn" : "ok", offer ? "Available" : "Applied");
1133
+ $("btn-upgrade-apply").hidden = $("btn-upgrade-decline").hidden = !offer;
1134
+ $("btn-upgrade-revert").hidden = !applied;
1135
+ if (data.check_error) setMsg("upgrade-msg", "error", "Could not check the catalog: " + data.check_error);
1136
+ } catch (error) {
1137
+ $("upgrade-card").hidden = false;
1138
+ setMsg("upgrade-msg", "error", "Could not read the model offer: " + error.message);
1139
+ }
1140
+ }
1141
+ for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-decline", "decline"], ["btn-upgrade-revert", "revert"]]) {
1142
+ $(id).addEventListener("click", async () => {
1143
+ setMsg("upgrade-msg", "", "Working…");
1144
+ try {
1145
+ const result = await post("/setup/models/upgrade", { action });
1146
+ await loadUpgrade();
1147
+ await loadModels();
1148
+ setMsg("upgrade-msg", "ok", result.message);
1149
+ } catch (error) {
1150
+ setMsg("upgrade-msg", "error", error.message);
1151
+ }
1152
+ });
1153
+ }
1154
+ async function loadHealth() {
1155
+ try {
1156
+ const data = await getJSON("/setup/models/health");
1157
+ const failing = data.models.filter((row) => row.failing).length;
1158
+ setPill("health-pill", failing ? "bad" : "ok", failing ? plural(failing, "model") + " failing" : "No problem");
1159
+ const models = data.models.map((row) => `<tr><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${row.errors} / ${row.calls}</td><td>${Math.round(row.rate * 100)}%</td></tr>`).join("");
1160
+ const failures = data.failures.map((row) => `<tr><td>${esc(new Date(row.at * 1000).toLocaleString())}</td><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
1161
+ $("health-table").innerHTML = (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
1162
+ + (failures ? `<table><tr><th>When</th><th>Role</th><th>Model</th><th>Tool</th><th>Error</th></tr>${failures}</table>` : "");
1163
+ } catch (error) {
1164
+ setPill("health-pill", "bad", "Unavailable");
1165
+ $("health-table").textContent = "Could not read the failure log: " + error.message;
1166
+ }
1167
+ }
1051
1168
  $("btn-save-models").addEventListener("click", async () => {
1052
1169
  if (!modelsLoaded) { setMsg("models-msg", "error", "The catalog has not loaded yet; nothing was saved."); return; }
1053
1170
  setMsg("models-msg", "", "Saving…");
@@ -1809,7 +1926,8 @@ $("btn-audit-run").addEventListener("click", async () => {
1809
1926
 
1810
1927
  renderGateway();
1811
1928
  renderOverview();
1812
- if (status.gateway.status === "ready") loadModels();
1929
+ if (status.gateway.status === "ready") { loadModels(); loadUpgrade(); }
1930
+ loadHealth();
1813
1931
  loadLocalModels();
1814
1932
  loadIntegration();
1815
1933
  loadAutostart();
@@ -55,6 +55,7 @@ import dossier
55
55
  import scout
56
56
  import embedding_cache
57
57
  import model_defaults
58
+ import model_health
58
59
  import project_identity
59
60
  import project_store
60
61
  import project_scheduler
@@ -3984,13 +3985,19 @@ def _dispatch_tool_call(params, session_id=None):
3984
3985
  return _tool_result(_sanitize_text(f"Unknown tool: {name}"), is_error=True)
3985
3986
  token = _MCP_SESSION.set(session_id)
3986
3987
  try:
3987
- return _tool_result(handler(arguments))
3988
- except Exception as exc:
3989
- # Boundary central pra qualquer handler atual/futuro: uma exceção pode
3990
- # carregar texto de uma fonte não totalmente confiável (ex.: corpo/reason
3991
- # de erro de um proxy externo) até este ponto sem ter passado por
3992
- # sanitização própria do handler.
3993
- return _tool_result(_sanitize_text(str(exc)), is_error=True)
3988
+ with model_health.tool_call(name) as failures:
3989
+ try:
3990
+ result = _tool_result(handler(arguments))
3991
+ except Exception as exc:
3992
+ # Boundary central pra qualquer handler atual/futuro: uma exceção pode
3993
+ # carregar texto de uma fonte não totalmente confiável (ex.: corpo/reason
3994
+ # de erro de um proxy externo) até este ponto sem ter passado por
3995
+ # sanitização própria do handler.
3996
+ result = _tool_result(_sanitize_text(str(exc)), is_error=True)
3997
+ warning = model_health.warning_text(failures, setup_ui.setup_origin())
3998
+ if warning:
3999
+ result["content"][0]["text"] += _sanitize_text(warning)
4000
+ return result
3994
4001
  finally:
3995
4002
  _MCP_SESSION.reset(token)
3996
4003
 
@@ -4059,6 +4066,7 @@ def _loopback_host(header_value, require_port=False):
4059
4066
 
4060
4067
  _SETUP_SAVES = {
4061
4068
  "/setup/models": setup_ui.handle_models_save,
4069
+ "/setup/models/upgrade": setup_ui.handle_upgrade_action,
4062
4070
  "/setup/providers": setup_ui.handle_providers_save,
4063
4071
  "/setup/local-models": setup_ui.handle_local_models_save,
4064
4072
  "/setup/integration": setup_ui.handle_integration_save,
@@ -4180,6 +4188,13 @@ class MCPHandler(BaseHTTPRequestHandler):
4180
4188
  except Exception as exc:
4181
4189
  self._write_json(500, {"error": _sanitize_text(str(exc))})
4182
4190
  return
4191
+ if self.path in ("/setup/models/upgrade", "/setup/models/health"):
4192
+ try:
4193
+ self._write_json(200, setup_ui.handle_upgrade_get() if self.path.endswith("upgrade")
4194
+ else setup_ui.handle_health_get())
4195
+ except Exception as exc:
4196
+ self._write_json(500, {"error": _sanitize_text(str(exc))})
4197
+ return
4183
4198
  if self.path == "/projects":
4184
4199
  import projects_ui
4185
4200
  self._write_html(200, projects_ui.render_projects_page())
@@ -4263,6 +4278,12 @@ class MCPHandler(BaseHTTPRequestHandler):
4263
4278
  if urllib.parse.urlsplit(self.path).path == "/hooks/session-audit":
4264
4279
  self._write_json(200, _session_audit_hook(self))
4265
4280
  return
4281
+ if self.path == "/setup/notices":
4282
+ try:
4283
+ self._write_json(200, setup_ui.handle_notices())
4284
+ except Exception as exc:
4285
+ self._write_json(500, {"error": _sanitize_text(str(exc))})
4286
+ return
4266
4287
  save = _SETUP_SAVES.get(self.path)
4267
4288
  if save:
4268
4289
  self._post_setup(save)