mlx-code 0.0.18__tar.gz → 0.0.19__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 (26) hide show
  1. {mlx_code-0.0.18 → mlx_code-0.0.19}/PKG-INFO +2 -2
  2. {mlx_code-0.0.18 → mlx_code-0.0.19}/README.md +1 -1
  3. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/gits.py +52 -21
  4. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/repl.py +70 -32
  5. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/tools.py +1 -1
  6. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code.egg-info/PKG-INFO +2 -2
  7. {mlx_code-0.0.18 → mlx_code-0.0.19}/setup.py +1 -1
  8. {mlx_code-0.0.18 → mlx_code-0.0.19}/LICENSE +0 -0
  9. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/__init__.py +0 -0
  10. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/apis.py +0 -0
  11. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/lsp_tool.py +0 -0
  12. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/main.py +0 -0
  13. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/mcb.py +0 -0
  14. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/mcb_tool.py +0 -0
  15. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/stream_log.py +0 -0
  16. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/util.py +0 -0
  17. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/view_git.py +0 -0
  18. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code/view_log.py +0 -0
  19. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code.egg-info/SOURCES.txt +0 -0
  20. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code.egg-info/dependency_links.txt +0 -0
  21. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code.egg-info/entry_points.txt +0 -0
  22. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code.egg-info/requires.txt +0 -0
  23. {mlx_code-0.0.18 → mlx_code-0.0.19}/mlx_code.egg-info/top_level.txt +0 -0
  24. {mlx_code-0.0.18 → mlx_code-0.0.19}/setup.cfg +0 -0
  25. {mlx_code-0.0.18 → mlx_code-0.0.19}/tests/__init__.py +0 -0
  26. {mlx_code-0.0.18 → mlx_code-0.0.19}/tests/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -38,7 +38,7 @@ Dynamic: summary
38
38
 
39
39
  A lightweight coding agent built on Apple's MLX framework.
40
40
 
