clink-cli 0.2.1__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.
Files changed (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. utils/token_utils.py +54 -0
@@ -0,0 +1,346 @@
1
+ """Validate Claude generation streams without publishing tool or intermediate text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .base import (
13
+ MAX_METADATA_NUMBER,
14
+ BaseParser,
15
+ ParsedCLIResponse,
16
+ ParserError,
17
+ bounded_number,
18
+ cli_failure,
19
+ load_json,
20
+ model_identifier,
21
+ )
22
+
23
+ MAX_EVENTS = 16384
24
+ MAX_STREAM_BYTES = 10 * 1024 * 1024
25
+ _SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
26
+ _NOTICE_SUBTYPES = frozenset(
27
+ {
28
+ "commands_changed",
29
+ "session_title_changed",
30
+ "informational",
31
+ "instruction_size_warning",
32
+ "api_retry",
33
+ "thinking_tokens",
34
+ }
35
+ )
36
+ _SYSTEM_SUBTYPES = (
37
+ frozenset(
38
+ {"init", "status", "compact_boundary", "hook_started", "hook_progress", "hook_response", "permission_denied"}
39
+ )
40
+ | _NOTICE_SUBTYPES
41
+ )
42
+ _RETRY_ERRORS = frozenset(
43
+ {"overloaded", "rate_limit", "authentication_failed", "server_error", "cloud_credential_error", "unknown"}
44
+ )
45
+ _DIAGNOSTIC_REASONS = frozenset(
46
+ {
47
+ "stream_bounds",
48
+ "json_invalid",
49
+ "event_envelope",
50
+ "unknown_event",
51
+ "unknown_system_subtype",
52
+ "notice_shape",
53
+ "notice_stop",
54
+ "session_invalid",
55
+ "session_mismatch",
56
+ "init_required",
57
+ "duplicate_init",
58
+ "init_model_invalid",
59
+ "message_shape",
60
+ "native_failure",
61
+ "permission_denied",
62
+ "rate_limited",
63
+ "terminal_shape",
64
+ "empty_result",
65
+ "post_terminal",
66
+ "terminal_missing",
67
+ }
68
+ )
69
+ _DIAGNOSTIC_PREFIX = "Claude native generation output violates its protocol"
70
+ _DIAGNOSTIC = re.compile(re.escape(_DIAGNOSTIC_PREFIX) + r" \[reason=([a-z_]+);event_index=(null|[1-9][0-9]{0,4})\]\Z")
71
+ PARSER_CONTRACT = (
72
+ "claude-native-generation-stream-json/v3;strict-utf8-jsonl;no-duplicate-keys-or-nonfinite-numbers;"
73
+ "max-events=16384;max-stream-bytes=10485760;one-init-before-messages;one-session;one-last-result;"
74
+ "answer=exact-nonblank-successful-terminal-result.result;primary-model=system.init.model;"
75
+ "modelUsage-is-not-primary-model;errors-and-permission-denials-refuse;no-raw-metadata;"
76
+ "six-validated-operational-system-notices;thinking-progress-after-init;only-validated-api-retry-error-is-recoverable;"
77
+ "informational-stop-and-prompt-submit-outcome-refuse;closed-diagnostic-reason-and-event-index;"
78
+ "identity=sha256(canonical-json(contract,source-sha256(claude_native_generation.py,base.py)))"
79
+ )
80
+
81
+
82
+ class _ClaudeParseError(ParserError):
83
+ def __init__(self, reason: str, event_index: int | None = None, *, code: str = "protocol_error"):
84
+ self.reason = reason
85
+ index = "null" if event_index is None else str(event_index)
86
+ super().__init__(f"{_DIAGNOSTIC_PREFIX} [reason={reason};event_index={index}]", code=code)
87
+
88
+
89
+ def _protocol(reason: str = "event_envelope") -> ParserError:
90
+ return _ClaudeParseError(reason)
91
+
92
+
93
+ def protocol_diagnostic(message: object) -> dict[str, Any] | None:
94
+ """Extract only a closed Claude diagnostic, never native output or arbitrary errors."""
95
+ match = _DIAGNOSTIC.fullmatch(message) if isinstance(message, str) else None
96
+ if match is None or match[1] not in _DIAGNOSTIC_REASONS:
97
+ return None
98
+ index = None if match[2] == "null" else int(match[2])
99
+ if index is not None and index > MAX_EVENTS:
100
+ return None
101
+ return {"reason": match[1], "event_index": index}
102
+
103
+
104
+ def parser_contract_digest() -> str:
105
+ sources = {}
106
+ try:
107
+ for name in ("claude_native_generation.py", "base.py"):
108
+ with Path(__file__).with_name(name).open("rb") as stream:
109
+ content = stream.read(1024 * 1024 + 1)
110
+ if len(content) > 1024 * 1024:
111
+ raise _protocol("stream_bounds")
112
+ sources[name] = hashlib.sha256(content).hexdigest()
113
+ except OSError:
114
+ raise _protocol("stream_bounds") from None
115
+ identity = json.dumps(
116
+ {"contract": PARSER_CONTRACT, "source_sha256": sources}, sort_keys=True, separators=(",", ":")
117
+ )
118
+ return hashlib.sha256(identity.encode()).hexdigest()
119
+
120
+
121
+ def _valid_text(value: object) -> bool:
122
+ if not isinstance(value, str):
123
+ return False
124
+ try:
125
+ value.encode("utf-8")
126
+ except UnicodeError:
127
+ return False
128
+ return True
129
+
130
+
131
+ def _validate_json(value: Any) -> None:
132
+ pending = [value]
133
+ while pending:
134
+ part = pending.pop()
135
+ if isinstance(part, dict):
136
+ if not all(_valid_text(key) for key in part):
137
+ raise _protocol("json_invalid")
138
+ pending.extend(part.values())
139
+ elif isinstance(part, list):
140
+ pending.extend(part)
141
+ elif isinstance(part, str) and not _valid_text(part):
142
+ raise _protocol("json_invalid")
143
+ elif isinstance(part, float) and not math.isfinite(part):
144
+ raise _protocol("json_invalid")
145
+
146
+
147
+ def _integer(value: object, minimum: int = 0, maximum: int = MAX_METADATA_NUMBER) -> bool:
148
+ return type(value) is int and minimum <= value <= maximum
149
+
150
+
151
+ def _notice(event: dict[str, Any]) -> None:
152
+ subtype = event["subtype"]
153
+ if subtype == "session_title_changed":
154
+ valid = isinstance(event.get("title"), str) and bool(event["title"].strip())
155
+ elif subtype == "commands_changed":
156
+ commands = event.get("commands")
157
+ valid = isinstance(commands, list) and all(
158
+ isinstance(command, dict)
159
+ and all(isinstance(command.get(field), str) for field in ("name", "description", "argumentHint"))
160
+ and (
161
+ "aliases" not in command
162
+ or isinstance(command["aliases"], list)
163
+ and all(isinstance(alias, str) for alias in command["aliases"])
164
+ )
165
+ and ("builtin" not in command or type(command["builtin"]) is bool)
166
+ for command in commands
167
+ )
168
+ elif subtype == "informational":
169
+ level = event.get("level")
170
+ valid = (
171
+ isinstance(event.get("content"), str)
172
+ and isinstance(level, str)
173
+ and level in {"info", "notice", "suggestion", "warning"}
174
+ and ("tool_use_id" not in event or isinstance(event["tool_use_id"], str))
175
+ and ("prevent_continuation" not in event or type(event["prevent_continuation"]) is bool)
176
+ )
177
+ elif subtype == "thinking_tokens":
178
+ valid = all(
179
+ _integer(event.get(field), -MAX_METADATA_NUMBER) for field in ("estimated_tokens", "estimated_tokens_delta")
180
+ ) and ("user_message_uuid" not in event or isinstance(event["user_message_uuid"], str))
181
+ elif subtype == "instruction_size_warning":
182
+ valid = all(_integer(event.get(field)) for field in ("total_chars", "total_limit_chars", "file_count")) and (
183
+ "largest_chars" not in event or _integer(event["largest_chars"])
184
+ )
185
+ else:
186
+ error = event.get("error")
187
+ response = event.get("no_response")
188
+ valid = (
189
+ _integer(event.get("attempt"), 1)
190
+ and _integer(event.get("max_retries"))
191
+ and bounded_number(event.get("retry_delay_ms")) is not None
192
+ and "error_status" in event
193
+ and (event["error_status"] is None or _integer(event["error_status"]))
194
+ and isinstance(error, str)
195
+ and error in _RETRY_ERRORS
196
+ and (
197
+ "no_response" not in event
198
+ or isinstance(response, dict)
199
+ and all(bounded_number(response.get(field)) is not None for field in ("waited_ms", "retry_wait_ms"))
200
+ )
201
+ )
202
+ if not valid:
203
+ raise _protocol("notice_shape")
204
+
205
+
206
+ def _failure(event: dict[str, Any], stderr: str, *, validated_retry: bool = False) -> None:
207
+ if "is_error" in event and type(event["is_error"]) is not bool:
208
+ raise _protocol()
209
+ if "errors" in event and not isinstance(event["errors"], list):
210
+ raise _protocol()
211
+ status = event.get("status")
212
+ subtype = event.get("subtype")
213
+ if status is not None and not isinstance(status, str):
214
+ raise _protocol()
215
+ if subtype is not None and not isinstance(subtype, str):
216
+ raise _protocol()
217
+ if (
218
+ event.get("type") == "error"
219
+ or event.get("is_error") is True
220
+ or (event.get("error") is not None and not validated_retry)
221
+ or event.get("errors")
222
+ or status in {"error", "failed", "cancelled", "aborted", "interrupted"}
223
+ or (isinstance(subtype, str) and subtype.startswith("error"))
224
+ ):
225
+ raise cli_failure(event, stderr, message="Claude native generation did not complete", code="protocol_error")
226
+
227
+
228
+ class ClaudeNativeGenerationParser(BaseParser):
229
+ """Publish only one validated final result, preserving its exact text."""
230
+
231
+ name = "claude_native_generation_jsonl"
232
+
233
+ def parse(self, stdout: str, stderr: str = "") -> ParsedCLIResponse:
234
+ if not _valid_text(stdout) or not _valid_text(stderr):
235
+ raise _protocol("json_invalid")
236
+ if len(stdout.encode()) > MAX_STREAM_BYTES:
237
+ raise _protocol("stream_bounds")
238
+ lines = [line for line in stdout.split("\n") if line.strip(" \t\r")]
239
+ if len(lines) > MAX_EVENTS:
240
+ raise _protocol("stream_bounds")
241
+ if not lines:
242
+ failure = cli_failure(stderr, code="protocol_error")
243
+ raise _ClaudeParseError("terminal_missing", code=failure.code)
244
+ initialized = False
245
+ session: str | None = None
246
+ actual_model: str | None = None
247
+ terminal: dict[str, Any] | None = None
248
+ for event_index, line in enumerate(lines, 1):
249
+ try:
250
+ try:
251
+ event = load_json(line)
252
+ except ParserError:
253
+ raise _protocol("json_invalid") from None
254
+ _validate_json(event)
255
+ if terminal is not None:
256
+ raise _protocol("post_terminal")
257
+ if not isinstance(event, dict) or not isinstance(event.get("type"), str):
258
+ raise _protocol("event_envelope")
259
+ kind = event["type"]
260
+ subtype = event.get("subtype")
261
+ notice = kind == "system" and isinstance(subtype, str) and subtype in _NOTICE_SUBTYPES
262
+ if "permission_denials" in event:
263
+ denials = event["permission_denials"]
264
+ if not isinstance(denials, list):
265
+ raise _protocol("terminal_shape" if kind == "result" else "event_envelope")
266
+ if denials:
267
+ raise _ClaudeParseError("permission_denied", code="permission_needed")
268
+ if notice:
269
+ _notice(event)
270
+ _failure(event, stderr, validated_retry=notice and subtype == "api_retry")
271
+ observed = event.get("session_id")
272
+ if observed is not None:
273
+ if not isinstance(observed, str) or not _SESSION.fullmatch(observed):
274
+ raise _protocol("session_invalid")
275
+ if session is not None and session != observed:
276
+ raise _protocol("session_mismatch")
277
+ session = observed
278
+ if notice and observed is None:
279
+ raise _protocol("session_invalid")
280
+ if kind == "system":
281
+ if subtype not in _SYSTEM_SUBTYPES:
282
+ raise _protocol("unknown_system_subtype")
283
+ if subtype == "permission_denied":
284
+ raise _ClaudeParseError("permission_denied", code="permission_needed")
285
+ if subtype == "thinking_tokens" and not initialized:
286
+ raise _protocol("init_required")
287
+ if subtype == "informational" and (
288
+ event.get("prevent_continuation") is True or "prompt_submit_outcome" in event
289
+ ):
290
+ raise _protocol("notice_stop")
291
+ if subtype == "init":
292
+ if initialized:
293
+ raise _protocol("duplicate_init")
294
+ actual_model = model_identifier(event.get("model"))
295
+ if observed is None:
296
+ raise _protocol("session_invalid")
297
+ if actual_model is None:
298
+ raise _protocol("init_model_invalid")
299
+ initialized = True
300
+ elif kind in {"assistant", "user"}:
301
+ if not initialized:
302
+ raise _protocol("init_required")
303
+ if not isinstance(event.get("message"), dict):
304
+ raise _protocol("message_shape")
305
+ blocks = event["message"].get("content")
306
+ if not isinstance(blocks, list):
307
+ raise _protocol("message_shape")
308
+ for block in blocks:
309
+ if not isinstance(block, dict) or not isinstance(block.get("type"), str):
310
+ raise _protocol("message_shape")
311
+ _failure(block, stderr)
312
+ if block["type"] == "text" and not isinstance(block.get("text"), str):
313
+ raise _protocol("message_shape")
314
+ elif kind == "result":
315
+ if not initialized:
316
+ raise _protocol("init_required")
317
+ if (
318
+ observed is None
319
+ or event.get("subtype") != "success"
320
+ or event.get("is_error") is not False
321
+ or event.get("status", "success") not in {"success", "completed"}
322
+ or not isinstance(event.get("result"), str)
323
+ ):
324
+ raise _protocol("terminal_shape")
325
+ if not event["result"].strip():
326
+ raise _ClaudeParseError("empty_result", code="empty_response")
327
+ terminal = event
328
+ elif kind == "rate_limit_event":
329
+ if not initialized:
330
+ raise _protocol("init_required")
331
+ if not isinstance(event.get("rate_limit_info"), dict):
332
+ raise _protocol("event_envelope")
333
+ if event["rate_limit_info"].get("status") == "rejected":
334
+ raise _ClaudeParseError("rate_limited", code="rate_limited")
335
+ else:
336
+ raise _protocol("unknown_event")
337
+ except ParserError as error:
338
+ reason = error.reason if isinstance(error, _ClaudeParseError) else "native_failure"
339
+ raise _ClaudeParseError(reason, event_index, code=error.code) from None
340
+ if terminal is None:
341
+ failure = cli_failure(stderr, code="protocol_error")
342
+ raise _ClaudeParseError("terminal_missing", code=failure.code)
343
+ return ParsedCLIResponse(
344
+ content=terminal["result"],
345
+ metadata={"actual_model": actual_model, "tool_error_count": 0, "permission_denial_count": 0},
346
+ )
clink/parsers/codex.py ADDED
@@ -0,0 +1,172 @@
1
+ """Parser for Codex CLI JSONL output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import (
8
+ MAX_SOURCE_URLS,
9
+ BaseParser,
10
+ ParsedCLIResponse,
11
+ ParserError,
12
+ bounded_counts,
13
+ cli_failure,
14
+ load_json,
15
+ model_identifier,
16
+ source_url,
17
+ )
18
+
19
+ _USAGE_FIELDS = (
20
+ "input_tokens",
21
+ "cached_input_tokens",
22
+ "cache_write_input_tokens",
23
+ "output_tokens",
24
+ "total_tokens",
25
+ "reasoning_output_tokens",
26
+ )
27
+
28
+
29
+ class CodexJSONLParser(BaseParser):
30
+ """Parse stdout emitted by `codex exec --json`."""
31
+
32
+ name = "codex_jsonl"
33
+
34
+ def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
35
+ lines = [line.strip() for line in (stdout or "").splitlines() if line.strip()]
36
+ agent_messages: list[str] = []
37
+ terminal: dict[str, Any] | None = None
38
+ search_performed = False
39
+ source_urls: list[str] = []
40
+
41
+ for line in lines:
42
+ event = load_json(line, allow_codex_web_search_ids=True)
43
+ if not isinstance(event, dict) or not isinstance(event.get("type"), str) or not event["type"]:
44
+ raise ParserError("Codex CLI JSONL events must be typed objects")
45
+ if "status" in event and not isinstance(event["status"], str):
46
+ raise ParserError("Codex CLI event status must be textual")
47
+
48
+ event_type = event["type"]
49
+ if (
50
+ event_type == "error"
51
+ or event_type.endswith(".failed")
52
+ or event_type in {"turn.cancelled", "turn.aborted", "turn.interrupted"}
53
+ or event.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
54
+ or event.get("error") is not None
55
+ or event.get("errors")
56
+ or event.get("is_error", False) is not False
57
+ ):
58
+ raise cli_failure(event, stderr, message="Codex CLI reported a failed turn")
59
+ if terminal is not None:
60
+ raise ParserError("Codex CLI emitted events after its terminal result")
61
+
62
+ if event_type.startswith("item."):
63
+ item = event.get("item")
64
+ if not isinstance(item, dict) or not isinstance(item.get("type"), str) or not item["type"]:
65
+ raise ParserError("Codex CLI item events must contain typed item objects")
66
+ if "status" in item and not isinstance(item["status"], str):
67
+ raise ParserError("Codex CLI item status must be textual")
68
+ if (
69
+ item["type"] == "error"
70
+ or item.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
71
+ or item.get("error") is not None
72
+ or item.get("errors")
73
+ or item.get("is_error", False) is not False
74
+ ):
75
+ raise cli_failure(item, stderr, message="Codex CLI reported a failed item")
76
+
77
+ if event_type == "item.completed":
78
+ if item["type"] == "web_search":
79
+ performed, urls = self._search_evidence(item, stderr)
80
+ search_performed = search_performed or performed
81
+ for url in urls:
82
+ if url not in source_urls and len(source_urls) < MAX_SOURCE_URLS:
83
+ source_urls.append(url)
84
+ if item["type"] == "command_execution" and "exit_code" in item:
85
+ exit_code = item["exit_code"]
86
+ if not isinstance(exit_code, int) or isinstance(exit_code, bool):
87
+ raise ParserError("Codex CLI command exit code must be an integer")
88
+ if exit_code != 0:
89
+ raise cli_failure(item, stderr, message="Codex CLI command execution failed")
90
+ if item["type"] == "agent_message":
91
+ text = item.get("text")
92
+ if not isinstance(text, str) or not text.strip():
93
+ raise ParserError("Codex CLI agent_message must contain nonempty text")
94
+ agent_messages.append(text.strip())
95
+ elif event_type == "turn.completed":
96
+ if event.get("status", "completed") not in ("completed", "success"):
97
+ raise ParserError("Codex CLI terminal status did not indicate success")
98
+ if "usage" in event and not isinstance(event["usage"], dict):
99
+ raise ParserError("Codex CLI usage must be an object")
100
+ terminal = event
101
+
102
+ if terminal is None:
103
+ raise cli_failure(stderr, message="Codex CLI JSONL output did not include turn.completed")
104
+ if not agent_messages:
105
+ raise cli_failure(
106
+ stderr,
107
+ message="Codex CLI JSONL output did not include an agent_message item",
108
+ code="empty_response",
109
+ )
110
+
111
+ content = "\n\n".join(agent_messages).strip()
112
+ metadata: dict[str, Any] = {}
113
+ usage = bounded_counts(terminal.get("usage"), _USAGE_FIELDS)
114
+ if usage:
115
+ metadata["usage"] = usage
116
+ actual_model = model_identifier(terminal.get("model"))
117
+ if actual_model:
118
+ metadata["actual_model"] = actual_model
119
+ if search_performed:
120
+ metadata["search_performed"] = True
121
+ metadata["source_urls"] = source_urls
122
+
123
+ return ParsedCLIResponse(content=content, metadata=metadata)
124
+
125
+ def _search_evidence(self, item: dict[str, Any], stderr: str) -> tuple[bool, list[str]]:
126
+ """Read only the source-tested flat text_result URL shape from returned results."""
127
+
128
+ action = item.get("action")
129
+ results = item.get("results")
130
+ if "query" in item and not isinstance(item["query"], str):
131
+ raise ParserError("Codex CLI web search query must be textual")
132
+ if action is not None and not isinstance(action, dict):
133
+ raise ParserError("Codex CLI web search action must be an object")
134
+ action_type = action.get("type") if isinstance(action, dict) else None
135
+ if isinstance(action, dict):
136
+ if not isinstance(action_type, str):
137
+ raise ParserError("Codex CLI web search action must be typed")
138
+ for field in ("query", "url", "pattern"):
139
+ if action.get(field) is not None and not isinstance(action[field], str):
140
+ raise ParserError("Codex CLI web search action text fields must be textual")
141
+ queries = action.get("queries")
142
+ if queries is not None and not (
143
+ isinstance(queries, list) and all(isinstance(query, str) for query in queries)
144
+ ):
145
+ raise ParserError("Codex CLI web search queries must be an array of text")
146
+ if results is None:
147
+ return False, []
148
+ if not isinstance(results, list):
149
+ raise ParserError("Codex CLI web search results must be an array")
150
+
151
+ urls: list[str] = []
152
+ recognized = not results
153
+ for result in results:
154
+ if not isinstance(result, dict):
155
+ continue
156
+ if (
157
+ result.get("type") in ("error", "failed")
158
+ or result.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
159
+ or result.get("error") is not None
160
+ or result.get("errors")
161
+ or (result.get("is_error") is not None and result["is_error"] is not False)
162
+ ):
163
+ raise cli_failure(result, stderr, message="Codex CLI web search returned a failed result")
164
+ if result.get("type") != "text_result":
165
+ continue
166
+ url = source_url(result.get("url"))
167
+ if url:
168
+ recognized = True
169
+ if url not in urls and len(urls) < MAX_SOURCE_URLS:
170
+ urls.append(url)
171
+ known_action = action_type in ("search", "open_page", "find_in_page")
172
+ return action_type == "search" and recognized, urls if known_action else []