drydock-cli 3.0.31__tar.gz → 3.0.46__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 (62) hide show
  1. {drydock_cli-3.0.31/drydock_cli.egg-info → drydock_cli-3.0.46}/PKG-INFO +2 -2
  2. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/agent.py +25 -25
  4. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/cli.py +52 -8
  5. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/config.py +30 -5
  6. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/providers.py +100 -11
  7. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tools/__init__.py +76 -8
  8. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tui/app.py +50 -4
  9. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tui/widgets.py +1 -1
  10. {drydock_cli-3.0.31 → drydock_cli-3.0.46/drydock_cli.egg-info}/PKG-INFO +2 -2
  11. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock_cli.egg-info/SOURCES.txt +7 -1
  12. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/pyproject.toml +2 -2
  13. drydock_cli-3.0.46/tests/test_bash_process_group.py +57 -0
  14. drydock_cli-3.0.46/tests/test_bash_timeout_network.py +52 -0
  15. drydock_cli-3.0.46/tests/test_compact_command.py +47 -0
  16. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_config.py +6 -6
  17. drydock_cli-3.0.46/tests/test_config_migration.py +123 -0
  18. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_failure_loop.py +34 -0
  19. drydock_cli-3.0.46/tests/test_oneshot_unreachable.py +39 -0
  20. drydock_cli-3.0.46/tests/test_providers_unreachable.py +95 -0
  21. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_tui.py +40 -6
  22. drydock_cli-3.0.46/tests/test_vision_input.py +84 -0
  23. drydock_cli-3.0.31/tests/test_providers_unreachable.py +0 -51
  24. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/LICENSE +0 -0
  25. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/NOTICE +0 -0
  26. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/README.md +0 -0
  27. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/__main__.py +0 -0
  28. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/bash_safety.py +0 -0
  29. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/compaction.py +0 -0
  30. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/detect.py +0 -0
  31. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/guards.py +0 -0
  32. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/loop_detect.py +0 -0
  33. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tool_registry.py +0 -0
  34. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tui/__init__.py +0 -0
  35. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tui/approval.py +0 -0
  36. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tui/messages.py +0 -0
  37. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock/tuning.py +0 -0
  38. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock_cli.egg-info/dependency_links.txt +0 -0
  39. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock_cli.egg-info/entry_points.txt +0 -0
  40. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock_cli.egg-info/requires.txt +0 -0
  41. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/drydock_cli.egg-info/top_level.txt +0 -0
  42. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/setup.cfg +0 -0
  43. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_approval.py +0 -0
  44. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_back_command.py +0 -0
  45. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_bash_safety.py +0 -0
  46. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_cli_agents.py +0 -0
  47. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_compaction.py +0 -0
  48. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_detect.py +0 -0
  49. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_empty_response.py +0 -0
  50. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_first_run_setup.py +0 -0
  51. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_guards_and_tools.py +0 -0
  52. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_hallucinated_tools.py +0 -0
  53. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_leaked_tool_call.py +0 -0
  54. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_loop_detect.py +0 -0
  55. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_plan_autocontinue.py +0 -0
  56. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_stop.py +0 -0
  57. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_streaming_newlines.py +0 -0
  58. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_subagent.py +0 -0
  59. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_todo.py +0 -0
  60. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_tool_arg_parsing.py +0 -0
  61. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_tools_undo.py +0 -0
  62. {drydock_cli-3.0.31 → drydock_cli-3.0.46}/tests/test_tuning.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.31
3
+ Version: 3.0.46
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/fbobe321/drydock
8
8
  Project-URL: Repository, https://github.com/fbobe321/drydock
9
9
  Project-URL: Issues, https://github.com/fbobe321/drydock/issues
10
- Requires-Python: >=3.12
10
+ Requires-Python: >=3.11
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  License-File: NOTICE
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.31"
10
+ __version__ = "3.0.40"
@@ -115,13 +115,15 @@ def run(
115
115
  leaked_call_retries = 0
116
116
  plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
117
117
  empty_response_nudges = 0 # consecutive "you returned nothing" nudges
118
- # Safety valve for a degenerate loop: the SAME tool call FAILING over and
119
- # over (a weak model that ignores the advisory loop-note — seen failing a
120
- # Write 160× on a too-large file). Real iterative fixing changes the args,
121
- # so the streak resets; only byte-identical repeated FAILURES trip it.
122
- identical_fail_streak = 0
123
- last_fail_sig = None
124
- IDENTICAL_FAIL_CAP = 8
118
+ # Safety valve for a degenerate loop: the SAME tool call run over and over
119
+ # with the SAME result success OR failure (seen failing a Write 160×, and
120
+ # re-running an identical passing `pytest` 92× for 25 min). The advisory
121
+ # loop-note is ignored by weak models, so after a cap we end the turn. Real
122
+ # iterative fixing changes the args, and polling yields a changing result —
123
+ # both reset the streak, so only a truly pointless loop trips it.
124
+ identical_repeat_streak = 0
125
+ last_call_sig = None
126
+ IDENTICAL_REPEAT_CAP = 8
125
127
  run_iteration = 0 # stream calls within THIS run() (resets per user message)
126
128
  loop_tracker = LoopTracker()
127
129
 
@@ -295,16 +297,15 @@ def run(
295
297
  )
296
298
  else:
297
299
  result = execute(tc["name"], tc["input"], config)
298
- # Track byte-identical repeated FAILURES (checked before annotate
299
- # prepends its advisory note) for the safety valve below.
300
- failed = result.lstrip().startswith(("Error", "REFUSED"))
301
- sig = (tc["name"], str(tc["input"]))
302
- if failed and sig == last_fail_sig:
303
- identical_fail_streak += 1
304
- elif failed:
305
- identical_fail_streak, last_fail_sig = 1, sig
300
+ # Track consecutive byte-identical calls same name, args AND raw
301
+ # result (captured before annotate prepends its note, which changes
302
+ # each call) — for the safety valve below. A differing result
303
+ # (polling) or differing args (real iteration) resets the streak.
304
+ sig = (tc["name"], str(tc["input"]), result)
305
+ if sig == last_call_sig:
306
+ identical_repeat_streak += 1
306
307
  else:
307
- identical_fail_streak, last_fail_sig = 0, None
308
+ identical_repeat_streak, last_call_sig = 1, sig
308
309
  # Guide (never block) on exact-repeat tool calls: prepend an
309
310
  # advisory note when the same call is made again.
310
311
  result = loop_tracker.annotate(tc["name"], tc["input"], result)
@@ -319,16 +320,15 @@ def run(
319
320
  "content": result,
320
321
  })
321
322
 
322
- # Safety valve: the same call has FAILED identically too many times
323
- # repeating it won't help. End the turn and hand control back with a
324
- # clear note rather than burning turns toward MAX_TOOL_TURNS.
325
- if identical_fail_streak >= IDENTICAL_FAIL_CAP:
323
+ # Safety valve: the same call has run identically (same args AND result)
324
+ # too many times in a row — repeating it changes nothing. End the turn
325
+ # and hand control back rather than burning turns toward MAX_TOOL_TURNS.
326
+ if identical_repeat_streak >= IDENTICAL_REPEAT_CAP and last_call_sig:
326
327
  yield TextChunk(
327
- f"\n[Stopped: the same {last_fail_sig[0]} call failed "
328
- f"{identical_fail_streak}× in a row repeating it won't change "
329
- "the result. If it's a Write, the file is likely too large for "
330
- "one response; write it in smaller pieces. Control is back to "
331
- "you.]\n"
328
+ f"\n[Stopped: the same {last_call_sig[0]} call ran "
329
+ f"{identical_repeat_streak}× in a row with the same result "
330
+ "repeating it changes nothing. Control is back to you; tell me "
331
+ "how you'd like to proceed.]\n"
332
332
  )
333
333
  break
334
334
 
@@ -116,17 +116,35 @@ def run_interactive(config: dict) -> None:
116
116
 
117
117
  def run_oneshot(prompt: str, config: dict) -> None:
118
118
  """One-shot mode: run a single prompt and exit."""
119
+ from drydock.providers import LLMUnreachable
120
+
119
121
  system = SYSTEM_PROMPT + load_project_instructions()
120
122
  state = AgentState()
121
123
 
122
- for event in run(prompt, state, config, system):
123
- if isinstance(event, TextChunk):
124
- print(event.text, end="", flush=True)
125
- elif isinstance(event, ToolStart):
126
- print(f" [{event.name}]", file=sys.stderr, flush=True)
127
- elif isinstance(event, ToolEnd):
128
- pass
129
- print()
124
+ try:
125
+ for event in run(prompt, state, config, system):
126
+ if isinstance(event, TextChunk):
127
+ print(event.text, end="", flush=True)
128
+ elif isinstance(event, ToolStart):
129
+ # Include an input summary so the trace (captured to drydock.log
130
+ # under -p) shows WHAT each tool did, not just its name. Without
131
+ # this, a timed-out run is an opaque wall of "[Bash]" with no way
132
+ # to tell a genuine 100-command exploration from a tight loop.
133
+ print(f" [{event.name}] {_summarize(event.inputs)}",
134
+ file=sys.stderr, flush=True)
135
+ elif isinstance(event, ToolEnd):
136
+ # One-line outcome so failures/repeats are visible in the trace.
137
+ preview = " ".join(str(event.result).split())[:100]
138
+ print(f" -> {preview}", file=sys.stderr, flush=True)
139
+ print()
140
+ except LLMUnreachable as e:
141
+ # The model server is down, the URL is wrong, or a turn overran the read
142
+ # timeout. The TUI and --cli paths already surface this; one-shot mode
143
+ # (-p, used by automation/harnesses) had no handler, so it crashed with a
144
+ # raw traceback that buried the actionable message. Print the remediation
145
+ # cleanly to stderr and exit non-zero so callers can detect the failure.
146
+ print(f"\nError: {e}", file=sys.stderr, flush=True)
147
+ sys.exit(2)
130
148
 
131
149
 
132
150
  def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
@@ -145,6 +163,25 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
145
163
  state.turn_count = 0
146
164
  print_colored(" Conversation cleared.", "green")
147
165
  return True
166
+ elif cmd == "/compact":
167
+ from drydock.compaction import compact, estimate_tokens
168
+
169
+ if not state.messages:
170
+ print_colored(" Nothing to compact.", "dim")
171
+ return True
172
+ before = estimate_tokens(state.messages)
173
+ limit = config.get("context_limit", 65536) or 65536
174
+ state.messages = compact(state.messages, limit)
175
+ after = estimate_tokens(state.messages)
176
+ saved = before - after
177
+ if saved > 0:
178
+ print_colored(
179
+ f" Compacted: ~{before:,} → ~{after:,} tokens (freed ~{saved:,}).",
180
+ "green",
181
+ )
182
+ else:
183
+ print_colored(f" Already compact (~{before:,} tokens).", "dim")
184
+ return True
148
185
  elif cmd == "/status":
149
186
  print_colored(f" Turns: {state.turn_count}", "cyan")
150
187
  print_colored(f" Messages: {len(state.messages)}", "cyan")
@@ -213,6 +250,12 @@ def main():
213
250
  parser.add_argument("--max-tool-calls", type=int, default=0, help="Max tool calls (0=unlimited)")
214
251
  parser.add_argument("--force-first-tool", action="store_true", help="Force tool_choice=required on first turn")
215
252
  parser.add_argument("--cli", action="store_true", help="Plain readline mode instead of the TUI")
253
+ parser.add_argument(
254
+ "--dangerously-skip-permissions",
255
+ dest="dangerously_skip_permissions",
256
+ action="store_true",
257
+ help="Auto-approve all tool calls without prompting (for CI/cron use)",
258
+ )
216
259
  args = parser.parse_args()
217
260
 
218
261
  from drydock import config as cfgmod
@@ -251,6 +294,7 @@ def main():
251
294
  "context_limit": 65536,
252
295
  "max_tool_calls": args.max_tool_calls,
253
296
  "force_first_tool": args.force_first_tool,
297
+ "_approve_all": args.dangerously_skip_permissions,
254
298
  "cwd": os.getcwd(),
255
299
  "history_path": str(Path.home() / ".drydock" / "history"),
256
300
  "onboarding": onboarding,
@@ -21,7 +21,11 @@ from pathlib import Path
21
21
  DEFAULTS: dict[str, object] = {
22
22
  "model": "gemma4",
23
23
  "provider": "vllm",
24
- "base_url": "",
24
+ # Concrete (not "") so a freshly-written config SHOWS the endpoint and the
25
+ # user can edit it / point at another box. Empty fell back to the provider
26
+ # default invisibly, which left base_url absent from the file. For a non-vllm
27
+ # provider, override base_url (or use --base-url / the first-run prompt).
28
+ "base_url": "http://localhost:8000/v1",
25
29
  "max_tokens": 8192, # 4096 truncated large file writes mid-JSON (→ _raw fail)
26
30
  "temperature": 0.2,
27
31
  "theme": "harbor",
@@ -93,10 +97,14 @@ def resolve(cli_overrides: dict, path: Path | None = None) -> dict:
93
97
 
94
98
  A NON-existent file is created once with pure DEFAULTS (no CLI flags — a
95
99
  transient --base-url for one run must not be baked in permanently). An
96
- EXISTING file is never modified: missing keys are backfilled only in the
97
- returned dict, never written back. This is deliberately conservative —
98
- the file may be shared or hand-edited, so we never reorder, reformat, or
99
- drop keys we don't recognize.
100
+ EXISTING v3 file is never modified: missing keys are backfilled only in the
101
+ returned dict, never written back. This is deliberately conservative — the
102
+ file may be shared or hand-edited, so we never reorder, reformat, or drop
103
+ keys we don't recognize.
104
+
105
+ A LEGACY/foreign file (a v2 config, or a malformed one) is migrated: backed
106
+ up to <name>.bak once, then replaced with a fresh DEFAULTS file so the user
107
+ has an editable v3 config with a visible endpoint.
100
108
  """
101
109
  path = path or default_config_path()
102
110
  if not path.exists():
@@ -104,4 +112,21 @@ def resolve(cli_overrides: dict, path: Path | None = None) -> dict:
104
112
  file_cfg = {}
105
113
  else:
106
114
  file_cfg = load_file(path)
115
+ # Legacy/foreign-config migration. A file with NONE of our keys is either
116
+ # a v2 mistral-vibe config (nested [[providers]]/[[models]]/active_model)
117
+ # or malformed/empty — in every case the user can't edit it to set
118
+ # base_url, model, etc., and v2's nested tables can't round-trip through
119
+ # our flat emitter. Back it up once and write a fresh, complete v3 file so
120
+ # the endpoint is visible and editable (the v2->v3 upgrade path). A real
121
+ # v3 file (>=1 recognized key) is left untouched — we never reformat it or
122
+ # drop keys we don't recognize.
123
+ if file_cfg and not any(k in DEFAULTS for k in file_cfg):
124
+ backup = path.with_name(path.name + ".bak")
125
+ try:
126
+ if not backup.exists():
127
+ backup.write_bytes(path.read_bytes())
128
+ except OSError:
129
+ pass
130
+ save_file(dict(DEFAULTS), path)
131
+ file_cfg = {}
107
132
  return merge(file_cfg, cli_overrides)
@@ -71,18 +71,43 @@ def _friendly_unreachable(base_url: str, provider: str) -> str:
71
71
  )
72
72
 
73
73
 
74
- def _safe_create(client, kwargs: dict, base_url: str, provider: str):
74
+ def _friendly_timeout(base_url: str, timeout_s: float) -> str:
75
+ """The server IS reachable but a single generation overran the read timeout.
76
+ Common with slow local models doing long reasoning turns (a non-streaming
77
+ response only arrives once the whole generation finishes). Distinct from
78
+ 'unreachable' so the user fixes the right thing."""
79
+ mins = timeout_s / 60.0
80
+ return (
81
+ f"The model server at {base_url} is reachable, but the request exceeded "
82
+ f"the {timeout_s:.0f}s ({mins:.0f} min) read timeout before finishing.\n"
83
+ f" This usually means the model is generating a very long response "
84
+ f"(e.g. a big reasoning turn on a slow local model), not that the server "
85
+ f"is down.\n"
86
+ f" 1. Raise the limit: set request_timeout (seconds) in "
87
+ f"~/.drydock/config.toml.\n"
88
+ f" 2. Or shorten turns: lower the server's reasoning budget "
89
+ f"(llama.cpp --reasoning-budget).\n"
90
+ f" Your last message was not lost — just send it again."
91
+ )
92
+
93
+
94
+ def _safe_create(client, kwargs: dict, base_url: str, provider: str, timeout_s: float = 600.0):
75
95
  """Call chat.completions.create, mapping a connection failure to a clean
76
96
  LLMUnreachable instead of a raw traceback / 12-minute hang."""
77
97
  import openai
78
98
 
79
99
  try:
80
100
  return client.chat.completions.create(**kwargs)
101
+ # APITimeoutError subclasses APIConnectionError — catch it FIRST so a slow
102
+ # (but alive) server gets the accurate "timed out" message, not "down".
103
+ except openai.APITimeoutError as e:
104
+ raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
81
105
  except openai.APIConnectionError as e:
82
106
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
83
107
 
84
108
 
85
- def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel):
109
+ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel,
110
+ timeout_s: float = 600.0):
86
111
  """Like _safe_create, but runs off-thread and polls a cancel Event so STOP
87
112
  can abandon a blocked decode (raising _StopRequested). The orphaned request
88
113
  runs to completion in the pool thread and its result is dropped."""
@@ -95,6 +120,9 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
95
120
  except concurrent.futures.TimeoutError:
96
121
  if cancel is not None and cancel.is_set():
97
122
  raise _StopRequested
123
+ # APITimeoutError subclasses APIConnectionError — catch it FIRST.
124
+ except openai.APITimeoutError as e:
125
+ raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
98
126
  except openai.APIConnectionError as e:
99
127
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
100
128
 
@@ -177,13 +205,67 @@ def _parse_tool_args(raw: str) -> dict:
177
205
 
178
206
  # ── Message format conversion ─────────────────────────────────────────────
179
207
 
208
+ _IMAGE_MIME = {
209
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
210
+ ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
211
+ }
212
+
213
+
214
+ def _user_content_with_images(content):
215
+ """Vision support: if the user's text references image file paths that exist
216
+ on disk, attach them as OpenAI multimodal image_url blocks (works with any
217
+ --mmproj-enabled server). Text-only prompts pass through unchanged as a plain
218
+ string, so display / loop-detection / compaction / token-counting (which all
219
+ assume string content) are untouched — the multimodal list is built ONLY here,
220
+ at the API boundary."""
221
+ if not isinstance(content, str) or "." not in content:
222
+ return content
223
+ import base64
224
+ import os
225
+ import re
226
+
227
+ exts = "png|jpe?g|gif|webp|bmp"
228
+ cands = re.findall(
229
+ rf'"([^"]+?\.(?:{exts}))"|\'([^\']+?\.(?:{exts}))\'|(\S+\.(?:{exts}))',
230
+ content, re.IGNORECASE,
231
+ )
232
+ seen: list[str] = []
233
+ for tup in cands:
234
+ raw = next((x for x in tup if x), None)
235
+ if not raw:
236
+ continue
237
+ # the greedy \S+ branch grabs surrounding markdown/punctuation, e.g. a
238
+ # path in backticks `/app/code.png`, parens, or with trailing .,;: —
239
+ # strip it so os.path.isfile sees the real path (else vision silently
240
+ # never attaches).
241
+ raw = raw.strip("`'\"()[]{}<>").rstrip(".,;:!?")
242
+ p = os.path.expanduser(raw)
243
+ if os.path.isfile(p) and p not in seen:
244
+ seen.append(p)
245
+ if not seen:
246
+ return content
247
+ blocks: list[dict] = [{"type": "text", "text": content}]
248
+ for p in seen:
249
+ mime = _IMAGE_MIME.get(os.path.splitext(p)[1].lower(), "image/png")
250
+ try:
251
+ with open(p, "rb") as f:
252
+ b64 = base64.b64encode(f.read()).decode()
253
+ except OSError:
254
+ continue
255
+ blocks.append({"type": "image_url",
256
+ "image_url": {"url": f"data:{mime};base64,{b64}"}})
257
+ return blocks if len(blocks) > 1 else content
258
+
259
+
180
260
  def messages_to_openai(messages: list, system: str) -> list:
181
261
  """Convert neutral messages to OpenAI API format."""
182
- result = [{"role": "system", "content": system}]
262
+ # value type is mixed (str content, multimodal list content, tool_calls list)
263
+ result: list[dict] = [{"role": "system", "content": system}]
183
264
  for m in messages:
184
265
  role = m["role"]
185
266
  if role == "user":
186
- result.append({"role": "user", "content": m["content"]})
267
+ result.append({"role": "user",
268
+ "content": _user_content_with_images(m["content"])})
187
269
  elif role == "assistant":
188
270
  msg = {"role": "assistant", "content": m.get("content") or None}
189
271
  tcs = m.get("tool_calls", [])
@@ -250,13 +332,19 @@ def stream(
250
332
  )
251
333
 
252
334
  # Fail fast on a dead endpoint: a 10s connect timeout and no retries surface
253
- # an unreachable server in seconds. Generation itself can still take minutes
254
- # (long read timeout).
335
+ # an unreachable server in seconds. Generation itself can take many minutes
336
+ # a slow local model doing a long reasoning turn (non-streaming, so the whole
337
+ # response only lands once it finishes) can exceed 10 min. Default the read
338
+ # timeout to 30 min and let the operator raise it via config request_timeout.
339
+ try:
340
+ read_timeout = float(config.get("request_timeout") or 1800.0)
341
+ except (TypeError, ValueError):
342
+ read_timeout = 1800.0
255
343
  client = OpenAI(
256
344
  api_key=api_key,
257
345
  base_url=base_url,
258
346
  max_retries=0,
259
- timeout=httpx.Timeout(600.0, connect=10.0),
347
+ timeout=httpx.Timeout(read_timeout, connect=10.0),
260
348
  )
261
349
  # Expose the client so the TUI's STOP (Esc / Ctrl+C) can close it mid-call —
262
350
  # closing the connection aborts an in-flight blocking decode immediately
@@ -303,7 +391,7 @@ def stream(
303
391
  # streaming. If STOP closes the client mid-call the blocking request
304
392
  # raises — swallow that as a clean cancel rather than an error.
305
393
  try:
306
- yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel)
394
+ yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout)
307
395
  except _StopRequested:
308
396
  return # STOP — abandon the in-flight request, clean stop
309
397
  except Exception:
@@ -319,7 +407,7 @@ def stream(
319
407
  in_tok = out_tok = 0
320
408
 
321
409
  try:
322
- response = _create_abortable(client, kwargs, base_url, provider, cancel)
410
+ response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout)
323
411
  except _StopRequested:
324
412
  config.get("_abort", {}).pop("client", None)
325
413
  return
@@ -379,13 +467,14 @@ def stream(
379
467
 
380
468
 
381
469
  def _complete_nonstreaming(
382
- client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None
470
+ client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None,
471
+ timeout_s: float = 600.0,
383
472
  ) -> Generator:
384
473
  """Single non-streaming completion. Yields one TextChunk (if any text)
385
474
  then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
386
475
  Runs off-thread so STOP can abandon a blocked decode.
387
476
  """
388
- resp = _create_abortable(client, kwargs, base_url, provider, cancel)
477
+ resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s)
389
478
  choice = resp.choices[0]
390
479
  msg = choice.message
391
480
 
@@ -9,6 +9,7 @@ import os
9
9
  import re
10
10
  import difflib
11
11
  import glob as _glob
12
+ import signal
12
13
  import subprocess
13
14
  import time
14
15
  from pathlib import Path
@@ -83,7 +84,7 @@ SCHEMAS = [
83
84
  "type": "object",
84
85
  "properties": {
85
86
  "command": {"type": "string"},
86
- "timeout": {"type": "integer", "description": "Seconds (default 30)"},
87
+ "timeout": {"type": "integer", "description": "Seconds (default 120; bump to 1800 for builds/training/cracking)"},
87
88
  },
88
89
  "required": ["command"],
89
90
  },
@@ -438,6 +439,57 @@ def tool_edit(params: dict, config: dict) -> str:
438
439
  return f"Error editing {fp}: {e}"
439
440
 
440
441
 
442
+ def kill_process_group(proc) -> None:
443
+ """Kill a Popen launched with start_new_session AND all its descendants by
444
+ signalling the whole process group. proc.kill() alone only kills the direct
445
+ shell, orphaning children (which keep running and hold the stdout pipe open,
446
+ hanging communicate() and freezing the TUI on STOP). Falls back to proc.kill()
447
+ if the group can't be resolved (e.g. process already gone)."""
448
+ if proc is None:
449
+ return
450
+ try:
451
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
452
+ except (ProcessLookupError, PermissionError, OSError):
453
+ try:
454
+ proc.kill()
455
+ except Exception: # noqa: BLE001
456
+ pass
457
+
458
+
459
+ _OFFLINE_HINT = (
460
+ "\n[Note: this looks like a network/download command and the environment "
461
+ "appears to be OFFLINE — downloads will keep failing no matter how many "
462
+ "times you retry. Do NOT repeat the fetch. Use only files/data already "
463
+ "present in the project; if the required data genuinely isn't here, say so "
464
+ "and stop rather than looping on the download.]"
465
+ )
466
+ # Commands that reach the network (so an offline failure is expected, not a bug).
467
+ _NETWORK_CMD_RE = re.compile(
468
+ r"\b(pip3?\s+install|pip\s+download|uv\s+(pip\s+)?(install|add)|conda\s+install|"
469
+ r"npm\s+(install|i|ci)|yarn\s+add|wget|curl\s+[^|]*https?://|git\s+clone|"
470
+ r"apt(-get)?\s+(install|update)|hf\s+download|huggingface-cli\s+download|"
471
+ r"load_dataset|hf_hub_download|snapshot_download|from_pretrained)\b",
472
+ re.IGNORECASE,
473
+ )
474
+ # Output signatures of a name-resolution / connectivity failure.
475
+ _NETWORK_FAIL_RE = re.compile(
476
+ r"(could not resolve|name or service not known|temporary failure in name "
477
+ r"resolution|network is unreachable|no route to host|connection (timed out|"
478
+ r"refused)|failed to establish a new connection|max retries exceeded|"
479
+ r"getaddrinfo|no address associated|could not find a version|offline mode "
480
+ r"is enabled|connectionerror|read timed out)",
481
+ re.IGNORECASE,
482
+ )
483
+
484
+
485
+ def _is_network_command(cmd: str) -> bool:
486
+ return bool(_NETWORK_CMD_RE.search(cmd or ""))
487
+
488
+
489
+ def _looks_like_network_failure(output: str) -> bool:
490
+ return bool(_NETWORK_FAIL_RE.search(output or ""))
491
+
492
+
441
493
  def tool_bash(params: dict, config: dict) -> str:
442
494
  cmd = params.get("command")
443
495
  if not cmd:
@@ -449,7 +501,11 @@ def tool_bash(params: dict, config: dict) -> str:
449
501
  "(escape any newline as \\n; avoid raw line breaks in the JSON)."
450
502
  )
451
503
  return "Error: Bash needs a non-empty 'command'."
452
- timeout = params.get("timeout", 30)
504
+ # Default 120s (was 30s): real coding tasks compile, train, run test suites,
505
+ # and crack hashes — a 30s wall killed legitimate long work before it could
506
+ # finish (terminal-bench: 16 tasks died at 30s on builds/training). Bounded,
507
+ # and the model can pass a larger `timeout` for genuinely heavy commands.
508
+ timeout = params.get("timeout", 120)
453
509
  reason = bash_safety.dangerous_command(cmd)
454
510
  if reason is not None:
455
511
  return bash_safety.refusal_message(cmd, reason)
@@ -469,7 +525,12 @@ def tool_bash(params: dict, config: dict) -> str:
469
525
  f"({approval_reason}).\nCommand: {cmd.strip()}"
470
526
  )
471
527
  # Run via Popen (not subprocess.run) so STOP can kill it mid-execution:
472
- # the handle is stashed in config["_abort"]["proc"] for action_stop to .kill().
528
+ # the handle is stashed in config["_abort"]["proc"] for action_stop to kill.
529
+ # start_new_session=True puts the shell in its OWN process group so we can
530
+ # kill the WHOLE tree (kill_process_group): a bare proc.kill() only kills the
531
+ # /bin/sh shell, orphaning its children (e.g. a brute-force script and its
532
+ # subprocesses) which keep running AND keep the stdout pipe open — so the
533
+ # follow-up communicate() blocks forever and the TUI is stuck "working".
473
534
  # We poll communicate() in short slices, checking the cancel Event and the
474
535
  # overall timeout between slices.
475
536
  cancel = config.get("_cancel")
@@ -477,7 +538,7 @@ def tool_bash(params: dict, config: dict) -> str:
477
538
  try:
478
539
  proc = subprocess.Popen(
479
540
  cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
480
- text=True, cwd=config.get("cwd"),
541
+ text=True, cwd=config.get("cwd"), start_new_session=True,
481
542
  )
482
543
  config.setdefault("_abort", {})["proc"] = proc
483
544
  start = time.monotonic()
@@ -487,22 +548,29 @@ def tool_bash(params: dict, config: dict) -> str:
487
548
  break
488
549
  except subprocess.TimeoutExpired:
489
550
  if cancel is not None and cancel.is_set():
490
- proc.kill()
551
+ kill_process_group(proc)
491
552
  proc.communicate()
492
553
  return "[stopped by user]"
493
554
  if time.monotonic() - start > timeout:
494
- proc.kill()
555
+ kill_process_group(proc)
495
556
  proc.communicate()
496
- bigger = min(timeout * 4, 600)
497
- return (
557
+ bigger = min(timeout * 4, 1800)
558
+ msg = (
498
559
  f"Error: command timed out after {timeout}s. If it is "
499
560
  f"legitimately slow (a big query, build, download, or "
500
561
  f"test run), retry with a larger timeout — pass "
501
562
  f"timeout: {bigger}. Otherwise it may be hung."
502
563
  )
564
+ if _is_network_command(cmd):
565
+ msg += _OFFLINE_HINT
566
+ return msg
503
567
  output = out or ""
504
568
  if proc.returncode != 0:
505
569
  output += f"\n[exit code: {proc.returncode}]"
570
+ # Offline environments make downloads fail forever; the model tends
571
+ # to retry the same fetch in a loop. Tell it to stop and work local.
572
+ if _is_network_command(cmd) and _looks_like_network_failure(output):
573
+ output += _OFFLINE_HINT
506
574
  return output.strip() or "(no output)"
507
575
  except Exception as e:
508
576
  return f"Error: {e}"
@@ -165,10 +165,11 @@ class DrydockApp(App):
165
165
  pass
166
166
  proc = abort.get("proc")
167
167
  if proc is not None:
168
- try:
169
- proc.kill()
170
- except Exception: # noqa: BLE001
171
- pass
168
+ # kill the whole process group, not just the shell — else a command's
169
+ # child processes survive STOP and keep the stdout pipe open, hanging
170
+ # the bash tool's communicate() and freezing the TUI on "working".
171
+ from drydock.tools import kill_process_group
172
+ kill_process_group(proc)
172
173
 
173
174
  def action_scroll_up(self) -> None:
174
175
  self._scroll.scroll_page_up()
@@ -396,6 +397,12 @@ class DrydockApp(App):
396
397
  """Start an agent turn for an already-displayed user prompt."""
397
398
  self._current_assistant = None
398
399
  self._cancel.clear() # fresh turn — clear any prior STOP
400
+ # Reset the pinned plan so the previous task's checklist doesn't linger
401
+ # in the panel during an unrelated new request, and a prior *unfinished*
402
+ # plan can't fire a stale continue-nudge (_plan_has_unfinished) on this
403
+ # turn. If this turn emits its own todo, _render_todo repopulates it.
404
+ self.config.pop("_todo", None)
405
+ self.query_one("#todo", Static).update("")
399
406
  self._busy = True
400
407
  self._work_start = time.monotonic()
401
408
  self._work_word = random.choice(_WORKING_WORDS)
@@ -446,6 +453,8 @@ class DrydockApp(App):
446
453
  )
447
454
  else:
448
455
  self._info("Nothing to go back to.")
456
+ elif cmd == "/compact":
457
+ self._cmd_compact()
449
458
  elif cmd == "/status":
450
459
  t = self.state
451
460
  self._info(
@@ -464,6 +473,7 @@ class DrydockApp(App):
464
473
  " /back rewind the last turn from the model's context\n"
465
474
  " /stop stop the running turn (or press Esc)\n"
466
475
  " /status session model, cwd, turns, tokens\n"
476
+ " /compact shrink old context to free up the window\n"
467
477
  " /clear reset the conversation\n"
468
478
  " /quit exit\n"
469
479
  "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
@@ -471,6 +481,42 @@ class DrydockApp(App):
471
481
  else:
472
482
  self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
473
483
 
484
+ def _cmd_compact(self) -> None:
485
+ """Manually compact the conversation to reclaim context NOW, without
486
+ waiting for the automatic 60%-of-window threshold (agent.maybe_compact).
487
+ Truncates/drops old tool results and oversized tool-call arguments while
488
+ keeping recent turns intact (see compaction.compact)."""
489
+ from drydock.compaction import compact, estimate_tokens
490
+
491
+ msgs = self.state.messages
492
+ if not msgs:
493
+ self._info("Nothing to compact — the conversation is empty.")
494
+ return
495
+ before = estimate_tokens(msgs)
496
+ limit = self.config.get("context_limit", 65536) or 65536
497
+ self.state.messages = compact(msgs, limit)
498
+ after = estimate_tokens(self.state.messages)
499
+ saved = before - after
500
+ if saved > 0:
501
+ # Only touch the gauge when we ACTUALLY shrank the history. The gauge
502
+ # normally shows the server's exact prompt-token count; our estimate
503
+ # (chars/3) runs lower, so overwriting it on a no-op compaction would
504
+ # fake a large drop. Scale the real count by the same ratio we shrank
505
+ # the estimate, so the gauge moves proportionally and stays honest;
506
+ # the next real turn replaces it with the server's exact count.
507
+ self._ctx_tokens = round(self._ctx_tokens * (after / before)) if before else after
508
+ self._refresh_status()
509
+ self._info(
510
+ f"Compacted context: ~{before:,} → ~{after:,} tokens "
511
+ f"(freed ~{saved:,}). Older tool output was truncated/dropped; "
512
+ "recent turns are kept intact."
513
+ )
514
+ else:
515
+ self._info(
516
+ f"Already compact — ~{before:,} tokens, nothing to free "
517
+ "(recent turns are always preserved)."
518
+ )
519
+
474
520
  def _persist_config(self) -> None:
475
521
  """Save the persistable settings (model/provider/base_url/… — save_file
476
522
  filters to those) to ~/.drydock/config.toml so setup survives restart."""