deepparallel 0.4.0__tar.gz → 0.4.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 (59) hide show
  1. {deepparallel-0.4.0 → deepparallel-0.4.2}/PKG-INFO +7 -2
  2. {deepparallel-0.4.0 → deepparallel-0.4.2}/README.md +6 -1
  3. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/__init__.py +1 -1
  4. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/agent.py +2 -0
  5. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/cli.py +21 -4
  6. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/renderer.py +73 -32
  7. deepparallel-0.4.2/deepparallel/system_prompt.txt +4 -0
  8. deepparallel-0.4.2/deepparallel/tools/shell.py +69 -0
  9. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel.egg-info/PKG-INFO +7 -2
  10. {deepparallel-0.4.0 → deepparallel-0.4.2}/pyproject.toml +1 -1
  11. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_renderer.py +47 -11
  12. deepparallel-0.4.2/tests/test_tools_shell.py +48 -0
  13. deepparallel-0.4.0/deepparallel/system_prompt.txt +0 -4
  14. deepparallel-0.4.0/deepparallel/tools/shell.py +0 -38
  15. deepparallel-0.4.0/tests/test_tools_shell.py +0 -24
  16. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/backend.py +0 -0
  17. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/branding.py +0 -0
  18. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/config.py +0 -0
  19. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/fusion.py +0 -0
  20. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/licensing.py +0 -0
  21. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/registry.json +0 -0
  22. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/research/__init__.py +0 -0
  23. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/research/conduit.py +0 -0
  24. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/supply_chain.py +0 -0
  25. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/__init__.py +0 -0
  26. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/codeast.py +0 -0
  27. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/edit.py +0 -0
  28. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/files.py +0 -0
  29. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/registry.py +0 -0
  30. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/sandbox.py +0 -0
  31. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/search.py +0 -0
  32. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/vision.py +0 -0
  33. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel/tools/web.py +0 -0
  34. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel.egg-info/SOURCES.txt +0 -0
  35. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel.egg-info/dependency_links.txt +0 -0
  36. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel.egg-info/entry_points.txt +0 -0
  37. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel.egg-info/requires.txt +0 -0
  38. {deepparallel-0.4.0 → deepparallel-0.4.2}/deepparallel.egg-info/top_level.txt +0 -0
  39. {deepparallel-0.4.0 → deepparallel-0.4.2}/setup.cfg +0 -0
  40. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_agent.py +0 -0
  41. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_backend.py +0 -0
  42. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_backend_chat.py +0 -0
  43. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_backend_stream.py +0 -0
  44. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_branding.py +0 -0
  45. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_cli.py +0 -0
  46. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_config.py +0 -0
  47. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_fusion.py +0 -0
  48. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_issuer_signer.py +0 -0
  49. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_licensing.py +0 -0
  50. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_research.py +0 -0
  51. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_supply_chain.py +0 -0
  52. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tool_registry.py +0 -0
  53. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_codeast.py +0 -0
  54. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_edit.py +0 -0
  55. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_files.py +0 -0
  56. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_sandbox.py +0 -0
  57. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_search.py +0 -0
  58. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_vision.py +0 -0
  59. {deepparallel-0.4.0 → deepparallel-0.4.2}/tests/test_tools_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -115,7 +115,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
115
115
  - Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
116
116
  `ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
117
117
  - Mutating / executing (require confirmation): `write_file`, `edit_file`,
118
- `ast_replace_symbol`, `run_shell`, `run_code`.
118
+ `ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
119
+ or URL in the OS default app - e.g. an HTML report in the browser).
120
+
121
+ In interactive chat, type `/auto` to toggle auto-approve (tools run without
122
+ asking; edits outside the project and unknown dependencies still confirm), or
123
+ start with `deepparallel --yes`.
119
124
 
120
125
  `web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
121
126
  box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
@@ -91,7 +91,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
91
91
  - Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
92
92
  `ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
93
93
  - Mutating / executing (require confirmation): `write_file`, `edit_file`,
94
- `ast_replace_symbol`, `run_shell`, `run_code`.
94
+ `ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
95
+ or URL in the OS default app - e.g. an HTML report in the browser).
96
+
97
+ In interactive chat, type `/auto` to toggle auto-approve (tools run without
98
+ asking; edits outside the project and unknown dependencies still confirm), or
99
+ start with `deepparallel --yes`.
95
100
 
96
101
  `web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
97
102
  box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.2"
@@ -71,6 +71,8 @@ def _summarize_result(name: str, result: str) -> str:
71
71
  return "edited"
72
72
  if name == "ast_replace_symbol":
73
73
  return "replaced"
74
+ if name == "open_path":
75
+ return f"opened {obj.get('opened', '')}" if "opened" in obj else "ok"
74
76
  if name == "run_shell":
75
77
  lines = (obj.get("stdout") or "").count("\n")
76
78
  return f"rc {obj.get('return_code')} · {_plural(lines, 'line', 'lines')}"
@@ -289,8 +289,10 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
289
289
  messages: list[dict] = [{"role": "system", "content": system}]
290
290
  mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
291
291
  deep_next = False
292
+ auto = settings.auto_approve
292
293
  while True:
293
- tag = f"[{mode}] " if mode != "off" else ""
294
+ bits = ([mode] if mode != "off" else []) + (["auto"] if auto else [])
295
+ tag = f"[{' · '.join(bits)}] " if bits else ""
294
296
  try:
295
297
  user_msg = console.input(f"{tag}{branding.user_prefix()}› ").strip()
296
298
  except (EOFError, KeyboardInterrupt):
@@ -302,9 +304,19 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
302
304
  break
303
305
  if user_msg == "/help":
304
306
  branding.info(
305
- "/quit · /reset · /info · /tools · /fast //fuse //escalate //deep · prompt"
307
+ "/quit · /reset · /info · /tools · /auto · /fast //fuse //escalate //deep · prompt"
306
308
  )
307
309
  continue
310
+ if user_msg in {"/auto", "/yes"}:
311
+ auto = not auto
312
+ if auto:
313
+ branding.info(
314
+ "auto-approve ON - tools run without asking. Edits outside this "
315
+ "project and unknown dependencies still confirm. /auto to turn off."
316
+ )
317
+ else:
318
+ branding.info("auto-approve OFF - mutating tools will ask first.")
319
+ continue
308
320
  if user_msg == "/reset":
309
321
  messages = [{"role": "system", "content": system}]
310
322
  branding.info("conversation cleared.")
@@ -334,7 +346,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
334
346
  settings,
335
347
  renderer,
336
348
  interactive=True,
337
- auto_approve=settings.auto_approve,
349
+ auto_approve=auto,
338
350
  stream=True,
339
351
  guardian=guardian,
340
352
  )
@@ -376,14 +388,19 @@ def _chat_loop(settings: Settings) -> None:
376
388
  )
377
389
  @click.version_option(__version__, prog_name="deepparallel")
378
390
  @click.option("--temperature", "-t", default=None, type=float, help="Sampling temperature.")
391
+ @click.option(
392
+ "--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions (toggle with /auto)."
393
+ )
379
394
  @click.pass_context
380
- def main(ctx: click.Context, temperature: float | None) -> None:
395
+ def main(ctx: click.Context, temperature: float | None, assume_yes: bool) -> None:
381
396
  """DeepParallel - a focused agentic CLI for the DeepParallel model."""
382
397
  load_dotenv()
383
398
  ctx.ensure_object(dict)
384
399
  settings = resolve_settings()
385
400
  if temperature is not None:
386
401
  settings = replace(settings, temperature=temperature)
402
+ if assume_yes:
403
+ settings = replace(settings, auto_approve=True)
387
404
  ctx.obj["settings"] = settings
388
405
  if ctx.invoked_subcommand is None:
389
406
  _chat_loop(settings)
@@ -13,7 +13,6 @@ incrementally (no redrawing Live regions) so output never ghosts.
13
13
  from __future__ import annotations
14
14
 
15
15
  import sys
16
- import threading
17
16
  import time
18
17
  from abc import ABC, abstractmethod
19
18
  from typing import Iterable
@@ -25,6 +24,15 @@ from deepparallel import branding
25
24
  _REVEAL_SECONDS = 0.04 # per-line delay for the animated intro (tests set 0)
26
25
 
27
26
 
27
+ def _balance_fences(text: str) -> str:
28
+ """Close a dangling ``` while streaming so a half-arrived code block renders
29
+ as code instead of leaking its fence as literal text. The closing fence is
30
+ cosmetic for the in-progress frame; the final frame has the real one."""
31
+ if text.count("```") % 2 == 1:
32
+ return text + "\n```"
33
+ return text
34
+
35
+
28
36
  class Renderer(ABC):
29
37
  @abstractmethod
30
38
  def welcome(
@@ -123,8 +131,6 @@ class RichRenderer(Renderer):
123
131
  self._console = console or branding.console
124
132
  self._input_fn = input_fn or self._console.input
125
133
  self._cur: str | None = None
126
- self._timer_stop: threading.Event | None = None
127
- self._timer_thread: threading.Thread | None = None
128
134
 
129
135
  def welcome(self, backend_label, *, version="", tool_count=0, fusion_modes=()) -> None:
130
136
  animate = self._console.is_terminal and _REVEAL_SECONDS > 0
@@ -146,10 +152,20 @@ class RichRenderer(Renderer):
146
152
  self._console.print(branding.build_transcript_markdown(self._console, text))
147
153
 
148
154
  def answer_stream(self, chunks: Iterable[str]) -> str:
149
- # Inline token streaming: no Live/transient panels, so it never ghosts
150
- # in wide terminals or when tool turns interleave. The marker is printed
151
- # only on the first VISIBLE character, so empty / whitespace-leading
152
- # (tool-only) turns render no stray marker.
155
+ """Stream the answer. On a real terminal, render Markdown block by block,
156
+ append-only: each complete block (a paragraph/heading/list ended by a
157
+ blank line, or a closed code fence) is rendered once as it settles. This
158
+ never moves the cursor up, so it cannot ghost or stack the way a growing
159
+ Live region does - it works the same on any terminal and at any answer
160
+ length. On a pipe / non-tty, fall back to raw inline streaming."""
161
+ if self._console.is_terminal:
162
+ return self._stream_markdown_blocks(chunks)
163
+ return self._stream_inline(chunks)
164
+
165
+ def _stream_inline(self, chunks: Iterable[str]) -> str:
166
+ # Raw token streaming for pipes / non-tty: no Live, never ghosts. The
167
+ # marker is printed only on the first VISIBLE character, so empty /
168
+ # whitespace-leading (tool-only) turns render no stray marker.
153
169
  parts: list[str] = []
154
170
  started = False
155
171
  for c in chunks:
@@ -158,7 +174,7 @@ class RichRenderer(Renderer):
158
174
  self._console.print(c, end="", soft_wrap=True, highlight=False, markup=False)
159
175
  continue
160
176
  if not "".join(parts).strip():
161
- continue # only whitespace so far; hold the marker back
177
+ continue
162
178
  started = True
163
179
  self._console.print(
164
180
  f"[{branding.DP_ACCENT}]{branding.MARK}[/] ", end="", highlight=False
@@ -168,38 +184,63 @@ class RichRenderer(Renderer):
168
184
  self._console.print()
169
185
  return "".join(parts)
170
186
 
187
+ def _stream_markdown_blocks(self, chunks: Iterable[str]) -> str:
188
+ from rich.markdown import Markdown
189
+ from rich.padding import Padding
190
+
191
+ full: list[str] = []
192
+ buf = "" # text not yet split into complete lines
193
+ block: list[str] = [] # lines of the block currently accumulating
194
+ in_fence = False
195
+ started = False
196
+
197
+ def emit() -> None:
198
+ nonlocal block, started
199
+ text = "\n".join(block).strip("\n")
200
+ block = []
201
+ if not text.strip():
202
+ return
203
+ if not started:
204
+ started = True
205
+ self._console.print(f"[{branding.DP_ACCENT}]{branding.MARK}[/]", highlight=False)
206
+ self._console.print(
207
+ Padding(Markdown(_balance_fences(text)), (0, 0, 0, branding.GUTTER))
208
+ )
209
+
210
+ for c in chunks:
211
+ full.append(c)
212
+ buf += c
213
+ while "\n" in buf: # only act on complete lines; keep the partial
214
+ line, buf = buf.split("\n", 1)
215
+ if line.strip().startswith("```"):
216
+ block.append(line)
217
+ if in_fence: # this line closes the fence -> block is complete
218
+ in_fence = False
219
+ emit()
220
+ else:
221
+ in_fence = True
222
+ elif in_fence:
223
+ block.append(line)
224
+ elif line.strip() == "": # blank line ends a prose block
225
+ emit()
226
+ else:
227
+ block.append(line)
228
+ if buf.strip(): # trailing partial line at end of stream
229
+ block.append(buf)
230
+ emit() # flush whatever block remains (fence balanced if still open)
231
+ return "".join(full)
232
+
171
233
  def reasoning(self, text: str) -> None:
172
234
  self._console.print(branding.build_reasoning_panel(self._console, text))
173
235
 
174
236
  def tool_start(self, name: str, args_preview: str) -> None:
237
+ # Append-only: render the running card and stop. A per-tick elapsed timer
238
+ # needs carriage-return overwrites, which some terminals don't honor and
239
+ # then flood with hundreds of lines; the final card carries the duration.
175
240
  self._cur = name
176
241
  branding.render_tool_card(self._console, name, args_preview, status="running")
177
- if self._console.is_terminal:
178
- self._timer_stop = threading.Event()
179
- start = time.monotonic()
180
- fh = self._console.file
181
-
182
- def tick() -> None:
183
- while not self._timer_stop.wait(0.5): # type: ignore[union-attr]
184
- fh.write(f"\r {name}... {time.monotonic() - start:.0f}s ")
185
- fh.flush()
186
-
187
- self._timer_thread = threading.Thread(target=tick, daemon=True)
188
- self._timer_thread.start()
189
-
190
- def _stop_timer(self) -> None:
191
- if self._timer_stop is None:
192
- return
193
- self._timer_stop.set()
194
- if self._timer_thread is not None:
195
- self._timer_thread.join(timeout=1.0)
196
- self._timer_stop = None
197
- self._timer_thread = None
198
- self._console.file.write("\r" + " " * 48 + "\r") # clear the timer line
199
- self._console.file.flush()
200
242
 
201
243
  def tool_result(self, ok: bool, summary: str, duration_s: float) -> None:
202
- self._stop_timer()
203
244
  branding.render_tool_card(
204
245
  self._console,
205
246
  self._cur or "",
@@ -0,0 +1,4 @@
1
+ You are DeepParallel, a precise and capable coding assistant served via Crowe Logic.
2
+ Answer clearly and directly. When a problem benefits from step-by-step reasoning, reason carefully before giving the final answer. Be concise unless asked for depth.
3
+
4
+ You can use tools to read, search, analyze, edit, open, and run code. Use them when they help; do not call them speculatively. When the user asks to "open" a file (an HTML report, image, PDF, or folder) for viewing, use open_path to launch it in the default app rather than read_file, which only returns text contents. When asked to run something with different parameters, prefer non-destructive approaches (command-line arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing that file is the actual goal, and explain what you changed.
@@ -0,0 +1,69 @@
1
+ """Shell execution tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ from deepparallel.tools import tool
11
+
12
+
13
+ @tool(dangerous=True)
14
+ def run_shell(command: str, working_directory: str = "", timeout_seconds: int = 120) -> str:
15
+ """Run a shell command and capture its output.
16
+
17
+ :param command: Command to execute via the shell.
18
+ :param working_directory: Directory to run in (default: current).
19
+ :param timeout_seconds: Maximum execution time in seconds.
20
+ """
21
+ timeout_seconds = min(int(timeout_seconds), 600)
22
+ try:
23
+ r = subprocess.run(
24
+ command,
25
+ shell=True,
26
+ capture_output=True,
27
+ text=True,
28
+ timeout=timeout_seconds,
29
+ cwd=working_directory or None,
30
+ )
31
+ except subprocess.TimeoutExpired:
32
+ return json.dumps(
33
+ {"error": f"Command timed out after {timeout_seconds}s", "return_code": -1}
34
+ )
35
+ stdout = r.stdout or ""
36
+ if len(stdout) > 50000:
37
+ stdout = stdout[:50000] + "\n... (output truncated at 50KB)"
38
+ return json.dumps(
39
+ {"stdout": stdout, "stderr": (r.stderr or "")[:10000], "return_code": r.returncode}
40
+ )
41
+
42
+
43
+ def _opener_argv(path: str) -> list[str]:
44
+ if sys.platform == "darwin":
45
+ return ["open", path]
46
+ if sys.platform.startswith("win"):
47
+ return ["cmd", "/c", "start", "", path]
48
+ return ["xdg-open", path]
49
+
50
+
51
+ @tool(dangerous=True)
52
+ def open_path(path: str) -> str:
53
+ """Open a file, directory, or URL in the operating system's default app.
54
+
55
+ Use this when the user asks to "open" something for viewing - an HTML report
56
+ in the browser, a PNG/PDF in the image/document viewer, a folder in the file
57
+ manager. This launches the real app; it does not return the file contents
58
+ (use read_file for text or analyze_image to inspect an image).
59
+
60
+ :param path: File path, directory, or URL to open.
61
+ """
62
+ argv = _opener_argv(path)
63
+ if shutil.which(argv[0]) is None:
64
+ return json.dumps({"error": f"no opener available ({argv[0]} not found)"})
65
+ try:
66
+ subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
67
+ except Exception as e: # noqa: BLE001 - surface launch failure to the model
68
+ return json.dumps({"error": f"{type(e).__name__}: {e}"})
69
+ return json.dumps({"opened": path})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -115,7 +115,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
115
115
  - Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
116
116
  `ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
117
117
  - Mutating / executing (require confirmation): `write_file`, `edit_file`,
118
- `ast_replace_symbol`, `run_shell`, `run_code`.
118
+ `ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
119
+ or URL in the OS default app - e.g. an HTML report in the browser).
120
+
121
+ In interactive chat, type `/auto` to toggle auto-approve (tools run without
122
+ asking; edits outside the project and unknown dependencies still confirm), or
123
+ start with `deepparallel --yes`.
119
124
 
120
125
  `web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
121
126
  box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepparallel"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -95,7 +95,8 @@ def test_rich_tool_timer_path_renders_card(monkeypatch):
95
95
  out = buf.getvalue()
96
96
  assert "run_shell" in out
97
97
  assert "✓" in out and "rc 0" in out
98
- assert r._timer_thread is None # timer cleaned up
98
+ # append-only: no ticking timer thread is spawned
99
+ assert not hasattr(r, "_timer_thread")
99
100
 
100
101
 
101
102
  def test_rich_error_uses_cross():
@@ -123,14 +124,40 @@ def test_rich_confirm_shows_detail():
123
124
  assert "added line" in out
124
125
 
125
126
 
126
- def test_rich_answer_stream_returns_text_and_prints_once():
127
+ def test_rich_answer_stream_returns_text_and_renders_markdown():
127
128
  buf = io.StringIO()
128
129
  con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
129
130
  r = RichRenderer(console=con)
130
131
  full = r.answer_stream(iter(["Hel", "lo ", "world"]))
131
- assert full == "Hello world"
132
- # inline streaming prints the content exactly once (no transient-Live ghost)
133
- assert buf.getvalue().count("world") == 1
132
+ out = buf.getvalue()
133
+ assert full == "Hello world" # full text preserved for history
134
+ assert "Hello world" in out # rendered as a markdown block
135
+ assert "◆" in out # answer marker printed once
136
+
137
+
138
+ def test_rich_answer_stream_code_block_renders_atomically():
139
+ # a code block with an internal blank line must not be split mid-block
140
+ buf = io.StringIO()
141
+ con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
142
+ r = RichRenderer(console=con)
143
+ code = "intro\n\n```python\na = 1\n\nb = 2\n```\n\ndone"
144
+ full = r.answer_stream(iter([code]))
145
+ out = buf.getvalue()
146
+ assert full == code
147
+ assert "a = 1" in out and "b = 2" in out and "intro" in out and "done" in out
148
+ assert "```" not in out # fence rendered, not shown literally
149
+
150
+
151
+ def test_rich_answer_stream_renders_markdown_formatting():
152
+ buf = io.StringIO()
153
+ con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
154
+ r = RichRenderer(console=con)
155
+ full = r.answer_stream(iter(["# Big\n\n", "body **bold**"]))
156
+ out = buf.getvalue()
157
+ assert full == "# Big\n\nbody **bold**"
158
+ # markdown is rendered, not shown raw: the heading hash is consumed
159
+ assert "Big" in out and "body" in out
160
+ assert "# Big" not in out
134
161
 
135
162
 
136
163
  def test_rich_answer_stream_empty_prints_nothing():
@@ -139,20 +166,29 @@ def test_rich_answer_stream_empty_prints_nothing():
139
166
  r = RichRenderer(console=con)
140
167
  full = r.answer_stream(iter([]))
141
168
  assert full == ""
142
- assert buf.getvalue() == "" # tool-only turns render no answer marker
169
+ assert buf.getvalue() == "" # tool-only turns never start the live panel
143
170
 
144
171
 
145
- def test_rich_answer_stream_whitespace_leading_no_stray_marker():
172
+ def test_rich_answer_stream_whitespace_leading_holds_panel():
146
173
  buf = io.StringIO()
147
174
  con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
148
175
  r = RichRenderer(console=con)
149
176
  full = r.answer_stream(iter(["\n", " ", "\n", "Hello"]))
150
177
  out = buf.getvalue()
151
178
  assert full == "\n \nHello" # full text preserved for history
152
- # marker appears exactly once and immediately precedes the visible text
153
- assert out.count("◆") == 1
154
- assert "◆ Hello" in out
155
- assert "◆ \n" not in out and "◆ \n" not in out # no stray marker on blank lines
179
+ assert "Hello" in out and "◆" in out
180
+
181
+
182
+ def test_rich_answer_stream_inline_when_not_a_terminal():
183
+ # pipes / CI: raw inline streaming, no Live panel (no border characters)
184
+ buf = io.StringIO()
185
+ con = Console(no_color=True, width=80, file=buf, highlight=False) # not a terminal
186
+ r = RichRenderer(console=con)
187
+ full = r.answer_stream(iter(["Hel", "lo"]))
188
+ out = buf.getvalue()
189
+ assert full == "Hello"
190
+ assert "Hello" in out
191
+ assert "─" not in out and "answer" not in out # no panel chrome
156
192
 
157
193
 
158
194
  # ---------------------------------------------------------------- FakeRenderer
@@ -0,0 +1,48 @@
1
+ import json
2
+
3
+ import deepparallel.tools.shell as shell_mod
4
+ from deepparallel.tools import get_registry
5
+
6
+
7
+ def test_run_shell_captures_stdout_and_rc():
8
+ out = json.loads(shell_mod.run_shell("echo hello"))
9
+ assert out["return_code"] == 0
10
+ assert "hello" in out["stdout"]
11
+
12
+
13
+ def test_run_shell_respects_cwd(tmp_path):
14
+ out = json.loads(shell_mod.run_shell("pwd", working_directory=str(tmp_path)))
15
+ assert str(tmp_path) in out["stdout"]
16
+
17
+
18
+ def test_run_shell_timeout_returns_error():
19
+ out = json.loads(shell_mod.run_shell("sleep 5", timeout_seconds=1))
20
+ assert "timed out" in out["error"]
21
+
22
+
23
+ def test_run_shell_is_dangerous():
24
+ assert get_registry().get("run_shell").dangerous is True
25
+
26
+
27
+ def test_open_path_launches_default_opener(monkeypatch):
28
+ calls = {}
29
+ monkeypatch.setattr(shell_mod.shutil, "which", lambda _: "/usr/bin/open")
30
+
31
+ def fake_popen(argv, **kw):
32
+ calls["argv"] = argv
33
+ return object()
34
+
35
+ monkeypatch.setattr(shell_mod.subprocess, "Popen", fake_popen)
36
+ out = json.loads(shell_mod.open_path("/tmp/report.html"))
37
+ assert out["opened"] == "/tmp/report.html"
38
+ assert "/tmp/report.html" in calls["argv"]
39
+
40
+
41
+ def test_open_path_reports_missing_opener(monkeypatch):
42
+ monkeypatch.setattr(shell_mod.shutil, "which", lambda _: None)
43
+ out = json.loads(shell_mod.open_path("/tmp/x.png"))
44
+ assert "error" in out
45
+
46
+
47
+ def test_open_path_is_dangerous():
48
+ assert get_registry().get("open_path").dangerous is True
@@ -1,4 +0,0 @@
1
- You are DeepParallel, a precise and capable coding assistant served via Crowe Logic.
2
- Answer clearly and directly. When a problem benefits from step-by-step reasoning, reason carefully before giving the final answer. Be concise unless asked for depth.
3
-
4
- You can use tools to read, search, analyze, edit, and run code. Use them when they help; do not call them speculatively. When asked to run something with different parameters, prefer non-destructive approaches (command-line arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing that file is the actual goal, and explain what you changed.
@@ -1,38 +0,0 @@
1
- """Shell execution tool."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import subprocess
7
-
8
- from deepparallel.tools import tool
9
-
10
-
11
- @tool(dangerous=True)
12
- def run_shell(command: str, working_directory: str = "", timeout_seconds: int = 120) -> str:
13
- """Run a shell command and capture its output.
14
-
15
- :param command: Command to execute via the shell.
16
- :param working_directory: Directory to run in (default: current).
17
- :param timeout_seconds: Maximum execution time in seconds.
18
- """
19
- timeout_seconds = min(int(timeout_seconds), 600)
20
- try:
21
- r = subprocess.run(
22
- command,
23
- shell=True,
24
- capture_output=True,
25
- text=True,
26
- timeout=timeout_seconds,
27
- cwd=working_directory or None,
28
- )
29
- except subprocess.TimeoutExpired:
30
- return json.dumps(
31
- {"error": f"Command timed out after {timeout_seconds}s", "return_code": -1}
32
- )
33
- stdout = r.stdout or ""
34
- if len(stdout) > 50000:
35
- stdout = stdout[:50000] + "\n... (output truncated at 50KB)"
36
- return json.dumps(
37
- {"stdout": stdout, "stderr": (r.stderr or "")[:10000], "return_code": r.returncode}
38
- )
@@ -1,24 +0,0 @@
1
- import json
2
-
3
- import deepparallel.tools.shell as shell_mod
4
- from deepparallel.tools import get_registry
5
-
6
-
7
- def test_run_shell_captures_stdout_and_rc():
8
- out = json.loads(shell_mod.run_shell("echo hello"))
9
- assert out["return_code"] == 0
10
- assert "hello" in out["stdout"]
11
-
12
-
13
- def test_run_shell_respects_cwd(tmp_path):
14
- out = json.loads(shell_mod.run_shell("pwd", working_directory=str(tmp_path)))
15
- assert str(tmp_path) in out["stdout"]
16
-
17
-
18
- def test_run_shell_timeout_returns_error():
19
- out = json.loads(shell_mod.run_shell("sleep 5", timeout_seconds=1))
20
- assert "timed out" in out["error"]
21
-
22
-
23
- def test_run_shell_is_dangerous():
24
- assert get_registry().get("run_shell").dangerous is True
File without changes