mlx-code 0.0.29__tar.gz → 0.0.31__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.
- {mlx_code-0.0.29 → mlx_code-0.0.31}/PKG-INFO +19 -10
- {mlx_code-0.0.29 → mlx_code-0.0.31}/README.md +14 -3
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/apis.py +2 -2
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/bats.py +20 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/gits.py +32 -5
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/main.py +22 -42
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/repl.py +100 -36
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/tools.py +13 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/tui.py +0 -1
- mlx_code-0.0.31/mlx_code/view_git.py +995 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/view_log.py +1 -1
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/web.py +38 -75
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/PKG-INFO +19 -10
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/requires.txt +4 -6
- {mlx_code-0.0.29 → mlx_code-0.0.31}/setup.py +11 -11
- mlx_code-0.0.29/mlx_code/view_git.py +0 -824
- {mlx_code-0.0.29 → mlx_code-0.0.31}/LICENSE +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/bare.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code/util.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/SOURCES.txt +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/setup.cfg +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/tests/__init__.py +0 -0
- {mlx_code-0.0.29 → mlx_code-0.0.31}/tests/test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mlx-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.31
|
|
4
4
|
Summary: Coding Agent for Mac
|
|
5
5
|
Home-page: https://josefalbers.github.io/mlx-code/
|
|
6
6
|
Author: J Joe
|
|
@@ -15,13 +15,11 @@ License-File: LICENSE
|
|
|
15
15
|
Requires-Dist: mlx-lm>=0.31.3; platform_system == "Darwin"
|
|
16
16
|
Requires-Dist: httpx
|
|
17
17
|
Requires-Dist: pydantic
|
|
18
|
-
Requires-Dist: textual>=8.2.7
|
|
19
|
-
Requires-Dist: rich>=15.0.0
|
|
20
|
-
Requires-Dist: starlette
|
|
21
|
-
Requires-Dist: uvicorn
|
|
22
18
|
Provides-Extra: all
|
|
23
|
-
Requires-Dist:
|
|
24
|
-
Requires-Dist:
|
|
19
|
+
Requires-Dist: starlette; extra == "all"
|
|
20
|
+
Requires-Dist: uvicorn; extra == "all"
|
|
21
|
+
Requires-Dist: textual>=8.2.7; extra == "all"
|
|
22
|
+
Requires-Dist: rich>=15.0.0; extra == "all"
|
|
25
23
|
Requires-Dist: pygments; extra == "all"
|
|
26
24
|
Dynamic: author
|
|
27
25
|
Dynamic: author-email
|
|
@@ -76,7 +74,7 @@ Agents: ├───────────────────
|
|
|
76
74
|
│ │ Gemini │ │ Edit Bash │ │
|
|
77
75
|
│ │ Claude │ │ Grep Find │ │
|
|
78
76
|
│ │ Codex │ │ Ls Skill │ │
|
|
79
|
-
│ │ DeepSeek │ │ Agent
|
|
77
|
+
│ │ DeepSeek │ │ Agent ────────────────► Recursively spawns sub-Agents
|
|
80
78
|
│ └────────────────┘ └────────────────┘ │
|
|
81
79
|
│ Git worktree │
|
|
82
80
|
│ (isolation + session state) │
|
|
@@ -108,10 +106,10 @@ result = await agent.run('refactor utils.py to use dataclasses')
|
|
|
108
106
|
|
|
109
107
|
```bash
|
|
110
108
|
# ephemeral run (no installation)
|
|
111
|
-
uvx --from mlx-code mlc
|
|
109
|
+
uvx --from mlx-code[all] mlc
|
|
112
110
|
|
|
113
111
|
# or install into the current environment
|
|
114
|
-
pip install mlx-code
|
|
112
|
+
pip install mlx-code[all]
|
|
115
113
|
|
|
116
114
|
# launch
|
|
117
115
|
mlc # with a local MLX model
|
|
@@ -483,6 +481,7 @@ agent = Agent(extra_tool_classes=[LiveDBTool], tool_names=["QueryDB"])
|
|
|
483
481
|
| `/abort` | Abort the running agent |
|
|
484
482
|
| `/errors` | Show timestamped error log for the current tab |
|
|
485
483
|
| `/export [path]` | Export session to JSON |
|
|
484
|
+
| `/merge` | Merge current branch into parent tab |
|
|
486
485
|
| `/exit [--all]` | Close branch tab, or exit the app |
|
|
487
486
|
| `/help` | Show command reference |
|
|
488
487
|
| `!command` | Run a shell command; output captured in the TUI (eg, `ls`, `cat hello.c`) |
|
|
@@ -548,8 +547,18 @@ mlc --leash claude # claude code
|
|
|
548
547
|
- subdomain: jjoe
|
|
549
548
|
- domain: mlx-code.com
|
|
550
549
|
- service url: http://host.containers.internal:8080
|
|
550
|
+
|
|
551
|
+
[protect & connect]-[zero trust]-[access controls]-[applications]-[create new application]-[self-hosted and private]:
|
|
552
|
+
- subdomain: jjoe
|
|
553
|
+
- domain: mlx-code.com
|
|
554
|
+
- [create new policy]
|
|
555
|
+
- policy name: Kaputt
|
|
556
|
+
- action: Allow
|
|
557
|
+
- [policy rules]-[(or) include]-[selector is]
|
|
558
|
+
|
|
551
559
|
mlc --host 0.0.0.0 --engine batch --web &
|
|
552
560
|
podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
|
|
561
|
+
|
|
553
562
|
phone http://jjoe.mlx-code.com
|
|
554
563
|
```
|
|
555
564
|
|
|
@@ -38,7 +38,7 @@ Agents: ├───────────────────
|
|
|
38
38
|
│ │ Gemini │ │ Edit Bash │ │
|
|
39
39
|
│ │ Claude │ │ Grep Find │ │
|
|
40
40
|
│ │ Codex │ │ Ls Skill │ │
|
|
41
|
-
│ │ DeepSeek │ │ Agent
|
|
41
|
+
│ │ DeepSeek │ │ Agent ────────────────► Recursively spawns sub-Agents
|
|
42
42
|
│ └────────────────┘ └────────────────┘ │
|
|
43
43
|
│ Git worktree │
|
|
44
44
|
│ (isolation + session state) │
|
|
@@ -70,10 +70,10 @@ result = await agent.run('refactor utils.py to use dataclasses')
|
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
# ephemeral run (no installation)
|
|
73
|
-
uvx --from mlx-code mlc
|
|
73
|
+
uvx --from mlx-code[all] mlc
|
|
74
74
|
|
|
75
75
|
# or install into the current environment
|
|
76
|
-
pip install mlx-code
|
|
76
|
+
pip install mlx-code[all]
|
|
77
77
|
|
|
78
78
|
# launch
|
|
79
79
|
mlc # with a local MLX model
|
|
@@ -445,6 +445,7 @@ agent = Agent(extra_tool_classes=[LiveDBTool], tool_names=["QueryDB"])
|
|
|
445
445
|
| `/abort` | Abort the running agent |
|
|
446
446
|
| `/errors` | Show timestamped error log for the current tab |
|
|
447
447
|
| `/export [path]` | Export session to JSON |
|
|
448
|
+
| `/merge` | Merge current branch into parent tab |
|
|
448
449
|
| `/exit [--all]` | Close branch tab, or exit the app |
|
|
449
450
|
| `/help` | Show command reference |
|
|
450
451
|
| `!command` | Run a shell command; output captured in the TUI (eg, `ls`, `cat hello.c`) |
|
|
@@ -510,8 +511,18 @@ mlc --leash claude # claude code
|
|
|
510
511
|
- subdomain: jjoe
|
|
511
512
|
- domain: mlx-code.com
|
|
512
513
|
- service url: http://host.containers.internal:8080
|
|
514
|
+
|
|
515
|
+
[protect & connect]-[zero trust]-[access controls]-[applications]-[create new application]-[self-hosted and private]:
|
|
516
|
+
- subdomain: jjoe
|
|
517
|
+
- domain: mlx-code.com
|
|
518
|
+
- [create new policy]
|
|
519
|
+
- policy name: Kaputt
|
|
520
|
+
- action: Allow
|
|
521
|
+
- [policy rules]-[(or) include]-[selector is]
|
|
522
|
+
|
|
513
523
|
mlc --host 0.0.0.0 --engine batch --web &
|
|
514
524
|
podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
|
|
525
|
+
|
|
515
526
|
phone http://jjoe.mlx-code.com
|
|
516
527
|
```
|
|
517
528
|
|
|
@@ -75,7 +75,7 @@ class ClaudeChat:
|
|
|
75
75
|
out.append(blk)
|
|
76
76
|
elif t == 'thinking':
|
|
77
77
|
if b['redacted']:
|
|
78
|
-
out.append({'type': 'redacted_thinking', 'data': b
|
|
78
|
+
out.append({'type': 'redacted_thinking', 'data': b.get('thinking', b.get('redacted_data', ''))})
|
|
79
79
|
else:
|
|
80
80
|
out.append({'type': 'thinking', 'thinking': b['thinking'], 'signature': b.get('signature') or ''})
|
|
81
81
|
elif t == 'toolCall':
|
|
@@ -156,7 +156,7 @@ class ClaudeChat:
|
|
|
156
156
|
msg['content'].append({'type': 'thinking', 'thinking': '', 'signature': None, 'redacted': False})
|
|
157
157
|
elif bt == 'redacted_thinking':
|
|
158
158
|
_idx[idx] = len(msg['content'])
|
|
159
|
-
msg['content'].append({'type': 'thinking', '
|
|
159
|
+
msg['content'].append({'type': 'thinking', 'redacted_data': blk.get('data', ''), 'signature': None, 'redacted': True})
|
|
160
160
|
elif bt == 'tool_use':
|
|
161
161
|
_idx[idx] = len(msg['content'])
|
|
162
162
|
msg['content'].append({'type': 'toolCall', 'id': blk['id'], 'name': blk['name'], 'arguments': {}})
|
|
@@ -294,6 +294,26 @@ def make_batch_app(model_name: str, cache_dir: str='.cache'):
|
|
|
294
294
|
n_cached = sum((1 for _ in pc.cache_dir.glob('*.safetensors')))
|
|
295
295
|
return JSONResponse({'status': 'ok', 'model': model_name, 'active_sequences': len(state['active']), 'prefix_cache_files': n_cached})
|
|
296
296
|
return Starlette(routes=[Route('/v1/models', list_models, methods=['GET']), Route('/v1/messages/count_tokens', count_tokens, methods=['POST']), Route('/v1/chat/completions', generate_endpoint, methods=['POST']), Route('/v1/messages', generate_endpoint, methods=['POST']), Route('/v1/responses', generate_endpoint, methods=['POST']), Route('/v1beta/models/{rest:path}', generate_endpoint, methods=['POST']), Route('/generate', simple_generate, methods=['POST']), Route('/health', health, methods=['GET'])], lifespan=lifespan)
|
|
297
|
+
|
|
298
|
+
class BatchServer:
|
|
299
|
+
import uvicorn
|
|
300
|
+
|
|
301
|
+
def __init__(self, app, host: str, port: int):
|
|
302
|
+
config = uvicorn.Config(app, host=host, port=port, loop='asyncio', log_level='warning')
|
|
303
|
+
self._server = uvicorn.Server(config)
|
|
304
|
+
self.host = host
|
|
305
|
+
self.port = port
|
|
306
|
+
|
|
307
|
+
def serve_forever(self):
|
|
308
|
+
self._server.run()
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def started(self) -> bool:
|
|
312
|
+
return self._server.started
|
|
313
|
+
|
|
314
|
+
def make_batch_server(host: str, port: int, model, cache_dir: str='.cache') -> BatchServer:
|
|
315
|
+
app = make_batch_app(model, cache_dir=cache_dir)
|
|
316
|
+
return BatchServer(app, host, port)
|
|
297
317
|
if __name__ == '__main__':
|
|
298
318
|
import uvicorn
|
|
299
319
|
uvicorn.run(make_batch_app('mlx-community/Qwen3.5-4B-OptiQ-4bit'), host='0.0.0.0', port=8000)
|
|
@@ -9,7 +9,6 @@ import uuid
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
|
-
_ADD_EXCLUDES = ['_*', '*.bin', '*.gguf', '*.safetensors', '*.pt', '*.pth', '.cache/', '.log.json', '*.egg-info/', '.eggs/', 'build/', 'dist/', '__pycache__/', '*.pyc', '*.pyo', '*.pyd', '.pytest_cache/', '.tox/', '.nox/', '.coverage', 'htmlcov/', '.venv/', 'venv/', 'env/', '.DS_Store', 'Thumbs.db']
|
|
13
12
|
|
|
14
13
|
class GitError(RuntimeError):
|
|
15
14
|
pass
|
|
@@ -57,11 +56,22 @@ def _count_user_turns(commit_body: str) -> int:
|
|
|
57
56
|
if messages:
|
|
58
57
|
return sum((1 for m in messages if m.get('role') == 'user'))
|
|
59
58
|
return 0
|
|
59
|
+
_ADD_EXCLUDES = ['_*', '_*/', '*.bin', '*.gguf', '*.safetensors', '*.pt', '*.pth', '.cache/', '.log.json', '*.egg-info/', '.eggs/', 'build/', 'dist/', '__pycache__/', '*.pyc', '*.pyo', '*.pyd', '.pytest_cache/', '.tox/', '.nox/', '.coverage', 'htmlcov/', '.venv/', 'venv/', 'env/', '.DS_Store', 'Thumbs.db']
|
|
60
|
+
|
|
61
|
+
def _exclude_pathspecs(patterns: list[str]) -> list[str]:
|
|
62
|
+
specs = []
|
|
63
|
+
for p in patterns:
|
|
64
|
+
if p.endswith('/'):
|
|
65
|
+
name = p[:-1]
|
|
66
|
+
specs.append(f':(exclude,glob)**/{name}/**')
|
|
67
|
+
else:
|
|
68
|
+
specs.append(f':(exclude,glob)**/{p}')
|
|
69
|
+
return specs
|
|
60
70
|
|
|
61
71
|
def git_add_filtered(cwd: str) -> None:
|
|
62
|
-
excludes =
|
|
72
|
+
excludes = _exclude_pathspecs(_ADD_EXCLUDES)
|
|
63
73
|
try:
|
|
64
|
-
_git(cwd, 'add', '-A', '--', '.', *excludes)
|
|
74
|
+
_git(cwd, '-c', 'advice.addIgnoredFile=false', 'add', '-A', '--', '.', *excludes)
|
|
65
75
|
except GitError as e:
|
|
66
76
|
logger.warning('git add warning (ignored): %s', e)
|
|
67
77
|
|
|
@@ -83,7 +93,7 @@ def create_worktree(repo_dir: str, *, worktree_dir: str | None=None, ref: str='H
|
|
|
83
93
|
root = repo_dir
|
|
84
94
|
gi = os.path.join(root, '.gitignore')
|
|
85
95
|
if not os.path.exists(gi):
|
|
86
|
-
Path(gi).write_text('\n'.join(['
|
|
96
|
+
Path(gi).write_text('\n'.join(['.log.json']))
|
|
87
97
|
if not _git(root, 'config', 'user.email', check=False):
|
|
88
98
|
_git(root, 'config', 'user.email', 'agent@local')
|
|
89
99
|
if not _git(root, 'config', 'user.name', check=False):
|
|
@@ -349,4 +359,21 @@ def get_branch_base_sha(worktree: str) -> str | None:
|
|
|
349
359
|
except GitError:
|
|
350
360
|
pass
|
|
351
361
|
root_sha = _git(worktree, 'rev-list', '--max-parents=0', 'HEAD', check=False)
|
|
352
|
-
return root_sha or None
|
|
362
|
+
return root_sha or None
|
|
363
|
+
|
|
364
|
+
def merge_branch_into_worktree(parent_gwt, child_gwt) -> tuple[bool, str]:
|
|
365
|
+
if parent_gwt is None or child_gwt is None:
|
|
366
|
+
out = f'Not git: parent_gwt={parent_gwt!r} child_gwt={child_gwt!r}'
|
|
367
|
+
logger.debug(out)
|
|
368
|
+
return (False, out)
|
|
369
|
+
try:
|
|
370
|
+
out = _git(parent_gwt.worktree, 'merge', '--no-edit', '--no-ff', child_gwt.branch)
|
|
371
|
+
logger.info(out)
|
|
372
|
+
return (True, out)
|
|
373
|
+
except GitError as exc:
|
|
374
|
+
logger.error('Failed merge')
|
|
375
|
+
try:
|
|
376
|
+
_git(parent_gwt.worktree, 'merge', '--abort', check=False)
|
|
377
|
+
except Exception as _exc:
|
|
378
|
+
logger.error('Failed merge --abort')
|
|
379
|
+
return (False, str(exc))
|
|
@@ -387,10 +387,11 @@ def is_stuck(tokens, pattern_size=100, min_repeats=3):
|
|
|
387
387
|
if len(tokens) < pattern_size * min_repeats:
|
|
388
388
|
return False
|
|
389
389
|
pattern = tuple(tokens[-pattern_size:])
|
|
390
|
+
window = tokens[-(pattern_size * min_repeats + pattern_size):]
|
|
390
391
|
positions = []
|
|
391
|
-
limit = len(
|
|
392
|
+
limit = len(window) - pattern_size
|
|
392
393
|
for i in range(limit):
|
|
393
|
-
if tuple(
|
|
394
|
+
if tuple(window[i:i + pattern_size]) == pattern:
|
|
394
395
|
positions.append(i)
|
|
395
396
|
if len(positions) < min_repeats:
|
|
396
397
|
return False
|
|
@@ -889,11 +890,12 @@ def _serve_cache(host, port, model, cache, system, tools, skips, *, fixed_port=F
|
|
|
889
890
|
raise
|
|
890
891
|
|
|
891
892
|
def _serve_batch(host, port, model, cache_dir='.cache', *, fixed_port=False):
|
|
892
|
-
|
|
893
|
-
|
|
893
|
+
try:
|
|
894
|
+
from .bats import make_batch_server
|
|
895
|
+
except ImportError:
|
|
896
|
+
print('[warning] uvicorn not installed — batch engine requires: pip install mlx-code[all]')
|
|
897
|
+
return _serve_cache(host, port, model, cache_dir, None, None, None, fixed_port=fixed_port)
|
|
894
898
|
import socket
|
|
895
|
-
import time
|
|
896
|
-
app = make_batch_app(model, cache_dir=cache_dir)
|
|
897
899
|
while True:
|
|
898
900
|
try:
|
|
899
901
|
with socket.socket() as s:
|
|
@@ -908,24 +910,10 @@ def _serve_batch(host, port, model, cache_dir='.cache', *, fixed_port=False):
|
|
|
908
910
|
raise
|
|
909
911
|
else:
|
|
910
912
|
break
|
|
911
|
-
|
|
912
|
-
uv_server = uvicorn.Server(config)
|
|
913
|
-
t = threading.Thread(target=uv_server.run, daemon=True)
|
|
914
|
-
t.start()
|
|
915
|
-
start_time = time.time()
|
|
916
|
-
notified = False
|
|
917
|
-
while True:
|
|
918
|
-
try:
|
|
919
|
-
with socket.create_connection((host, port), timeout=0.1):
|
|
920
|
-
break
|
|
921
|
-
except OSError:
|
|
922
|
-
if not notified and time.time() - start_time > 3.0:
|
|
923
|
-
logger.info('Waiting for batch server to start (model may be downloading)...')
|
|
924
|
-
notified = True
|
|
925
|
-
time.sleep(0.2)
|
|
913
|
+
server = make_batch_server(host, port, model, cache_dir=cache_dir)
|
|
926
914
|
url = f'http://{host}:{port}'
|
|
927
915
|
logger.debug(f'Batch server bound to {url}')
|
|
928
|
-
return (
|
|
916
|
+
return (server, url)
|
|
929
917
|
|
|
930
918
|
def main():
|
|
931
919
|
parser = argparse.ArgumentParser(description='mlx-code MAIN')
|
|
@@ -966,28 +954,20 @@ def main():
|
|
|
966
954
|
else:
|
|
967
955
|
server, url = _serve_cache(host=args.host, port=port, model=args.model, cache=cache, system=None if args.leash in ('none', 'noapi') else args.system, tools=args.tools, skips=args.skips, fixed_port=fixed_port, gwt=gwt)
|
|
968
956
|
if args.leash == 'none':
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
else:
|
|
975
|
-
try:
|
|
976
|
-
server.serve_forever()
|
|
977
|
-
except KeyboardInterrupt:
|
|
978
|
-
print('\nShutting down server...')
|
|
979
|
-
server.server_close()
|
|
957
|
+
try:
|
|
958
|
+
server.serve_forever()
|
|
959
|
+
except KeyboardInterrupt:
|
|
960
|
+
print('\nShutting down server...')
|
|
961
|
+
server.server_close()
|
|
980
962
|
else:
|
|
981
|
-
|
|
982
|
-
|
|
963
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
964
|
+
while not getattr(server, 'started', True):
|
|
965
|
+
time.sleep(0.05)
|
|
983
966
|
if args.leash == 'noapi':
|
|
984
|
-
if args.web
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
else:
|
|
989
|
-
from .repl import run_repl
|
|
990
|
-
run_repl(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, bare=args.bare)
|
|
967
|
+
ui_mode = 'web' if args.web else 'bare' if args.bare else 'tui'
|
|
968
|
+
web_port = args.web_port if args.web_port is not None else port + 80
|
|
969
|
+
from .repl import run_repl
|
|
970
|
+
run_repl(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, ui_mode=ui_mode, web_host=args.host, web_port=web_port)
|
|
991
971
|
else:
|
|
992
972
|
env['GOOGLE_GEMINI_BASE_URL'] = url
|
|
993
973
|
env['GEMINI_API_KEY'] = 'mc'
|
|
@@ -148,16 +148,17 @@ class Agent:
|
|
|
148
148
|
r['content'].insert(0, {'type': 'text', 'text': warn})
|
|
149
149
|
self.messages.extend(results)
|
|
150
150
|
await self._emit({'type': 'tool_results_ready', 'payload': {}})
|
|
151
|
-
new_gwt, diff_stat = commit_worktree(self.ctx['gwt'], self.messages)
|
|
152
|
-
self.ctx['gwt'] = new_gwt
|
|
153
|
-
if diff_stat:
|
|
154
|
-
sha = new_gwt.commit[:8] if new_gwt else ''
|
|
155
|
-
self.messages.append({'role': 'commit', 'content': f'[{sha}]\n{diff_stat}', 'sha': sha})
|
|
156
|
-
await self._emit({'type': 'commit', 'payload': {'diff_stat': diff_stat, 'sha': sha}})
|
|
157
151
|
if self._signal and self._signal.is_set():
|
|
158
152
|
final['stop_reason'] = 'aborted'
|
|
159
153
|
break
|
|
154
|
+
new_gwt, diff_stat = commit_worktree(self.ctx['gwt'], self.messages)
|
|
155
|
+
self.ctx['gwt'] = new_gwt
|
|
156
|
+
if diff_stat:
|
|
157
|
+
sha = new_gwt.commit[:8] if new_gwt else ''
|
|
158
|
+
self.messages.append({'role': 'commit', 'content': f'[{sha}]\n{diff_stat}', 'sha': sha})
|
|
159
|
+
await self._emit({'type': 'commit', 'payload': {'diff_stat': diff_stat, 'sha': sha}})
|
|
160
160
|
await self._emit({'type': 'agent_end', 'payload': {'message': final}})
|
|
161
|
+
logger.debug(json.dumps(self.messages, indent=2, ensure_ascii=False))
|
|
161
162
|
return final
|
|
162
163
|
|
|
163
164
|
async def _execute_tools(self, calls: list[dict]) -> list[dict]:
|
|
@@ -237,7 +238,7 @@ class UIAdapter(Protocol):
|
|
|
237
238
|
|
|
238
239
|
def exit_app(self, summary: list[dict]) -> None:
|
|
239
240
|
...
|
|
240
|
-
HELP_TEXT = '\nCommands:\n/help show this message\n/clear [--config F] clear conversation; --config reconfigures agent from YAML/JSON\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] [--no-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n/branches list all tabs/branches\n/abort abort the running agent\n/export [path] export session to JSON\n/verbose toggle verbose mode (show raw tool calls and output)\n/exit /quit [--all] close branch tab, or exit the app\n!command run shell command in worktree (output captured)\n$command run interactive shell command (terminal handed to process)\n e.g. !ls !git diff !cat file.py\n $vim file.py $yazi $less log.txt\n\nKeys (TUI only):\nEnter submit\nCtrl-J insert newline in editor\nCtrl-1 … Ctrl-9 jump directly to tab N\nCtrl-, / Ctrl-. cycle through tabs\nCtrl-C abort running agent\nCtrl-D close branch tab (exit app if last tab)\nCtrl-R recall last prompt into editor\n'
|
|
241
|
+
HELP_TEXT = '\nCommands:\n/help show this message\n/clear [--config F] clear conversation; --config reconfigures agent from YAML/JSON\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] [--no-worktree] [prompt]\n open a branch tab; optional prompt runs immediately\n/branches list all tabs/branches\n/abort abort the running agent\n/export [path] export session to JSON\n/verbose toggle verbose mode (show raw tool calls and output)\n/merge merge this branch into its parent tab, then close\n/exit /quit [--all] close branch tab, or exit the app\n!command run shell command in worktree (output captured)\n$command run interactive shell command (terminal handed to process)\n e.g. !ls !git diff !cat file.py\n $vim file.py $yazi $less log.txt\n\nKeys (TUI only):\nEnter submit\nCtrl-J insert newline in editor\nCtrl-1 … Ctrl-9 jump directly to tab N\nCtrl-, / Ctrl-. cycle through tabs\nCtrl-C abort running agent\nCtrl-D close branch tab (exit app if last tab)\nCtrl-R recall last prompt into editor\n'
|
|
241
242
|
|
|
242
243
|
class CommandEngine:
|
|
243
244
|
|
|
@@ -289,6 +290,15 @@ class CommandEngine:
|
|
|
289
290
|
self._unsubscribers[key]()
|
|
290
291
|
del self._unsubscribers[key]
|
|
291
292
|
|
|
293
|
+
def _find_parent_tab(self, tab: TabModel) -> TabModel | None:
|
|
294
|
+
if not tab.index_path:
|
|
295
|
+
return None
|
|
296
|
+
parent_path = tab.index_path[:-1]
|
|
297
|
+
for t in self.tabs:
|
|
298
|
+
if t.index_path == parent_path:
|
|
299
|
+
return t
|
|
300
|
+
return None
|
|
301
|
+
|
|
292
302
|
async def handle_input(self, text: str) -> None:
|
|
293
303
|
text = text.strip()
|
|
294
304
|
if not text:
|
|
@@ -310,7 +320,7 @@ class CommandEngine:
|
|
|
310
320
|
cmd, _, arg = text.partition(' ')
|
|
311
321
|
cmd = cmd.lower().strip()
|
|
312
322
|
arg = arg.strip()
|
|
313
|
-
handlers = {'/help': self._cmd_help, '/clear': self._cmd_clear, '/history': self._cmd_history, '/diff': self._cmd_diff, '/errors': self._cmd_errors, '/tools': self._cmd_tools, '/abort': self._cmd_abort, '/branch': self._cmd_branch, '/branches': self._cmd_branches, '/tab': self._cmd_tab, '/export': self._cmd_export, '/verbose': self._cmd_verbose, '/exit': self._cmd_exit, '/quit': self._cmd_exit}
|
|
323
|
+
handlers = {'/help': self._cmd_help, '/clear': self._cmd_clear, '/history': self._cmd_history, '/diff': self._cmd_diff, '/errors': self._cmd_errors, '/tools': self._cmd_tools, '/abort': self._cmd_abort, '/branch': self._cmd_branch, '/branches': self._cmd_branches, '/tab': self._cmd_tab, '/export': self._cmd_export, '/verbose': self._cmd_verbose, '/merge': self._cmd_merge, '/exit': self._cmd_exit, '/quit': self._cmd_exit}
|
|
314
324
|
handler = handlers.get(cmd)
|
|
315
325
|
if handler:
|
|
316
326
|
await handler(arg)
|
|
@@ -330,6 +340,9 @@ class CommandEngine:
|
|
|
330
340
|
self.ui.show_error(f'Config error: {e}')
|
|
331
341
|
return
|
|
332
342
|
tab = self.active_tab
|
|
343
|
+
if tab.is_running:
|
|
344
|
+
self.ui.show_error('Agent is running — /abort first before /clear --config.')
|
|
345
|
+
return
|
|
333
346
|
old = tab.agent
|
|
334
347
|
new_ctx = {k: v for k, v in old.ctx.items() if k != 'agent'}
|
|
335
348
|
new_agent = Agent(system=cfg.get('system'), api=cfg.get('api'), model=cfg.get('model'), api_key=cfg.get('api_key'), base_url=cfg.get('base_url'), tool_names=cfg.get('tools'), extra_tool_classes=old._extra_tool_classes, ctx=new_ctx)
|
|
@@ -557,6 +570,36 @@ class CommandEngine:
|
|
|
557
570
|
state = 'on' if self.verbose else 'off'
|
|
558
571
|
self.ui.show_command_result('/verbose', f'Verbose mode {state}.')
|
|
559
572
|
|
|
573
|
+
async def _cmd_merge(self, arg: str) -> None:
|
|
574
|
+
tab = self.active_tab
|
|
575
|
+
if tab.is_main:
|
|
576
|
+
self.ui.show_error('Cannot /merge the main tab — it has no parent.')
|
|
577
|
+
return
|
|
578
|
+
if tab.is_running:
|
|
579
|
+
self.ui.show_error('Agent is running — /abort first.')
|
|
580
|
+
return
|
|
581
|
+
parent = self._find_parent_tab(tab)
|
|
582
|
+
if parent is None:
|
|
583
|
+
self.ui.show_error(f'Cannot find parent tab for {tab.title!r}.')
|
|
584
|
+
return
|
|
585
|
+
child_gwt = tab.agent.ctx.get('gwt')
|
|
586
|
+
parent_gwt = parent.agent.ctx.get('gwt')
|
|
587
|
+
if child_gwt is None or parent_gwt is None:
|
|
588
|
+
self.ui.show_error('Both tabs need git worktrees to merge.')
|
|
589
|
+
return
|
|
590
|
+
commit_worktree(child_gwt, tab.agent.messages)
|
|
591
|
+
from .gits import merge_branch_into_worktree
|
|
592
|
+
success, msg = merge_branch_into_worktree(parent_gwt, child_gwt)
|
|
593
|
+
if not success:
|
|
594
|
+
self.ui.show_error(f'Merge failed: {msg}')
|
|
595
|
+
return
|
|
596
|
+
new_parent_gwt, diff_stat = commit_worktree(parent_gwt, parent.agent.messages)
|
|
597
|
+
parent.agent.ctx['gwt'] = new_parent_gwt
|
|
598
|
+
self.ui.show_command_result('/merge', f'Merged {tab.title!r} into {parent.title!r}.\n' + (f'{diff_stat}' if diff_stat else '(no changes)'))
|
|
599
|
+
self._do_close_or_exit()
|
|
600
|
+
parent_idx = self.tabs.index(parent)
|
|
601
|
+
self.switch_tab(parent_idx)
|
|
602
|
+
|
|
560
603
|
async def _cmd_exit(self, arg: str) -> None:
|
|
561
604
|
if arg == '--all':
|
|
562
605
|
summary = self._build_exit_summary()
|
|
@@ -636,6 +679,12 @@ class CommandEngine:
|
|
|
636
679
|
tab = self.active_tab
|
|
637
680
|
removed_index = self.active_index
|
|
638
681
|
self.detach_agent(tab)
|
|
682
|
+
gwt_ref = tab.agent.ctx.get('gwt')
|
|
683
|
+
if gwt_ref and getattr(gwt_ref, 'worktree', None):
|
|
684
|
+
try:
|
|
685
|
+
cleanup_worktree(gwt_ref, remove_branch=True)
|
|
686
|
+
except Exception:
|
|
687
|
+
logger.error('Failed worktree cleanup')
|
|
639
688
|
self.tabs.pop(removed_index)
|
|
640
689
|
if self.active_index >= len(self.tabs):
|
|
641
690
|
self.active_index = len(self.tabs) - 1
|
|
@@ -693,9 +742,9 @@ _AGENT_ENV_ALLOWLIST: re.Pattern = re.compile('\n ^(\n PATH\n | MANPATH
|
|
|
693
742
|
def _make_agent_env(base: dict[str, str]) -> dict[str, str]:
|
|
694
743
|
return {k: v for k, v in base.items() if _AGENT_ENV_ALLOWLIST.match(k)}
|
|
695
744
|
|
|
696
|
-
async def repl(engine: CommandEngine, init_prompt=None,
|
|
745
|
+
async def repl(engine: CommandEngine, init_prompt=None, ui_mode='tui'):
|
|
697
746
|
is_tty = sys.stdin.isatty() and sys.stdout.isatty()
|
|
698
|
-
if bare and is_tty:
|
|
747
|
+
if ui_mode == 'bare' and is_tty:
|
|
699
748
|
from .bare import BareRepl
|
|
700
749
|
r = BareRepl(engine, init_prompt=init_prompt)
|
|
701
750
|
await r.run()
|
|
@@ -705,12 +754,21 @@ async def repl(engine: CommandEngine, init_prompt=None, bare=False):
|
|
|
705
754
|
if user_input:
|
|
706
755
|
await _stream_to_stdout(engine.active_tab.agent, user_input)
|
|
707
756
|
return None
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
757
|
+
if ui_mode == 'tui':
|
|
758
|
+
try:
|
|
759
|
+
from .tui import ReplApp
|
|
760
|
+
except ImportError:
|
|
761
|
+
print('[warning] textual/rich not installed — falling back to bare.\n Install TUI deps with: pip install mlx-code[all]')
|
|
762
|
+
from .bare import BareRepl
|
|
763
|
+
r = BareRepl(engine, init_prompt=init_prompt)
|
|
764
|
+
await r.run()
|
|
765
|
+
return None
|
|
766
|
+
app = ReplApp(engine, init_prompt=init_prompt)
|
|
767
|
+
await app.run_async()
|
|
768
|
+
return app
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
def run_repl(*, base_url=None, model=None, api='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, ui_mode='tui', web_host='127.0.0.1', web_port=8080):
|
|
714
772
|
repo = os.path.abspath(repo or os.getcwd())
|
|
715
773
|
with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as _home:
|
|
716
774
|
if gwt is None:
|
|
@@ -736,9 +794,6 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
|
|
|
736
794
|
system = '\n\n'.join(filter(None, [system, skill_prompt]))
|
|
737
795
|
merged_ctx = {'cwd': cwd, 'user_cwd': user_cwd, 'skills': skills, 'gwt': gwt, 'env': agent_env, **(ctx or {})}
|
|
738
796
|
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)
|
|
739
|
-
engine = CommandEngine()
|
|
740
|
-
main_tab = TabModel(agent, title='main', is_main=True)
|
|
741
|
-
engine.tabs = [main_tab]
|
|
742
797
|
log_fp = None
|
|
743
798
|
if stream is not None:
|
|
744
799
|
from .stream_log import StreamLogger
|
|
@@ -751,26 +806,35 @@ def run_repl(*, base_url=None, model=None, api: Literal['claude', 'codex', 'gemi
|
|
|
751
806
|
agent.messages = list(resume_messages)
|
|
752
807
|
print(f'[resumed {len(resume_messages)} messages from checkpoint]')
|
|
753
808
|
try:
|
|
754
|
-
|
|
809
|
+
if ui_mode == 'web':
|
|
810
|
+
try:
|
|
811
|
+
from .web import run_web
|
|
812
|
+
except ImportError:
|
|
813
|
+
print('[warning] starlette/uvicorn not installed — falling back to bare.\n Install web deps with: pip install mlx-code[all]')
|
|
814
|
+
engine = CommandEngine()
|
|
815
|
+
main_tab = TabModel(agent, title='main', is_main=True)
|
|
816
|
+
engine.tabs = [main_tab]
|
|
817
|
+
asyncio.run(repl(engine, init_prompt=init_prompt, ui_mode='bare'))
|
|
818
|
+
else:
|
|
819
|
+
run_web(agent=agent, init_prompt=init_prompt, web_host=web_host, web_port=web_port)
|
|
820
|
+
else:
|
|
821
|
+
engine = CommandEngine()
|
|
822
|
+
main_tab = TabModel(agent, title='main', is_main=True)
|
|
823
|
+
engine.tabs = [main_tab]
|
|
824
|
+
asyncio.run(repl(engine, init_prompt=init_prompt, ui_mode=ui_mode))
|
|
755
825
|
finally:
|
|
756
826
|
if log_fp:
|
|
757
827
|
log_fp.close()
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
print('\n--- Session Exit Summary ---')
|
|
769
|
-
for item in engine.exit_summary:
|
|
770
|
-
title = item['title']
|
|
771
|
-
branch = item['branch'] or '(no branch)'
|
|
772
|
-
marker = ' * <-- exit origin' if item['is_exit_tab'] else ''
|
|
773
|
-
print(f' {title} ({branch}){marker}')
|
|
828
|
+
if 'engine' in locals() and hasattr(engine, 'tabs'):
|
|
829
|
+
cleaned: set[str] = set()
|
|
830
|
+
for tab in engine.tabs:
|
|
831
|
+
gwt_ref = tab.agent.ctx.get('gwt')
|
|
832
|
+
if gwt_ref and getattr(gwt_ref, 'worktree', None) and (gwt_ref.worktree not in cleaned):
|
|
833
|
+
cleaned.add(gwt_ref.worktree)
|
|
834
|
+
try:
|
|
835
|
+
cleanup_worktree(gwt_ref)
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
774
838
|
|
|
775
839
|
def main():
|
|
776
840
|
import argparse
|
|
@@ -386,6 +386,7 @@ class AgentTool(Tool):
|
|
|
386
386
|
parameters = AgentParams
|
|
387
387
|
|
|
388
388
|
async def execute(self, params: AgentParams, signal=None) -> dict:
|
|
389
|
+
from gits import create_worktree, commit_worktree, merge_branch_into_worktree
|
|
389
390
|
parent = self.ctx['agent']
|
|
390
391
|
overrides = {}
|
|
391
392
|
if params.api is not None:
|
|
@@ -394,11 +395,23 @@ class AgentTool(Tool):
|
|
|
394
395
|
overrides['system'] = params.system
|
|
395
396
|
overrides['tool_names'] = params.tools
|
|
396
397
|
child = parent.spawn(**overrides)
|
|
398
|
+
parent_gwt = parent.ctx.get('gwt')
|
|
399
|
+
parent_cwd = parent.ctx.get('cwd', os.getcwd())
|
|
400
|
+
repo_dir = parent_gwt.worktree if parent_gwt else parent_cwd
|
|
401
|
+
child_gwt = create_worktree(repo_dir, prefix='subagent')
|
|
402
|
+
if child_gwt is None:
|
|
403
|
+
return tout('Agent failed: could not create isolated worktree. Check .log.json for git errors.', True)
|
|
404
|
+
child.ctx['gwt'] = child_gwt
|
|
405
|
+
child.ctx['cwd'] = child_gwt.worktree
|
|
406
|
+
if 'env' in child.ctx:
|
|
407
|
+
child.ctx['env']['PWD'] = child_gwt.worktree
|
|
397
408
|
if '_stream_log_fp' in parent.ctx:
|
|
398
409
|
from .stream_log import StreamLogger
|
|
399
410
|
StreamLogger.attach_to_child(child, parent.ctx, tool_name=f'sub-agent-{''.join(random.choices(string.ascii_letters + string.digits, k=2))}')
|
|
400
411
|
try:
|
|
401
412
|
result = await child.run(params.task)
|
|
413
|
+
commit_worktree(child_gwt, child.messages)
|
|
414
|
+
merge_branch_into_worktree(parent_gwt, child_gwt)
|
|
402
415
|
texts = [b.get('text', '') for b in result['content'] if b.get('type') == 'text']
|
|
403
416
|
combined = ''.join(texts).strip()
|
|
404
417
|
if not combined:
|
|
@@ -234,7 +234,6 @@ class Tab(Vertical):
|
|
|
234
234
|
self._stream_blocks.append({'type': 'thinking', 'text': delta})
|
|
235
235
|
self.refresh_stream()
|
|
236
236
|
elif et == 'tool_start':
|
|
237
|
-
self._stream_blocks.append({'type': 'toolCall', 'name': payload.get('name', 'tool'), 'arguments': payload.get('args', {}), 'id': 'streaming'})
|
|
238
237
|
self.refresh_stream()
|
|
239
238
|
elif et == 'tool_end':
|
|
240
239
|
if payload.get('is_error'):
|