euron-code 0.5.1__tar.gz → 0.5.2__tar.gz

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.
Files changed (99) hide show
  1. {euron_code-0.5.1 → euron_code-0.5.2}/CHANGELOG.md +13 -0
  2. {euron_code-0.5.1 → euron_code-0.5.2}/PKG-INFO +1 -1
  3. {euron_code-0.5.1 → euron_code-0.5.2}/pyproject.toml +1 -1
  4. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/__init__.py +1 -1
  5. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/agent.py +105 -4
  6. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/api.py +7 -1
  7. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/cli.py +36 -8
  8. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/permissions.py +6 -0
  9. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/prompts.py +9 -0
  10. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/erie.py +28 -17
  11. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/oss.py +1 -1
  12. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/ui.py +15 -2
  13. {euron_code-0.5.1 → euron_code-0.5.2}/tests/mock_gateway.py +43 -0
  14. euron_code-0.5.2/tests/test_qa_attach_secrets.py +59 -0
  15. euron_code-0.5.2/tests/test_qa_long_task.py +88 -0
  16. euron_code-0.5.2/tests/test_qa_print_output.py +101 -0
  17. euron_code-0.5.2/tests/test_qa_reuse_unrelated.py +105 -0
  18. euron_code-0.5.2/tests/test_qa_yolo_outside.py +59 -0
  19. {euron_code-0.5.1 → euron_code-0.5.2}/.gitignore +0 -0
  20. {euron_code-0.5.1 → euron_code-0.5.2}/LICENSE +0 -0
  21. {euron_code-0.5.1 → euron_code-0.5.2}/README.md +0 -0
  22. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/__main__.py +0 -0
  23. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/activity.py +0 -0
  24. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/attachments.py +0 -0
  25. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/auth.py +0 -0
  26. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/builtin_skills/build-from-reference/SKILL.md +0 -0
  27. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/builtin_skills/explain-codebase/SKILL.md +0 -0
  28. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/builtin_skills/review/SKILL.md +0 -0
  29. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/builtin_skills/test-writer/SKILL.md +0 -0
  30. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/cache.py +0 -0
  31. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/checkpoints.py +0 -0
  32. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/config.py +0 -0
  33. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/context.py +0 -0
  34. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/contextforge/LICENSE +0 -0
  35. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/contextforge/__init__.py +0 -0
  36. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/contextforge/text.py +0 -0
  37. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/contextforge/toolout.py +0 -0
  38. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/credentials.py +0 -0
  39. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/doctor.py +0 -0
  40. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/documents.py +0 -0
  41. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/error_hints.py +0 -0
  42. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/errorlog.py +0 -0
  43. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/frontmatter.py +0 -0
  44. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/hooks.py +0 -0
  45. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/keys.py +0 -0
  46. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/licensing.py +0 -0
  47. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/memory.py +0 -0
  48. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/project_setup.py +0 -0
  49. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/repl.py +0 -0
  50. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/repomap.py +0 -0
  51. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/rules.py +0 -0
  52. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/sessions.py +0 -0
  53. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/skills.py +0 -0
  54. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/textsafe.py +0 -0
  55. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/__init__.py +0 -0
  56. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/background.py +0 -0
  57. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/base.py +0 -0
  58. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/db.py +0 -0
  59. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/document.py +0 -0
  60. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/fs.py +0 -0
  61. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/ignore.py +0 -0
  62. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/reuse.py +0 -0
  63. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/search.py +0 -0
  64. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/shell.py +0 -0
  65. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/skill.py +0 -0
  66. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/symbols.py +0 -0
  67. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/task.py +0 -0
  68. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/todo.py +0 -0
  69. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/tools/web.py +0 -0
  70. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/trust.py +0 -0
  71. {euron_code-0.5.1 → euron_code-0.5.2}/src/euron_code/usage_view.py +0 -0
  72. {euron_code-0.5.1 → euron_code-0.5.2}/tests/conftest.py +0 -0
  73. {euron_code-0.5.1 → euron_code-0.5.2}/tests/helpers.py +0 -0
  74. {euron_code-0.5.1 → euron_code-0.5.2}/tests/serve_helper.py +0 -0
  75. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_agent_mock.py +0 -0
  76. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_api_shell.py +0 -0
  77. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_bench.py +0 -0
  78. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_cli_e2e.py +0 -0
  79. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_context_memory_sessions.py +0 -0
  80. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_db.py +0 -0
  81. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_erie.py +0 -0
  82. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_permissions.py +0 -0
  83. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_powershell_exit_codes.py +0 -0
  84. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_repl.py +0 -0
  85. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_search.py +0 -0
  86. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_textsafe.py +0 -0
  87. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_tools_fs.py +0 -0
  88. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v2_rules_skills_hooks.py +0 -0
  89. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v2_tokens_shortcuts.py +0 -0
  90. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v2_usage_headers.py +0 -0
  91. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v31_security.py +0 -0
  92. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v3_accounts_value_setup.py +0 -0
  93. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v3_activity.py +0 -0
  94. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v3_attachments.py +0 -0
  95. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v3_context.py +0 -0
  96. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v3_live_repl.py +0 -0
  97. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v3_single_login.py +0 -0
  98. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v4.py +0 -0
  99. {euron_code-0.5.1 → euron_code-0.5.2}/tests/test_v5.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2 (2026-11-05)
4
+
5
+ Security and reliability fixes from pre-launch testing.
6
+
7
+ - Bypass mode (`--yolo`) now asks before any command that names a path outside your project, including
8
+ quoted paths and paths in your home folder.
9
+ - Files that usually hold secrets (such as `.env`) are never sent with your message without a yes.
10
+ - An answer that comes back empty is asked for once more instead of ending silently; `-p` always prints a
11
+ result, and JSON mode reports errors as JSON.
12
+ - Hindi, emoji and other non-English text print correctly when output is piped on Windows.
13
+ - Long tasks no longer stop early as a false "loop", or with an unfinished task list.
14
+ - Reuse planning picks the right components and records every reused source.
15
+
3
16
  ## 0.5.1 (2026-11-05)
4
17
 
5
18
  - Running `euron` again right after a run finished no longer says Euron Code is already running for
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: euron-code
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Euron Code: an agentic coding assistant in your terminal for Euron Plus and Super 30 students.
5
5
  Project-URL: Homepage, https://euron.one/code-agent
6
6
  Author-email: Euron <contact@euron.one>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "euron-code"
7
- version = "0.5.1"
7
+ version = "0.5.2"
8
8
  description = "Euron Code: an agentic coding assistant in your terminal for Euron Plus and Super 30 students."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """Euron Code: an agentic coding assistant for the terminal."""
2
2
 
3
- __version__ = "0.5.1"
3
+ __version__ = "0.5.2"
@@ -167,7 +167,9 @@ class Agent:
167
167
  self.pending_images: List[Dict[str, Any]] = [] # image_url parts for the next prompt
168
168
  self.pending_image_paths: List[Path] = [] # their local files (a photo may belong on the page)
169
169
  self._placeholder_nudged = False
170
+ self._ctx_note_band = 0 # 10% band of the last small "Context was N% full" note
170
171
  self.pending_paths: List[Path] = [] # /attach, /paste-image: attached to the next prompt
172
+ self._notice_nudged = False # one THIRD_PARTY_NOTICES nudge per prompt
171
173
  self.memory_files: List[memory.MemoryFile] = []
172
174
  self.rules = rules.RuleSet([])
173
175
  self.skills: Dict[str, skills.Skill] = {}
@@ -319,6 +321,7 @@ class Agent:
319
321
  errorlog.get().task_id = self.client.task_id
320
322
  self._nudged = False
321
323
  self._placeholder_nudged = False
324
+ self._notice_nudged = False
322
325
  for r in self._run_hooks("UserPromptSubmit", {"prompt": user_text}):
323
326
  if r.code == 2:
324
327
  self.ui.warn("A UserPromptSubmit hook blocked this prompt: " + (r.stderr.strip() or "(no reason given)"))
@@ -379,8 +382,11 @@ class Agent:
379
382
  continues = 0 # answer cut by the output limit, continued automatically
380
383
  cut_tools = 0
381
384
  effort_retry = False
385
+ empty_retry = False # one retry when the model answers with nothing at all
382
386
  partial = "" # text of an answer that is being continued
383
387
  todos_before = list(self.ctx.todos)
388
+ todo_nudged = False # one "your task list is not finished" nudge per request
389
+ todos_at_start = list(self.ctx.todos)
384
390
  while True:
385
391
  if max_turns and turns >= max_turns:
386
392
  return TurnResult(last_text, "max_turns", turns)
@@ -433,6 +439,15 @@ class Agent:
433
439
  self._append({"role": "user", "content": prompts.ANSWER_NUDGE})
434
440
  self._effort_once = "low"
435
441
  continue
442
+ if (not resp.tool_calls and not (partial + resp.content).strip() and not empty_retry
443
+ and not self.is_subagent):
444
+ # the model ended the turn without a word (seen live): ask once instead of
445
+ # printing nothing and exiting 0
446
+ empty_retry = True
447
+ self._append({"role": "assistant", "content": "(empty reply)"})
448
+ self._append({"role": "user", "content": prompts.EMPTY_NUDGE})
449
+ self.ui.dim("(the answer came back empty; asking again)")
450
+ continue
436
451
  msg: Dict[str, Any] = {"role": "assistant", "content": resp.content}
437
452
  if resp.tool_calls:
438
453
  msg["tool_calls"] = resp.tool_calls
@@ -455,11 +470,29 @@ class Agent:
455
470
  self._append({"role": "user", "content": prompts.PLACEHOLDER_NUDGE.format(where=where)})
456
471
  self.ui.dim("(asking Euron Code to replace placeholder text with your details)")
457
472
  continue
473
+ missing = self._missing_notices() if not self._notice_nudged and not self.is_subagent else ""
474
+ if missing:
475
+ self._notice_nudged = True
476
+ self._append({"role": "user", "content": prompts.NOTICE_NUDGE.format(repos=missing)})
477
+ self.ui.dim("(asking Euron Code to record the reused sources in THIRD_PARTY_NOTICES.md)")
478
+ continue
458
479
  if self._needs_verify_nudge():
459
480
  self._nudged = True
460
481
  self._append({"role": "user", "content": prompts.verify_nudge(self.test_command)})
461
482
  self.ui.dim("(asking Euron Code to check its change)")
462
483
  continue
484
+ open_todos = [t for t in self.ctx.todos if isinstance(t, dict)
485
+ and t.get("status") in ("pending", "in_progress")]
486
+ if (open_todos and self.ctx.todos != todos_at_start and not todo_nudged and not self.is_subagent
487
+ and self.permissions.mode != "plan"
488
+ and len((resp.content or "").strip()) < 400):
489
+ # QA 2026-11-05: a portfolio build ended on "Scaffolding the Next.js project now..." with 6 of 6
490
+ # todos open and no file written; exit 0, nothing built
491
+ todo_nudged = True
492
+ self._append({"role": "user", "content": prompts.TODO_NUDGE.format(
493
+ todos="; ".join(str(t.get("content"))[:80] for t in open_todos[:5]))})
494
+ self.ui.dim("(the task list is not finished; asking Euron Code to continue)")
495
+ continue
463
496
  if not self.is_subagent:
464
497
  self._footer()
465
498
  return TurnResult(partial + resp.content, "end_turn", turns)
@@ -755,7 +788,11 @@ class Agent:
755
788
  args = str(fn.get("arguments"))
756
789
  failed = content.startswith("Error") or (content.startswith("Exit code: ") and
757
790
  not content.startswith("Exit code: 0"))
758
- key = (fn.get("name"), args, failed, content[:300] if failed else "")
791
+ # A failing run is "the same" only when its start AND its end match (numbers ignored, so timings
792
+ # do not hide a real loop). QA 2026-11-05: pytest printed the same 300-char warning first every time,
793
+ # so 15 failed / collection error / 4 failed counted as one call failing 5 times and the turn stopped.
794
+ key = (fn.get("name"), args, failed,
795
+ (content[:300], re.sub(r"\d+", "N", content[-300:])) if failed else "")
759
796
  n = self._calls[key] = self._calls.get(key, 0) + 1
760
797
  if failed:
761
798
  if n >= LOOP_STOP_AT:
@@ -879,6 +916,9 @@ class Agent:
879
916
  model call), so the model starts from proven code whatever it would have chosen; and put the
880
917
  user's photo into the project. Returns text for the end of the user message."""
881
918
  from euron_code.tools import erie
919
+ # only what the user typed: an attached document's text ("The service must expose GET /health")
920
+ # turned a question about it into a build request (QA 2026-11-05)
921
+ user_text = re.sub(r'<attached-file path="[^"]*">.*?</attached-file>', " ", user_text, flags=re.S)
882
922
  if not erie.is_build_request(user_text):
883
923
  return ""
884
924
  out = ""
@@ -942,6 +982,36 @@ class Agent:
942
982
  hits.append(f"{rel} ({', '.join(found[:3])})")
943
983
  return "; ".join(hits[:6])
944
984
 
985
+ def _missing_notices(self) -> str:
986
+ """Repos read this prompt that a changed file names (an "Adapted from owner/repo" header) but that
987
+ THIRD_PARTY_NOTICES.md does not list. QA 2026-11-05: a portfolio's Hero said "Adapted from
988
+ euron-sde-team/euron-open-multi-tenant-lms (MIT)" and the notices file had no entry for it."""
989
+ from euron_code import licensing
990
+ repos = list(dict.fromkeys(r for r, _, _ in self.ledger.get("reused") or []))
991
+ if not repos:
992
+ return ""
993
+ try:
994
+ notices = (self.root / licensing.NOTICES_FILE).read_text(encoding="utf-8", errors="ignore")
995
+ except OSError:
996
+ notices = ""
997
+ listed = {ln[3:].strip().lower() for ln in notices.splitlines() if ln.startswith("## ")}
998
+ todo = [r for r in repos if r.lower() not in listed]
999
+ if not todo:
1000
+ return ""
1001
+ named = []
1002
+ for rel in self.files_changed():
1003
+ p = self.root / rel
1004
+ if p.name == licensing.NOTICES_FILE:
1005
+ continue
1006
+ try:
1007
+ if not p.is_file() or p.stat().st_size > 400_000:
1008
+ continue
1009
+ text = p.read_text(encoding="utf-8", errors="ignore")
1010
+ except OSError:
1011
+ continue
1012
+ named += [r for r in todo if r in text and r not in named]
1013
+ return ", ".join(named[:6])
1014
+
945
1015
  def _verified(self) -> bool:
946
1016
  if not self.ledger["edits"]:
947
1017
  return True
@@ -960,7 +1030,10 @@ class Agent:
960
1030
  """Local report after a prompt that changed files (shown to the user, not sent to the model)."""
961
1031
  changed = self.files_changed()
962
1032
  reused = self.ledger.get("reused") or []
963
- planned = [c for c in self.reuse_report() if c["decision"] in ("REUSE", "ADAPT")]
1033
+ read_repos = {r for r, _, _ in reused}
1034
+ # "x reused from repo" only when that repo was actually read (QA 2026-11-05: a question about two
1035
+ # attached documents printed "Reuse plan: api reused from euron-open-smart-router" with nothing read)
1036
+ planned = [c for c in self.reuse_report() if c["decision"] in ("REUSE", "ADAPT") and c.get("repo") in read_repos]
964
1037
  if planned:
965
1038
  self._phase("Integration completed")
966
1039
  self.ui.dim("Reuse plan: " + "; ".join(f"{c['component']} {_PAST.get(c['decision'], c['decision'].lower())} from {c['repo']} "
@@ -1193,7 +1266,14 @@ class Agent:
1193
1266
  self.savings.add("micro", saved)
1194
1267
  self._persist_compaction()
1195
1268
  after = self.context_usage()
1196
- if before - after >= 2000 or before * 100 // self.usable_window >= 60:
1269
+ pct = before * 100 // self.usable_window
1270
+ if pct < 60:
1271
+ self._ctx_note_band = 0
1272
+ # above 60% the note came on EVERY step, even for 32 freed tokens (QA 2026-11-05: 11 lines in one
1273
+ # build); now a small clearing is shown once per 10% band
1274
+ if before - after >= 2000 or (pct >= 60 and pct // 10 > self._ctx_note_band):
1275
+ if before - after < 2000:
1276
+ self._ctx_note_band = pct // 10
1197
1277
  self.ui.dim(f"Context was {before * 100 // self.usable_window}% full: cleared {len(ids)} tool "
1198
1278
  f"output(s), freed {max(0, before - after):,} tokens, now "
1199
1279
  f"{after * 100 // self.usable_window}%.")
@@ -1243,7 +1323,11 @@ class Agent:
1243
1323
  if not manual:
1244
1324
  context.elide_old_tool_outputs(self.messages, keep_last=1, min_chars=300)
1245
1325
  self._persist_compaction()
1246
- self.ui.warn("Nothing older to summarise; the current turn alone is large. Consider /clear.")
1326
+ if len(users) <= 1: # the first message itself is too big: /clear cannot help
1327
+ self.ui.warn("Your message alone is too large for the context. Put long text in a file and "
1328
+ "mention it with @file instead.")
1329
+ else:
1330
+ self.ui.warn("Nothing older to summarise; the current turn alone is large. Consider /clear.")
1247
1331
  return False
1248
1332
  old = self.messages[1:cut]
1249
1333
  recent = self.messages[cut:]
@@ -1459,6 +1543,10 @@ class Agent:
1459
1543
  self.pending_image_paths.append(p)
1460
1544
  attachments.privacy_notice_once(self.ui)
1461
1545
  return None
1546
+ from euron_code.permissions import is_sensitive
1547
+ if is_sensitive(p) and not self._consent_to_secret(p):
1548
+ return (f"(not attached: {self.ctx.rel(p)} looks like a secrets file, so its contents were not "
1549
+ "sent. Do not read it unless the user asks you to.)")
1462
1550
  if documents.is_document(p):
1463
1551
  return documents.extract(p, cap_tokens=cap)
1464
1552
  data = p.read_bytes()[:8192]
@@ -1469,6 +1557,19 @@ class Agent:
1469
1557
  except (ToolError, documents.DocumentError, OSError) as e:
1470
1558
  return f"(could not attach: {e})"
1471
1559
 
1560
+ def _consent_to_secret(self, p: Path) -> bool:
1561
+ """A .env / key / credentials file named in a prompt (typed or dragged, @ or a bare absolute
1562
+ path) is only sent after a yes. QA 2026-11-05: "the values are in C:\\Users\\me\\app\\.env" uploaded
1563
+ the file's contents without a question, in default mode and from outside the project."""
1564
+ rel = self.ctx.rel(p)
1565
+ if not getattr(self.ui, "interactive", False):
1566
+ self.ui.warn(f"Not attached: {rel} looks like a secrets file (its contents would be sent with your "
1567
+ "message). Attach it in an interactive session if you mean to.")
1568
+ return False
1569
+ choice = self.ui.ask_permission(f"Attach {rel}? It looks like a secrets file: its contents are sent with "
1570
+ "your message.", None, [("y", "yes"), ("n", "no")])
1571
+ return choice == "y"
1572
+
1472
1573
  # ---------------------------------------------------------- sub-agent
1473
1574
  def run_subagent(self, prompt: str) -> str:
1474
1575
  sub_ui = _SubagentUI(self.ui)
@@ -58,6 +58,10 @@ class AuthError(EuronError):
58
58
  SIGNED_IN_ELSEWHERE_MESSAGE = ("You signed in to Euron Code on another device, so this one was signed out. "
59
59
  "One login at a time: signing in on a new computer signs out the old one.")
60
60
  SESSION_IN_USE_MESSAGE = "Euron Code is running for your account in another terminal or computer."
61
+ #: shown instead of the gateway's raw text, which named the model's context size and the serving
62
+ #: stack's parameters (QA 2026-11-05, a 100 KB prompt)
63
+ CONTEXT_TOO_LONG_MESSAGE = ("This request is too long for Euron Code to read in one go. Put long text in a file in "
64
+ "your project and mention it with @file (it is then read in parts), or split the request.")
61
65
 
62
66
 
63
67
  class NotEntitledError(EuronError):
@@ -208,7 +212,9 @@ def error_from_body(status: int, body: Any, retry_after: Optional[float] = None)
208
212
  if status == 413 or code == "context_length_exceeded" or (
209
213
  status == 400 and "context length" in message.lower()
210
214
  ):
211
- return ContextTooLongError(message, **kw)
215
+ e = ContextTooLongError(CONTEXT_TOO_LONG_MESSAGE, **kw)
216
+ e.detail = message # the gateway's own text, never shown
217
+ return e
212
218
  if status == 503:
213
219
  return BusyError(message, **kw)
214
220
  if status == 429:
@@ -27,11 +27,27 @@ def _fix_windows_console() -> None:
27
27
  for stream in (sys.stdout, sys.stderr):
28
28
  try:
29
29
  if stream and hasattr(stream, "reconfigure") and (stream.encoding or "").lower() not in ("utf-8", "utf8"):
30
- stream.reconfigure(errors="replace")
30
+ if not stream.isatty():
31
+ # redirected to a file or pipe: write UTF-8. With the legacy code page, Hindi and emoji
32
+ # answers came out as "???" (QA 2026-11-05: `euron -p ... > out.txt` lost every character)
33
+ stream.reconfigure(encoding="utf-8", errors="replace")
34
+ else:
35
+ stream.reconfigure(errors="replace")
31
36
  except Exception:
32
37
  pass
33
38
 
34
39
 
40
+ def _positive_int(raw: str) -> int:
41
+ """--max-turns: 0 silently meant "no limit" and -1 stopped before the first step."""
42
+ try:
43
+ n = int(raw)
44
+ except ValueError:
45
+ raise argparse.ArgumentTypeError(f"expected a whole number, got {raw!r}") from None
46
+ if n < 1:
47
+ raise argparse.ArgumentTypeError("must be 1 or more")
48
+ return n
49
+
50
+
35
51
  def main_parser() -> argparse.ArgumentParser:
36
52
  p = argparse.ArgumentParser(
37
53
  prog="euron",
@@ -43,7 +59,7 @@ def main_parser() -> argparse.ArgumentParser:
43
59
  p.add_argument("-p", "--print", dest="print_mode", action="store_true",
44
60
  help="Non-interactive: run the prompt, print the result, exit.")
45
61
  p.add_argument("--output-format", choices=["text", "json"], default="text", help="With -p: text or json.")
46
- p.add_argument("--max-turns", type=int, default=None, help="Maximum model steps for this run.")
62
+ p.add_argument("--max-turns", type=_positive_int, default=None, help="Maximum model steps for this run.")
47
63
  p.add_argument("--model", default=None, help="Model id (default: your account's default model).")
48
64
  p.add_argument("-c", "--continue", dest="cont", action="store_true", help="Continue the latest session in this folder.")
49
65
  p.add_argument("-r", "--resume", nargs="?", const="", default=None, metavar="ID",
@@ -280,15 +296,26 @@ def run_config(ui, args) -> int:
280
296
  # ---------------------------------------------------------------- main session
281
297
 
282
298
  def run_main(args) -> int:
283
- from euron_code.agent import Agent
284
- from euron_code.permissions import Permissions
285
-
286
299
  root = config.find_project_root()
287
300
  settings = config.load_settings(root)
288
301
  if args.print_mode:
289
302
  ui = QuietUI(verbose=args.verbose)
290
303
  else:
291
304
  ui = UI(reasoning=str(settings.get("reasoning") or "collapse"))
305
+ code = _run_session(args, ui, root, settings)
306
+ if (args.print_mode and args.output_format == "json" and code != EXIT_OK
307
+ and not getattr(ui, "result_written", False)):
308
+ # a failure before the turn started (no prompt, not logged in, unreachable): scripts still get JSON
309
+ msg = getattr(ui, "last_error", None) or "Euron Code could not start."
310
+ sys.stdout.write(json.dumps({"type": "result", "subtype": "error", "is_error": True, "result": "",
311
+ "exit_code": code, "error": {"message": msg}}, ensure_ascii=False) + "\n")
312
+ sys.stdout.flush()
313
+ return code
314
+
315
+
316
+ def _run_session(args, ui, root, settings) -> int:
317
+ from euron_code.agent import Agent
318
+ from euron_code.permissions import Permissions
292
319
 
293
320
  prompt = args.prompt
294
321
  if args.print_mode:
@@ -491,7 +518,7 @@ def run_print(agent, ui, client, prompt: str, args, session) -> int:
491
518
  "type": "result",
492
519
  "subtype": "success" if code == 0 else ("error_max_turns" if code == EXIT_MAX_TURNS else "error"),
493
520
  "is_error": code != 0,
494
- "result": res.text,
521
+ "result": (res.text or "").lstrip("\r\n"),
495
522
  "stop_reason": res.stop_reason,
496
523
  "num_turns": res.turns,
497
524
  "session_id": session.id,
@@ -510,8 +537,9 @@ def run_print(agent, ui, client, prompt: str, args, session) -> int:
510
537
  out["error"] = {"code": err.code, "status": err.status, "message": message}
511
538
  sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
512
539
  else:
513
- if res.text:
514
- sys.stdout.write(res.text.rstrip() + "\n")
540
+ if res.text and res.text.strip():
541
+ sys.stdout.write(res.text.strip("\r\n").rstrip() + "\n") # no leading blank lines before the answer
542
+ ui.result_written = True
515
543
  sys.stdout.flush()
516
544
  return code
517
545
 
@@ -177,6 +177,9 @@ DESTRUCTIVE = [re.compile(p, re.IGNORECASE) for p in (
177
177
  r"\bmkfs(\.\w+)?\b", r"\bdd\s+if=", r"\b(shutdown|reboot|halt|poweroff)\b", r"\bformat\s+[a-z]:",
178
178
  r"\b(del|erase)\s+(/[a-z]\s+)*/s\b", r"\b(rd|rmdir)\s+(/[a-z]\s+)*/s\b",
179
179
  r"\bRemove-Item\b.*-Recurse\b.*(?:~|\$HOME|\$env:USERPROFILE|[A-Za-z]:\\?\s*$|\s/\s*$)",
180
+ # the home folder named BEFORE -Recurse as well (QA 2026-11-05: `Remove-Item -Path "$env:USERPROFILE\x"
181
+ # -Recurse -Force` deleted a folder outside the project in bypass mode)
182
+ r"\bRemove-Item\b.*(?:~|\$HOME|\$env:USERPROFILE|%USERPROFILE%).*\s-Recurse\b",
180
183
  r"\bchmod\s+-R\s+0?777\b", r":\(\)\s*\{\s*:\|:&\s*\};", r"\bdrop\s+(database|schema)\b",
181
184
  r"\b(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^|;]*\|\s*(sudo\s+)?(ba|z)?sh\b",
182
185
  r"\b(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^|;]*\|\s*(iex|Invoke-Expression|python3?)\b",
@@ -204,6 +207,9 @@ def outside_paths(command: str, roots: List[Path]) -> List[str]:
204
207
  for seg in _segments(command.replace(">", " ").replace("<", " ")):
205
208
  for tok in _tokens(seg):
206
209
  for part in [tok] + tok.split("=")[1:]:
210
+ # shlex keeps the quotes on Windows (posix=False), so a quoted path ("C:\x", "$env:USERPROFILE\x")
211
+ # was never seen as a path and skipped this guard (QA 2026-11-05)
212
+ part = part.strip().strip('"').strip("'")
207
213
  if not _PATHLIKE.match(part) or (os.name == "nt" and _WIN_SWITCH.match(part)):
208
214
  continue
209
215
  if part.startswith("/dev/") or part in ("/dev/null", "NUL"):
@@ -71,16 +71,25 @@ Do not invent anything. Do not call tools. Plain text or Markdown, at most about
71
71
  PLACEHOLDER_NUDGE = ("These files still contain placeholder text: {where}. Replace it with the details the user gave "
72
72
  "(name, photo, email, links). If the user did not give a detail, remove it or ask for it.")
73
73
 
74
+ NOTICE_NUDGE = ("Your files say they reuse code from {repos}, but THIRD_PARTY_NOTICES.md has no entry for it. "
75
+ "Record each one with add_third_party_notice (source, licence, the files that use it), then finish. "
76
+ "If you did not actually use that code, remove the attribution comment instead.")
77
+
74
78
  BUILD_NOTE = ("[Build request: start with find_reusable (split it into 1-8 components) and follow its "
75
79
  "REUSE / ADAPT / GENERATE decisions before writing code.]")
76
80
 
77
81
  CONTINUE_NUDGE = "Continue exactly where you stopped. Do not repeat what you already wrote."
78
82
 
83
+ TODO_NUDGE = ("You stopped, but your task list is not finished: {todos}. Continue now with the next item. If you "
84
+ "cannot finish it, say exactly what is left and why.")
85
+
79
86
  CUT_TOOL_NUDGE = ("Your last tool call was cut off by the output limit, so it was not run. Make the change in smaller "
80
87
  "steps: use edit_file for targeted edits, or write the file in parts (create it with the first part, "
81
88
  "then add the rest with edit_file).")
82
89
 
83
90
  ANSWER_NUDGE = "Answer now, briefly, with what you have. Do not deliberate further."
91
+ EMPTY_NUDGE = ("Your last reply was empty. Reply to the user now: answer the request, or say briefly what "
92
+ "you did and what is left.")
84
93
 
85
94
 
86
95
  INIT_PROMPT = """Write (or improve) EURON.md at the project root: the memory file Euron Code reads at the start of every session. Keep it short (under ~70 lines): every line costs tokens in every future request.
@@ -40,21 +40,26 @@ UI_WORDS = re.compile(r"\b(website|site|portfolio|landing|page|pages|ui|frontend
40
40
  r"resume|cv|shop|store|form|react|next\.?js|vue|svelte|html|css|tailwind)\b", re.I)
41
41
  # (component name, keyword pattern, query); UI queries get "react tsx component" so Euron's TS/TSX kits match
42
42
  _PARTS = [
43
- ("hero", r"portfolio|landing|personal|resume|cv|about me|profile", "hero section with name photo and intro"),
44
- ("projects", r"portfolio|projects|work|case stud", "projects grid cards"),
45
- ("navbar", r"website|site|portfolio|landing|page|pages|blog|shop|store|dashboard", "responsive navbar"),
46
- ("contact", r"contact|portfolio|landing|email|form", "contact form"),
47
- ("dashboard", r"dashboard|admin|analytics|chart", "dashboard layout charts cards"),
48
- ("blog", r"\bblog|posts|articles", "blog post list and markdown rendering"),
49
- ("cart", r"shop|store|cart|checkout|e-?commerce", "shopping cart and checkout"),
50
- ("auth", r"login|signup|sign up|sign in|auth|jwt|oauth|account", "login signup jwt authentication"),
51
- ("api", r"\bapi\b|backend|server|crud|endpoint|rest|fastapi|flask|django|express", "rest api crud endpoints"),
52
- ("database", r"database|\bdb\b|postgres|sqlite|mongo|prisma|orm|crud", "database models and queries"),
53
- ("payments", r"payment|razorpay|stripe|subscription", "payment integration"),
54
- ("chat", r"chat|chatbot|\bbot\b|assistant|llm|rag", "chat ui and llm integration"),
55
- ("upload", r"upload|file storage|s3|image upload", "file upload"),
56
- ("scraper", r"scrap|crawl", "web scraper"),
57
- ("cli", r"\bcli\b|command[- ]line|terminal tool", "command line interface"),
43
+ # whole words only (QA 2026-11-05: "rag" in "storage" gave a todo API a chat component; "orm" in "form",
44
+ # "rest" in "interest", "work" in "framework", "auth" in "author", "form" in "platform" did the same)
45
+ ("hero", r"\b(portfolio|landing|personal|resume|cv|about me|profile)s?\b", "hero section with name photo and intro"),
46
+ ("projects", r"\b(portfolio|projects?|work|case stud(y|ies))\b", "projects grid cards"),
47
+ ("navbar", r"\b(website|site|portfolio|landing|page|blog|shop|store|dashboard)s?\b", "responsive navbar"),
48
+ ("contact", r"\b(contact|portfolio|landing|email|form)s?\b", "contact form"),
49
+ ("dashboard", r"\b(dashboard|admin|analytics|chart)s?\b", "dashboard layout charts cards"),
50
+ ("blog", r"\b(blog|posts|articles)\b", "blog post list and markdown rendering"),
51
+ ("cart", r"\b(shop|store|cart|checkout|e-?commerce)\b", "shopping cart and checkout"),
52
+ ("auth", r"\b(login|signup|sign up|sign in|auth|authentication|authenticate|authorization|jwt|oauth|accounts?)\b",
53
+ "login signup jwt authentication"),
54
+ ("api", r"\b(api|apis|backend|server|crud|endpoints?|rest|restful|fastapi|flask|django|express)\b",
55
+ "rest api crud endpoints"),
56
+ ("database", r"\b(database|db|postgres|postgresql|sqlite|sqlite3|mongo|mongodb|prisma|orm|crud)\b",
57
+ "database models and queries"),
58
+ ("payments", r"\b(payments?|razorpay|stripe|subscriptions?)\b", "payment integration"),
59
+ ("chat", r"\b(chat|chatbot|bot|assistant|llm|rag)s?\b", "chat ui and llm integration"),
60
+ ("upload", r"\b(uploads?|uploading|file storage|s3|image upload)\b", "file upload"),
61
+ ("scraper", r"\b(scrap\w*|crawl\w*)\b", "web scraper"),
62
+ ("cli", r"\b(cli|command[- ]line|terminal tool)\b", "command line interface"),
58
63
  ]
59
64
 
60
65
 
@@ -186,6 +191,12 @@ def _indexed(tier: Any) -> bool:
186
191
  return False
187
192
 
188
193
 
194
+ #: QA 2026-11-05: the engine marked an unrelated test file REUSE (0.92) for a word-count CLI; the model saw it was
195
+ #: unrelated, wrote its own code, and still added a licence header and a THIRD_PARTY_NOTICES entry for it.
196
+ UNRELATED_NOTE = (" If those files turn out to be unrelated to this component, write it yourself instead and add "
197
+ "no licence header or notice for them.")
198
+
199
+
189
200
  def instructions(c: Dict[str, Any]) -> str:
190
201
  cand = c.get("candidate") or {}
191
202
  d = c.get("decision")
@@ -195,10 +206,10 @@ def instructions(c: Dict[str, Any]) -> str:
195
206
  if d == "REUSE":
196
207
  return (f"- {c['name']}: REUSE {repo} {', '.join(paths[:4])} - read them with {tool}, integrate with "
197
208
  "minimal edits, keep the licence header; once its files are written, record it once with "
198
- "add_third_party_notice.")
209
+ "add_third_party_notice." + UNRELATED_NOTE)
199
210
  if d == "ADAPT":
200
211
  return (f"- {c['name']}: ADAPT {repo} {', '.join(paths[:4])} - read with {tool}, then modify it to fit; keep "
201
- "the attribution.")
212
+ "the attribution." + UNRELATED_NOTE)
202
213
  return f"- {c['name']}: GENERATE - write it yourself ({c.get('reason') or 'no suitable reusable code'})."
203
214
 
204
215
 
@@ -149,7 +149,7 @@ class OssRead(Tool):
149
149
 
150
150
  class AddThirdPartyNotice(Tool):
151
151
  name = "add_third_party_notice"
152
- description = 'Record a reused source in THIRD_PARTY_NOTICES.md (once per repo).'
152
+ description = 'Record a source you copied or adapted code from in THIRD_PARTY_NOTICES.md (once per repo).'
153
153
  parameters = {
154
154
  "type": "object",
155
155
  "properties": {
@@ -324,6 +324,16 @@ class QuietUI(UI):
324
324
  def __init__(self, verbose: bool = False):
325
325
  super().__init__(reasoning="hide", stderr=True)
326
326
  self.verbose = verbose
327
+ self.last_error: Optional[str] = None # for the -p JSON result when the run cannot start
328
+ self.result_written = False
329
+
330
+ def error(self, msg: str) -> None:
331
+ self.last_error = msg
332
+ super().error(msg)
333
+
334
+ def exact(self, msg: str) -> None:
335
+ self.last_error = msg
336
+ super().exact(msg)
327
337
 
328
338
  def reasoning(self, text: str) -> None:
329
339
  pass
@@ -361,8 +371,11 @@ class QuietUI(UI):
361
371
  time.sleep(min(0.1, max(0.0, end - time.monotonic())))
362
372
 
363
373
  def ask_permission(self, title, detail, options) -> str:
364
- self.console.print(Text(f"Denied (non-interactive, use --allowed-tools or --yolo): {title}",
365
- style="yellow"))
374
+ # even --yolo asks for paths outside the project and destructive commands: say so instead of
375
+ # suggesting --yolo to someone who already used it (QA 2026-11-05)
376
+ self.console.print(Text(f"Denied (non-interactive): {title} Edits and commands inside the project can be "
377
+ "allowed with --allowed-tools or --yolo; paths outside the project and destructive "
378
+ "commands need a yes in an interactive session.", style="yellow"))
366
379
  return "n"
367
380
 
368
381
 
@@ -36,8 +36,13 @@ Error scenarios are chosen by a tag in the user's message, e.g. "[mock:rate]":
36
36
  erie ERIE: find_reusable (slugify REUSE, money ADAPT, cli GENERATE), reads the pinned files, writes a small
37
37
  price-list app from them plus tests, records the notice, runs pytest, answers
38
38
  slowtool runs `python -c "import time; time.sleep(N)"` (N from "[mock:slowtool N]"), then answers
39
+ todostop writes a 2-item task list, then stops on "Scaffolding the project now..."; finishes when nudged
40
+ todostopalways the same, but never finishes
39
41
  disabled 503 code_agent_disabled
40
42
  simple a plain text answer, no tools
43
+ emptyonce an empty answer (no text, no tools) until the CLI asks again, then two blank lines and "ok"
44
+ emptyalways an empty answer every time
45
+ hindi a Hindi word and an emoji
41
46
  longtask reads big.txt 400 lines at a time until the end (to force auto-compaction)
42
47
  (no tag) the default edit script on calc.py / test_calc.py
43
48
 
@@ -316,6 +321,23 @@ def script_reply(scenario: str, messages: List[Dict[str, Any]], body: Optional[D
316
321
  "start": 1, "end": 5})]}
317
322
  return {"reasoning": "", "content": "Found Euron's hybrid_search; I will adapt it and keep the MIT notice.",
318
323
  "tool_calls": []}
324
+ if scenario == "nonotice": # QA 2026-11-05: adapted code with a header, but no THIRD_PARTY_NOTICES entry
325
+ if "THIRD_PARTY_NOTICES.md has no entry" in last_user and last is None:
326
+ return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
327
+ "add_third_party_notice", {"source": "euron-sde-team/euron-rag-kit", "license": "MIT",
328
+ "files": "retrieve.py"})]}
329
+ if last is None:
330
+ return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
331
+ "euron_reuse_read", {"repo": "euron-sde-team/euron-rag-kit", "path": "src/rag/retrieve.py",
332
+ "start": 1, "end": 5})]}
333
+ if last == "euron_reuse_read":
334
+ return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
335
+ "write_file", {"path": "retrieve.py", "content": "# Adapted from euron-sde-team/euron-rag-kit (MIT)\n"
336
+ "def hybrid_search(q):\n return [q]\n"})]}
337
+ if last == "write_file":
338
+ return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
339
+ "bash", {"command": "python -c \"import retrieve\"", "description": "Check it imports"})]}
340
+ return {"reasoning": "", "content": "Adapted hybrid_search into retrieve.py.", "tool_calls": []}
319
341
  if scenario == "noverify": # edits, then answers without running anything
320
342
  if last_user.startswith("You changed files but did not run anything"):
321
343
  return {"reasoning": "", "content": "I could not run tests here; I re-read the change and it is correct.",
@@ -341,10 +363,31 @@ def script_reply(scenario: str, messages: List[Dict[str, Any]], body: Optional[D
341
363
  if (body or {}).get("reasoning_effort") == "low":
342
364
  return {"reasoning": "short thought", "content": "Answer after less thinking.", "tool_calls": []}
343
365
  return {"reasoning": "thinking " * 200, "content": "", "tool_calls": [], "finish": "length"}
366
+ if scenario == "hindi":
367
+ return {"reasoning": "short thought", "content": "चार \U0001F3AF", "tool_calls": []}
368
+ if scenario in ("emptyonce", "emptyalways"):
369
+ # the live model sometimes ends a turn with no text at all (QA 2026-11-05)
370
+ nudged = any("Your last reply was empty" in str(m.get("content") or "") for m in messages
371
+ if m.get("role") == "user")
372
+ if scenario == "emptyonce" and nudged:
373
+ return {"reasoning": "short thought", "content": "\n\nok", "tool_calls": []}
374
+ return {"reasoning": "short thought", "content": "", "tool_calls": []}
344
375
  if scenario == "simple" or scenario == "rate" or scenario == "busy" or scenario == "queue_full" \
345
376
  or scenario == "ctx" or scenario == "keepalive" or scenario == "queued" or scenario == "noimages":
346
377
  return {"reasoning": REASONING_TEXT, "content": "Hello from the MOCK gateway. Nothing to change.",
347
378
  "tool_calls": []}
379
+ if scenario in ("todostop", "todostopalways"): # QA 2026-11-05: plans, then stops on "doing it now..."
380
+ if last_user.startswith("You stopped, but your task list is not finished") and scenario == "todostop":
381
+ if last is None:
382
+ return {"reasoning": "", "content": "", "tool_calls": [_tool_call("todo_write", {"todos": [
383
+ {"content": "scaffold the app", "status": "completed"},
384
+ {"content": "write the hero", "status": "completed"}]})]}
385
+ return {"reasoning": "", "content": "Built the app.", "tool_calls": []}
386
+ if last is None and "task list is not finished" not in last_user:
387
+ return {"reasoning": "", "content": "", "tool_calls": [_tool_call("todo_write", {"todos": [
388
+ {"content": "scaffold the app", "status": "in_progress"},
389
+ {"content": "write the hero", "status": "pending"}]})]}
390
+ return {"reasoning": "", "content": "Scaffolding the project now...", "tool_calls": []}
348
391
  if scenario == "slowtool":
349
392
  if last is None:
350
393
  secs = re.findall(r"\[mock:slowtool (\d+(?:\.\d+)?)\]", " ".join(