41
- [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
41
+ https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
42
42
 
43
43
  ---
44
44
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  A lightweight coding agent built on Apple's MLX framework.
4
4
 
5
- [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
5
+ https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
6
6
 
7
7
  ---
8
8
 
@@ -19,7 +19,8 @@ def _git(cwd: str, *args: str, check: bool=True) -> str:
19
19
  r = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True, check=check)
20
20
  return r.stdout.strip()
21
21
  except subprocess.CalledProcessError as exc:
22
- raise GitError(f'git {' '.join(args)!r} failed in {cwd!r}: {exc.stderr.strip()}') from exc
22
+ args_str = repr(' '.join(args))
23
+ raise GitError(f'git {args_str} failed in {cwd!r}: {exc.stderr.strip()}') from exc
23
24
  except FileNotFoundError as exc:
24
25
  raise GitError('git executable not found in PATH') from exc
25
26
 
@@ -28,19 +29,33 @@ def _make_commit_message(messages) -> str:
28
29
  return messages
29
30
  filtered = [m for m in messages if m.get('role') != 'commit']
30
31
  last_user = next((m['content'] for m in reversed(filtered) if m.get('role') == 'user'), None)
31
- title = last_user.replace('\n', ' ').strip()[:30] if isinstance(last_user, str) else ''
32
- return f'{title}\n\n{json.dumps(filtered, indent=2, ensure_ascii=False)}'
32
+ title = last_user.replace('\n', ' ').strip()[:60] if isinstance(last_user, str) else 'update'
33
+ body = json.dumps(filtered, indent=2, ensure_ascii=False)
34
+ return f'{title}\n\n--- BEGIN MESSAGES ---\n{body}'
33
35
 
34
36
  def _parse_messages_from_commit(raw: str) -> list[dict]:
35
37
  if not raw or raw in ('snapshot', 'update'):
36
38
  return []
37
- try:
39
+ marker = '--- BEGIN MESSAGES ---\n'
40
+ idx = raw.find(marker)
41
+ if idx != -1:
42
+ payload = raw[idx + len(marker):]
43
+ else:
38
44
  parts = raw.split('\n\n', 1)
39
- msgs = json.loads(parts[1] if len(parts) == 2 else parts[0])
45
+ payload = parts[1] if len(parts) == 2 else parts[0]
46
+ try:
47
+ msgs = json.loads(payload)
40
48
  return msgs if isinstance(msgs, list) else []
41
- except (json.JSONDecodeError, IndexError):
49
+ except json.JSONDecodeError:
50
+ logger.warning('_parse_messages_from_commit: could not parse JSON from commit body')
42
51
  return []
43
52
 
53
+ def _count_user_turns(commit_body: str) -> int:
54
+ messages = _parse_messages_from_commit(commit_body)
55
+ if messages:
56
+ return sum((1 for m in messages if m.get('role') == 'user'))
57
+ return 0
58
+
44
59
  def git_add_filtered(cwd: str) -> None:
45
60
  excludes = [f':(exclude){p}' for p in _ADD_EXCLUDES]
46
61
  try:
@@ -117,22 +132,37 @@ def commit_worktree(point: LedgerPoint | None, message: str='update') -> tuple[L
117
132
  return (None, '')
118
133
 
119
134
  def cleanup_worktree(point: LedgerPoint, *, remove_branch: bool=False) -> None:
120
- root = _git(point.worktree, 'rev-parse', '--show-toplevel', check=False) or None
121
- if root:
122
- try:
123
- _git(root, 'worktree', 'remove', '--force', point.worktree)
124
- except Exception as e:
125
- logger.warning('git worktree remove failed: %s', e)
135
+ root: str | None = None
126
136
  if os.path.exists(point.worktree):
137
+ root = _git(point.worktree, 'rev-parse', '--show-toplevel', check=False) or None
138
+ if root:
139
+ try:
140
+ _git(root, 'worktree', 'remove', '--force', point.worktree)
141
+ except Exception as e:
142
+ logger.warning('git worktree remove failed: %s', e)
127
143
  try:
128
144
  shutil.rmtree(point.worktree, ignore_errors=True)
129
145
  except Exception as e:
130
146
  logger.warning('Filesystem cleanup failed for %s: %s', point.worktree, e)
131
- if remove_branch and root:
147
+ else:
148
+ logger.warning('cleanup_worktree: worktree path %r no longer exists; skipping removal', point.worktree)
149
+ try:
150
+ root = _git(os.path.dirname(point.worktree), 'rev-parse', '--show-toplevel', check=False) or None
151
+ except Exception:
152
+ pass
153
+ if root:
132
154
  try:
133
- _git(root, 'branch', '-D', point.branch)
155
+ _git(root, 'worktree', 'prune')
134
156
  except Exception as e:
135
- logger.warning('git branch deletion failed: %s', e)
157
+ logger.warning('git worktree prune failed: %s', e)
158
+ if remove_branch:
159
+ if root:
160
+ try:
161
+ _git(root, 'branch', '-D', point.branch)
162
+ except Exception as e:
163
+ logger.warning('git branch deletion failed for %r: %s', point.branch, e)
164
+ else:
165
+ logger.warning('cleanup_worktree: could not locate repo root; branch %r was NOT deleted', point.branch)
136
166
 
137
167
  def resume_worktree(repo_dir: str, commit: str, *, worktree_dir: str | None=None, prefix: str='resume') -> tuple[LedgerPoint | None, list[dict]]:
138
168
  try:
@@ -208,7 +238,7 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
208
238
  if line.startswith('COMMIT:'):
209
239
  if current_commit:
210
240
  body_text = '\n'.join(body_lines)
211
- current_commit['user_turns'] = body_text.count('"role": "user"')
241
+ current_commit['user_turns'] = _count_user_turns(body_text)
212
242
  commits.append(current_commit)
213
243
  current_commit = {'sha': line[7:], 'short_sha': '', 'refs': '', 'subject': '', 'files': []}
214
244
  in_body = False
@@ -237,16 +267,17 @@ def get_commit_history_with_stats(worktree: str, limit: int=50) -> list[dict]:
237
267
  current_commit['files'].append(line)
238
268
  if current_commit:
239
269
  body_text = '\n'.join(body_lines)
240
- current_commit['user_turns'] = body_text.count('"role": "user"')
270
+ current_commit['user_turns'] = _count_user_turns(body_text)
241
271
  commits.append(current_commit)
242
272
  return commits
243
273
  except Exception as e:
244
274
  logger.warning(f'get_commit_history_with_stats failed: {e}')
245
275
  return []
246
276
 
247
- def find_rev_commit(worktree: str, n: int) -> str | None:
277
+ def find_rev_commit(worktree: str, n: int, *, limit: int=0) -> str | None:
278
+ limit_args = ['-n', str(limit)] if limit > 0 else []
248
279
  try:
249
- result = subprocess.run(['git', 'log', '--format=COMMIT:%H%n%b%nEND_BODY', '-n', '500'], cwd=worktree, capture_output=True, text=True, check=True)
280
+ result = subprocess.run(['git', 'log', '--format=COMMIT:%H%n%b%nEND_BODY', *limit_args], cwd=worktree, capture_output=True, text=True, check=True)
250
281
  output = result.stdout
251
282
  best_exact: str | None = None
252
283
  best_below: str | None = None
@@ -256,7 +287,7 @@ def find_rev_commit(worktree: str, n: int) -> str | None:
256
287
  if line.startswith('COMMIT:'):
257
288
  if current_sha is not None:
258
289
  body = '\n'.join(body_lines)
259
- count = body.count('"role": "user"')
290
+ count = _count_user_turns(body)
260
291
  if count == n and best_exact is None:
261
292
  best_exact = current_sha
262
293
  if count < n and best_below is None:
@@ -269,7 +300,7 @@ def find_rev_commit(worktree: str, n: int) -> str | None:
269
300
  body_lines.append(line)
270
301
  if current_sha is not None:
271
302
  body = '\n'.join(body_lines)
272
- count = body.count('"role": "user"')
303
+ count = _count_user_turns(body)
273
304
  if count == n and best_exact is None:
274
305
  best_exact = current_sha
275
306
  if count < n and best_below is None:
@@ -27,6 +27,7 @@ from rich.text import Text as RichText
27
27
  from rich.table import Table
28
28
  from rich.markup import escape
29
29
  from rich.markdown import Markdown
30
+ from rich.cells import cell_len
30
31
 
31
32
  class Agent:
32
33
 
@@ -51,7 +52,7 @@ class Agent:
51
52
  self.tools = tools
52
53
 
53
54
  def spawn(self, **overrides) -> 'Agent':
54
- kwargs = {'api': self.api, 'system': self.system, 'extra_tool_classes': self._extra_tool_classes, 'model': self.model, 'api_key': self.api_key, 'base_url': self.base_url, 'ctx': {k: v for k, v in self.ctx.items() if k != 'agent'}}
55
+ kwargs = {'system': self.system, 'extra_tool_classes': self._extra_tool_classes, 'model': self.model, 'api_key': self.api_key, 'base_url': self.base_url, 'ctx': {k: v for k, v in self.ctx.items() if k != 'agent'}}
55
56
  kwargs.update(overrides)
56
57
  return Agent(**kwargs)
57
58
 
@@ -63,6 +64,8 @@ class Agent:
63
64
  async def run(self, prompt: str) -> dict:
64
65
  await self._wait()
65
66
  self._signal = None
67
+ self._last_result_sig = None
68
+ self._same_result_count = 0
66
69
  self.messages.append({'role': 'user', 'content': prompt})
67
70
  return await self._loop()
68
71
 
@@ -250,10 +253,13 @@ def _render_split_diff(diff_text: str, ref1_label: str='HEAD~1', ref2_label: str
250
253
  _flush(left_lines, right_lines)
251
254
  return tbl
252
255
 
253
- def render_history(messages: list[dict]) -> Table:
256
+ def _make_empty_history_table() -> Table:
254
257
  tbl = Table(show_header=False, show_lines=False, box=None, pad_edge=False, expand=True, padding=0)
255
258
  tbl.add_column(width=2)
256
259
  tbl.add_column(ratio=1)
260
+ return tbl
261
+
262
+ def append_to_history_table(tbl: Table, messages: list[dict]) -> None:
257
263
  for msg in messages:
258
264
  role = msg.get('role')
259
265
  content = msg.get('content')
@@ -313,6 +319,10 @@ def render_history(messages: list[dict]) -> Table:
313
319
  else:
314
320
  body = RichText(escape(text), style=style)
315
321
  tbl.add_row(RichText(prefix, style=style), body)
322
+
323
+ def render_history(messages: list[dict]) -> Table:
324
+ tbl = _make_empty_history_table()
325
+ append_to_history_table(tbl, messages)
316
326
  return tbl
317
327
  REPL_HELP = '\nCommands:\n/help show this message\n/clear clear conversation (transcript + agent memory)\n/history show full conversation transcript\n/history --raw show raw API message log (debug)\n/diff [--all] show side-by-side diff of changes\n/errors show timestamped error log for this tab\n/tools list active tools\n/branch [--rev N] [--as-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n/abort abort the running agent\n/export [path] export session to JSON\n/exit /quit close branch tab, or exit the app\n!command run shell command in worktree (output captured in TUI)\n!!command run interactive shell command (TUI suspends, terminal handed to process)\n e.g. !ls !git diff !cat file.py\n !!vim file.py !!yazi !!less log.txt\nKeys:\nEnter submit\nCtrl-J insert newline in editor\nAlt-1 … Alt-9 jump directly to tab N\nTab / Shift-Tab cycle through tabs\nCtrl-C abort running agent\nCtrl-D close branch tab, or exit app\nCtrl-R recall last prompt into editor\n'
318
328
 
@@ -368,6 +378,7 @@ class Tab(Vertical):
368
378
  self._stream_blocks: list[dict] = []
369
379
  self._command_blocks: list[dict] = []
370
380
  self._cache_count: int = -1
381
+ self._rendered_cache: Table | None = None
371
382
 
372
383
  @property
373
384
  def is_running(self) -> bool:
@@ -439,12 +450,19 @@ class Tab(Vertical):
439
450
  self.status = 'idle'
440
451
 
441
452
  def refresh_cache(self) -> None:
442
- if len(self.agent.messages) != self._cache_count:
443
- self._cache_count = len(self.agent.messages)
444
- self.query_one('#cache', Static).update(render_history(self.agent.messages))
445
- scroll = self.query_one('#scroll', VerticalScroll)
446
- if scroll.max_scroll_y - scroll.scroll_y < 3:
447
- self.app.call_after_refresh(scroll.scroll_end, animate=False)
453
+ new_count = len(self.agent.messages)
454
+ if new_count == self._cache_count:
455
+ return
456
+ if self._rendered_cache is None or new_count < self._cache_count:
457
+ self._rendered_cache = render_history(self.agent.messages)
458
+ else:
459
+ new_msgs = self.agent.messages[self._cache_count:]
460
+ append_to_history_table(self._rendered_cache, new_msgs)
461
+ self._cache_count = new_count
462
+ self.query_one('#cache', Static).update(self._rendered_cache)
463
+ scroll = self.query_one('#scroll', VerticalScroll)
464
+ if scroll.max_scroll_y - scroll.scroll_y < 3:
465
+ self.app.call_after_refresh(scroll.scroll_end, animate=False)
448
466
 
449
467
  def refresh_stream(self) -> None:
450
468
  msgs = []
@@ -468,6 +486,7 @@ class Tab(Vertical):
468
486
  self.query_one('#cache', Static).update('')
469
487
  self.query_one('#stream', Static).update('')
470
488
  self._cache_count = 0
489
+ self._rendered_cache = None
471
490
  self._command_blocks = []
472
491
  self._stream_blocks = []
473
492
 
@@ -490,8 +509,9 @@ class TabBar(Static):
490
509
  for i, tab in enumerate(tabs):
491
510
  marker = '●' if tab.is_running else ' '
492
511
  label = f' {marker}{i + 1}:{tab.title} '
493
- self._ranges.append((x, x + len(label), i))
494
- x += len(label)
512
+ width = cell_len(label)
513
+ self._ranges.append((x, x + width, i))
514
+ x += width
495
515
  if i == active_index:
496
516
  t.append(label, style='bold green reverse')
497
517
  elif tab.is_running:
@@ -621,6 +641,11 @@ class ReplApp(App[None]):
621
641
  tab.errors.clear()
622
642
  tab.last_error = ''
623
643
  self.query_one('#helpbar', HelpBar).show_idle()
644
+ if text.startswith('!!'):
645
+ command = text[2:].strip()
646
+ if command:
647
+ await self._run_interactive_command(tab, command)
648
+ return
624
649
  if text.startswith('!'):
625
650
  command = text[1:].strip()
626
651
  if not command:
@@ -662,15 +687,28 @@ class ReplApp(App[None]):
662
687
  cwd = gwt.worktree if gwt and getattr(gwt, 'worktree', None) else tab.agent.ctx.get('cwd') or os.getcwd()
663
688
  env = tab.agent.ctx.get('env')
664
689
  try:
665
- proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env if env else None)
666
- stdout, _ = await proc.communicate()
667
- output = stdout.decode(errors='replace').rstrip('\n')
690
+ proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env if env else None)
691
+ stdout, stderr = await proc.communicate()
692
+ out = stdout.decode(errors='replace').rstrip('\n')
693
+ err = stderr.decode(errors='replace').rstrip('\n')
694
+ body = out
695
+ if err:
696
+ body = (body + '\n' if body else '') + f'[stderr]\n{err}'
668
697
  if proc.returncode and proc.returncode != 0:
669
- output += f'\n[exit {proc.returncode}]'
670
- tab.show_command(f'!{command}', output or '(no output)')
698
+ body += f'\n[exit {proc.returncode}]'
699
+ tab.show_command(f'!{command}', body or '(no output)')
671
700
  except Exception as e:
672
701
  tab.show_command(f'!{command}', f'Error: {e}')
673
702
 
703
+ async def _run_interactive_command(self, tab: Tab, command: str) -> None:
704
+ gwt = tab.agent.ctx.get('gwt')
705
+ cwd = gwt.worktree if gwt and getattr(gwt, 'worktree', None) else tab.agent.ctx.get('cwd') or os.getcwd()
706
+ env = tab.agent.ctx.get('env')
707
+ with self.suspend():
708
+ proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdin=None, stdout=None, stderr=None, env=env if env else None)
709
+ returncode = await proc.wait()
710
+ tab.show_command(f'!!{command}', f'[exited {returncode}]')
711
+
674
712
  async def _handle_command(self, tab: Tab, text: str) -> None:
