anolisa-tokenless 0.7.13 → 0.8.0

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 (34) hide show
  1. package/README.md +219 -87
  2. package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +1 -1
  3. package/adapters/tokenless/claude-code/hooks/run-hook.sh +62 -0
  4. package/adapters/tokenless/codex/.codex-plugin/plugin.json +5 -4
  5. package/adapters/tokenless/codex/README.md +22 -32
  6. package/adapters/tokenless/codex/hooks/hooks.json +3 -3
  7. package/adapters/tokenless/codex/scripts/response-diagnostics +170 -0
  8. package/adapters/tokenless/common/cosh-extension.json +4 -4
  9. package/adapters/tokenless/common/hooks/compress_response_hook.py +250 -307
  10. package/adapters/tokenless/common/hooks/compress_schema_hook.py +34 -42
  11. package/adapters/tokenless/common/hooks/hook_utils.py +281 -44
  12. package/adapters/tokenless/common/hooks/rewrite_hook.py +53 -169
  13. package/adapters/tokenless/dsh/dist/index.js +353 -264
  14. package/adapters/tokenless/dsh/package.json +2 -2
  15. package/adapters/tokenless/hermes/__init__.py +191 -355
  16. package/adapters/tokenless/hermes/plugin.yaml +2 -2
  17. package/adapters/tokenless/manifest.json +18 -3
  18. package/adapters/tokenless/openclaw/dist/index.d.ts +4 -16
  19. package/adapters/tokenless/openclaw/dist/index.js +291 -507
  20. package/adapters/tokenless/openclaw/index.ts +408 -628
  21. package/adapters/tokenless/openclaw/openclaw.plugin.json +4 -20
  22. package/adapters/tokenless/openclaw/package.json +6 -4
  23. package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +1 -1
  24. package/adapters/tokenless/qwencode/hooks/run-hook.sh +62 -0
  25. package/adapters/tokenless/qwencode/qwen-extension.json +4 -4
  26. package/adapters/tokenless/qwenpaw/plugin.json +17 -0
  27. package/adapters/tokenless/qwenpaw/plugin.py +390 -0
  28. package/adapters/tokenless/qwenpaw/requirements.txt +6 -0
  29. package/adapters/tokenless/qwenpaw/scripts/detect.sh +131 -0
  30. package/adapters/tokenless/qwenpaw/scripts/install.sh +98 -0
  31. package/adapters/tokenless/qwenpaw/scripts/uninstall.sh +61 -0
  32. package/package.json +5 -5
  33. package/adapters/tokenless/codex/scripts/compress-response +0 -445
  34. package/adapters/tokenless/common/hooks/compress_toon_hook.py +0 -171
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  """Tokenless schema compression hook.
3
3
 
4
- Reads a BeforeModel JSON from stdin, extracts the tools array,
5
- invokes ``tokenless compress-schema --batch`` via subprocess, and
4
+ Reads a BeforeModel JSON from stdin, extracts the tools array, forwards it
5
+ to the unified ``tokenless compress`` Protocol v2 BeforeModel operation and
6
6
  writes a HookOutput JSON to stdout.
7
+ The entry point returns the original array on no-savings, which this hook
8
+ wraps exactly like a compressed one — the historical behavior of the
9
+ ``compress-schema`` flow it replaces.
7
10
 
8
11
  Hook point: **BeforeModel**
9
12
 
@@ -16,7 +19,6 @@ from __future__ import annotations
16
19
  import contextlib
17
20
  import json
18
21
  import os
19
- import subprocess
20
22
  import sys
21
23
 
22
24
  try: # POSIX hosts (cosh / Cosh-NG) — the platforms these hooks target.
@@ -30,9 +32,11 @@ from hook_utils import (
30
32
  _TOKENLESS_FALLBACK,
31
33
  _TOKENLESS_LOCAL_LIB,
32
34
  _TOKENLESS_LOCAL_SHARE,
35
+ build_before_model_request,
33
36
  resolve_agent_id,
34
37
  resolve_binary,
35
38
  resolve_tool_call_id,
39
+ run_compress,
36
40
  secure_write_text,
37
41
  skip,
38
42
  warn,
@@ -42,6 +46,11 @@ from hook_utils import (
42
46
 
43
47
  _AGENT_ID = resolve_agent_id()
44
48
 
49
+ # Below the extension manifests' 10 s host wrapper, so a pathological batch
50
+ # is killed here (fail-open skip) instead of racing the host's kill of the
51
+ # whole hook (the old subprocess timeout was 10 s against a 10 s wrapper).
52
+ _COMPRESS_TIMEOUT = 8
53
+
45
54
  # One marker file holds the session keys that already emitted the "no tool
46
55
  # declarations" warning — one key per line, most recent last — so the warning
47
56
  # repeats at most once per session even though BeforeModel fires on every
@@ -103,14 +112,6 @@ def _marker_lock():
103
112
  # -- helpers -----------------------------------------------------------------
104
113
 
105
114
 
106
- def _is_json_array(data: str) -> bool:
107
- try:
108
- obj = json.loads(data)
109
- return isinstance(obj, list)
110
- except (json.JSONDecodeError, ValueError):
111
- return False
112
-
113
-
114
115
  def _session_warn_key(session_id: str) -> str:
115
116
  """Normalize the session ID into the dedup key for the no-tools warning.
