codexcw 0.3.0__tar.gz → 0.4.0__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 (36) hide show
  1. {codexcw-0.3.0 → codexcw-0.4.0}/Cargo.lock +3 -3
  2. {codexcw-0.3.0 → codexcw-0.4.0}/Cargo.toml +2 -2
  3. {codexcw-0.3.0 → codexcw-0.4.0}/PKG-INFO +1 -1
  4. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/src/lib.rs +9 -0
  5. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/uv.lock +1 -1
  6. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/claude.rs +96 -5
  7. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/decoder.rs +85 -2
  8. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/event.rs +10 -1
  9. {codexcw-0.3.0 → codexcw-0.4.0}/pyproject.toml +1 -1
  10. {codexcw-0.3.0 → codexcw-0.4.0}/python/codexcw/_codexcw.pyi +3 -0
  11. {codexcw-0.3.0 → codexcw-0.4.0}/README.md +0 -0
  12. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/Cargo.toml +0 -0
  13. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/README.md +0 -0
  14. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/tests/fixtures/fake_claude.sh +0 -0
  15. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/tests/fixtures/fake_codex.sh +0 -0
  16. {codexcw-0.3.0 → codexcw-0.4.0}/bindings/python/tests/test_smoke.py +0 -0
  17. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/Cargo.toml +0 -0
  18. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/README.md +0 -0
  19. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/examples/run.rs +0 -0
  20. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/account_usage.rs +0 -0
  21. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/args.rs +0 -0
  22. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/claude_account_usage.rs +0 -0
  23. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/error.rs +0 -0
  24. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/group.rs +0 -0
  25. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/lib.rs +0 -0
  26. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/request.rs +0 -0
  27. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/runner.rs +0 -0
  28. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/session.rs +0 -0
  29. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/src/tail.rs +0 -0
  30. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/tests/account_usage_it.rs +0 -0
  31. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/tests/claude_account_usage_it.rs +0 -0
  32. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/tests/common/mod.rs +0 -0
  33. {codexcw-0.3.0 → codexcw-0.4.0}/crates/codexcw/tests/runner_it.rs +0 -0
  34. {codexcw-0.3.0 → codexcw-0.4.0}/python/codexcw/__init__.py +0 -0
  35. {codexcw-0.3.0 → codexcw-0.4.0}/python/codexcw/aio.py +0 -0
  36. {codexcw-0.3.0 → codexcw-0.4.0}/python/codexcw/py.typed +0 -0
@@ -22,7 +22,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
22
22
 
23
23
  [[package]]
24
24
  name = "codexcw"
25
- version = "0.3.0"
25
+ version = "0.4.0"
26
26
  dependencies = [
27
27
  "serde",
28
28
  "serde_json",
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "codexcw-node"
38
- version = "0.3.0"
38
+ version = "0.4.0"
39
39
  dependencies = [
40
40
  "codexcw",
41
41
  "napi",
@@ -47,7 +47,7 @@ dependencies = [
47
47
 
48
48
  [[package]]
49
49
  name = "codexcw-py"
50
- version = "0.3.0"
50
+ version = "0.4.0"
51
51
  dependencies = [
52
52
  "codexcw",
53
53
  "pyo3",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/codexcw", "bindings/python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.0"
6
+ version = "0.4.0"
7
7
  edition = "2021"
8
8
  rust-version = "1.90"
9
9
  license = "CC0-1.0"
@@ -12,7 +12,7 @@ homepage = "https://github.com/c3-oss/codexcw"
12
12
  authors = ["c3-oss"]
13
13
 
14
14
  [workspace.dependencies]
15
- codexcw = { path = "crates/codexcw", version = "0.3.0" }
15
+ codexcw = { path = "crates/codexcw", version = "0.4.0" }
16
16
  tokio = { version = "1", default-features = false, features = [
17
17
  "process",
18
18
  "io-util",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codexcw
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Operating System :: POSIX
@@ -122,6 +122,12 @@ pub struct PyItem {
122
122
  raw: String,
123
123
  #[pyo3(get)]
124
124
  changes: Vec<PyFileChange>,
125
+ #[pyo3(get)]
126
+ tool: String,
127
+ #[pyo3(get)]
128
+ sender_thread_id: String,
129
+ #[pyo3(get)]
130
+ receiver_thread_ids: Vec<String>,
125
131
  }
126
132
 
127
133
  /// One decoded agent event.
@@ -628,6 +634,9 @@ fn to_py_item(item: &Item) -> PyItem {
628
634
  kind: c.kind.clone(),
629
635
  })
630
636
  .collect(),
637
+ tool: item.tool.clone(),
638
+ sender_thread_id: item.sender_thread_id.clone(),
639
+ receiver_thread_ids: item.receiver_thread_ids.clone(),
631
640
  }
632
641
  }
633
642
 
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "codexcw"
20
- version = "0.3.0"
20
+ version = "0.4.0"
21
21
  source = { editable = "." }
22
22
 
23
23
  [package.optional-dependencies]
@@ -341,6 +341,11 @@ impl ClaudeDecoder {
341
341
  item.changes[0].kind = kind;
342
342
  }
343
343
  }
344
+ if item.kind == ItemKind::CollabToolCall {
345
+ if let Some(agent_id) = collab_agent_id(wire.tool_use_result.as_deref()) {
346
+ item.receiver_thread_ids = vec![agent_id];
347
+ }
348
+ }
344
349
  events.push(item_completed(&base, item));
345
350
  }
346
351
  if events.is_empty() {
@@ -403,21 +408,48 @@ impl ClaudeDecoder {
403
408
  }
404
409
 
405
410
  fn result_usage(wire: &WireEvent) -> Usage {
411
+ // result.usage covers the root agent only; modelUsage and the cost also
412
+ // cover subagents. The full-run total comes from modelUsage when Claude
413
+ // reports it.
414
+ let mut total_tokens = wire.usage.input_tokens
415
+ + wire.usage.cache_creation_input_tokens
416
+ + wire.usage.cache_read_input_tokens
417
+ + wire.usage.output_tokens;
418
+ if !wire.model_usage.is_empty() {
419
+ total_tokens = wire
420
+ .model_usage
421
+ .values()
422
+ .map(|usage| {
423
+ usage.input_tokens
424
+ + usage.cache_creation_input_tokens
425
+ + usage.cache_read_input_tokens
426
+ + usage.output_tokens
427
+ })
428
+ .sum();
429
+ }
430
+
406
431
  Usage {
407
432
  input_tokens: wire.usage.input_tokens,
408
433
  cache_creation_input_tokens: wire.usage.cache_creation_input_tokens,
409
434
  cached_input_tokens: wire.usage.cache_read_input_tokens,
410
435
  output_tokens: wire.usage.output_tokens,
411
- total_tokens: wire.usage.input_tokens
412
- + wire.usage.cache_creation_input_tokens
413
- + wire.usage.cache_read_input_tokens
414
- + wire.usage.output_tokens,
436
+ total_tokens,
415
437
  total_cost_usd: wire.total_cost_usd,
416
438
  model_usage: wire.model_usage.clone(),
417
439
  reasoning_output_tokens: 0,
418
440
  }
419
441
  }
420
442
 
443
+ fn collab_agent_id(tool_use_result: Option<&RawValue>) -> Option<String> {
444
+ #[derive(serde::Deserialize)]
445
+ struct Wire {
446
+ #[serde(rename = "agentId", default)]
447
+ agent_id: String,
448
+ }
449
+ let wire: Wire = serde_json::from_str(tool_use_result?.get()).ok()?;
450
+ (!wire.agent_id.is_empty()).then_some(wire.agent_id)
451
+ }
452
+
421
453
  fn tool_item(block: &WireBlock, raw_block: &str) -> Item {
422
454
  let input: WireToolInput = block
423
455
  .input
@@ -455,7 +487,12 @@ fn tool_item(block: &WireBlock, raw_block: &str) -> Item {
455
487
  }];
456
488
  }
457
489
  "WebSearch" => item.kind = ItemKind::WebSearch,
458
- "Task" => item.kind = ItemKind::CollabToolCall,
490
+ // "Task" is the legacy name of the subagent tool; current Claude
491
+ // Code CLIs call it "Agent".
492
+ "Task" | "Agent" => {
493
+ item.kind = ItemKind::CollabToolCall;
494
+ item.tool = block.name.clone();
495
+ }
459
496
  "TodoWrite" => item.kind = ItemKind::PlanUpdate,
460
497
  name if name.starts_with("mcp__") => item.kind = ItemKind::McpToolCall,
461
498
  _ => item.kind = ItemKind::ToolCall,
@@ -716,11 +753,65 @@ mod tests {
716
753
  .unwrap();
717
754
  let task = started[0].item_started().unwrap();
718
755
  assert_eq!(task.kind, ItemKind::CollabToolCall);
756
+ assert_eq!(task.tool, "Task");
719
757
  assert!(task.raw.contains("subagent_type"));
720
758
  let todo = started[1].item_started().unwrap();
721
759
  assert_eq!(todo.kind, ItemKind::PlanUpdate);
722
760
  }
723
761
 
762
+ #[test]
763
+ fn agent_tool_maps_to_collab_with_receiver_id() {
764
+ // Current Claude Code CLIs call the subagent tool "Agent"; "Task" is
765
+ // the legacy name. This mirrors a real 2.1.x payload.
766
+ let mut decoder = ClaudeDecoder::default();
767
+ let started = decoder
768
+ .decode(
769
+ br#"{"type":"assistant","message":{"id":"msg_1","content":[{"type":"tool_use","id":"toolu_1","name":"Agent","input":{"subagent_type":"Explore","prompt":"reply SUBAGENT_CHILD_OK"}}]},"session_id":"s"}"#,
770
+ "run-x",
771
+ "s",
772
+ SystemTime::UNIX_EPOCH,
773
+ )
774
+ .unwrap();
775
+ let item = started[0].item_started().unwrap();
776
+ assert_eq!(item.kind, ItemKind::CollabToolCall);
777
+ assert_eq!(item.tool, "Agent");
778
+
779
+ let completed = decoder
780
+ .decode(
781
+ br#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"SUBAGENT_CHILD_OK"}]}]},"tool_use_result":{"status":"completed","agentType":"Explore","agentId":"agent-123","resolvedModel":"claude-sonnet-5","totalTokens":12460},"session_id":"s"}"#,
782
+ "run-x",
783
+ "s",
784
+ SystemTime::UNIX_EPOCH,
785
+ )
786
+ .unwrap();
787
+ let item = completed[0].item_completed().unwrap();
788
+ assert_eq!(item.kind, ItemKind::CollabToolCall);
789
+ assert_eq!(item.tool, "Agent");
790
+ assert_eq!(item.status, "completed");
791
+ assert_eq!(item.receiver_thread_ids, vec!["agent-123".to_string()]);
792
+ assert_eq!(item.aggregated_output, "SUBAGENT_CHILD_OK");
793
+ }
794
+
795
+ #[test]
796
+ fn result_total_tokens_come_from_model_usage_when_present() {
797
+ let mut decoder = ClaudeDecoder::default();
798
+ let events = decoder
799
+ .decode(
800
+ br#"{"type":"result","is_error":false,"result":"ok","session_id":"s","total_cost_usd":0.5,"usage":{"input_tokens":100,"cache_creation_input_tokens":200,"cache_read_input_tokens":300,"output_tokens":50},"modelUsage":{"claude-sonnet-5":{"inputTokens":100,"outputTokens":50,"cacheReadInputTokens":300,"cacheCreationInputTokens":200},"claude-haiku-4-5-20251001":{"inputTokens":8000,"outputTokens":460,"cacheReadInputTokens":3000,"cacheCreationInputTokens":1000}}}"#,
801
+ "run-x",
802
+ "s",
803
+ SystemTime::UNIX_EPOCH,
804
+ )
805
+ .unwrap();
806
+ let EventPayload::TurnCompleted { usage } = &events.last().unwrap().payload else {
807
+ panic!("expected turn.completed");
808
+ };
809
+ assert_eq!(usage.input_tokens, 100);
810
+ // The root result.usage sums to 650; the subagent's 12460 tokens only
811
+ // appear in modelUsage, and the full-run total must include them.
812
+ assert_eq!(usage.total_tokens, 650 + 12460);
813
+ }
814
+
724
815
  #[test]
725
816
  fn bash_failure_extracts_exit_code() {
726
817
  let mut decoder = ClaudeDecoder::default();
@@ -46,6 +46,12 @@ struct WireItem {
46
46
  exit_code: Option<i32>,
47
47
  #[serde(default)]
48
48
  changes: Vec<FileChange>,
49
+ #[serde(default)]
50
+ tool: String,
51
+ #[serde(default)]
52
+ sender_thread_id: String,
53
+ #[serde(default)]
54
+ receiver_thread_ids: Vec<String>,
49
55
  }
50
56
 
51
57
  /// Decodes one JSONL line into an [`Event`].
@@ -83,10 +89,12 @@ pub(crate) fn decode_event(
83
89
  }
84
90
  }
85
91
  EventKind::TurnStarted => EventPayload::TurnStarted,
86
- EventKind::TurnCompleted => EventPayload::TurnCompleted { usage: wire.usage },
92
+ EventKind::TurnCompleted => EventPayload::TurnCompleted {
93
+ usage: normalize_codex_usage(wire.usage),
94
+ },
87
95
  EventKind::TurnFailed => EventPayload::TurnFailed {
88
96
  error: decode_event_error(wire.error.as_deref()),
89
- usage: wire.usage,
97
+ usage: normalize_codex_usage(wire.usage),
90
98
  },
91
99
  EventKind::ItemStarted => EventPayload::ItemStarted(decode_item(wire.item.as_deref())?),
92
100
  EventKind::ItemCompleted => EventPayload::ItemCompleted(decode_item(wire.item.as_deref())?),
@@ -129,9 +137,22 @@ fn decode_item(raw: Option<&RawValue>) -> Result<Item, String> {
129
137
  aggregated_output: wire.aggregated_output,
130
138
  exit_code: wire.exit_code,
131
139
  changes: wire.changes,
140
+ tool: wire.tool,
141
+ sender_thread_id: wire.sender_thread_id,
142
+ receiver_thread_ids: wire.receiver_thread_ids,
132
143
  })
133
144
  }
134
145
 
146
+ /// Derives the total when Codex omits `total_tokens`. `cached_input_tokens`
147
+ /// is a subset of `input_tokens` on the codex wire, so the derived total is
148
+ /// input plus output; an explicit total is preserved.
149
+ fn normalize_codex_usage(mut usage: Usage) -> Usage {
150
+ if usage.total_tokens == 0 {
151
+ usage.total_tokens = usage.input_tokens + usage.output_tokens;
152
+ }
153
+ usage
154
+ }
155
+
135
156
  fn decode_event_error(raw: Option<&RawValue>) -> ErrorPayload {
136
157
  let Some(raw) = raw else {
137
158
  return ErrorPayload::default();
@@ -216,6 +237,68 @@ mod tests {
216
237
  assert_eq!(item.kind, ItemKind::CollabToolCall);
217
238
  assert_eq!(item.status, "in_progress");
218
239
  assert!(item.raw.contains("\"tool\":\"wait\""));
240
+ assert_eq!(item.tool, "wait");
241
+ assert_eq!(item.sender_thread_id, "t-parent");
242
+ assert!(item.receiver_thread_ids.is_empty());
243
+ }
244
+
245
+ #[test]
246
+ fn collab_tool_call_exposes_receiver_thread_ids() {
247
+ let event = decode_event(
248
+ br#"{"type":"item.started","item":{"id":"i0","type":"collab_tool_call","tool":"spawn_agent","sender_thread_id":"t-parent","receiver_thread_ids":["t-child"],"agents_states":{},"status":"in_progress"}}"#,
249
+ "run-x",
250
+ "t-parent",
251
+ SystemTime::UNIX_EPOCH,
252
+ )
253
+ .unwrap();
254
+ let item = event.item_started().unwrap();
255
+ assert_eq!(item.tool, "spawn_agent");
256
+ assert_eq!(item.receiver_thread_ids, vec!["t-child".to_string()]);
257
+ }
258
+
259
+ #[test]
260
+ fn turn_completed_total_tokens_derived_when_omitted() {
261
+ // cached_input_tokens is a subset of input_tokens on the codex wire
262
+ // and must not be counted twice.
263
+ let event = decode_event(
264
+ br#"{"type":"turn.completed","usage":{"input_tokens":12966,"cached_input_tokens":8960,"output_tokens":6,"reasoning_output_tokens":0}}"#,
265
+ "run-x",
266
+ "t-1",
267
+ SystemTime::UNIX_EPOCH,
268
+ )
269
+ .unwrap();
270
+ let EventPayload::TurnCompleted { usage } = &event.payload else {
271
+ panic!("expected turn.completed");
272
+ };
273
+ assert_eq!(usage.total_tokens, 12972);
274
+
275
+ let explicit = decode_event(
276
+ br#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":3,"total_tokens":999}}"#,
277
+ "run-x",
278
+ "t-1",
279
+ SystemTime::UNIX_EPOCH,
280
+ )
281
+ .unwrap();
282
+ let EventPayload::TurnCompleted { usage } = &explicit.payload else {
283
+ panic!("expected turn.completed");
284
+ };
285
+ assert_eq!(usage.total_tokens, 999);
286
+ }
287
+
288
+ #[test]
289
+ fn turn_failed_total_tokens_derived_when_omitted() {
290
+ let event = decode_event(
291
+ br#"{"type":"turn.failed","error":{"message":"boom"},"usage":{"input_tokens":12966,"cached_input_tokens":8960,"output_tokens":6}}"#,
292
+ "run-x",
293
+ "t-1",
294
+ SystemTime::UNIX_EPOCH,
295
+ )
296
+ .unwrap();
297
+ let EventPayload::TurnFailed { error, usage } = &event.payload else {
298
+ panic!("expected turn.failed");
299
+ };
300
+ assert_eq!(error.message, "boom");
301
+ assert_eq!(usage.total_tokens, 12972);
219
302
  }
220
303
 
221
304
  #[test]
@@ -151,7 +151,9 @@ pub struct Usage {
151
151
  /// Number of reasoning output tokens.
152
152
  #[serde(default)]
153
153
  pub reasoning_output_tokens: i64,
154
- /// Total tokens reported or calculated for the turn.
154
+ /// Total token count for the full run, subagents included. When the
155
+ /// agent omits an explicit total it is derived: input plus output tokens
156
+ /// for Codex, and the per-model sums when Claude reports model usage.
155
157
  #[serde(default)]
156
158
  pub total_tokens: i64,
157
159
  /// Total Claude API cost in US dollars.
@@ -226,6 +228,13 @@ pub struct Item {
226
228
  pub exit_code: Option<i32>,
227
229
  /// File edits for `file_change` items.
228
230
  pub changes: Vec<FileChange>,
231
+ /// Collab operation (`spawn_agent`, `wait`, ...) for `collab_tool_call`
232
+ /// items.
233
+ pub tool: String,
234
+ /// Thread that issued a `collab_tool_call` item.
235
+ pub sender_thread_id: String,
236
+ /// Agent threads targeted by a `collab_tool_call` item.
237
+ pub receiver_thread_ids: Vec<String>,
229
238
  }
230
239
 
231
240
  impl Default for ItemKind {
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "codexcw"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Run Codex or Claude Code non-interactively from Python, backed by a Rust core."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -37,6 +37,9 @@ class PyItem:
37
37
  exit_code: Optional[int]
38
38
  raw: str
39
39
  changes: List[PyFileChange]
40
+ tool: str
41
+ sender_thread_id: str
42
+ receiver_thread_ids: List[str]
40
43
 
41
44
  class PyEvent:
42
45
  type: str
File without changes
File without changes
File without changes