dispatchbox 0.1.2__tar.gz → 0.1.3__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 (35) hide show
  1. {dispatchbox-0.1.2/dispatchbox.egg-info → dispatchbox-0.1.3}/PKG-INFO +1 -1
  2. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/__init__.py +1 -1
  3. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/hub.py +34 -3
  4. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/worker.py +3 -0
  5. {dispatchbox-0.1.2 → dispatchbox-0.1.3/dispatchbox.egg-info}/PKG-INFO +1 -1
  6. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/pyproject.toml +1 -1
  7. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/test_e2e.py +4 -1
  8. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/test_step0.py +57 -0
  9. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/LICENSE +0 -0
  10. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/MANIFEST.in +0 -0
  11. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/README.md +0 -0
  12. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/__main__.py +0 -0
  13. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/chat.py +0 -0
  14. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/cron.py +0 -0
  15. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/feishu.py +0 -0
  16. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/net.py +0 -0
  17. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/rules.py +0 -0
  18. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/server.py +0 -0
  19. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/slack.py +0 -0
  20. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox/store.py +0 -0
  21. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox.egg-info/SOURCES.txt +0 -0
  22. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox.egg-info/dependency_links.txt +0 -0
  23. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox.egg-info/entry_points.txt +0 -0
  24. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox.egg-info/requires.txt +0 -0
  25. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/dispatchbox.egg-info/top_level.txt +0 -0
  26. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/examples/hello-codex.json +0 -0
  27. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/examples/hello.json +0 -0
  28. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/rules.example.json +0 -0
  29. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/setup.cfg +0 -0
  30. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/__init__.py +0 -0
  31. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/helpers.py +0 -0
  32. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/test_chat.py +0 -0
  33. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/test_cron_rules.py +0 -0
  34. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/test_hub.py +0 -0
  35. {dispatchbox-0.1.2 → dispatchbox-0.1.3}/tests/test_net.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dispatchbox
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Route events to Claude Code and Codex running on your own machines, and ask you only when it matters.
5
5
  Author: Xixian Yang
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """dispatchbox: a thin event router that hands work to coding agents on your own machines."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
@@ -231,7 +231,7 @@ class Hub:
231
231
 
232
232
  def finish(self, run_id: int, worker: str, result: dict[str, Any]) -> dict[str, Any]:
233
233
  with self.lock:
234
- self._running(run_id, worker)
234
+ row = self._running(run_id, worker)
235
235
  exit_code = int(result.get("exit_code", 1))
236
236
  output = str(result.get("output", ""))
237
237
  summary = result.get("summary") or extract_summary(output)
@@ -246,19 +246,50 @@ class Hub:
246
246
  status = "failed"
247
247
  else:
248
248
  status = "succeeded"
249
+ usage = (_num(result.get("cost_usd"), float), _num(result.get("input_tokens"), int),
250
+ _num(result.get("output_tokens"), int))
251
+ if row["parent_run"] and result.get("session_id"):
252
+ usage = self._own_share(row, str(result["session_id"]), *usage)
249
253
  self.db.execute(
250
254
  "UPDATE runs SET status=?, finished_at=?, exit_code=?, summary=?, session_id=?, output_tail=?, "
251
255
  "decision_request=?, cost_usd=?, input_tokens=?, output_tokens=? WHERE id=?",
252
256
  (status, self.clock(), exit_code, (summary or "")[:2000], result.get("session_id"),
253
257
  output[-8000:], json.dumps(decision_req, ensure_ascii=False) if decision_req else None,
254
- _num(result.get("cost_usd"), float), _num(result.get("input_tokens"), int),
255
- _num(result.get("output_tokens"), int), run_id),
258
+ *usage, run_id),
256
259
  )
257
260
  self.cv.notify_all()
258
261
  run = self.get_run(run_id)
259
262
  self._maybe_notify(run)
260
263
  return {"run_id": run_id, "status": status}
261
264
 
265
+ def _own_share(self, row: Any, session_id: str, cost: float | None, tokens_in: int | None,
266
+ tokens_out: int | None) -> tuple[float | None, int | None, int | None]:
267
+ """A continued session reports running totals: claude's total_cost_usd covers every call of the session so
268
+ far (its token counts cover the call alone), and codex's token counts do the same. Verified with claude 2.1.310
269
+ and codex 0.166. Take off what earlier runs of the same session recorded, so each run holds only its own share.
270
+ A figure below that total is a per-call one and is kept as reported."""
271
+ prev_cost, prev_in, prev_out = 0.0, 0, 0
272
+ rid = row["parent_run"]
273
+ while rid:
274
+ earlier = self.db.execute(
275
+ "SELECT parent_run, session_id, cost_usd, input_tokens, output_tokens FROM runs WHERE id=?", (rid,)
276
+ ).fetchone()
277
+ if earlier is None or earlier["session_id"] != session_id:
278
+ break
279
+ prev_cost += earlier["cost_usd"] or 0
280
+ prev_in += earlier["input_tokens"] or 0
281
+ prev_out += earlier["output_tokens"] or 0
282
+ rid = earlier["parent_run"]
283
+ runner = json.loads(row["spec"]).get("runner")
284
+ if runner == "claude" and cost is not None and cost >= prev_cost:
285
+ cost -= prev_cost
286
+ if runner == "codex":
287
+ if tokens_in is not None and tokens_in >= prev_in:
288
+ tokens_in -= prev_in
289
+ if tokens_out is not None and tokens_out >= prev_out:
290
+ tokens_out -= prev_out
291
+ return cost, tokens_in, tokens_out
292
+
262
293
  def decide(self, run_id: int, choice: str, note: str = "", by: str = "human") -> dict[str, Any]:
263
294
  with self.lock:
264
295
  run = self.get_run(run_id)
@@ -167,6 +167,9 @@ def parse_output(runner: str, stdout: str, stderr: str, tmpdir: str) -> dict[str
167
167
  data = json.loads(stdout.strip().splitlines()[-1]) if stdout.strip() else {}
168
168
  except (json.JSONDecodeError, IndexError):
169
169
  data = {}
170
+ if isinstance(data, list):
171
+ # With verbose on, by flag or in its settings, Claude prints every message as one JSON array on one line.
172
+ data = next((m for m in reversed(data) if isinstance(m, dict) and m.get("type") == "result"), {})
170
173
  if isinstance(data, dict) and "result" in data:
171
174
  out = {"output": str(data.get("result") or ""), "session_id": data.get("session_id")}
172
175
  if data.get("is_error"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dispatchbox
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Route events to Claude Code and Codex running on your own machines, and ask you only when it matters.
5
5
  Author: Xixian Yang
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dispatchbox"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Route events to Claude Code and Codex running on your own machines, and ask you only when it matters."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -34,8 +34,9 @@ elif "DELETE" in prompt:
34
34
  else:
35
35
  text = "did the work\nDISPATCHBOX_SUMMARY: reviewed " + os.environ["DISPATCHBOX_RULE"]
36
36
  assert "DISPATCHBOX_TOKEN" not in os.environ, "token leaked to agent"
37
+ cost = 0.03 if "--resume" in args else 0.01 # like the real CLI, a resumed session reports its running total
37
38
  print(json.dumps({"type": "result", "result": text, "session_id": "sess-42", "is_error": False,
38
- "total_cost_usd": 0.01, "usage": {"input_tokens": 10, "cache_read_input_tokens": 90,
39
+ "total_cost_usd": cost, "usage": {"input_tokens": 10, "cache_read_input_tokens": 90,
39
40
  "output_tokens": 7}}))
40
41
  """
41
42
 
@@ -171,6 +172,8 @@ class E2ETest(unittest.TestCase):
171
172
  follow = [r for r in self.hub.list_runs() if r.get("parent_run") == rid][0]
172
173
  run = self.wait_status(follow["id"], {"succeeded", "failed"})
173
174
  self.assertEqual(run["summary"], "resumed sess-42")
175
+ self.assertAlmostEqual(run["cost_usd"], 0.02, msg="only this run's share of the session total")
176
+ self.assertEqual((run["input_tokens"], run["output_tokens"]), (100, 7))
174
177
 
175
178
  def test_agent_reported_failure(self):
176
179
  self.start_worker()
@@ -16,8 +16,16 @@ RULES = {
16
16
  "rules": [
17
17
  {"name": "job", "when": {"source": "s"}, "prompt": "do {subject}", "group": "{subject}"},
18
18
  {"name": "retry-job", "when": {"source": "r"}, "prompt": "do {subject}", "retries": 1, "notify": "on_failure"},
19
+ {"name": "after", "when": {"source": "dispatchbox", "type": "decision.made"}, "prompt": "go on"},
19
20
  ]
20
21
  }
22
+ CODEX_RULES = {
23
+ "rules": [
24
+ {"name": "job", "when": {"source": "s"}, "runner": "codex", "prompt": "do {subject}"},
25
+ {"name": "after", "when": {"source": "dispatchbox", "type": "decision.made"}, "runner": "codex", "prompt": "go on"},
26
+ ]
27
+ }
28
+ ASK = 'DISPATCHBOX_DECISION: {"question": "Go on?", "options": ["yes", "no"]}'
21
29
 
22
30
  # Shapes copied from real `claude -p --output-format json` and `codex exec --json` output.
23
31
  CLAUDE_OUT = json.dumps({"type": "result", "is_error": False, "session_id": "s-1", "total_cost_usd": 0.0024359,
@@ -104,6 +112,44 @@ class Step0Hub(unittest.TestCase):
104
112
  self.hub.finish(task["run_id"], "w", {"exit_code": exit_code, "output": output, **extra})
105
113
  return self.hub.get_run(task["run_id"])
106
114
 
115
+ def continue_session(self, run_id, output="done", **extra):
116
+ """Answer the decision run_id waits for, then finish the follow-up run with the given result."""
117
+ follow = self.hub.decide(run_id, "yes")["follow_up"]["runs"][0]
118
+ task = self.hub.claim("w")
119
+ self.assertEqual((task["run_id"], task["resume_session"]), (follow, self.hub.get_run(run_id)["session_id"]))
120
+ self.hub.finish(follow, "w", {"exit_code": 0, "output": output, **extra})
121
+ return self.hub.get_run(follow)
122
+
123
+ def test_continued_claude_session_records_only_its_own_cost(self):
124
+ # Figures from a real claude 2.1.310 session: the resumed call reports the session's running total cost,
125
+ # while its token counts cover that call alone.
126
+ first = self.run_once(ASK, session_id="s-1", cost_usd=0.72866, input_tokens=36424, output_tokens=4)
127
+ second = self.continue_session(first["id"], ASK, session_id="s-1", cost_usd=0.895048,
128
+ input_tokens=36466, output_tokens=4)
129
+ self.assertAlmostEqual(second["cost_usd"], 0.166388)
130
+ self.assertEqual((second["input_tokens"], second["output_tokens"]), (36466, 4))
131
+ third = self.continue_session(second["id"], session_id="s-1", cost_usd=1.0, input_tokens=100, output_tokens=2)
132
+ self.assertAlmostEqual(third["cost_usd"], 0.104952, msg="the whole chain counts, not just the parent")
133
+ self.assertAlmostEqual(first["cost_usd"], 0.72866, msg="the first run is left alone")
134
+
135
+ def test_continued_codex_session_records_only_its_own_tokens(self):
136
+ # Figures from a real codex 0.166 session: the resumed call reports the session's running token totals.
137
+ self.hub.close()
138
+ self.hub = Hub(write_rules(self.tmp.name, CODEX_RULES), str(Path(self.tmp.name, "c.db")), lost_after=60,
139
+ clock=self.clock)
140
+ first = self.run_once(ASK, session_id="t-1", input_tokens=23372, output_tokens=5)
141
+ second = self.continue_session(first["id"], session_id="t-1", input_tokens=50928, output_tokens=10)
142
+ self.assertEqual((second["input_tokens"], second["output_tokens"]), (27556, 5))
143
+ self.assertIsNone(second["cost_usd"])
144
+
145
+ def test_usage_of_a_new_session_or_below_the_total_is_kept(self):
146
+ first = self.run_once(ASK, session_id="s-1", cost_usd=0.5)
147
+ fresh = self.continue_session(first["id"], session_id="s-2", cost_usd=0.4)
148
+ self.assertAlmostEqual(fresh["cost_usd"], 0.4, msg="a new session owes nothing to the old one")
149
+ first = self.run_once(ASK, session_id="s-3", cost_usd=0.5)
150
+ same = self.continue_session(first["id"], session_id="s-3", cost_usd=0.1)
151
+ self.assertAlmostEqual(same["cost_usd"], 0.1, msg="a figure below the running total is per call")
152
+
107
153
  def test_agent_failed_verdict_beats_clean_exit(self):
108
154
  run = self.run_once("tried\nDISPATCHBOX_STATUS: failed\nDISPATCHBOX_SUMMARY: permission denied, file kept")
109
155
  self.assertEqual(run["status"], "failed")
@@ -178,6 +224,17 @@ class Step0Units(unittest.TestCase):
178
224
  self.assertEqual(out["output_tokens"], 4)
179
225
  self.assertEqual(out["session_id"], "s-1")
180
226
 
227
+ def test_parse_claude_verbose_array(self):
228
+ # With verbose on, claude 2.1.310 prints every message as one JSON array on a single line.
229
+ line = json.dumps([{"type": "system", "subtype": "init"}, {"type": "assistant"}, {"type": "rate_limit_event"},
230
+ json.loads(CLAUDE_OUT)])
231
+ with tempfile.TemporaryDirectory() as d:
232
+ out = parse_output("claude", line + "\n", "", d)
233
+ self.assertEqual((out["output"], out["session_id"], out["cost_usd"]), ("ok", "s-1", 0.0024359))
234
+ with tempfile.TemporaryDirectory() as d:
235
+ none = parse_output("claude", '[{"type": "system"}]\n', "", d)
236
+ self.assertNotIn("session_id", none, "an array without a result is read as raw output")
237
+
181
238
  def test_parse_codex_success_and_failure(self):
182
239
  with tempfile.TemporaryDirectory() as d:
183
240
  Path(d, "last_message.txt").write_text("ok\n")
File without changes
File without changes
File without changes
File without changes