devops-bot-sdk 1.4.13__tar.gz → 1.4.17__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 (38) hide show
  1. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/ipc/handlers.py +58 -17
  7. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/local_exec.py +114 -7
  8. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/README.md +0 -0
  9. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  10. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/collectors/__init__.py +0 -0
  15. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/collectors/files.py +0 -0
  16. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/collectors/process.py +0 -0
  17. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/collectors/screenshot.py +0 -0
  18. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/config.py +0 -0
  19. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/crucial.py +0 -0
  20. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/exceptions.py +0 -0
  21. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/git_ops.py +0 -0
  22. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/graphify.py +0 -0
  23. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/hooks/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/hooks/crucial_guard.py +0 -0
  25. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/ipc/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/ipc/electron_bridge.py +0 -0
  27. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.13 → devops_bot_sdk-1.4.17}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.13
3
+ Version: 1.4.17
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.13
3
+ Version: 1.4.17
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.13"
7
+ version = "1.4.17"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.13
3
+ Version: 1.4.17
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.13"
33
+ __version__ = "1.4.17"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.13"
39
+ SDK_VERSION = "1.4.17"
40
40
  _POLL_INTERVAL = 3.0
41
41
  _POLL_TIMEOUT = 600.0
42
42
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -149,6 +149,7 @@ def _build_local_prompt(
149
149
  structured_plan: dict | None,
150
150
  human_instructions: str | None = None,
151
151
  agent_mode: bool = True,
152
+ git_managed: bool = True,
152
153
  ) -> str:
153
154
  """Compose the coding prompt for the local claude CLI from the server plan.
154
155
 
@@ -204,14 +205,29 @@ def _build_local_prompt(
204
205
  "above are authoritative if anything conflicts) ===\n"
205
206
  + _clip_text(user_input.strip())
206
207
  )
207
- parts.append(
208
- "Implement all of the above in the current project directory. "
209
- "Create and edit every file needed for a complete, working result.\n"
210
- "Git for THIS repo is handled for you: a feature branch is already checked "
211
- "out, and the system will commit, push, and open the PR after you finish — "
212
- "so do NOT create branches, commit, push, or open a PR for the current repo. "
213
- "(You may still clone and open PRs for OTHER repositories the instructions name.)"
214
- )
208
+ if git_managed:
209
+ # The target is a git repo the SDK manages it branches before and
210
+ # commits/pushes/opens the PR after, so the agent must not touch git here.
211
+ parts.append(
212
+ "Complete all of the above in the current project directory. "
213
+ "Create and edit every file needed for a complete, working result.\n"
214
+ "Git for THIS repo is handled for you: a feature branch is already checked "
215
+ "out, and the system will commit, push, and open the PR after you finish — "
216
+ "so do NOT create branches, commit, push, or open a PR for the current repo. "
217
+ "(You may still clone and open PRs for OTHER repositories the instructions name.)"
218
+ )
219
+ else:
220
+ # Plain local working folder (no GitHub repo / not under our git control) —
221
+ # e.g. a non-developer (BA/analyst) task that just produces files/docs.
222
+ parts.append(
223
+ "Complete all of the above in the current working directory by creating and "
224
+ "editing the necessary files (documents, .md files, spreadsheets, code — "
225
+ "whatever the task asks for). This is a plain local folder, NOT a git "
226
+ "repository under our control: do NOT initialise git, create branches, "
227
+ "commit, push, or open a PR for it unless the instructions explicitly say so. "
228
+ "Just produce the requested deliverables here. (You may still clone and open "
229
+ "PRs for OTHER repositories the instructions explicitly name.)"
230
+ )
215
231
  # agentMode=true → require approval before crucial/irreversible actions.
216
232
  if agent_mode:
217
233
  from sdk import crucial
@@ -399,7 +415,6 @@ async def _code_one_repo(
399
415
  repo_path: str,
400
416
  repo_url: str | None,
401
417
  pending: dict,
402
- prompt: str,
403
418
  gh_token: str | None,
404
419
  agent_mode: bool,
405
420
  approval_dir: str | None,
@@ -474,6 +489,14 @@ async def _code_one_repo(
474
489
  data={"status": "running", "current_step": f"Executing locally in {name} (claude_code)"},
475
490
  ).model_dump())
476
491
 
492
+ # Build the prompt with framing that matches the target: a git repo we manage
493
+ # (branch + PR) vs a plain local folder (BA/analyst deliverables, no git/PR).
494
+ prompt = _build_local_prompt(
495
+ pending["user_input"], pending.get("structured_plan"),
496
+ pending.get("human_instructions"),
497
+ agent_mode=agent_mode, git_managed=bool(branch_info),
498
+ )
499
+
477
500
  # 3. initial claude run
478
501
  result = await local_exec.run_claude_local(
479
502
  prompt, repo_path, on_event=_on_event,
@@ -636,11 +659,8 @@ async def handle_pipeline_approve(
636
659
  return
637
660
 
638
661
  # ── Approved: run the local claude CLI on EACH repo the ticket names ──
639
- prompt = _build_local_prompt(
640
- pending["user_input"], pending.get("structured_plan"),
641
- pending.get("human_instructions"),
642
- agent_mode=bool(pending.get("agent_mode", True)),
643
- )
662
+ # The prompt is built per-repo inside _code_one_repo so its framing matches
663
+ # whether that target is a git repo (branch + PR) or a plain local folder.
644
664
  agent_mode = bool(pending.get("agent_mode", True))
645
665
 
646
666
  # Fetch the user's GitHub token from the backend so the local agent can
@@ -675,7 +695,7 @@ async def handle_pipeline_approve(
675
695
  runs.append(await _code_one_repo(
676
696
  client, repo_path=tgt["path"], repo_url=tgt.get("url"),
677
697
  base=tgt.get("base"),
678
- pending=pending, prompt=prompt, gh_token=gh_token,
698
+ pending=pending, gh_token=gh_token,
679
699
  agent_mode=agent_mode, approval_dir=approval_dir,
680
700
  send=send, thread_id=thread_id, task_id=task_id,
681
701
  ))
@@ -1202,8 +1222,13 @@ _PATH_ROOTS = (
1202
1222
  "home|root|var|opt|srv|usr|mnt|media|data|app|apps|"
1203
1223
  "project|projects|workspace|workspaces|repos|code|tmp|Users"
1204
1224
  )
1205
- _ABS_PATH_RE = re.compile(rf"(?<![\w.])/(?:{_PATH_ROOTS})(?:/[A-Za-z0-9._\-]+)+")
1206
- _HOME_PATH_RE = re.compile(r"(?<![\w.])~(?:/[A-Za-z0-9._\-]+)+")
1225
+ # Unix / ~-home paths. Folder names may contain SPACES too (e.g.
1226
+ # "/home/me/BA activities/repo"), so segments allow spaces and the path runs to
1227
+ # end-of-line. The root is anchored to a known top-level dir to limit false
1228
+ # positives in prose; for an inline path followed by other words on the SAME
1229
+ # line, quote it (see _QUOTED_PATH_RE) to avoid grabbing the trailing text.
1230
+ _ABS_PATH_RE = re.compile(rf"(?<![\w.])/(?:{_PATH_ROOTS})(?:/[A-Za-z0-9._\- ]+)+")
1231
+ _HOME_PATH_RE = re.compile(r"(?<![\w.])~(?:/[A-Za-z0-9._\- ]+)+")
1207
1232
  # Windows path. Folder names may contain SPACES (e.g. "BA activities clone"), so a
1208
1233
  # segment is any run of valid path chars (everything except \ / : * ? " < > | and
1209
1234
  # newline) up to the next separator — it must NOT stop at the first space.
@@ -1429,6 +1454,19 @@ def _extract_repo_targets(text: str | None) -> list[dict]:
1429
1454
  return targets
1430
1455
 
1431
1456
 
1457
+ def _task_created_key(task: dict):
1458
+ """Sortable Jira creation time for a raw task (e.g. ``createdAt`` =
1459
+ "2026-06-22T15:07:52.452Z"). Used to process tickets oldest-first. A
1460
+ missing/invalid timestamp sorts LAST so well-formed, earlier tickets win."""
1461
+ from datetime import datetime, timezone
1462
+
1463
+ raw = str(task.get("createdAt") or task.get("created_at") or "").strip()
1464
+ try:
1465
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
1466
+ except (ValueError, TypeError):
1467
+ return datetime.max.replace(tzinfo=timezone.utc)
1468
+
1469
+
1432
1470
  def _resolve_local_project_path(task: dict, payload: dict) -> str:
1433
1471
  """Pick the LOCAL directory to write a task's code into (on the user's box).
1434
1472
 
@@ -1579,6 +1617,9 @@ async def handle_orchestrate_auto_local_run(
1579
1617
 
1580
1618
  # RAW /tasks-ml records carry agentMode / jiraIssueKey / humanInstructions.
1581
1619
  tasks = await client.list_todo_tasks_raw(status="To Do")
1620
+ # Process oldest-created first (FIFO by Jira `createdAt`) so the ticket
1621
+ # raised earliest is started first. Unknown/invalid timestamps sort last.
1622
+ tasks = sorted(tasks, key=_task_created_key)
1582
1623
 
1583
1624
  await send(Envelope(
1584
1625
  type="pipeline_started", thread_id=thread_id,
@@ -22,6 +22,7 @@ import json
22
22
  import os
23
23
  import re
24
24
  import shutil
25
+ from datetime import datetime
25
26
  from pathlib import Path
26
27
  from typing import Any, Awaitable, Callable
27
28
 
@@ -92,6 +93,70 @@ def _parse_reset_epoch(text: str | None) -> int | None:
92
93
  return None
93
94
 
94
95
 
96
+ # ── Per-run claude log file (on the user's machine, local-timezone stamps) ──
97
+ # Every local claude run writes a readable, timestamped transcript so a box can
98
+ # be debugged after the fact. Dir is AGENTOS_LOG_DIR or ~/.agentos/logs.
99
+
100
+ def _open_run_log(project_path: str, resume_session_id: str | None):
101
+ """Open a per-run log file under the local logs dir. Returns (file, path) or
102
+ (None, None). Best-effort — never breaks the run."""
103
+ try:
104
+ base = os.getenv("AGENTOS_LOG_DIR") or str(Path.home() / ".agentos" / "logs")
105
+ d = Path(base).expanduser()
106
+ d.mkdir(parents=True, exist_ok=True)
107
+ now = datetime.now().astimezone() # local system tz
108
+ proj = Path(project_path).name or "run"
109
+ # Filenames are filesystem-safe (no ':'); stamp encodes the local tz offset.
110
+ fname = f"claude_{proj}_{now.strftime('%Y-%m-%d_%H-%M-%S%z')}.log"
111
+ path = d / fname
112
+ f = open(path, "a", encoding="utf-8")
113
+ return f, str(path)
114
+ except Exception: # noqa: BLE001
115
+ return None, None
116
+
117
+
118
+ def _logln(f, msg: str) -> None:
119
+ """Append a local-timezone timestamped line. Best-effort."""
120
+ if not f:
121
+ return
122
+ try:
123
+ ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
124
+ f.write(f"[{ts}] {msg}\n")
125
+ f.flush()
126
+ except Exception: # noqa: BLE001
127
+ pass
128
+
129
+
130
+ def _event_logline(event: dict) -> str | None:
131
+ """A compact, human-readable line for one stream-json event (or None to skip)."""
132
+ def _short(s, n=200):
133
+ return " ".join(str(s or "").split())[:n]
134
+
135
+ t = event.get("type")
136
+ if t == "system":
137
+ return f"SYSTEM {event.get('subtype', '')}".rstrip()
138
+ if t == "assistant":
139
+ parts: list[str] = []
140
+ for c in event.get("message", {}).get("content", []):
141
+ if c.get("type") == "text" and c.get("text", "").strip():
142
+ parts.append("TEXT: " + _short(c["text"], 300))
143
+ elif c.get("type") == "tool_use":
144
+ inp = c.get("input") or {}
145
+ detail = (inp.get("command") or inp.get("file_path") or inp.get("pattern")
146
+ or inp.get("url") or inp.get("description") or "")
147
+ parts.append(f"TOOL {c.get('name', 'tool')}" + (f" → {_short(detail)}" if detail else ""))
148
+ return " | ".join(parts) if parts else None
149
+ if t == "user":
150
+ tr = event.get("tool_use_result") or event.get("message", {})
151
+ if isinstance(tr, dict) and tr.get("is_error"):
152
+ return "TOOL_ERROR: " + _short(tr.get("content") or tr.get("stderr") or "", 300)
153
+ return None
154
+ if t == "result":
155
+ return (f"RESULT subtype={event.get('subtype')} is_error={event.get('is_error')} "
156
+ f"cost=${event.get('total_cost_usd', 0)}")
157
+ return None
158
+
159
+
95
160
  def _ctx_exceeded(text: str | None) -> bool:
96
161
  low = (text or "").lower()
97
162
  return any(m in low for m in _CONTEXT_MARKERS)
@@ -157,8 +222,12 @@ async def run_claude_local(
157
222
  path.mkdir(parents=True, exist_ok=True)
158
223
 
159
224
  tools = allowed_tools or DEFAULT_ALLOWED_TOOLS
225
+ # The prompt is fed via STDIN (not as an argv), so a long plan/instructions
226
+ # can't blow past the OS command-line limit — on Windows that's ~32 KB and
227
+ # raised "The command line is too long." `claude -p` reads the prompt from a
228
+ # piped stdin.
160
229
  cmd = [
161
- cli, "-p", prompt,
230
+ cli, "-p",
162
231
  "--output-format", "stream-json",
163
232
  "--verbose", # required for stream-json
164
233
  "--permission-mode", "acceptEdits", # apply file edits without prompting
@@ -205,12 +274,24 @@ async def run_claude_local(
205
274
  proc = await asyncio.create_subprocess_exec(
206
275
  *cmd,
207
276
  cwd=str(path),
277
+ stdin=asyncio.subprocess.PIPE,
208
278
  stdout=asyncio.subprocess.PIPE,
209
279
  stderr=asyncio.subprocess.PIPE,
210
280
  env=env,
211
281
  limit=_STREAM_LIMIT_BYTES,
212
282
  )
213
283
 
284
+ # Feed the prompt via stdin (claude -p reads it), then close stdin so claude
285
+ # starts. Bounded write before draining stdout — no deadlock (claude buffers
286
+ # the prompt before emitting any events).
287
+ if proc.stdin is not None:
288
+ try:
289
+ proc.stdin.write((prompt or "").encode("utf-8"))
290
+ await proc.stdin.drain()
291
+ proc.stdin.close()
292
+ except Exception: # noqa: BLE001 — broken pipe if claude already exited
293
+ pass
294
+
214
295
  result_event: dict | None = None
215
296
  files_changed: list[str] = []
216
297
  session_id: str | None = resume_session_id
@@ -218,6 +299,26 @@ async def run_claude_local(
218
299
  started = _time.monotonic()
219
300
  stall = {"timed_out": False, "reason": ""}
220
301
 
302
+ # Per-run local log (timezone-stamped) for after-the-fact debugging.
303
+ logf, logpath = _open_run_log(str(path), resume_session_id)
304
+ _logln(logf, f"START claude run | project={path} | resume={resume_session_id or '-'} "
305
+ f"| idle_timeout={idle_timeout:.0f}s | cap={timeout:.0f}s")
306
+ _logln(logf, "PROMPT: " + " ".join((prompt or "").split())[:600])
307
+
308
+ def _finish(result: dict) -> dict:
309
+ """Write the run footer, close the log, and return the result."""
310
+ _logln(logf, f"END ok={result.get('ok')} error={result.get('error')} "
311
+ f"tokens={result.get('total_tokens')} cost=${result.get('cost_usd')} "
312
+ f"files_changed={len(result.get('files_changed') or [])} "
313
+ f"limit_exceeded={result.get('limit_exceeded')} "
314
+ f"session={result.get('session_id')}")
315
+ if logf:
316
+ try:
317
+ logf.close()
318
+ except Exception: # noqa: BLE001
319
+ pass
320
+ return result
321
+
221
322
  async def _drain_stdout() -> None:
222
323
  nonlocal result_event, session_id
223
324
  assert proc.stdout is not None
@@ -250,6 +351,10 @@ async def run_claude_local(
250
351
  except json.JSONDecodeError:
251
352
  continue
252
353
 
354
+ _ll = _event_logline(event)
355
+ if _ll:
356
+ _logln(logf, _ll)
357
+
253
358
  # Capture the claude session id (emitted on init + result events) so
254
359
  # the caller can --resume this exact session later.
255
360
  if event.get("session_id"):
@@ -284,23 +389,25 @@ async def run_claude_local(
284
389
  proc.kill()
285
390
  except ProcessLookupError:
286
391
  pass
287
- return {
392
+ return _finish({
288
393
  "ok": False, "result": "", "error": f"claude CLI timed out — {stall['reason']}",
289
394
  "input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
290
395
  "cost_usd": 0.0, "files_changed": files_changed,
291
396
  "session_id": session_id, "context_exceeded": False,
292
- }
397
+ })
293
398
 
294
399
  if result_event is None:
295
400
  stderr = (await proc.stderr.read()).decode("utf-8", errors="replace") if proc.stderr else ""
296
401
  err = stderr.strip()[:500] or "no result event from claude CLI"
297
- return {
402
+ if stderr.strip():
403
+ _logln(logf, "STDERR: " + " ".join(stderr.split())[:600])
404
+ return _finish({
298
405
  "ok": False, "result": "", "error": err,
299
406
  "input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
300
407
  "cost_usd": 0.0, "files_changed": files_changed,
301
408
  "session_id": session_id, "context_exceeded": _ctx_exceeded(err),
302
409
  "limit_exceeded": _is_limit(err), "limit_reset_epoch": _parse_reset_epoch(err),
303
- }
410
+ })
304
411
 
305
412
  usage = result_event.get("usage") or {}
306
413
  in_tok = int(usage.get("input_tokens", 0) or 0)
@@ -321,7 +428,7 @@ async def run_claude_local(
321
428
  elif limit_hit:
322
429
  err_text = result_text or "claude usage limit reached"
323
430
 
324
- return {
431
+ return _finish({
325
432
  "ok": (not is_error) and (not limit_hit),
326
433
  "result": result_text,
327
434
  "input_tokens": in_tok,
@@ -334,4 +441,4 @@ async def run_claude_local(
334
441
  "context_exceeded": is_error and _ctx_exceeded(err_text),
335
442
  "limit_exceeded": limit_hit,
336
443
  "limit_reset_epoch": reset_epoch,
337
- }
444
+ })