116
117
 
@@ -266,42 +267,33 @@ def main() -> None:
266
267
  )
267
268
  skip()
268
269
 
269
- tools_json = json.dumps(tools, separators=(",", ":"))
270
-
271
270
  # 4. Extract caller context
272
271
  session_id = input_data.get("session_id", "")
273
272
  tool_use_id = resolve_tool_call_id(_AGENT_ID, input_data)
274
273
 
275
- # 5. Compress schemas via tokenless compress-schema --batch
276
- cmd = [tokenless_bin, "compress-schema", "--batch", "--agent-id", _AGENT_ID]
277
- if session_id:
278
- cmd.extend(["--session-id", session_id])
279
- if tool_use_id:
280
- cmd.extend(["--tool-use-id", tool_use_id])
281
-
282
- try:
283
- proc = subprocess.run(
284
- cmd,
285
- input=tools_json,
286
- capture_output=True,
287
- text=True,
288
- timeout=10,
289
- )
290
- except Exception:
274
+ # 5. Compress schemas via the unified entry point (one subprocess).
275
+ llm_request = input_data.get("llm_request", {})
276
+ visible_context = dict(llm_request) if isinstance(llm_request, dict) else {}
277
+ visible_context.pop("tools", None)
278
+ config = visible_context.get("config")
279
+ if isinstance(config, dict):
280
+ visible_context["config"] = dict(config)
281
+ visible_context["config"].pop("tools", None)
282
+ request = build_before_model_request(
283
+ tools,
284
+ visible_context,
285
+ _AGENT_ID,
286
+ session_id=session_id,
287
+ tool_use_id=tool_use_id,
288
+ )
289
+ response = run_compress(
290
+ tokenless_bin, request, _COMPRESS_TIMEOUT, "before_model"
291
+ )
292
+ if response is None:
291
293
  warn("Schema compression subprocess failed. Passing through unchanged.")
292
294
  skip()
293
-
294
- if proc.returncode != 0:
295
- detail = (proc.stderr or "").strip()[:200]
296
- warn(
297
- f"Schema compression failed with exit code {proc.returncode}: {detail}"
298
- if detail
299
- else f"Schema compression failed with exit code {proc.returncode}. Passing through unchanged."
300
- )
301
- skip()
302
-
303
- compressed = proc.stdout.strip()
304
- if not compressed or not _is_json_array(compressed):
295
+ compressed = response.get("tools")
296
+ if not isinstance(compressed, list):
305
297
  warn(
306
298
  "Schema compression returned invalid JSON. Passing through unchanged."
307
299
  )
@@ -313,7 +305,7 @@ def main() -> None:
313
305
  "hookEventName": "BeforeModel",
314
306
  "llm_request": {
315
307
  "config": {
316
- "tools": json.loads(compressed),
308
+ "tools": compressed,
317
309
  },
318
310
  },
319
311
  },
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import json
6
7
  import os
7
8
  import re
8
9
  import shutil
9
10
  import subprocess
10
11
  import sys
