code-puppy 0.0.348__py3-none-any.whl → 0.0.361__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/agents/__init__.py +2 -0
- code_puppy/agents/agent_manager.py +49 -0
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/base_agent.py +17 -4
- code_puppy/agents/event_stream_handler.py +101 -8
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +73 -0
- code_puppy/claude_cache_client.py +249 -34
- code_puppy/command_line/core_commands.py +85 -0
- code_puppy/config.py +66 -62
- code_puppy/messaging/__init__.py +15 -0
- code_puppy/messaging/messages.py +27 -0
- code_puppy/messaging/queue_console.py +1 -1
- code_puppy/messaging/rich_renderer.py +36 -1
- code_puppy/messaging/spinner/__init__.py +20 -2
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_utils.py +54 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
- code_puppy/plugins/antigravity_oauth/transport.py +1 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +83 -33
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +6 -6
- code_puppy/tools/browser/browser_interactions.py +21 -20
- code_puppy/tools/browser/browser_locators.py +9 -9
- code_puppy/tools/browser/browser_navigation.py +7 -7
- code_puppy/tools/browser/browser_screenshot.py +78 -140
- code_puppy/tools/browser/browser_scripts.py +15 -13
- code_puppy/tools/browser/camoufox_manager.py +226 -64
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +292 -101
- code_puppy/tools/common.py +176 -1
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/subagent_context.py +158 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/RECORD +69 -38
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.348.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.348.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/licenses/LICENSE +0 -0
|
@@ -5,6 +5,11 @@ ClaudeCacheAsyncClient: httpx client that tries to patch /v1/messages bodies.
|
|
|
5
5
|
We now also expose `patch_anthropic_client_messages` which monkey-patches
|
|
6
6
|
AsyncAnthropic.messages.create() so we can inject cache_control BEFORE
|
|
7
7
|
serialization, avoiding httpx/Pydantic internals.
|
|
8
|
+
|
|
9
|
+
This module also handles:
|
|
10
|
+
- Tool name prefixing/unprefixing for Claude Code OAuth compatibility
|
|
11
|
+
- Header transformations (anthropic-beta, user-agent)
|
|
12
|
+
- URL modifications (adding ?beta=true query param)
|
|
8
13
|
"""
|
|
9
14
|
|
|
10
15
|
from __future__ import annotations
|
|
@@ -12,8 +17,10 @@ from __future__ import annotations
|
|
|
12
17
|
import base64
|
|
13
18
|
import json
|
|
14
19
|
import logging
|
|
20
|
+
import re
|
|
15
21
|
import time
|
|
16
22
|
from typing import Any, Callable, MutableMapping
|
|
23
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
17
24
|
|
|
18
25
|
import httpx
|
|
19
26
|
|
|
@@ -22,6 +29,13 @@ logger = logging.getLogger(__name__)
|
|
|
22
29
|
# Refresh token if it's older than 1 hour (3600 seconds)
|
|
23
30
|
TOKEN_MAX_AGE_SECONDS = 3600
|
|
24
31
|
|
|
32
|
+
# Tool name prefix for Claude Code OAuth compatibility
|
|
33
|
+
# Tools are prefixed on outgoing requests and unprefixed on incoming responses
|
|
34
|
+
TOOL_PREFIX = "cp_"
|
|
35
|
+
|
|
36
|
+
# User-Agent to send with Claude Code OAuth requests
|
|
37
|
+
CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)"
|
|
38
|
+
|
|
25
39
|
try:
|
|
26
40
|
from anthropic import AsyncAnthropic
|
|
27
41
|
except ImportError: # pragma: no cover - optional dep
|
|
@@ -29,6 +43,22 @@ except ImportError: # pragma: no cover - optional dep
|
|
|
29
43
|
|
|
30
44
|
|
|
31
45
|
class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
46
|
+
"""Async HTTP client with Claude Code OAuth transformations.
|
|
47
|
+
|
|
48
|
+
Handles:
|
|
49
|
+
- Cache control injection for prompt caching
|
|
50
|
+
- Tool name prefixing on outgoing requests
|
|
51
|
+
- Tool name unprefixing on incoming streaming responses
|
|
52
|
+
- Header transformations (anthropic-beta, user-agent)
|
|
53
|
+
- URL modifications (adding ?beta=true)
|
|
54
|
+
- Proactive token refresh
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# Regex pattern for unprefixing tool names in streaming responses
|
|
58
|
+
_TOOL_UNPREFIX_PATTERN = re.compile(
|
|
59
|
+
rf'"name"\s*:\s*"{re.escape(TOOL_PREFIX)}([^"]+)"'
|
|
60
|
+
)
|
|
61
|
+
|
|
32
62
|
def _get_jwt_age_seconds(self, token: str | None) -> float | None:
|
|
33
63
|
"""Decode a JWT and return its age in seconds.
|
|
34
64
|
|
|
@@ -107,9 +137,100 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
107
137
|
)
|
|
108
138
|
return should_refresh
|
|
109
139
|
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _prefix_tool_names(body: bytes) -> bytes | None:
|
|
142
|
+
"""Prefix all tool names in the request body with TOOL_PREFIX.
|
|
143
|
+
|
|
144
|
+
This is required for Claude Code OAuth compatibility - tools must be
|
|
145
|
+
prefixed on outgoing requests and unprefixed on incoming responses.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(body.decode("utf-8"))
|
|
149
|
+
except Exception:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
tools = data.get("tools")
|
|
156
|
+
if not isinstance(tools, list) or not tools:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
modified = False
|
|
160
|
+
for tool in tools:
|
|
161
|
+
if isinstance(tool, dict) and "name" in tool:
|
|
162
|
+
name = tool["name"]
|
|
163
|
+
if name and not name.startswith(TOOL_PREFIX):
|
|
164
|
+
tool["name"] = f"{TOOL_PREFIX}{name}"
|
|
165
|
+
modified = True
|
|
166
|
+
|
|
167
|
+
if not modified:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return json.dumps(data).encode("utf-8")
|
|
171
|
+
|
|
172
|
+
def _unprefix_tool_names_in_text(self, text: str) -> str:
|
|
173
|
+
"""Remove TOOL_PREFIX from tool names in streaming response text."""
|
|
174
|
+
return self._TOOL_UNPREFIX_PATTERN.sub(r'"name": "\1"', text)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _transform_headers_for_claude_code(
|
|
178
|
+
headers: MutableMapping[str, str],
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Transform headers for Claude Code OAuth compatibility.
|
|
181
|
+
|
|
182
|
+
- Sets user-agent to claude-cli
|
|
183
|
+
- Merges anthropic-beta headers appropriately
|
|
184
|
+
- Removes x-api-key (using Bearer auth instead)
|
|
185
|
+
"""
|
|
186
|
+
# Set user-agent
|
|
187
|
+
headers["user-agent"] = CLAUDE_CLI_USER_AGENT
|
|
188
|
+
|
|
189
|
+
# Handle anthropic-beta header
|
|
190
|
+
incoming_beta = headers.get("anthropic-beta", "")
|
|
191
|
+
incoming_betas = [b.strip() for b in incoming_beta.split(",") if b.strip()]
|
|
192
|
+
|
|
193
|
+
# Check if claude-code beta was explicitly requested
|
|
194
|
+
include_claude_code = "claude-code-20250219" in incoming_betas
|
|
195
|
+
|
|
196
|
+
# Build merged betas list
|
|
197
|
+
merged_betas = [
|
|
198
|
+
"oauth-2025-04-20",
|
|
199
|
+
"interleaved-thinking-2025-05-14",
|
|
200
|
+
]
|
|
201
|
+
if include_claude_code:
|
|
202
|
+
merged_betas.append("claude-code-20250219")
|
|
203
|
+
|
|
204
|
+
headers["anthropic-beta"] = ",".join(merged_betas)
|
|
205
|
+
|
|
206
|
+
# Remove x-api-key if present (we use Bearer auth)
|
|
207
|
+
for key in ["x-api-key", "X-API-Key", "X-Api-Key"]:
|
|
208
|
+
if key in headers:
|
|
209
|
+
del headers[key]
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _add_beta_query_param(url: httpx.URL) -> httpx.URL:
|
|
213
|
+
"""Add ?beta=true query parameter to the URL if not already present."""
|
|
214
|
+
# Parse the URL
|
|
215
|
+
parsed = urlparse(str(url))
|
|
216
|
+
query_params = parse_qs(parsed.query)
|
|
217
|
+
|
|
218
|
+
# Only add if not already present
|
|
219
|
+
if "beta" not in query_params:
|
|
220
|
+
query_params["beta"] = ["true"]
|
|
221
|
+
# Rebuild query string
|
|
222
|
+
new_query = urlencode(query_params, doseq=True)
|
|
223
|
+
# Rebuild URL
|
|
224
|
+
new_parsed = parsed._replace(query=new_query)
|
|
225
|
+
return httpx.URL(urlunparse(new_parsed))
|
|
226
|
+
|
|
227
|
+
return url
|
|
228
|
+
|
|
110
229
|
async def send(
|
|
111
230
|
self, request: httpx.Request, *args: Any, **kwargs: Any
|
|
112
231
|
) -> httpx.Response: # type: ignore[override]
|
|
232
|
+
is_messages_endpoint = request.url.path.endswith("/v1/messages")
|
|
233
|
+
|
|
113
234
|
# Proactive token refresh: check JWT age before every request
|
|
114
235
|
if not request.extensions.get("claude_oauth_refresh_attempted"):
|
|
115
236
|
try:
|
|
@@ -131,50 +252,88 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
131
252
|
except Exception as exc:
|
|
132
253
|
logger.debug("Error during proactive token refresh check: %s", exc)
|
|
133
254
|
|
|
134
|
-
|
|
135
|
-
|
|
255
|
+
# Apply Claude Code OAuth transformations for /v1/messages
|
|
256
|
+
if is_messages_endpoint:
|
|
257
|
+
try:
|
|
136
258
|
body_bytes = self._extract_body_bytes(request)
|
|
259
|
+
headers = dict(request.headers)
|
|
260
|
+
url = request.url
|
|
261
|
+
body_modified = False
|
|
262
|
+
headers_modified = False
|
|
263
|
+
|
|
264
|
+
# 1. Transform headers for Claude Code OAuth
|
|
265
|
+
self._transform_headers_for_claude_code(headers)
|
|
266
|
+
headers_modified = True
|
|
267
|
+
|
|
268
|
+
# 2. Add ?beta=true query param
|
|
269
|
+
url = self._add_beta_query_param(url)
|
|
270
|
+
|
|
271
|
+
# 3. Prefix tool names in request body
|
|
137
272
|
if body_bytes:
|
|
138
|
-
|
|
139
|
-
if
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
273
|
+
prefixed_body = self._prefix_tool_names(body_bytes)
|
|
274
|
+
if prefixed_body is not None:
|
|
275
|
+
body_bytes = prefixed_body
|
|
276
|
+
body_modified = True
|
|
277
|
+
|
|
278
|
+
# 4. Inject cache_control
|
|
279
|
+
cached_body = self._inject_cache_control(body_bytes)
|
|
280
|
+
if cached_body is not None:
|
|
281
|
+
body_bytes = cached_body
|
|
282
|
+
body_modified = True
|
|
283
|
+
|
|
284
|
+
# Rebuild request if anything changed
|
|
285
|
+
if body_modified or headers_modified or url != request.url:
|
|
286
|
+
try:
|
|
287
|
+
rebuilt = self.build_request(
|
|
288
|
+
method=request.method,
|
|
289
|
+
url=url,
|
|
290
|
+
headers=headers,
|
|
291
|
+
content=body_bytes,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Copy core internals so httpx uses the modified body/stream
|
|
295
|
+
if hasattr(rebuilt, "_content"):
|
|
296
|
+
setattr(request, "_content", rebuilt._content) # type: ignore[attr-defined]
|
|
297
|
+
if hasattr(rebuilt, "stream"):
|
|
298
|
+
request.stream = rebuilt.stream
|
|
299
|
+
if hasattr(rebuilt, "extensions"):
|
|
300
|
+
request.extensions = rebuilt.extensions
|
|
301
|
+
|
|
302
|
+
# Update URL
|
|
303
|
+
request.url = url
|
|
304
|
+
|
|
305
|
+
# Update headers
|
|
306
|
+
for key, value in headers.items():
|
|
307
|
+
request.headers[key] = value
|
|
308
|
+
|
|
309
|
+
# Ensure Content-Length matches the new body
|
|
310
|
+
if body_bytes:
|
|
311
|
+
request.headers["Content-Length"] = str(len(body_bytes))
|
|
312
|
+
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
logger.debug("Error rebuilding request: %s", exc)
|
|
315
|
+
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
logger.debug("Error in Claude Code transformations: %s", exc)
|
|
318
|
+
|
|
319
|
+
# Send the request
|
|
166
320
|
response = await super().send(request, *args, **kwargs)
|
|
321
|
+
|
|
322
|
+
# Transform streaming response to unprefix tool names
|
|
323
|
+
if is_messages_endpoint and response.status_code == 200:
|
|
324
|
+
try:
|
|
325
|
+
response = self._wrap_response_with_tool_unprefixing(response, request)
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
logger.debug("Error wrapping response for tool unprefixing: %s", exc)
|
|
328
|
+
|
|
329
|
+
# Handle auth errors with token refresh
|
|
167
330
|
try:
|
|
168
|
-
# Check for both 401 and 400 - Anthropic/Cloudflare may return 400 for auth errors
|
|
169
|
-
# Also check if it's a Cloudflare HTML error response
|
|
170
331
|
if response.status_code in (400, 401) and not request.extensions.get(
|
|
171
332
|
"claude_oauth_refresh_attempted"
|
|
172
333
|
):
|
|
173
|
-
# Determine if this is an auth error (including Cloudflare HTML errors)
|
|
174
334
|
is_auth_error = response.status_code == 401
|
|
175
335
|
|
|
176
336
|
if response.status_code == 400:
|
|
177
|
-
# Check if this is a Cloudflare HTML error
|
|
178
337
|
is_auth_error = self._is_cloudflare_html_error(response)
|
|
179
338
|
if is_auth_error:
|
|
180
339
|
logger.info(
|
|
@@ -203,8 +362,64 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
|
|
|
203
362
|
logger.warning("Token refresh failed, returning original error")
|
|
204
363
|
except Exception as exc:
|
|
205
364
|
logger.debug("Error during token refresh attempt: %s", exc)
|
|
365
|
+
|
|
206
366
|
return response
|
|
207
367
|
|
|
368
|
+
def _wrap_response_with_tool_unprefixing(
|
|
369
|
+
self, response: httpx.Response, request: httpx.Request
|
|
370
|
+
) -> httpx.Response:
|
|
371
|
+
"""Wrap a streaming response to unprefix tool names.
|
|
372
|
+
|
|
373
|
+
Creates a new response with a transformed stream that removes the
|
|
374
|
+
TOOL_PREFIX from tool names in the response body.
|
|
375
|
+
"""
|
|
376
|
+
original_stream = response.stream
|
|
377
|
+
unprefix_fn = self._unprefix_tool_names_in_text
|
|
378
|
+
|
|
379
|
+
class UnprefixingStream(httpx.AsyncByteStream):
|
|
380
|
+
"""Async byte stream that unprefixes tool names.
|
|
381
|
+
|
|
382
|
+
Inherits from httpx.AsyncByteStream to ensure proper stream interface.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def __init__(self, inner_stream: Any) -> None:
|
|
386
|
+
self._inner = inner_stream
|
|
387
|
+
|
|
388
|
+
async def __aiter__(self):
|
|
389
|
+
async for chunk in self._inner:
|
|
390
|
+
if isinstance(chunk, bytes):
|
|
391
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
392
|
+
text = unprefix_fn(text)
|
|
393
|
+
yield text.encode("utf-8")
|
|
394
|
+
else:
|
|
395
|
+
yield chunk
|
|
396
|
+
|
|
397
|
+
async def aclose(self) -> None:
|
|
398
|
+
if hasattr(self._inner, "aclose"):
|
|
399
|
+
try:
|
|
400
|
+
result = self._inner.aclose()
|
|
401
|
+
# Handle both sync and async aclose
|
|
402
|
+
if hasattr(result, "__await__"):
|
|
403
|
+
await result
|
|
404
|
+
except Exception:
|
|
405
|
+
pass # Ignore close errors
|
|
406
|
+
elif hasattr(self._inner, "close"):
|
|
407
|
+
try:
|
|
408
|
+
self._inner.close()
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
# Create a new response with the transformed stream
|
|
413
|
+
# Must include request for raise_for_status() to work
|
|
414
|
+
new_response = httpx.Response(
|
|
415
|
+
status_code=response.status_code,
|
|
416
|
+
headers=response.headers,
|
|
417
|
+
stream=UnprefixingStream(original_stream),
|
|
418
|
+
extensions=response.extensions,
|
|
419
|
+
request=request,
|
|
420
|
+
)
|
|
421
|
+
return new_response
|
|
422
|
+
|
|
208
423
|
@staticmethod
|
|
209
424
|
def _extract_body_bytes(request: httpx.Request) -> bytes | None:
|
|
210
425
|
# Try public content first
|
|
@@ -772,6 +772,91 @@ def handle_mcp_command(command: str) -> bool:
|
|
|
772
772
|
return handler.handle_mcp_command(command)
|
|
773
773
|
|
|
774
774
|
|
|
775
|
+
@register_command(
|
|
776
|
+
name="api",
|
|
777
|
+
description="Manage the Code Puppy API server",
|
|
778
|
+
usage="/api [start|stop|status]",
|
|
779
|
+
category="core",
|
|
780
|
+
detailed_help="Start, stop, or check status of the local FastAPI server for GUI integration.",
|
|
781
|
+
)
|
|
782
|
+
def handle_api_command(command: str) -> bool:
|
|
783
|
+
"""Handle the /api command."""
|
|
784
|
+
import os
|
|
785
|
+
import signal
|
|
786
|
+
import subprocess
|
|
787
|
+
import sys
|
|
788
|
+
from pathlib import Path
|
|
789
|
+
|
|
790
|
+
from code_puppy.config import STATE_DIR
|
|
791
|
+
from code_puppy.messaging import emit_error, emit_info, emit_success
|
|
792
|
+
|
|
793
|
+
parts = command.split()
|
|
794
|
+
subcommand = parts[1] if len(parts) > 1 else "status"
|
|
795
|
+
|
|
796
|
+
pid_file = Path(STATE_DIR) / "api_server.pid"
|
|
797
|
+
|
|
798
|
+
if subcommand == "start":
|
|
799
|
+
# Check if already running
|
|
800
|
+
if pid_file.exists():
|
|
801
|
+
try:
|
|
802
|
+
pid = int(pid_file.read_text().strip())
|
|
803
|
+
os.kill(pid, 0) # Check if process exists
|
|
804
|
+
emit_info(f"API server already running (PID {pid})")
|
|
805
|
+
return True
|
|
806
|
+
except (OSError, ValueError):
|
|
807
|
+
pid_file.unlink(missing_ok=True) # Stale PID file
|
|
808
|
+
|
|
809
|
+
# Start the server in background
|
|
810
|
+
emit_info("Starting API server on http://127.0.0.1:8765 ...")
|
|
811
|
+
proc = subprocess.Popen(
|
|
812
|
+
[sys.executable, "-m", "code_puppy.api.main"],
|
|
813
|
+
stdout=subprocess.DEVNULL,
|
|
814
|
+
stderr=subprocess.DEVNULL,
|
|
815
|
+
start_new_session=True,
|
|
816
|
+
)
|
|
817
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
818
|
+
pid_file.write_text(str(proc.pid))
|
|
819
|
+
emit_success(f"API server started (PID {proc.pid})")
|
|
820
|
+
emit_info("Docs available at http://127.0.0.1:8765/docs")
|
|
821
|
+
return True
|
|
822
|
+
|
|
823
|
+
elif subcommand == "stop":
|
|
824
|
+
if not pid_file.exists():
|
|
825
|
+
emit_info("API server is not running")
|
|
826
|
+
return True
|
|
827
|
+
|
|
828
|
+
try:
|
|
829
|
+
pid = int(pid_file.read_text().strip())
|
|
830
|
+
os.kill(pid, signal.SIGTERM)
|
|
831
|
+
pid_file.unlink()
|
|
832
|
+
emit_success(f"API server stopped (PID {pid})")
|
|
833
|
+
except (OSError, ValueError) as e:
|
|
834
|
+
pid_file.unlink(missing_ok=True)
|
|
835
|
+
emit_error(f"Error stopping server: {e}")
|
|
836
|
+
return True
|
|
837
|
+
|
|
838
|
+
elif subcommand == "status":
|
|
839
|
+
if not pid_file.exists():
|
|
840
|
+
emit_info("API server is not running")
|
|
841
|
+
return True
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
pid = int(pid_file.read_text().strip())
|
|
845
|
+
os.kill(pid, 0) # Check if process exists
|
|
846
|
+
emit_success(f"API server is running (PID {pid})")
|
|
847
|
+
emit_info("URL: http://127.0.0.1:8765")
|
|
848
|
+
emit_info("Docs: http://127.0.0.1:8765/docs")
|
|
849
|
+
except (OSError, ValueError):
|
|
850
|
+
pid_file.unlink(missing_ok=True)
|
|
851
|
+
emit_info("API server is not running (stale PID file removed)")
|
|
852
|
+
return True
|
|
853
|
+
|
|
854
|
+
else:
|
|
855
|
+
emit_error(f"Unknown subcommand: {subcommand}")
|
|
856
|
+
emit_info("Usage: /api [start|stop|status]")
|
|
857
|
+
return True
|
|
858
|
+
|
|
859
|
+
|
|
775
860
|
@register_command(
|
|
776
861
|
name="generate-pr-description",
|
|
777
862
|
description="Generate comprehensive PR description",
|
code_puppy/config.py
CHANGED
|
@@ -75,6 +75,19 @@ def get_use_dbos() -> bool:
|
|
|
75
75
|
return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def get_subagent_verbose() -> bool:
|
|
79
|
+
"""Return True if sub-agent verbose output is enabled (default False).
|
|
80
|
+
|
|
81
|
+
When False (default), sub-agents produce quiet, sparse output suitable
|
|
82
|
+
for parallel execution. When True, sub-agents produce full verbose output
|
|
83
|
+
like the main agent (useful for debugging).
|
|
84
|
+
"""
|
|
85
|
+
cfg_val = get_value("subagent_verbose")
|
|
86
|
+
if cfg_val is None:
|
|
87
|
+
return False
|
|
88
|
+
return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
|
|
89
|
+
|
|
90
|
+
|
|
78
91
|
DEFAULT_SECTION = "puppy"
|
|
79
92
|
REQUIRED_KEYS = ["puppy_name", "owner_name"]
|
|
80
93
|
|
|
@@ -85,7 +98,6 @@ _CURRENT_AUTOSAVE_ID: Optional[str] = None
|
|
|
85
98
|
_model_validation_cache = {}
|
|
86
99
|
_default_model_cache = None
|
|
87
100
|
_default_vision_model_cache = None
|
|
88
|
-
_default_vqa_model_cache = None
|
|
89
101
|
|
|
90
102
|
|
|
91
103
|
def ensure_config_exists():
|
|
@@ -208,6 +220,9 @@ def get_config_keys():
|
|
|
208
220
|
"diff_context_lines",
|
|
209
221
|
"default_agent",
|
|
210
222
|
"temperature",
|
|
223
|
+
"frontend_emitter_enabled",
|
|
224
|
+
"frontend_emitter_max_recent_events",
|
|
225
|
+
"frontend_emitter_queue_size",
|
|
211
226
|
]
|
|
212
227
|
# Add DBOS control key
|
|
213
228
|
default_keys.append("enable_dbos")
|
|
@@ -237,6 +252,22 @@ def set_config_value(key: str, value: str):
|
|
|
237
252
|
config.write(f)
|
|
238
253
|
|
|
239
254
|
|
|
255
|
+
# Alias for API compatibility
|
|
256
|
+
def set_value(key: str, value: str) -> None:
|
|
257
|
+
"""Set a config value. Alias for set_config_value."""
|
|
258
|
+
set_config_value(key, value)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def reset_value(key: str) -> None:
|
|
262
|
+
"""Remove a key from the config file, resetting it to default."""
|
|
263
|
+
config = configparser.ConfigParser()
|
|
264
|
+
config.read(CONFIG_FILE)
|
|
265
|
+
if DEFAULT_SECTION in config and key in config[DEFAULT_SECTION]:
|
|
266
|
+
del config[DEFAULT_SECTION][key]
|
|
267
|
+
with open(CONFIG_FILE, "w") as f:
|
|
268
|
+
config.write(f)
|
|
269
|
+
|
|
270
|
+
|
|
240
271
|
# --- MODEL STICKY EXTENSION STARTS HERE ---
|
|
241
272
|
def load_mcp_server_configs():
|
|
242
273
|
"""
|
|
@@ -326,47 +357,6 @@ def _default_vision_model_from_models_json() -> str:
|
|
|
326
357
|
return "gpt-4.1"
|
|
327
358
|
|
|
328
359
|
|
|
329
|
-
def _default_vqa_model_from_models_json() -> str:
|
|
330
|
-
"""Select a default VQA-capable model, preferring vision-ready options."""
|
|
331
|
-
global _default_vqa_model_cache
|
|
332
|
-
|
|
333
|
-
if _default_vqa_model_cache is not None:
|
|
334
|
-
return _default_vqa_model_cache
|
|
335
|
-
|
|
336
|
-
try:
|
|
337
|
-
from code_puppy.model_factory import ModelFactory
|
|
338
|
-
|
|
339
|
-
models_config = ModelFactory.load_config()
|
|
340
|
-
if models_config:
|
|
341
|
-
# Allow explicit VQA hints if present
|
|
342
|
-
for name, config in models_config.items():
|
|
343
|
-
if config.get("supports_vqa"):
|
|
344
|
-
_default_vqa_model_cache = name
|
|
345
|
-
return name
|
|
346
|
-
|
|
347
|
-
# Reuse multimodal heuristics before falling back to generic default
|
|
348
|
-
preferred_candidates = (
|
|
349
|
-
"gpt-4.1",
|
|
350
|
-
"gpt-4.1-mini",
|
|
351
|
-
"claude-4-0-sonnet",
|
|
352
|
-
"gemini-2.5-flash-preview-05-20",
|
|
353
|
-
"gpt-4.1-nano",
|
|
354
|
-
)
|
|
355
|
-
for candidate in preferred_candidates:
|
|
356
|
-
if candidate in models_config:
|
|
357
|
-
_default_vqa_model_cache = candidate
|
|
358
|
-
return candidate
|
|
359
|
-
|
|
360
|
-
_default_vqa_model_cache = _default_model_from_models_json()
|
|
361
|
-
return _default_vqa_model_cache
|
|
362
|
-
|
|
363
|
-
_default_vqa_model_cache = "gpt-4.1"
|
|
364
|
-
return "gpt-4.1"
|
|
365
|
-
except Exception:
|
|
366
|
-
_default_vqa_model_cache = "gpt-4.1"
|
|
367
|
-
return "gpt-4.1"
|
|
368
|
-
|
|
369
|
-
|
|
370
360
|
def _validate_model_exists(model_name: str) -> bool:
|
|
371
361
|
"""Check if a model exists in models.json with caching to avoid redundant calls."""
|
|
372
362
|
global _model_validation_cache
|
|
@@ -392,15 +382,10 @@ def _validate_model_exists(model_name: str) -> bool:
|
|
|
392
382
|
|
|
393
383
|
def clear_model_cache():
|
|
394
384
|
"""Clear the model validation cache. Call this when models.json changes."""
|
|
395
|
-
global
|
|
396
|
-
_model_validation_cache, \
|
|
397
|
-
_default_model_cache, \
|
|
398
|
-
_default_vision_model_cache, \
|
|
399
|
-
_default_vqa_model_cache
|
|
385
|
+
global _model_validation_cache, _default_model_cache, _default_vision_model_cache
|
|
400
386
|
_model_validation_cache.clear()
|
|
401
387
|
_default_model_cache = None
|
|
402
388
|
_default_vision_model_cache = None
|
|
403
|
-
_default_vqa_model_cache = None
|
|
404
389
|
|
|
405
390
|
|
|
406
391
|
def model_supports_setting(model_name: str, setting: str) -> bool:
|
|
@@ -471,20 +456,6 @@ def set_model_name(model: str):
|
|
|
471
456
|
clear_model_cache()
|
|
472
457
|
|
|
473
458
|
|
|
474
|
-
def get_vqa_model_name() -> str:
|
|
475
|
-
"""Return the configured VQA model, falling back to an inferred default."""
|
|
476
|
-
stored_model = get_value("vqa_model_name")
|
|
477
|
-
if stored_model and _validate_model_exists(stored_model):
|
|
478
|
-
return stored_model
|
|
479
|
-
return _default_vqa_model_from_models_json()
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
def set_vqa_model_name(model: str):
|
|
483
|
-
"""Persist the configured VQA model name and refresh caches."""
|
|
484
|
-
set_config_value("vqa_model_name", model or "")
|
|
485
|
-
clear_model_cache()
|
|
486
|
-
|
|
487
|
-
|
|
488
459
|
def get_puppy_token():
|
|
489
460
|
"""Returns the puppy_token from config, or None if not set."""
|
|
490
461
|
return get_value("puppy_token")
|
|
@@ -1291,6 +1262,8 @@ DEFAULT_BANNER_COLORS = {
|
|
|
1291
1262
|
"invoke_agent": "deep_pink4", # Ruby - agent invocation
|
|
1292
1263
|
"subagent_response": "sea_green3", # Emerald - sub-agent success
|
|
1293
1264
|
"list_agents": "dark_slate_gray3", # Slate - neutral listing
|
|
1265
|
+
# Browser/Terminal tools - same color as edit_file (gold)
|
|
1266
|
+
"terminal_tool": "dark_goldenrod", # Gold - browser terminal operations
|
|
1294
1267
|
}
|
|
1295
1268
|
|
|
1296
1269
|
|
|
@@ -1584,3 +1557,34 @@ def set_default_agent(agent_name: str) -> None:
|
|
|
1584
1557
|
agent_name: The name of the agent to set as default.
|
|
1585
1558
|
"""
|
|
1586
1559
|
set_config_value("default_agent", agent_name)
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
# --- FRONTEND EMITTER CONFIGURATION ---
|
|
1563
|
+
def get_frontend_emitter_enabled() -> bool:
|
|
1564
|
+
"""Check if frontend emitter is enabled."""
|
|
1565
|
+
val = get_value("frontend_emitter_enabled")
|
|
1566
|
+
if val is None:
|
|
1567
|
+
return True # Enabled by default
|
|
1568
|
+
return str(val).lower() in ("1", "true", "yes", "on")
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def get_frontend_emitter_max_recent_events() -> int:
|
|
1572
|
+
"""Get max number of recent events to buffer."""
|
|
1573
|
+
val = get_value("frontend_emitter_max_recent_events")
|
|
1574
|
+
if val is None:
|
|
1575
|
+
return 100
|
|
1576
|
+
try:
|
|
1577
|
+
return int(val)
|
|
1578
|
+
except ValueError:
|
|
1579
|
+
return 100
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def get_frontend_emitter_queue_size() -> int:
|
|
1583
|
+
"""Get max subscriber queue size."""
|
|
1584
|
+
val = get_value("frontend_emitter_queue_size")
|
|
1585
|
+
if val is None:
|
|
1586
|
+
return 100
|
|
1587
|
+
try:
|
|
1588
|
+
return int(val)
|
|
1589
|
+
except ValueError:
|
|
1590
|
+
return 100
|
code_puppy/messaging/__init__.py
CHANGED
|
@@ -113,6 +113,7 @@ from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc.
|
|
|
113
113
|
StatusPanelMessage,
|
|
114
114
|
SubAgentInvocationMessage,
|
|
115
115
|
SubAgentResponseMessage,
|
|
116
|
+
SubAgentStatusMessage,
|
|
116
117
|
TextMessage,
|
|
117
118
|
UserInputRequest,
|
|
118
119
|
VersionCheckMessage,
|
|
@@ -120,6 +121,14 @@ from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc.
|
|
|
120
121
|
from .queue_console import QueueConsole, get_queue_console
|
|
121
122
|
from .renderers import InteractiveRenderer, SynchronousInteractiveRenderer
|
|
122
123
|
|
|
124
|
+
# Sub-agent console manager
|
|
125
|
+
from .subagent_console import (
|
|
126
|
+
AgentState,
|
|
127
|
+
SubAgentConsoleManager,
|
|
128
|
+
get_subagent_console_manager,
|
|
129
|
+
STATUS_STYLES as SUBAGENT_STATUS_STYLES,
|
|
130
|
+
)
|
|
131
|
+
|
|
123
132
|
# Renderer
|
|
124
133
|
from .rich_renderer import (
|
|
125
134
|
DEFAULT_STYLES,
|
|
@@ -193,6 +202,7 @@ __all__ = [
|
|
|
193
202
|
"AgentResponseMessage",
|
|
194
203
|
"SubAgentInvocationMessage",
|
|
195
204
|
"SubAgentResponseMessage",
|
|
205
|
+
"SubAgentStatusMessage",
|
|
196
206
|
"UserInputRequest",
|
|
197
207
|
"ConfirmationRequest",
|
|
198
208
|
"SelectionRequest",
|
|
@@ -229,4 +239,9 @@ __all__ = [
|
|
|
229
239
|
"DIFF_STYLES",
|
|
230
240
|
# Markdown patches
|
|
231
241
|
"patch_markdown_headings",
|
|
242
|
+
# Sub-agent console manager
|
|
243
|
+
"AgentState",
|
|
244
|
+
"SubAgentConsoleManager",
|
|
245
|
+
"get_subagent_console_manager",
|
|
246
|
+
"SUBAGENT_STATUS_STYLES",
|
|
232
247
|
]
|