675
713
  cmd, _, arg = text.partition(' ')
676
714
  cmd = cmd.lower().strip()
@@ -856,7 +894,11 @@ class ReplApp(App[None]):
856
894
  await self._run_submit(prompt)
857
895
  elif cmd == '/export':
858
896
  ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
859
- path = arg or os.path.join(os.getcwd(), f'session_{ts}.json')
897
+ user_cwd = tab.agent.ctx.get('user_cwd', os.getcwd())
898
+ if arg:
899
+ path = arg if os.path.isabs(arg) else os.path.join(user_cwd, arg)
900
+ else:
901
+ path = os.path.join(user_cwd, f'session_{ts}.json')
860
902
  data = {'version': 1, 'exported_at': ts, 'system': tab.agent.system, 'messages': tab.agent.messages}
861
903
  try:
862
904
  with open(path, 'w', encoding='utf-8') as f:
@@ -981,14 +1023,14 @@ def collect_skills(skills_dir, skills=None):
981
1023
  skills.append({'name': name, 'description': description, 'content': text})
982
1024
  skill_prompt = 'Available skills (use Skill to load full instructions when needed):\n' + '\n'.join((f'- {s['name']}: {s['description']}' for s in skills)) if skills else ''
983
1025
  return (skills, skill_prompt)
1026
+ _AGENT_ENV_ALLOWLIST: re.Pattern = re.compile('\n ^(\n # ── Execution paths ────────────────────────────────────────────────────\n PATH\n | MANPATH | INFOPATH\n\n # ── Python / conda / virtualenv ────────────────────────────────────────\n | CONDA_PREFIX | CONDA_DEFAULT_ENV | CONDA_EXE | CONDA_PYTHON_EXE\n | CONDA_SHLVL | CONDA_PROMPT_MODIFIER\n | MAMBA_ROOT_PREFIX | MAMBA_EXE\n | VIRTUAL_ENV | VIRTUAL_ENV_PROMPT\n | PYTHONPATH | PYTHONHOME | PYTHONSTARTUP\n | PYTHONDONTWRITEBYTECODE | PYTHONUNBUFFERED\n | PYTHONFAULTHANDLER | PYTHONUTF8\n | PIP_INDEX_URL | PIP_EXTRA_INDEX_URL # private PyPI mirrors (no auth tokens)\n | PIPENV_PIPFILE | POETRY_VIRTUALENVS_IN_PROJECT\n\n # ── Native / compiled libs ─────────────────────────────────────────────\n | LD_LIBRARY_PATH | LD_PRELOAD\n | DYLD_LIBRARY_PATH | DYLD_FALLBACK_LIBRARY_PATH # macOS\n | PKG_CONFIG_PATH\n | CMAKE_PREFIX_PATH | CMAKE_BUILD_TYPE\n\n # ── CUDA / GPU ─────────────────────────────────────────────────────────\n | CUDA_HOME | CUDA_PATH | CUDA_VISIBLE_DEVICES\n | NVIDIA_VISIBLE_DEVICES | NVIDIA_DRIVER_CAPABILITIES\n | HIP_PATH | ROCR_VISIBLE_DEVICES # AMD\n | METAL_DEVICE_WRAPPER_TYPE # Apple\n\n # ── Locale / encoding ──────────────────────────────────────────────────\n | LANG | LANGUAGE | LC_ALL | LC_CTYPE | LC_MESSAGES\n | LC_NUMERIC | LC_TIME | LC_COLLATE\n | PYTHONUTF8\n\n # ── Terminal (needed by tools that check if output is a tty) ───────────\n | TERM | TERM_PROGRAM | COLORTERM\n | NO_COLOR | CLICOLOR | CLICOLOR_FORCE\n | COLUMNS | LINES\n\n # ── Process identity (non-secret) ──────────────────────────────────────\n | HOME # overridden to temp dir by run_repl — safe\n | SHELL\n | TMPDIR | TEMP | TMP\n | XDG_RUNTIME_DIR | XDG_CACHE_HOME | XDG_CONFIG_HOME | XDG_DATA_HOME\n\n # ── Toolchain / build (no secrets) ─────────────────────────────────────\n | CC | CXX | AR | LD | FC\n | CFLAGS | CXXFLAGS | LDFLAGS | MAKEFLAGS\n | JAVA_HOME | GRADLE_HOME | MAVEN_HOME\n | GOPATH | GOROOT | GOMODCACHE\n | CARGO_HOME | RUSTUP_HOME\n | NODE_PATH # not NODE_AUTH_TOKEN\n | GEM_HOME | GEM_PATH | BUNDLE_PATH\n )$\n', re.VERBOSE)
1027
+
1028
+ def _make_agent_env(base: dict[str, str]) -> dict[str, str]:
1029
+ return {k: v for k, v in base.items() if _AGENT_ENV_ALLOWLIST.match(k)}
984
1030
 
985
1031
  def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemini', 'deepseek', 'noapi']='noapi', system='', sdir=None, skills=None, env=None, tool_names=None, extra_tool_classes=None, api_key=None, gwt=None, ctx=None, init_prompt=None, resume_messages=None, repo=None, resume=None, stream=None, verbose_transcript=False):
986
1032
  repo = os.path.abspath(repo or os.getcwd())
987
1033
  with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as _home:
988
- if env is None:
989
- env = os.environ.copy()
990
- env['HOME'] = _home
991
- env.setdefault('SHELL', '/bin/bash')
992
1034
  if gwt is None:
993
1035
  if resume:
994
1036
  result = resume_worktree(repo, resume, worktree_dir=os.path.join(_home, 'workspace'))
@@ -1000,15 +1042,17 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
1000
1042
  else:
1001
1043
  gwt = create_worktree(repo, worktree_dir=os.path.join(_home, 'workspace'))
1002
1044
  cwd = gwt.worktree if gwt else repo
1003
- env['PWD'] = cwd
1004
- _orig_cwd = os.getcwd()
1005
- _orig_environ = os.environ.copy()
1006
- os.environ.update(env)
1007
- os.chdir(cwd)
1045
+ if env is None:
1046
+ env = os.environ.copy()
1047
+ env.setdefault('SHELL', '/bin/bash')
1048
+ agent_env = _make_agent_env(env)
1049
+ agent_env['HOME'] = _home
1050
+ agent_env['PWD'] = cwd
1051
+ user_cwd = os.path.abspath(os.getcwd())
1008
1052
  sdir = os.path.abspath(sdir or cwd)
1009
1053
  skills, skill_prompt = collect_skills(sdir, skills)
1010
1054
  system = '\n\n'.join(filter(None, [system, skill_prompt]))
1011
- merged_ctx = {'cwd': cwd, 'skills': skills, 'gwt': gwt, 'env': env, **(ctx or {})}
1055
+ merged_ctx = {'cwd': cwd, 'user_cwd': user_cwd, 'skills': skills, 'gwt': gwt, 'env': agent_env, **(ctx or {})}
1012
1056
  agent = Agent(system=system, api=api, model=model, tool_names=tool_names, extra_tool_classes=extra_tool_classes, api_key=api_key, base_url=base_url, ctx=merged_ctx)
1013
1057
  log_fp = None
1014
1058
  if stream is not None:
@@ -1027,12 +1071,6 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
1027
1071
  finally:
1028
1072
  if log_fp:
1029
1073
  log_fp.close()
1030
- try:
1031
- os.chdir(_orig_cwd)
1032
- except OSError as exc:
1033
- logger.warning('Could not restore original cwd %r: %s', _orig_cwd, exc)
1034
- os.environ.clear()
1035
- os.environ.update(_orig_environ)
1036
1074
  if app_instance and hasattr(app_instance, '_exit_summary') and app_instance._exit_summary:
1037
1075
  print('\n--- Session Exit Summary ---')
1038
1076
  for item in app_instance._exit_summary:
@@ -213,7 +213,7 @@ class BashTool(Tool):
213
213
  parameters = BashParams
214
214
 
215
215
  async def execute(self, params: BashParams, signal=None) -> dict:
216
- proc_env = {**os.environ, **self.ctx.get('env', {})}
216
+ proc_env = self.ctx.get('env')
217
217
  proc = await asyncio.create_subprocess_shell(params.command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=self.ctx['cwd'], env=proc_env)
218
218
  communicate_task = asyncio.create_task(proc.communicate())
219
219
  abort_task: asyncio.Task | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -38,7 +38,7 @@ Dynamic: summary
38
38
 
39
39
  A lightweight coding agent built on Apple's MLX framework.
40
40
 
41
- [![demo](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code.gif)](https://youtu.be/0lkY7YQCyCo)
41
+ https://github.com/user-attachments/assets/0569d101-8d0a-4e67-9e82-fce84a5ef3f0
42
42
 
43
43
  ---
44
44
 
@@ -11,7 +11,7 @@ setup(
11
11
  author_email="albersj66@gmail.com",
12
12
  author="J Joe",
13
13
  license="Apache-2.0",
14
- version="0.0.18",
14
+ version="0.0.19",
15
15
  readme="README.md",
16
16
  description="Coding Agent for Mac",
17
17
  long_description=open("README.md").read(),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes