meshapi-code 0.4.2__py3-none-any.whl → 0.4.4__py3-none-any.whl

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.
meshapi/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.4.2"
1
+ __version__ = "0.4.4"
meshapi/attachments.py CHANGED
@@ -13,11 +13,19 @@ so a dragged-in file path can be attached without an explicit slash command.
13
13
  """
14
14
  import base64
15
15
  import mimetypes
16
+ import re
16
17
  from pathlib import Path
17
18
  from urllib.parse import urlparse
18
19
 
19
20
  import httpx
20
21
 
22
+ # Tokenizer for find_image_tokens: a single- or double-quoted span (kept
23
+ # whole, INCLUDING internal spaces) OR a run of non-whitespace. Quoted
24
+ # alternatives come first so a drag-dropped path like
25
+ # `'/Users/me/snake game/img.png'` stays one token instead of being shredded
26
+ # on the spaces by str.split().
27
+ _TOKEN_RE = re.compile(r"'[^']*'|\"[^\"]*\"|\S+")
28
+
21
29
  # Size guardrails. We don't refuse — vision tokens are the user's call — but we
22
30
  # do report sizes back so the user sees the cost.
23
31
  HARD_LIMIT_BYTES = 20 * 1024 * 1024 # 20 MB
@@ -60,43 +68,58 @@ def load_image(source: str, detail: str = "auto") -> tuple[dict, dict]:
60
68
  )
61
69
 
62
70
 
63
- def find_image_tokens(text: str) -> list[str]:
64
- """Return tokens in `text` that look like image paths or URLs.
71
+ def find_image_tokens(text: str) -> list[tuple[str, str]]:
72
+ """Return `(raw_token, normalized)` pairs for image references in `text`.
65
73
 
66
- Conservative on purpose only matches:
67
- - http(s) URLs ending in a known image extension
68
- - Local paths starting with `/`, `~/`, `./`, or `../` (and ending in an
69
- image extension, AND pointing at an existing file)
74
+ Strategy: be liberal about what looks like a file/URL, then verify by
75
+ actually checking existence (local) or extension (URL). The user's
76
+ natural workflow is "drag the file in" terminals wrap drag-dropped
77
+ paths in single quotes when convenient, so we strip wrapping quotes.
78
+ A bare filename like `screenshot.png` also matches if it exists in the
79
+ cwd. The only escape is a backtick prefix: `` `foo.png` `` is treated
80
+ as text.
70
81
 
71
- Bare filenames (`foo.png`) are NOT matched: too ambiguous with filenames
72
- mentioned in conversation. Tokens wrapped in backticks or quotes are
73
- skipped (user's escape hatch).
82
+ - http(s) URLs ending in a known image extension
83
+ - Any token that, after stripping wrapping quotes and trailing
84
+ punctuation, resolves to an existing file with an image extension
74
85
 
75
- Trailing sentence punctuation (`.,;:!?)`) is trimmed before matching.
86
+ `raw_token` is the exact substring to find/replace in the original
87
+ text (so quotes are preserved when stripping); `normalized` is the
88
+ cleaned path or URL to pass into load_image().
76
89
  """
77
- matches: list[str] = []
78
- for raw in text.split():
79
- if not raw or raw[0] in "`\"'":
90
+ matches: list[tuple[str, str]] = []
91
+ for raw in _TOKEN_RE.findall(text):
92
+ if not raw:
93
+ continue
94
+ # Backtick prefix = explicit "treat as text" escape.
95
+ if raw.startswith("`"):
80
96
  continue
97
+
81
98
  token = raw
99
+ # Strip a matching wrapping pair of single or double quotes
100
+ # (drag-drop on macOS Terminal/iTerm2 quotes paths automatically).
101
+ if len(token) >= 2 and token[0] == token[-1] and token[0] in "'\"":
102
+ token = token[1:-1]
103
+ # Strip trailing sentence punctuation but leave URL query strings.
82
104
  while token and token[-1] in ".,;:!?)":
83
105
  token = token[:-1]
84
106
  if not token:
85
107
  continue
108
+
86
109
  low = token.lower()
87
110
  if low.startswith(("http://", "https://")):
88
111
  path_part = token.split("?", 1)[0]
89
112
  if path_part.lower().endswith(IMAGE_EXTS):
90
- matches.append(token)
113
+ matches.append((raw, token))
91
114
  continue
92
- if token.startswith(("/", "~/", "./", "../")):
93
- if low.endswith(IMAGE_EXTS):
94
- try:
95
- p = Path(token).expanduser()
96
- if p.is_file():
97
- matches.append(token)
98
- except OSError:
99
- pass
115
+
116
+ if low.endswith(IMAGE_EXTS):
117
+ try:
118
+ p = Path(token).expanduser()
119
+ if p.is_file():
120
+ matches.append((raw, token))
121
+ except (OSError, ValueError):
122
+ pass
100
123
  return matches
101
124
 
102
125
 
@@ -109,6 +132,14 @@ def _looks_like_url(s: str) -> bool:
109
132
 
110
133
 
111
134
  def _fetch_url(url: str) -> tuple[bytes, str, str]:
135
+ # SSRF guard: refuse loopback/private/link-local before issuing the
136
+ # request. Imported lazily so attachments.py doesn't pull in safety on
137
+ # every code path.
138
+ from .safety import is_url_safe_for_fetch
139
+
140
+ ok, reason = is_url_safe_for_fetch(url)
141
+ if not ok:
142
+ raise AttachmentError(f"refusing to fetch {url}: {reason}")
112
143
  try:
113
144
  with httpx.Client(timeout=30, follow_redirects=True) as client:
114
145
  r = client.get(url)
meshapi/cli.py CHANGED
@@ -26,10 +26,14 @@ from . import __version__, statusbar
26
26
  from .attachments import AttachmentError, find_image_tokens, load_image
27
27
  from .client import stream_chat
28
28
  from .commands import handle_command
29
- from .config import CONFIG_FILE, HISTORY_FILE, load_config, secure_file
29
+ from .config import (
30
+ CONFIG_FILE, HISTORY_FILE, clear_servers_file, load_config, load_servers,
31
+ save_servers, secure_file,
32
+ )
30
33
  from .keywatcher import KeyWatcher
31
- from .permissions import LABELS, Mode, from_str, next_mode
34
+ from .permissions import AUTO_APPROVE, Mode, from_str, next_mode
32
35
  from .plan import Plan
36
+ from . import safety
33
37
  from .render import (
34
38
  BRAND, BRAND_BG, BRAND_BG_FG, BRAND_DIM, CODE, console, fmt_usd, pretty_cwd, render_stream,
35
39
  )
@@ -78,8 +82,8 @@ def parse_args(argv=None) -> argparse.Namespace:
78
82
  p.add_argument(
79
83
  "--mode",
80
84
  choices=[m.value for m in Mode],
81
- default="ask",
82
- help="Tool permission mode (default: ask). Cycle in-session with shift+tab.",
85
+ default="default",
86
+ help="Tool permission mode (default: ask each tool). Cycle in-session with shift+tab.",
83
87
  )
84
88
  return p.parse_args(argv)
85
89
 
@@ -410,11 +414,88 @@ def _kill_server(pid: int) -> None:
410
414
  pass
411
415
 
412
416
 
417
+ def _persist_servers(state: dict) -> None:
418
+ """Write current live servers to ~/.meshapi/servers.json. Best-effort —
419
+ a corrupt or missing file should never block the REPL."""
420
+ try:
421
+ save_servers(state.get("servers", []))
422
+ except Exception:
423
+ pass
424
+
425
+
413
426
  def _shutdown_servers(state: dict) -> None:
414
- """Kill every server we launched. Called on meshapi exit."""
427
+ """Kill every server we launched. Called on meshapi exit (clean or
428
+ via SIGTERM/SIGHUP). Also wipes the persisted servers file so the
429
+ next launch doesn't offer to kill ghosts."""
415
430
  for srv in state.get("servers", []):
416
431
  _kill_server(srv["pid"])
417
432
  state["servers"] = []
433
+ clear_servers_file()
434
+
435
+
436
+ def _adopt_orphaned_servers(state: dict) -> None:
437
+ """At startup, look for processes recorded by a previous (crashed)
438
+ meshapi and offer to terminate them. A hard kill of meshapi (SIGKILL,
439
+ laptop sleep + battery, segfault) skips atexit/SIGTERM, so this is
440
+ the safety net that catches leaked servers."""
441
+ rec = load_servers()
442
+ if not rec:
443
+ return
444
+ live = []
445
+ for s in rec:
446
+ pid = s.get("pid") if isinstance(s, dict) else None
447
+ if not isinstance(pid, int):
448
+ continue
449
+ try:
450
+ os.kill(pid, 0) # signal 0 = existence check, no actual signal
451
+ except (ProcessLookupError, PermissionError):
452
+ continue
453
+ except OSError:
454
+ continue
455
+ live.append(s)
456
+ if not live:
457
+ clear_servers_file()
458
+ return
459
+ console.print(
460
+ f"[yellow]Found {len(live)} background server(s) left running from a "
461
+ "previous session:[/yellow]"
462
+ )
463
+ for s in live:
464
+ console.print(
465
+ f" [dim]pid {s.get('pid')}, port {s.get('port')}, "
466
+ f"{s.get('cmd', '')}[/dim]"
467
+ )
468
+ try:
469
+ ans = console.input(
470
+ "Kill them now? [bold]y[/bold] (yes) / [bold]n[/bold] (no) › "
471
+ ).strip().lower()
472
+ except (KeyboardInterrupt, EOFError):
473
+ return
474
+ if ans in ("y", "yes"):
475
+ for s in live:
476
+ _kill_server(s.get("pid", 0))
477
+ clear_servers_file()
478
+ console.print(f"[dim]Killed {len(live)} server(s).[/dim]")
479
+ else:
480
+ clear_servers_file() # don't keep asking on every launch
481
+ console.print("[dim]Leaving them running.[/dim]")
482
+
483
+
484
+ def _check_image_cap(state: dict, additional_bytes: int) -> tuple[bool, str]:
485
+ """Per-session image-bytes budget. Counts both already-sent and queued
486
+ attachments — clearing the queue (/clear-attach) releases them again."""
487
+ sent = state.get("session_image_bytes", 0)
488
+ queued = sum(int(a.get("size_bytes", 0))
489
+ for a in (state.get("pending_attachments") or []))
490
+ total = sent + queued + additional_bytes
491
+ if total > safety.SESSION_IMAGE_BYTE_CAP:
492
+ cap_mb = safety.SESSION_IMAGE_BYTE_CAP // (1024 * 1024)
493
+ used_mb = max(1, (sent + queued) // (1024 * 1024))
494
+ return False, (
495
+ f"would exceed session image budget ({cap_mb} MB total, "
496
+ f"{used_mb} MB used)"
497
+ )
498
+ return True, ""
418
499
 
419
500
 
420
501
  def _handle_start_server(args: dict, state: dict) -> str:
@@ -520,10 +601,23 @@ def _handle_start_server(args: dict, state: dict) -> str:
520
601
  state.setdefault("servers", []).append({
521
602
  "pid": proc.pid, "port": port, "cmd": cmd, "url": url,
522
603
  })
604
+ _persist_servers(state) # survive a hard kill / crash
523
605
 
606
+ # Make the URL big, plain, on its own line — most terminals
607
+ # auto-detect bare URLs as cmd-clickable, which is more reliable
608
+ # than rich's OSC-8 `[link=...]` markup that some terminals
609
+ # (xterm.js, older Terminal.app) strip silently.
610
+ from rich.panel import Panel
524
611
  console.print(f" [green]✓ ready in {elapsed:.1f}s[/green]")
525
612
  console.print()
526
- console.print(f" 🌐 [bold green][link={url}]{url}[/link][/bold green] [dim](pid {proc.pid})[/dim]")
613
+ console.print(Panel.fit(
614
+ f"[bold green]{url}[/bold green]\n"
615
+ f"[dim]server running in the background · pid {proc.pid} · "
616
+ "⌘-click or paste the URL in your browser[/dim]",
617
+ title="🌐 ready",
618
+ border_style="green",
619
+ padding=(0, 2),
620
+ ))
527
621
  console.print()
528
622
  if preview.strip():
529
623
  console.print(" [dim]── server output ──[/dim]")
@@ -533,10 +627,14 @@ def _handle_start_server(args: dict, state: dict) -> str:
533
627
  console.print(f" [dim]{_rich_escape(line)}[/dim]")
534
628
 
535
629
  return (
536
- f"Server is running at {url} (pid {proc.pid}, ready in "
537
- f"{elapsed:.1f}s). It will keep running in the background "
538
- f"until meshapi exits. Tell the user the URL is {url}. "
539
- "Do not poll the server furtherthe user will visit the URL."
630
+ f"Server up at {url} (pid {proc.pid}, ready in {elapsed:.1f}s).\n"
631
+ "The user can already see the URL in their terminal — it was "
632
+ "printed by the CLI. Respond with a SINGLE short text line "
633
+ "(e.g. 'Server's up at " + url + " open it in your browser') "
634
+ "and END THE TURN. Do NOT call any more tools this turn — "
635
+ "no curl, no read_file, no anything. The server keeps running "
636
+ "in the background until meshapi exits; the user will interact "
637
+ "with it through the browser, not through you."
540
638
  )
541
639
  time.sleep(0.2)
542
640
 
@@ -614,7 +712,42 @@ def handle_tool_calls(tool_calls: list, mode: Mode, state: dict) -> None:
614
712
  # effects, so we don't gate them on the approval prompt.
615
713
  result = _handle_plan_tool(tc["name"], args, state)
616
714
  else:
617
- approved = mode == Mode.BYPASS or confirm_tool_call(
715
+ # Per-mode auto-approval: each Mode declares which tool names
716
+ # bypass the y/n prompt. Anything not in the set, OR anything
717
+ # that fails a safety check, falls back to confirmation —
718
+ # even in BYPASS we ask before truly dangerous shapes
719
+ # (sensitive paths, `rm -rf /`, sudo, curl | sh, ...).
720
+ auto_approved = tc["name"] in AUTO_APPROVE.get(mode, set())
721
+ safety_reason: str = ""
722
+ if auto_approved and tc["name"] == "write_file":
723
+ ok, reason = safety.is_path_safe_for_auto_write(
724
+ args.get("path"), mode
725
+ )
726
+ if not ok:
727
+ auto_approved = False
728
+ safety_reason = reason or "path safety check failed"
729
+ elif auto_approved and tc["name"] == "read_file":
730
+ # BYPASS auto-approves reads; we still block sensitive
731
+ # paths so the model can't silently leak ~/.ssh/...
732
+ # to the upstream provider.
733
+ ok, reason = safety.is_path_safe_for_auto_read(
734
+ args.get("path"), mode
735
+ )
736
+ if not ok:
737
+ auto_approved = False
738
+ safety_reason = reason or "path safety check failed"
739
+ elif auto_approved and tc["name"] in ("run_bash", "start_server"):
740
+ ok, reason = safety.is_command_safe_for_auto(
741
+ args.get("command"), mode
742
+ )
743
+ if not ok:
744
+ auto_approved = False
745
+ safety_reason = reason or "command safety check failed"
746
+ if not auto_approved and safety_reason:
747
+ console.print(
748
+ f"[yellow]⚠ auto-approval blocked: {safety_reason}[/yellow]"
749
+ )
750
+ approved = auto_approved or confirm_tool_call(
618
751
  tc["name"], args, watcher=state.get("watcher")
619
752
  )
620
753
  if approved:
@@ -682,7 +815,10 @@ def main() -> None:
682
815
  "mode": from_str(args.mode),
683
816
  "plan": None, # populated by the model via create_plan
684
817
  "servers": [], # background processes spawned via start_server
685
- "pending_attachments": [], # image content parts queued via /image
818
+ "pending_attachments": [], # list of {"part","size_bytes","name"}
819
+ # Cumulative bytes of attachments already sent to the model.
820
+ # Enforces safety.SESSION_IMAGE_BYTE_CAP across the whole session.
821
+ "session_image_bytes": 0,
686
822
  }
687
823
 
688
824
  # Mode cycle — used by both the prompt-toolkit keybinding (while at the
@@ -699,22 +835,14 @@ def main() -> None:
699
835
  _cycle_mode()
700
836
  event.app.invalidate()
701
837
 
702
- # Live mode indicator on the right side of the input line. Re-evaluated on
703
- # every prompt_toolkit redraw, so shift+tab updates it instantly while
704
- # typingthe statusbar.print_line above the rule is the "this turn
705
- # started in mode X" header; this is the "current mode RIGHT NOW" sticker.
706
- def prompt_rprompt():
707
- m = state["mode"]
708
- if m == Mode.BYPASS:
709
- color = "ansired"
710
- elif m == Mode.NONE:
711
- color = "ansiyellow"
712
- else:
713
- color = "ansigreen"
714
- return FormattedText([
715
- (f"bold {color}", f"▶▶ {LABELS[m]}"),
716
- ("ansibrightblack", " ⇧⇥"),
717
- ])
838
+ # Prompt is just the "› " marker. The mode indicator is rendered by
839
+ # statusbar.print_line ABOVE the cwd separator each turn (matches the
840
+ # user's mockup no extra indicator on the input line). Trade-off:
841
+ # shift+tab during typing still cycles the mode internally, but the
842
+ # repainted line is only visible at the next prompt or after the next
843
+ # tool batch (handle_tool_calls also fires statusbar.print_line).
844
+ def prompt_message():
845
+ return FormattedText([("class:prompt", "")])
718
846
 
719
847
  # Out-of-prompt key watcher: lets shift+tab cycle mode during streaming
720
848
  # and tool execution. Paused while prompt_toolkit owns stdin.
@@ -731,6 +859,7 @@ def main() -> None:
731
859
  )
732
860
 
733
861
  render_banner(cfg)
862
+ _adopt_orphaned_servers(state)
734
863
  watcher.start() # captures shift+tab whenever prompt_toolkit isn't reading
735
864
 
736
865
  # Make sure backgrounded servers die with us — even if Python exits via
@@ -753,10 +882,12 @@ def main() -> None:
753
882
 
754
883
  while True:
755
884
  try:
756
- # No static mode line above the prompt rprompt on the input
757
- # line itself is the source of truth here, and it updates live
758
- # on shift+tab. Printing a static copy above would only show a
759
- # stale snapshot whenever the user cycled mode while typing.
885
+ # cwd separator above the input box. The mode indicator is no
886
+ # longer printed here it lives in the bottom_toolbar below the
887
+ # prompt, which prompt_toolkit repaints live on shift+tab:
888
+ # ──────────────────────────────────────────── cli_for_meshapi_v1
889
+ # › ...
890
+ # ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt
760
891
  console.rule(
761
892
  title=f"[{BRAND_DIM}]{Path.cwd().name}[/{BRAND_DIM}]",
762
893
  align="right",
@@ -765,16 +896,21 @@ def main() -> None:
765
896
  )
766
897
  with watcher.paused():
767
898
  # Hand stdin off to prompt_toolkit (canonical-mode termios).
899
+ # The prompt itself is just "› "; the mode indicator is the
900
+ # bottom_toolbar, repainted live by the s-tab binding's
901
+ # event.app.invalidate(). "noreverse" kills prompt_toolkit's
902
+ # default inverted bar so the toggle reads as plain text.
768
903
  user_input = session.prompt(
769
- "› ",
770
- rprompt=prompt_rprompt,
904
+ prompt_message,
905
+ bottom_toolbar=lambda: statusbar.bottom_toolbar(state),
771
906
  style=Style.from_dict({
772
907
  "prompt": f"bold fg:{BRAND} bg:{BRAND_BG}",
773
908
  "": f"fg:{BRAND_BG_FG} bg:{BRAND_BG}",
774
- "rprompt": f"bg:{BRAND_BG}",
909
+ "bottom-toolbar": "noreverse bg:default",
775
910
  }),
776
911
  )
777
912
  console.rule(style=BRAND_DIM, characters="─")
913
+ console.print() # bottom padding under the input box per the mockup
778
914
  except (KeyboardInterrupt, EOFError):
779
915
  _shutdown_servers(state)
780
916
  watcher.stop()
@@ -788,37 +924,61 @@ def main() -> None:
788
924
  break
789
925
  continue
790
926
 
791
- # Auto-detect image paths/URLs in the prompt and attach them. Tokens
792
- # that look like unambiguous image paths (start with /, ~, ./, ../,
793
- # or http(s)://) and end in a known image extension are pulled out
794
- # and replaced with "[Image #N]" so the text reads naturally.
927
+ # Auto-detect image paths/URLs in the prompt and attach them. The
928
+ # detector is liberal drag-dropped paths (often quoted), bare
929
+ # filenames that exist in cwd, and URLs all work. Each match comes
930
+ # back as (raw_token, normalized): we replace `raw_token` in the
931
+ # original text (so wrapping quotes go too) with `[Image #N]`.
795
932
  auto_text = user_input
796
- auto_parts: list = []
933
+ auto_attachments: list = [] # list of {"part","size_bytes","name"}
797
934
  queued = state.get("pending_attachments") or []
798
935
  n_offset = len(queued)
799
- for token in find_image_tokens(user_input):
800
- if token not in auto_text:
801
- continue # already replaced (duplicate mention)
936
+ for raw_token, source in find_image_tokens(user_input):
937
+ if raw_token not in auto_text:
938
+ continue # already replaced (duplicate mention in same prompt)
802
939
  try:
803
- part, info = load_image(token)
940
+ part, info = load_image(source)
804
941
  except AttachmentError as e:
805
- console.print(f"[yellow]Couldn't auto-attach {token}: {e}[/yellow]")
942
+ console.print(f"[yellow]Couldn't auto-attach {source}: {e}[/yellow]")
806
943
  continue
807
- n = n_offset + len(auto_parts) + 1
808
- auto_text = auto_text.replace(token, f"[Image #{n}]")
809
- auto_parts.append(part)
944
+ # Session-cap check: refuse attachments that would push us past
945
+ # the cumulative budget. Already-sent + queued + this one.
946
+ ok, reason = _check_image_cap(
947
+ state,
948
+ info["size_bytes"]
949
+ + sum(int(a.get("size_bytes", 0)) for a in auto_attachments),
950
+ )
951
+ if not ok:
952
+ console.print(
953
+ f"[red]Skipping {info['name']}: {reason}[/red]"
954
+ )
955
+ continue
956
+ n = n_offset + len(auto_attachments) + 1
957
+ auto_text = auto_text.replace(raw_token, f"[Image #{n}]")
958
+ auto_attachments.append({
959
+ "part": part,
960
+ "size_bytes": info["size_bytes"],
961
+ "name": info["name"],
962
+ })
810
963
  size_kb = max(1, info["size_bytes"] // 1024)
811
964
  console.print(
812
965
  f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
813
966
  )
814
967
 
815
- all_parts = queued + auto_parts
816
- if all_parts:
817
- console.print(f"[dim]→ sending {len(all_parts)} image(s) with this prompt[/dim]")
818
- state["messages"].append({
819
- "role": "user",
820
- "content": [{"type": "text", "text": auto_text}] + all_parts,
821
- })
968
+ all_attachments = queued + auto_attachments
969
+ if all_attachments:
970
+ console.print(
971
+ f"[dim]→ sending {len(all_attachments)} image(s) with this prompt[/dim]"
972
+ )
973
+ parts = [{"type": "text", "text": auto_text}] + [
974
+ a["part"] for a in all_attachments
975
+ ]
976
+ state["messages"].append({"role": "user", "content": parts})
977
+ # Move the queued + auto bytes from "pending" to "sent" and clear
978
+ # the queue. session_image_bytes is what's enforced going forward.
979
+ state["session_image_bytes"] = state.get("session_image_bytes", 0) + sum(
980
+ int(a.get("size_bytes", 0)) for a in all_attachments
981
+ )
822
982
  state["pending_attachments"] = []
823
983
  else:
824
984
  state["messages"].append({"role": "user", "content": user_input})
@@ -845,12 +1005,48 @@ def main() -> None:
845
1005
  f"[yellow]Stopped after {hopped} tool hops — "
846
1006
  "model wasn't converging. Ask it to wrap up or revise the plan.[/yellow]"
847
1007
  )
1008
+ # Breadcrumb: record the incomplete state in history so a
1009
+ # "continue" turn resumes the right steps instead of the
1010
+ # model reconstructing (or hallucinating) progress.
1011
+ _plan = state.get("plan")
1012
+ if _plan is not None and not _plan.is_complete():
1013
+ state["messages"].append({
1014
+ "role": "system",
1015
+ "content": (
1016
+ f"[Execution was paused after {hopped} tool hops "
1017
+ f"with the plan incomplete {_plan.summary()}. "
1018
+ f"Remaining steps:\n{_plan.reminder_text()}\n"
1019
+ "When the user asks to continue, resume these "
1020
+ "remaining steps. Do not claim the task is "
1021
+ "finished until they are done.]"
1022
+ ),
1023
+ })
848
1024
  break
849
1025
  hopped += 1
850
1026
 
851
- tools_arg = TOOLS if state["mode"] != Mode.NONE else None
1027
+ # Re-ground the model in the current plan state on every hop.
1028
+ # The plan lives client-side; without this the model has to
1029
+ # reconstruct "what's left" from buried tool history and tends
1030
+ # to stop early or falsely claim completion. Injected
1031
+ # transiently (not persisted) so it always reflects live state
1032
+ # and history stays clean.
1033
+ turn_messages = state["messages"]
1034
+ _plan = state.get("plan")
1035
+ if _plan is not None and not _plan.is_complete():
1036
+ turn_messages = state["messages"] + [{
1037
+ "role": "system",
1038
+ "content": (
1039
+ f"[Active plan {_plan.summary()}. Steps still "
1040
+ f"remaining:\n{_plan.reminder_text()}\n"
1041
+ "Keep working through these now. Do NOT tell the "
1042
+ "user the task is complete, and do not treat "
1043
+ "starting a server as the final step, until every "
1044
+ "step above is done. If a step is genuinely "
1045
+ "impossible, mark it blocked and say why.]"
1046
+ ),
1047
+ }]
852
1048
  reply, meta = render_stream(
853
- stream_chat(state["messages"], state["cfg"], tools=tools_arg)
1049
+ stream_chat(turn_messages, state["cfg"], tools=TOOLS)
854
1050
  )
855
1051
  cost = meta.get("cost")
856
1052
  if cost is not None:
@@ -865,6 +1061,21 @@ def main() -> None:
865
1061
  tool_calls = meta.get("tool_calls") or []
866
1062
  if not tool_calls:
867
1063
  state["messages"].append({"role": "assistant", "content": reply})
1064
+ # Flag premature completion: the model ended its turn with
1065
+ # plan steps still open. Surfaces the gap to the user (and
1066
+ # the breadcrumb above keeps it in context for "continue").
1067
+ _plan = state.get("plan")
1068
+ if _plan is not None and not _plan.is_complete():
1069
+ _inc = _plan.incomplete()
1070
+ console.print(
1071
+ f"[yellow]⚠ ended its turn with {len(_inc)} plan "
1072
+ f"step(s) not completed:[/yellow]"
1073
+ )
1074
+ for _i, _s in _inc:
1075
+ console.print(f"[yellow] {_i}. {_s.title}[/yellow]")
1076
+ console.print(
1077
+ "[dim] If it stopped early, tell it to continue.[/dim]"
1078
+ )
868
1079
  break
869
1080
 
870
1081
  # Model called tools — execute and loop.
meshapi/commands.py CHANGED
@@ -71,11 +71,30 @@ def handle_command(cmd: str, state: dict) -> bool:
71
71
  except AttachmentError as e:
72
72
  console.print(f"[red]Can't attach: {e}[/red]")
73
73
  else:
74
- state.setdefault("pending_attachments", []).append(part)
75
- size_kb = max(1, info["size_bytes"] // 1024)
76
- console.print(
77
- f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
74
+ # Per-session image budget check (SSRF + 20 MB per-image
75
+ # are already enforced inside load_image).
76
+ from .safety import SESSION_IMAGE_BYTE_CAP
77
+ sent = state.get("session_image_bytes", 0)
78
+ queued_bytes = sum(
79
+ int(a.get("size_bytes", 0))
80
+ for a in (state.get("pending_attachments") or [])
78
81
  )
82
+ if sent + queued_bytes + info["size_bytes"] > SESSION_IMAGE_BYTE_CAP:
83
+ cap_mb = SESSION_IMAGE_BYTE_CAP // (1024 * 1024)
84
+ console.print(
85
+ f"[red]Can't attach: would exceed session image budget "
86
+ f"({cap_mb} MB).[/red]"
87
+ )
88
+ else:
89
+ state.setdefault("pending_attachments", []).append({
90
+ "part": part,
91
+ "size_bytes": info["size_bytes"],
92
+ "name": info["name"],
93
+ })
94
+ size_kb = max(1, info["size_bytes"] // 1024)
95
+ console.print(
96
+ f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
97
+ )
79
98
 
80
99
  elif name == "/clear-attach":
81
100
  had = len(state.get("pending_attachments") or [])
@@ -98,7 +117,7 @@ def handle_command(cmd: str, state: dict) -> bool:
98
117
 
99
118
  elif name == "/mode":
100
119
  if not arg:
101
- cur = state.get("mode", Mode.ASK)
120
+ cur = state.get("mode", Mode.DEFAULT)
102
121
  console.print(f"[dim]Current mode: {LABELS[cur]} ({cur.value})[/dim]")
103
122
  else:
104
123
  try:
@@ -113,7 +132,7 @@ def handle_command(cmd: str, state: dict) -> bool:
113
132
  "/clear reset conversation\n"
114
133
  "/model <name> switch model (e.g. anthropic/claude-sonnet-4.5)\n"
115
134
  "/route <mode> cheapest|fastest|balanced|default\n"
116
- "/mode <perm> ask|bypass|none (or shift+tab to cycle)\n"
135
+ "/mode <perm> default|accept-edits|auto|bypass (or shift+tab)\n"
117
136
  "/file <path> add text file to context\n"
118
137
  "/image <path|url> attach an image (base64) to the next prompt\n"
119
138
  "/clear-attach drop any queued image attachments\n"
@@ -122,7 +141,13 @@ def handle_command(cmd: str, state: dict) -> bool:
122
141
  "/help show this\n\n"
123
142
  "[dim]Image paths in a prompt auto-attach: drop /path/img.png in your\n"
124
143
  "input and it's sent as a base64 image part. Wrap in backticks to keep\n"
125
- "it as text. Multiple images per prompt are supported.[/dim]",
144
+ "it as text. Multiple images per prompt are supported.\n\n"
145
+ "Anything you /file, /image, or that the model reads via tools is sent\n"
146
+ "to the Mesh API gateway and the upstream model — including file\n"
147
+ "contents, screenshots, and shell output. Don't attach secrets.\n"
148
+ "Mode auto-approvals: accept-edits auto-writes inside cwd; auto adds\n"
149
+ "shell commands; bypass auto-approves everything (still asks before\n"
150
+ "writing to ~/.ssh, /etc, rm -rf, sudo, curl|sh, etc.).[/dim]",
126
151
  title="commands",
127
152
  border_style="cyan",
128
153
  ))