12
+ import time
11
13
 
12
14
  # -- Binary fallback paths ----------------------------------------------------
13
15
  #
@@ -35,16 +37,10 @@ def _user_path(*parts: str) -> str:
35
37
 
36
38
 
37
39
  _TOKENLESS_FALLBACK = "/usr/bin/tokenless"
38
- _TOKENLESS_LOCAL_SHARE = _user_path(
39
- ".local", "share", "anolisa", "tokenless", "tokenless"
40
- )
41
- _TOKENLESS_LOCAL_LIB = _user_path(
42
- ".local", "lib", "anolisa", "tokenless", "tokenless"
43
- )
40
+ _TOKENLESS_LOCAL_SHARE = _user_path(".local", "share", "anolisa", "tokenless", "tokenless")
41
+ _TOKENLESS_LOCAL_LIB = _user_path(".local", "lib", "anolisa", "tokenless", "tokenless")
44
42
  _RTK_FALLBACK = "/usr/libexec/anolisa/tokenless/rtk"
45
- _RTK_LOCAL_SHARE = _user_path(
46
- ".local", "share", "anolisa", "tokenless", "rtk"
47
- )
43
+ _RTK_LOCAL_SHARE = _user_path(".local", "share", "anolisa", "tokenless", "rtk")
48
44
  _RTK_LOCAL_LIB = _user_path(".local", "lib", "anolisa", "tokenless", "rtk")
49
45
 
50
46
  _TOKENLESS_HELPER_BINARIES = frozenset({"rtk"})
@@ -87,9 +83,7 @@ def _known_binary_paths(name: str, home: str | None = None) -> tuple[str, ...]:
87
83
  paths.append(os.path.join("/usr/local/bin", name))
88
84
  if name in _TOKENLESS_HELPER_BINARIES:
89
85
  # Anolisa CLI system mode.
90
- paths.append(
91
- os.path.join("/usr/local/libexec/anolisa/tokenless", name)
92
- )
86
+ paths.append(os.path.join("/usr/local/libexec/anolisa/tokenless", name))
93
87
  paths.append(os.path.join("/usr/bin", name))
94
88
  if name in _TOKENLESS_HELPER_BINARIES:
95
89
  paths.extend(
@@ -103,16 +97,13 @@ def _known_binary_paths(name: str, home: str | None = None) -> tuple[str, ...]:
103
97
  if user_home:
104
98
  paths.extend(
105
99
  [
106
- os.path.join(
107
- user_home, ".local", "share", "anolisa", "tokenless", name
108
- ),
109
- os.path.join(
110
- user_home, ".local", "lib", "anolisa", "tokenless", name
111
- ),
100
+ os.path.join(user_home, ".local", "share", "anolisa", "tokenless", name),
101
+ os.path.join(user_home, ".local", "lib", "anolisa", "tokenless", name),
112
102
  ]
113
103
  )
114
104
  return tuple(paths)
115
105
 
106
+
116
107
  # -- Unified tool categorization ----------------------------------------------
117
108
 
118
109
  # Tool categories are loaded from tool_categories.json, which serves as the
@@ -126,16 +117,38 @@ _TOOL_CATEGORIES_PATH = os.path.join(os.path.dirname(__file__), "tool_categories
126
117
  # invalid. Matches the minimum safe classification from before the JSON was
127
118
  # introduced, ensuring content-retrieval tools are never accidentally compressed.
128
119
  _FALLBACK_SKIP_TOOLS = [
129
- "Read", "read", "read_file", "read_many_files",
130
- "Glob", "glob", "search_file", "list_directory", "list_dir",
131
- "Grep", "grep", "grep_code", "grep_search", "search_files",
132
- "Lsp", "lsp",
133
- "NotebookRead", "notebook_read", "notebookread",
120
+ "Read",
121
+ "read",
122
+ "read_file",
123
+ "read_many_files",
124
+ "Glob",
125
+ "glob",
126
+ "search_file",
127
+ "list_directory",
128
+ "list_dir",
129
+ "Grep",
130
+ "grep",
131
+ "grep_code",
132
+ "grep_search",
133
+ "search_files",
134
+ "Lsp",
135
+ "lsp",
136
+ "NotebookRead",
137
+ "notebook_read",
138
+ "notebookread",
134
139
  ]
135
140
  _FALLBACK_SHELL_TOOLS = [
136
- "Bash", "bash", "Shell", "shell", "exec", "terminal",
137
- "run_shell_command", "run_in_terminal", "get_terminal_output",
138
- "execute_command", "process",
141
+ "Bash",
142
+ "bash",
143
+ "Shell",
144
+ "shell",
145
+ "exec",
146
+ "terminal",
147
+ "run_shell_command",
148
+ "run_in_terminal",
149
+ "get_terminal_output",
150
+ "execute_command",
151
+ "process",
139
152
  ]
140
153
 
141
154
 
@@ -187,6 +200,29 @@ SKIP_TOOLS: set[str] = set(_tool_categories.get("layer_1_skip", {}).get("tools",
187
200
  # These tools produce text output that can be safely truncated if too long.
188
201
  SHELL_TOOLS: set[str] = set(_tool_categories.get("layer_2_shell", {}).get("tools", []))
189
202
 
203
+ _TOKENLESS_RETRIEVE_COMMAND_RE = re.compile(
204
+ r"^[ \t]*(?:\"tokenless\"|'tokenless'|tokenless)[ \t]+retrieve[ \t]+"
205
+ r"(?:\"(?:[0-9a-f]{24}|<<tokenless:[0-9a-f]{24}>>)\"|"
206
+ r"'(?:[0-9a-f]{24}|<<tokenless:[0-9a-f]{24}>>)'|[0-9a-f]{24})[ \t]*$",
207
+ re.IGNORECASE,
208
+ )
209
+
210
+
211
+ def tokenless_retrieve_command_available() -> bool:
212
+ """Return whether a Marker command can invoke bare ``tokenless``."""
213
+ return shutil.which("tokenless") is not None
214
+
215
+
216
+ def is_tokenless_retrieve_command(tool_name: str, arguments: object) -> bool:
217
+ """Recognize the exact local recovery command emitted by Tokenless markers."""
218
+ if tool_name not in SHELL_TOOLS or not isinstance(arguments, dict):
219
+ return False
220
+ command = arguments.get("command")
221
+ if not isinstance(command, str):
222
+ return False
223
+ return _TOKENLESS_RETRIEVE_COMMAND_RE.fullmatch(command) is not None
224
+
225
+
190
226
  # Layer 3: API tools (zero-truncation).
191
227
  # These tools return structured data or API responses that should not be truncated.
192
228
  # No explicit set needed; tools not in SKIP_TOOLS or SHELL_TOOLS are Layer 3.
@@ -196,7 +232,7 @@ SHELL_TOOLS: set[str] = set(_tool_categories.get("layer_2_shell", {}).get("tools
196
232
  # is missing or the file failed to load.
197
233
 
198
234
  # Layer 2 thresholds: moderate truncation for shell/exec output.
199
- # Restores old ResponseCompressor defaults for shell commands (git log, ls,
235
+ # Restores the old JSON compression defaults for shell commands (git log, ls,
200
236
  # cat, etc.) where truncation is acceptable.
201
237
  # 64K strings: 95% of real shell output (git diff ~63K, git log ~34K) preserved.
202
238
  # 128 arrays: 95% of result sets (test results, audit reports) preserved.
@@ -211,11 +247,6 @@ _TRUNCATE_STRINGS_AT = _layer3_thr.get("truncate_strings_at", 1_048_576)
211
247
  _TRUNCATE_ARRAYS_AT = _layer3_thr.get("truncate_arrays_at", 65_536)
212
248
  _MAX_DEPTH = _layer3_thr.get("max_depth", 32)
213
249
 
214
- # Backward-compatible alias — direct reference (not a copy) so consumers see
215
- # the same set as SKIP_TOOLS. Used by compress_toon_hook.py for the standalone
216
- # TOON-only path where "content retrieval" is the more descriptive name.
217
- CONTENT_RETRIEVAL_TOOLS = SKIP_TOOLS
218
-
219
250
 
220
251
  def get_thresholds(tool_name: str) -> tuple[int, int, int]:
221
252
  """Return (truncate_strings_at, truncate_arrays_at, max_depth) for a tool.
@@ -231,9 +262,9 @@ def get_thresholds(tool_name: str) -> tuple[int, int, int]:
231
262
 
232
263
  # -- Shared environment error patterns ----------------------------------------
233
264
  #
234
- # Superset of patterns from both the codex and hook adapters. Uses regex
265
+ # Superset of patterns from both the Codex diagnostics and shared hook adapters. Uses regex
235
266
  # matching (case-insensitive) so patterns like "/bin/sh:.*: not found" work
236
- # correctly. Both codex/scripts/compress-response and compress_response_hook
267
+ # correctly. Both codex/scripts/response-diagnostics and compress_response_hook
237
268
  # import this list and the classify_env_error() function below.
238
269
 
239
270
  ENV_PATTERNS: list[tuple[list[str], str, str]] = [
@@ -315,7 +346,7 @@ def classify_env_error(tool_response) -> tuple[str | None, str | None]:
315
346
  Accepts either a parsed dict (with stderr/error/exit_code fields) or a
316
347
  plain string. Returns (category_tag, fix_hint) or (None, None).
317
348
 
318
- Shared by codex/scripts/compress-response and compress_response_hook.
349
+ Shared by codex/scripts/response-diagnostics and compress_response_hook.
319
350
  """
320
351
  if isinstance(tool_response, dict):
321
352
  text = str(tool_response.get("stderr", "")) + str(tool_response.get("error", ""))
@@ -346,6 +377,9 @@ def classify_env_error(tool_response) -> tuple[str | None, str | None]:
346
377
 
347
378
  _CONTEXT_DIR = os.path.join(os.path.expanduser("~"), ".tokenless")
348
379
  _CONTEXT_FILE = os.path.join(_CONTEXT_DIR, ".rewrite-context")
380
+ _OPTIMIZATION_STATE_DIR = os.path.join(_CONTEXT_DIR, "hook-state")
381
+ _OPTIMIZATION_STATE_TTL_SECONDS = 24 * 60 * 60
382
+ _OPTIMIZATION_STATE_MAX_FILES = 1024
349
383
 
350
384
  # -- Binary resolution (cached) -----------------------------------------------
351
385
 
@@ -412,9 +446,7 @@ def unwrap_string_json(raw: str) -> str | None:
412
446
  # characters (code points), not \uXXXX escape sequences, so
413
447
  # string-wrapped payloads are measured the same way as the
414
448
  # dict/list branch and the OpenClaw adapter.
415
- return json.dumps(
416
- inner_obj, separators=(",", ":"), ensure_ascii=False
417
- )
449
+ return json.dumps(inner_obj, separators=(",", ":"), ensure_ascii=False)
418
450
  return None
419
451
  return raw
420
452
 
@@ -474,6 +506,77 @@ def write_context(agent_id: str, session_id: str, tool_use_id: str) -> None:
474
506
  secure_write_text(_CONTEXT_FILE, f"{agent_id}\n{session_id}\n{tool_use_id}\n")
475
507
 
476
508
 
509
+ def _optimization_state_path(agent_id: str, session_id: str, tool_use_id: str) -> str:
510
+ identity = "\0".join((agent_id, session_id, tool_use_id)).encode()
511
+ digest = hashlib.sha256(identity).hexdigest()
512
+ return os.path.join(_OPTIMIZATION_STATE_DIR, digest)
513
+
514
+
515
+ def _prune_optimization_states() -> None:
516
+ """Bound abandoned per-call state when a host omits PostToolUse."""
517
+ try:
518
+ entries = os.scandir(_OPTIMIZATION_STATE_DIR)
519
+ except FileNotFoundError:
520
+ return
521
+
522
+ cutoff = time.time() - _OPTIMIZATION_STATE_TTL_SECONDS
523
+ live = []
524
+ with entries:
525
+ for entry in entries:
526
+ try:
527
+ if not entry.is_file(follow_symlinks=False):
528
+ continue
529
+ modified = entry.stat(follow_symlinks=False).st_mtime
530
+ if ".consuming." in entry.name and modified > cutoff:
531
+ continue
532
+ if modified <= cutoff:
533
+ os.unlink(entry.path)
534
+ else:
535
+ live.append((modified, entry.path))
536
+ except FileNotFoundError:
537
+ continue
538
+
539
+ excess = len(live) - _OPTIMIZATION_STATE_MAX_FILES + 1
540
+ if excess > 0:
541
+ for _, path in sorted(live)[:excess]:
542
+ try:
543
+ os.unlink(path)
544
+ except FileNotFoundError:
545
+ pass
546
+
547
+
548
+ def mark_rtk_optimized(agent_id: str, session_id: str, tool_use_id: str) -> None:
549
+ """Persist RTK ownership for one tool call before applying its rewrite."""
550
+ _prune_optimization_states()
551
+ secure_write_text(_optimization_state_path(agent_id, session_id, tool_use_id), "rtk\n")
552
+
553
+
554
+ def consume_output_optimization(agent_id: str, session_id: str, tool_use_id: str) -> str:
555
+ """Consume one tool call's optimization state for its final result."""
556
+ if not tool_use_id:
557
+ return "none"
558
+ path = _optimization_state_path(agent_id, session_id, tool_use_id)
559
+ consuming_path = f"{path}.consuming.{os.getpid()}"
560
+ try:
561
+ os.rename(path, consuming_path)
562
+ except FileNotFoundError:
563
+ return "none"
564
+
565
+ try:
566
+ flags = os.O_RDONLY
567
+ if hasattr(os, "O_NOFOLLOW"):
568
+ flags |= os.O_NOFOLLOW
569
+ fd = os.open(consuming_path, flags)
570
+ with os.fdopen(fd) as state_file:
571
+ state = state_file.read()
572
+ finally:
573
+ try:
574
+ os.unlink(consuming_path)
575
+ except FileNotFoundError:
576
+ pass
577
+ return "rtk" if state == "rtk\n" else "none"
578
+
579
+
477
580
  def forward_stderr(proc: subprocess.CompletedProcess) -> None:
478
581
  """Forward subprocess stderr on failure (non-zero exit) via warn()."""
479
582
  if proc.returncode != 0 and proc.stderr:
@@ -496,6 +599,144 @@ def run(args: list[str], input_data: str, timeout: int = 3) -> subprocess.Comple
496
599
  return None
497
600
 
498
601
 
602
+ def _attribution(
603
+ agent_id: str,
604
+ session_id: str = "",
605
+ tool_use_id: str = "",
606
+ ) -> dict:
607
+ """Build the shared Protocol v2 attribution object."""
608
+ value = {"agent_id": agent_id}
609
+ if session_id:
610
+ value["session_id"] = session_id
611
+ if tool_use_id:
612
+ value["tool_use_id"] = tool_use_id
613
+ return value
614
+
615
+
616
+ def build_before_model_request(
617
+ tools: list,
618
+ visible_context: object,
619
+ agent_id: str,
620
+ session_id: str = "",
621
+ tool_use_id: str = "",
622
+ ) -> dict:
623
+ """Build a Protocol v2 BeforeModel transport request."""
624
+ return {
625
+ "protocol_version": 2,
626
+ "operation": "before_model",
627
+ "attribution": _attribution(agent_id, session_id, tool_use_id),
628
+ "input": {
629
+ "tools": tools,
630
+ "visible_context": visible_context,
631
+ "capabilities": {
632
+ "replace_tools": True,
633
+ # A local CLI is not marker-scoped Agent authorization.
634
+ "recovery": {"kind": "none"},
635
+ },
636
+ },
637
+ }
638
+
639
+
640
+ def build_pre_tool_request(
641
+ arguments: dict,
642
+ agent_id: str,
643
+ tool_name: str,
644
+ command_field: str,
645
+ session_id: str = "",
646
+ tool_use_id: str = "",
647
+ replace_arguments: bool = True,
648
+ block_and_suggest: bool = False,
649
+ ) -> dict:
650
+ """Build a Protocol v2 PreTool transport request."""
651
+ return {
652
+ "protocol_version": 2,
653
+ "operation": "pre_tool",
654
+ "attribution": _attribution(agent_id, session_id, tool_use_id),
655
+ "input": {
656
+ "tool_name": tool_name,
657
+ "arguments": arguments,
658
+ "command_field": command_field,
659
+ "capabilities": {
660
+ "replace_arguments": replace_arguments,
661
+ "block_and_suggest": block_and_suggest,
662
+ },
663
+ },
664
+ }
665
+
666
+
667
+ def build_post_tool_request(
668
+ content: str,
669
+ agent_id: str,
670
+ tool_name: str,
671
+ status: str,
672
+ content_origin: str,
673
+ output_optimization: str,
674
+ *,
675
+ result_kind: str,
676
+ recovery: dict[str, str],
677
+ session_id: str = "",
678
+ tool_use_id: str = "",
679
+ replace_output: bool = False,
680
+ replace_with_text: bool = False,
681
+ ) -> dict:
682
+ """Build a Protocol v2 PostTool transport request."""
683
+ return {
684
+ "protocol_version": 2,
685
+ "operation": "post_tool",
686
+ "attribution": _attribution(agent_id, session_id, tool_use_id),
687
+ "input": {
688
+ "result_kind": result_kind,
689
+ "tool_name": tool_name,
690
+ "content": content,
691
+ "status": status,
692
+ "content_origin": content_origin,
693
+ "output_optimization": output_optimization,
694
+ "capabilities": {
695
+ "replace_output": replace_output,
696
+ "recovery": recovery,
697
+ "replace_with_text": replace_with_text,
698
+ },
699
+ },
700
+ }
701
+
702
+
703
+ def run_compress(
704
+ tokenless_bin: str, request: dict, timeout: int, expected_operation: str
705
+ ) -> dict | None:
706
+ """Run ``tokenless compress`` on one request; None on any failure.
707
+
708
+ The single Tokenless subprocess of a hook invocation (roadmap §5.6).
709
+ Fail-open: a dead binary, non-zero exit, or malformed stdout all
710
+ return None so the caller passes the original through.
711
+ """
712
+ proc = run(
713
+ [tokenless_bin, "compress"],
714
+ json.dumps(request, ensure_ascii=False),
715
+ timeout=timeout,
716
+ )
717
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
718
+ if proc is not None and proc.returncode != 0:
719
+ warn(f"tokenless compress exited {proc.returncode}")
720
+ return None
721
+ response = try_parse_json(proc.stdout.strip())
722
+ if not isinstance(response, dict):
723
+ warn("tokenless compress returned malformed output")
724
+ return None
725
+ if response.get("protocol_version") != 2:
726
+ # Version-skewed binary: never trust a response whose contract
727
+ # this adapter does not speak.
728
+ warn("tokenless compress returned an unsupported protocol version")
729
+ return None
730
+ if response.get("operation") != expected_operation:
731
+ warn("tokenless compress returned a mismatched operation")
732
+ return None
733
+ result = response.get("result")
734
+ if not isinstance(result, dict):
735
+ warn("tokenless compress returned a malformed operation result")
736
+ return None
737
+ return result
738
+
739
+
499
740
  def detect_cosh_ng_runtime() -> tuple | None:
500
741
  """Detect if we are running under Cosh-NG and return its version.
501
742
 
@@ -547,7 +788,7 @@ def _agent_id_from_argv(argv: list[str] | None) -> str | None:
547
788
  following = args[index + 1] if index + 1 < len(args) else ""
548
789
  return following or None
549
790
  if arg.startswith("--agent-id="):
550
- return arg[len("--agent-id="):] or None
791
+ return arg[len("--agent-id=") :] or None
551
792
  return None
552
793
 
553
794
 
@@ -575,11 +816,7 @@ def resolve_agent_id(default: str = "unknown", argv: list[str] | None = None) ->
575
816
  """
576
817
  if detect_cosh_ng_runtime() is not None:
577
818
  return "cosh-ng"
578
- return (
579
- _agent_id_from_argv(argv)
580
- or os.environ.get("TOKENLESS_AGENT_ID")
581
- or default
582
- )
819
+ return _agent_id_from_argv(argv) or os.environ.get("TOKENLESS_AGENT_ID") or default
583
820
 
584
821
 
585
822
  def parse_version(version_str: str) -> tuple | None: