anolisa-tokenless 0.7.7

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 (70) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +770 -0
  3. package/adapters/tokenless/claude-code/.claude-plugin/marketplace.json +15 -0
  4. package/adapters/tokenless/claude-code/.claude-plugin/plugin.json +9 -0
  5. package/adapters/tokenless/claude-code/hooks/hooks.json +38 -0
  6. package/adapters/tokenless/claude-code/scripts/detect.sh +193 -0
  7. package/adapters/tokenless/claude-code/scripts/install.sh +92 -0
  8. package/adapters/tokenless/claude-code/scripts/uninstall.sh +50 -0
  9. package/adapters/tokenless/codex/.codex-plugin/plugin.json +20 -0
  10. package/adapters/tokenless/codex/README.md +160 -0
  11. package/adapters/tokenless/codex/hooks/hooks.json +52 -0
  12. package/adapters/tokenless/codex/scripts/_common.sh +25 -0
  13. package/adapters/tokenless/codex/scripts/check-tokenless +94 -0
  14. package/adapters/tokenless/codex/scripts/compress-response +441 -0
  15. package/adapters/tokenless/codex/scripts/detect.sh +61 -0
  16. package/adapters/tokenless/codex/scripts/install.sh +151 -0
  17. package/adapters/tokenless/codex/scripts/rewrite-hook +282 -0
  18. package/adapters/tokenless/codex/scripts/tool-ready +303 -0
  19. package/adapters/tokenless/codex/scripts/uninstall.sh +78 -0
  20. package/adapters/tokenless/common/commands/tokenless-stats.toml +2 -0
  21. package/adapters/tokenless/common/cosh-extension.json +65 -0
  22. package/adapters/tokenless/common/hooks/compress_response_hook.py +487 -0
  23. package/adapters/tokenless/common/hooks/compress_schema_hook.py +144 -0
  24. package/adapters/tokenless/common/hooks/compress_toon_hook.py +160 -0
  25. package/adapters/tokenless/common/hooks/hook_utils.py +584 -0
  26. package/adapters/tokenless/common/hooks/rewrite_hook.py +223 -0
  27. package/adapters/tokenless/common/hooks/run-hook.sh +62 -0
  28. package/adapters/tokenless/common/hooks/tool_categories.json +99 -0
  29. package/adapters/tokenless/common/hooks/tool_ready_hook.sh +571 -0
  30. package/adapters/tokenless/common/tokenless-env-fix.sh +730 -0
  31. package/adapters/tokenless/common/tool-ready-spec.json +113 -0
  32. package/adapters/tokenless/dsh/cordis.patch.yml +8 -0
  33. package/adapters/tokenless/dsh/dist/index.js +399 -0
  34. package/adapters/tokenless/dsh/package.json +26 -0
  35. package/adapters/tokenless/hermes/__init__.py +572 -0
  36. package/adapters/tokenless/hermes/plugin.yaml +9 -0
  37. package/adapters/tokenless/hermes/scripts/detect.sh +80 -0
  38. package/adapters/tokenless/hermes/scripts/install.sh +60 -0
  39. package/adapters/tokenless/hermes/scripts/uninstall.sh +45 -0
  40. package/adapters/tokenless/manifest.json +147 -0
  41. package/adapters/tokenless/openclaw/dist/index.d.ts +27 -0
  42. package/adapters/tokenless/openclaw/dist/index.js +598 -0
  43. package/adapters/tokenless/openclaw/dist/tool_categories.json +99 -0
  44. package/adapters/tokenless/openclaw/index.ts +720 -0
  45. package/adapters/tokenless/openclaw/openclaw.plugin.json +40 -0
  46. package/adapters/tokenless/openclaw/package.json +24 -0
  47. package/adapters/tokenless/openclaw/scripts/detect.sh +102 -0
  48. package/adapters/tokenless/openclaw/scripts/install.sh +75 -0
  49. package/adapters/tokenless/openclaw/scripts/uninstall.sh +46 -0
  50. package/adapters/tokenless/openclaw/tsconfig.json +13 -0
  51. package/adapters/tokenless/opencode/plugin.js +246 -0
  52. package/adapters/tokenless/opencode/scripts/detect.sh +38 -0
  53. package/adapters/tokenless/opencode/scripts/install.sh +56 -0
  54. package/adapters/tokenless/opencode/scripts/uninstall.sh +29 -0
  55. package/adapters/tokenless/qoder/.qoder-plugin/plugin.json +11 -0
  56. package/adapters/tokenless/qoder/commands/tokenless-stats.md +8 -0
  57. package/adapters/tokenless/qoder/hooks/hooks.json +36 -0
  58. package/adapters/tokenless/qoder/hooks/run-hook.sh +51 -0
  59. package/adapters/tokenless/qoder/scripts/detect.sh +35 -0
  60. package/adapters/tokenless/qoder/scripts/install.sh +136 -0
  61. package/adapters/tokenless/qoder/scripts/uninstall.sh +106 -0
  62. package/adapters/tokenless/qwencode/qwen-extension.json +69 -0
  63. package/adapters/tokenless/qwencode/scripts/detect.sh +125 -0
  64. package/adapters/tokenless/qwencode/scripts/install.sh +128 -0
  65. package/adapters/tokenless/qwencode/scripts/uninstall.sh +62 -0
  66. package/bin/rtk +6 -0
  67. package/bin/tokenless +6 -0
  68. package/bin/toon +6 -0
  69. package/package.json +57 -0
  70. package/scripts/postinstall.js +208 -0
@@ -0,0 +1,584 @@
1
+ """Shared utilities for tokenless Python hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+
12
+ # -- Binary fallback paths ----------------------------------------------------
13
+ #
14
+ # Tokenless has three supported installers whose layouts differ:
15
+ #
16
+ # - Makefile: ~/.local/{bin,libexec} or /usr/{bin,libexec}
17
+ # - Anolisa CLI: ~/.local/{bin,lib/anolisa/libexec} or /usr/local/{bin,libexec}
18
+ # - RPM: /usr/{bin,libexec}
19
+ #
20
+ # Keep the legacy lib/share paths until installations made by older releases
21
+ # have aged out.
22
+ #
23
+ # KEEP IN SYNC with tool_ready_hook.sh::resolve_binary,
24
+ # env_check.rs::binary_fallback_paths, OpenClaw's fallback constants, and the
25
+ # Codex standalone scripts. Makefile and the Anolisa component manifest define
26
+ # the supported layouts; the canonical order is user, /usr/local, /usr, legacy.
27
+
28
+ _USER_HOME = os.path.expanduser("~")
29
+ if not _USER_HOME or not os.path.isabs(_USER_HOME):
30
+ _USER_HOME = ""
31
+
32
+
33
+ def _user_path(*parts: str) -> str:
34
+ return os.path.join(_USER_HOME, *parts) if _USER_HOME else ""
35
+
36
+
37
+ _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
+ )
44
+ _RTK_FALLBACK = "/usr/libexec/anolisa/tokenless/rtk"
45
+ _RTK_LOCAL_SHARE = _user_path(
46
+ ".local", "share", "anolisa", "tokenless", "rtk"
47
+ )
48
+ _RTK_LOCAL_LIB = _user_path(".local", "lib", "anolisa", "tokenless", "rtk")
49
+
50
+ _TOKENLESS_HELPER_BINARIES = frozenset({"rtk", "toon"})
51
+
52
+
53
+ def _known_binary_paths(name: str, home: str | None = None) -> tuple[str, ...]:
54
+ """Return install-layout fallbacks for a binary outside ``PATH``."""
55
+ if not name or os.path.basename(name) != name or name in {".", ".."}:
56
+ return ()
57
+ home = os.path.expanduser("~") if home is None else home
58
+ user_home = home if home and os.path.isabs(home) else None
59
+ paths = []
60
+ if user_home:
61
+ paths.append(os.path.join(user_home, ".local", "bin", name))
62
+ if name in _TOKENLESS_HELPER_BINARIES:
63
+ if user_home:
64
+ paths.extend(
65
+ [
66
+ # Anolisa CLI user mode.
67
+ os.path.join(
68
+ user_home,
69
+ ".local",
70
+ "lib",
71
+ "anolisa",
72
+ "libexec",
73
+ "tokenless",
74
+ name,
75
+ ),
76
+ # Makefile user mode.
77
+ os.path.join(
78
+ user_home,
79
+ ".local",
80
+ "libexec",
81
+ "anolisa",
82
+ "tokenless",
83
+ name,
84
+ ),
85
+ ]
86
+ )
87
+ paths.append(os.path.join("/usr/local/bin", name))
88
+ if name in _TOKENLESS_HELPER_BINARIES:
89
+ # Anolisa CLI system mode.
90
+ paths.append(
91
+ os.path.join("/usr/local/libexec/anolisa/tokenless", name)
92
+ )
93
+ paths.append(os.path.join("/usr/bin", name))
94
+ if name in _TOKENLESS_HELPER_BINARIES:
95
+ paths.extend(
96
+ [
97
+ # Makefile system mode and RPM.
98
+ os.path.join("/usr/libexec/anolisa/tokenless", name),
99
+ # Debian and pre-layout-migration compatibility.
100
+ os.path.join("/usr/lib/anolisa/tokenless", name),
101
+ ]
102
+ )
103
+ if user_home:
104
+ paths.extend(
105
+ [
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
+ ),
112
+ ]
113
+ )
114
+ return tuple(paths)
115
+
116
+ # -- Unified tool categorization ----------------------------------------------
117
+
118
+ # Tool categories are loaded from tool_categories.json, which serves as the
119
+ # single source of truth for both tool-ready and compression strategies.
120
+ # This fixes inconsistencies like Grep being classified as Shell in some places
121
+ # and Read in others.
122
+
123
+ _TOOL_CATEGORIES_PATH = os.path.join(os.path.dirname(__file__), "tool_categories.json")
124
+
125
+ # Hardcoded fallback sets — used only when tool_categories.json is missing or
126
+ # invalid. Matches the minimum safe classification from before the JSON was
127
+ # introduced, ensuring content-retrieval tools are never accidentally compressed.
128
+ _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",
134
+ ]
135
+ _FALLBACK_SHELL_TOOLS = [
136
+ "Bash", "bash", "Shell", "shell", "exec", "terminal",
137
+ "run_shell_command", "run_in_terminal", "get_terminal_output",
138
+ "execute_command", "process",
139
+ ]
140
+
141
+
142
+ def _load_tool_categories() -> dict:
143
+ """Load tool categories from unified JSON file with validation."""
144
+ try:
145
+ with open(_TOOL_CATEGORIES_PATH, "r") as f:
146
+ data = json.load(f)
147
+
148
+ # Validate required structure
149
+ required_layers = ["layer_1_skip", "layer_2_shell", "layer_3_api"]
150
+ for layer in required_layers:
151
+ if layer not in data:
152
+ raise ValueError(f"Missing required layer: {layer}")
153
+ if not isinstance(data[layer], dict):
154
+ raise ValueError(f"Layer {layer} must be a dict")
155
+
156
+ # layer_1 and layer_2 require a "tools" list; layer_3 is implicit
157
+ for layer in ("layer_1_skip", "layer_2_shell"):
158
+ if "tools" not in data[layer]:
159
+ raise ValueError(f"Layer {layer} missing 'tools' field")
160
+ if not isinstance(data[layer]["tools"], list):
161
+ raise ValueError(f"Layer {layer}.tools must be a list")
162
+
163
+ return data
164
+ except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
165
+ print(f"Warning: Failed to load tool_categories.json: {e}", file=sys.stderr)
166
+ # Fallback to hardcoded safe sets so content-retrieval tools are
167
+ # never accidentally compressed even if the JSON is unavailable.
168
+ return {
169
+ "layer_1_skip": {"tools": list(_FALLBACK_SKIP_TOOLS)},
170
+ "layer_2_shell": {"tools": list(_FALLBACK_SHELL_TOOLS)},
171
+ "layer_3_api": {},
172
+ }
173
+
174
+
175
+ _tool_categories = _load_tool_categories()
176
+
177
+ # 3-layer Compression strategy:
178
+ # Layer 1: Content retrieval (Read/Glob/Grep) → skip all compression
179
+ # Layer 2: Shell/exec (Bash/Shell/exec) → moderate truncation (64K strings)
180
+ # Layer 3: API/structured (all other) → zero-truncation cleanup (1M strings)
181
+
182
+ # Layer 1: Content retrieval tools — skip all compression (preserve integrity).
183
+ # These tools return file content or search results that should not be truncated.
184
+ SKIP_TOOLS: set[str] = set(_tool_categories.get("layer_1_skip", {}).get("tools", []))
185
+
186
+ # Layer 2: Shell/exec tools (moderate truncation).
187
+ # These tools produce text output that can be safely truncated if too long.
188
+ SHELL_TOOLS: set[str] = set(_tool_categories.get("layer_2_shell", {}).get("tools", []))
189
+
190
+ # Layer 3: API tools (zero-truncation).
191
+ # These tools return structured data or API responses that should not be truncated.
192
+ # No explicit set needed; tools not in SKIP_TOOLS or SHELL_TOOLS are Layer 3.
193
+
194
+ # Thresholds are read from tool_categories.json (single source of truth).
195
+ # Hardcoded fallbacks match the JSON defaults; used only if the JSON field
196
+ # is missing or the file failed to load.
197
+
198
+ # Layer 2 thresholds: moderate truncation for shell/exec output.
199
+ # Restores old ResponseCompressor defaults for shell commands (git log, ls,
200
+ # cat, etc.) where truncation is acceptable.
201
+ # 64K strings: 95% of real shell output (git diff ~63K, git log ~34K) preserved.
202
+ # 128 arrays: 95% of result sets (test results, audit reports) preserved.
203
+ _layer2_thr = _tool_categories.get("layer_2_shell", {}).get("thresholds", {})
204
+ _SHELL_TRUNCATE_STRINGS_AT = _layer2_thr.get("truncate_strings_at", 65_536)
205
+ _SHELL_TRUNCATE_ARRAYS_AT = _layer2_thr.get("truncate_arrays_at", 128)
206
+ _SHELL_MAX_DEPTH = _layer2_thr.get("max_depth", 8)
207
+
208
+ # Layer 3 thresholds: zero-truncation for API/structured data.
209
+ _layer3_thr = _tool_categories.get("layer_3_api", {}).get("thresholds", {})
210
+ _TRUNCATE_STRINGS_AT = _layer3_thr.get("truncate_strings_at", 1_048_576)
211
+ _TRUNCATE_ARRAYS_AT = _layer3_thr.get("truncate_arrays_at", 65_536)
212
+ _MAX_DEPTH = _layer3_thr.get("max_depth", 32)
213
+
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
+
220
+ def get_thresholds(tool_name: str) -> tuple[int, int, int]:
221
+ """Return (truncate_strings_at, truncate_arrays_at, max_depth) for a tool.
222
+
223
+ Layer 2 (shell/exec) tools use moderate truncation; all others use
224
+ Layer 3 zero-truncation thresholds. Single dispatch point used by all
225
+ adapters (codex, hermes, openclaw, compress_response_hook).
226
+ """
227
+ if tool_name in SHELL_TOOLS:
228
+ return (_SHELL_TRUNCATE_STRINGS_AT, _SHELL_TRUNCATE_ARRAYS_AT, _SHELL_MAX_DEPTH)
229
+ return (_TRUNCATE_STRINGS_AT, _TRUNCATE_ARRAYS_AT, _MAX_DEPTH)
230
+
231
+
232
+ # -- Shared environment error patterns ----------------------------------------
233
+ #
234
+ # Superset of patterns from both the codex and hook adapters. Uses regex
235
+ # matching (case-insensitive) so patterns like "/bin/sh:.*: not found" work
236
+ # correctly. Both codex/scripts/compress-response and compress_response_hook
237
+ # import this list and the classify_env_error() function below.
238
+
239
+ ENV_PATTERNS: list[tuple[list[str], str, str]] = [
240
+ (
241
+ [
242
+ "command not found",
243
+ "not installed",
244
+ "which: no",
245
+ r"No command\s",
246
+ "cannot execute",
247
+ "is not recognized",
248
+ "Could not find",
249
+ "unable to locate",
250
+ "Package not found",
251
+ r"/bin/sh:.*: not found",
252
+ "command not found:",
253
+ ],
254
+ "ENV_DEPENDENCY_MISSING",
255
+ "Missing dependency detected. Install it or ask the user for guidance.",
256
+ ),
257
+ (
258
+ [
259
+ "Permission denied",
260
+ "permission denied",
261
+ "Operation not permitted",
262
+ "EACCES",
263
+ "Access denied",
264
+ r"cannot open .* for writing",
265
+ ],
266
+ "ENV_PERMISSION",
267
+ "Permission denied. Check file/directory permissions or run with appropriate access.",
268
+ ),
269
+ (
270
+ [
271
+ "No such file or directory",
272
+ "ENOENT",
273
+ "cannot find",
274
+ "File not found",
275
+ "does not exist",
276
+ ],
277
+ "ENV_FILE_MISSING",
278
+ "Required file or directory not found. Verify the path or create it.",
279
+ ),
280
+ (
281
+ [
282
+ "Connection refused",
283
+ "Could not resolve host",
284
+ "Network is unreachable",
285
+ r"curl: \(7\)",
286
+ r"curl: \(6\)",
287
+ "Failed to connect",
288
+ "Name or service not known",
289
+ "Couldn't resolve host",
290
+ "Temporary failure in name resolution",
291
+ "ECONNREFUSED",
292
+ "ETIMEDOUT",
293
+ "Connection timed out",
294
+ ],
295
+ "ENV_NETWORK",
296
+ "Network connectivity issue. Check DNS, proxy, and firewall settings.",
297
+ ),
298
+ (
299
+ [
300
+ "ModuleNotFoundError",
301
+ "ImportError",
302
+ "No module named",
303
+ "cannot import name",
304
+ "npm ERR! 404",
305
+ ],
306
+ "ENV_PACKAGE_MISSING",
307
+ "Required package or module is missing. Install the needed dependency.",
308
+ ),
309
+ ]
310
+
311
+
312
+ def classify_env_error(tool_response) -> tuple[str | None, str | None]:
313
+ """Detect environment errors in tool output.
314
+
315
+ Accepts either a parsed dict (with stderr/error/exit_code fields) or a
316
+ plain string. Returns (category_tag, fix_hint) or (None, None).
317
+
318
+ Shared by codex/scripts/compress-response and compress_response_hook.
319
+ """
320
+ if isinstance(tool_response, dict):
321
+ text = str(tool_response.get("stderr", "")) + str(tool_response.get("error", ""))
322
+ # Use `is None` — `or` would treat exit_code=0 (success) as falsy and
323
+ # incorrectly fall through to exitCode.
324
+ exit_code = tool_response.get("exit_code")
325
+ if exit_code is None:
326
+ exit_code = tool_response.get("exitCode")
327
+ if exit_code is not None and exit_code == 0 and not text:
328
+ return None, None
329
+ elif isinstance(tool_response, str):
330
+ text = tool_response
331
+ else:
332
+ return None, None
333
+
334
+ if not text:
335
+ return None, None
336
+
337
+ for patterns, category, hint in ENV_PATTERNS:
338
+ for pat in patterns:
339
+ if re.search(pat, text, re.IGNORECASE):
340
+ return category, hint
341
+
342
+ return None, None
343
+
344
+
345
+ # -- Context file for rewrite session tracking --
346
+
347
+ _CONTEXT_DIR = os.path.join(os.path.expanduser("~"), ".tokenless")
348
+ _CONTEXT_FILE = os.path.join(_CONTEXT_DIR, ".rewrite-context")
349
+
350
+ # -- Binary resolution (cached) -----------------------------------------------
351
+
352
+ _resolved_cache: dict[tuple, str | None] = {}
353
+
354
+
355
+ def resolve_binary(name: str, *fallback_paths: str) -> str | None:
356
+ """Locate a binary by PATH search, install layouts, then explicit paths.
357
+
358
+ Results are cached per name, explicit fallbacks, and home directory so
359
+ callers with distinct install contexts get independent entries.
360
+ """
361
+ home = os.path.expanduser("~")
362
+ cache_key = (name, fallback_paths, home)
363
+ if cache_key in _resolved_cache:
364
+ return _resolved_cache[cache_key]
365
+
366
+ result: str | None = None
367
+ path = shutil.which(name)
368
+ if path:
369
+ result = path
370
+ else:
371
+ candidates = dict.fromkeys((*_known_binary_paths(name, home), *fallback_paths))
372
+ for fp in candidates:
373
+ if fp and os.path.isfile(fp) and os.access(fp, os.X_OK):
374
+ result = fp
375
+ break
376
+
377
+ _resolved_cache[cache_key] = result
378
+ return result
379
+
380
+
381
+ def skip() -> None:
382
+ print(json.dumps({}))
383
+ sys.exit(0)
384
+
385
+
386
+ def skip_silent() -> None:
387
+ """Exit silently with empty stdout (codex protocol: empty stdout = passthrough)."""
388
+ sys.exit(0)
389
+
390
+
391
+ def warn(msg: str) -> None:
392
+ print(f"[tokenless] WARNING: {msg}", file=sys.stderr)
393
+
394
+
395
+ def try_parse_json(data: str) -> object | None:
396
+ try:
397
+ return json.loads(data)
398
+ except (json.JSONDecodeError, ValueError):
399
+ return None
400
+
401
+
402
+ def unwrap_string_json(raw: str) -> str | None:
403
+ """If raw is a JSON-encoded string whose inner content is valid JSON,
404
+ unwrap it into the inner JSON string. Returns None for plain text."""
405
+ if not raw.startswith('"'):
406
+ return raw
407
+ inner = try_parse_json(raw)
408
+ if isinstance(inner, str):
409
+ inner_obj = try_parse_json(inner)
410
+ if inner_obj is not None and isinstance(inner_obj, (dict, list)):
411
+ return json.dumps(inner_obj, separators=(",", ":"))
412
+ return None
413
+ return raw
414
+
415
+
416
+ def is_skill_file(text: str) -> bool:
417
+ """Detect YAML frontmatter markdown (skill files) that must not be compressed."""
418
+ if not text.startswith("---"):
419
+ return False
420
+ lines = text.split("\n", 20)
421
+ for line in lines[1:]:
422
+ if line.startswith("name:") or line.startswith("description:"):
423
+ return True
424
+ return False
425
+
426
+
427
+ def resolve_tool_call_id(agent_id: str, input_data: dict) -> str:
428
+ """Resolve the tool call identifier to record for a hook invocation.
429
+
430
+ Qwen Code's serialized hook input carries two identifiers: the internal
431
+ ``tool_use_id`` (``toolu_<ts>_<rand>``, always present) and
432
+ ``tool_call_id`` (the LLM provider's original call ID, e.g. ``call_xxx``,
433
+ snake_case — Qwen Code's TS uses camelCase ``toolCallId`` internally but
434
+ normalizes to ``tool_call_id`` on the wire). For the qwencode agent,
435
+ prefer the provider call ID and fall back to the internal one; for other
436
+ agents, keep the existing priority (``tool_use_id`` first).
437
+ """
438
+ if agent_id == "qwencode":
439
+ return (
440
+ input_data.get("tool_call_id")
441
+ or input_data.get("toolCallId")
442
+ or input_data.get("tool_use_id", "")
443
+ )
444
+ return input_data.get("tool_use_id") or input_data.get("toolCallId", "")
445
+
446
+
447
+ def secure_write_text(path: str, content: str) -> None:
448
+ """Write a private hook state file (0o600, symlink-safe).
449
+
450
+ Shared hardening for state files under ~/.tokenless: the parent directory
451
+ is created 0o700, symlinks are refused (unlink + O_NOFOLLOW) and the file
452
+ stays owner-readable only, so hook state never leaks through a shared or
453
+ mounted HOME.
454
+ """
455
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
456
+ if os.path.islink(path):
457
+ os.unlink(path)
458
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
459
+ if hasattr(os, "O_NOFOLLOW"):
460
+ flags |= os.O_NOFOLLOW
461
+ fd = os.open(path, flags, 0o600)
462
+ with os.fdopen(fd, "w") as f:
463
+ f.write(content)
464
+
465
+
466
+ def write_context(agent_id: str, session_id: str, tool_use_id: str) -> None:
467
+ """Write context file for rtk rewrite session tracking."""
468
+ secure_write_text(_CONTEXT_FILE, f"{agent_id}\n{session_id}\n{tool_use_id}\n")
469
+
470
+
471
+ def forward_stderr(proc: subprocess.CompletedProcess) -> None:
472
+ """Forward subprocess stderr on failure (non-zero exit) via warn()."""
473
+ if proc.returncode != 0 and proc.stderr:
474
+ warn(proc.stderr.rstrip())
475
+
476
+
477
+ def run(args: list[str], input_data: str, timeout: int = 3) -> subprocess.CompletedProcess | None:
478
+ """Run a subprocess with input data, returning None on failure."""
479
+ try:
480
+ proc = subprocess.run(
481
+ args,
482
+ input=input_data,
483
+ capture_output=True,
484
+ text=True,
485
+ timeout=timeout,
486
+ )
487
+ forward_stderr(proc)
488
+ return proc
489
+ except Exception:
490
+ return None
491
+
492
+
493
+ def detect_cosh_ng_runtime() -> tuple | None:
494
+ """Detect if we are running under Cosh-NG and return its version.
495
+
496
+ Returns:
497
+ A (major, minor, patch) version tuple if Cosh-NG is detected,
498
+ or (0, 0, 0) if Cosh-NG is detected but version is unknown
499
+ (unsupported), or None if not running under Cosh-NG.
500
+
501
+ Detection checks (in order):
502
+ 1. ``COSH_NG_VERSION`` environment variable — set by Cosh-NG when
503
+ launching hook processes.
504
+ 2. ``COSH_RUNTIME`` environment variable set to ``cosh-ng``.
505
+
506
+ The ``(0, 0, 0)`` sentinel means "Cosh-NG detected but version unknown" —
507
+ callers should treat this as "unsupported version" and fail open (disable
508
+ compression) rather than falling back to duplicate injection.
509
+ """
510
+ version_str = os.environ.get("COSH_NG_VERSION", "")
511
+ if version_str:
512
+ ver = parse_version(version_str)
513
+ if ver:
514
+ return ver
515
+ # Detected Cosh-NG but can't parse version — unsupported
516
+ return (0, 0, 0)
517
+
518
+ runtime = os.environ.get("COSH_RUNTIME", "")
519
+ if runtime == "cosh-ng":
520
+ # Cosh-NG detected via runtime env var but no version available
521
+ return (0, 0, 0)
522
+
523
+ return None
524
+
525
+
526
+ def _agent_id_from_argv(argv: list[str] | None) -> str | None:
527
+ """Return an explicit ``--agent-id`` value from hook command arguments.
528
+
529
+ Adapters declare the agent id in ``hooks.json``. The host always honours
530
+ the hook ``command`` string, but does not reliably propagate the declared
531
+ ``env`` map to every hook execution context — PreToolUse rewrite and
532
+ sub-agent task runs have been observed dropping it, collapsing attribution
533
+ to the fallback. An id passed as a command argument is therefore the robust
534
+ channel. Accepts both ``--agent-id VALUE`` and ``--agent-id=VALUE``;
535
+ unrelated arguments are ignored so hooks that take no other options are
536
+ unaffected.
537
+ """
538
+ args = sys.argv[1:] if argv is None else argv
539
+ for index, arg in enumerate(args):
540
+ if arg == "--agent-id":
541
+ following = args[index + 1] if index + 1 < len(args) else ""
542
+ return following or None
543
+ if arg.startswith("--agent-id="):
544
+ return arg[len("--agent-id="):] or None
545
+ return None
546
+
547
+
548
+ def resolve_agent_id(default: str = "unknown", argv: list[str] | None = None) -> str:
549
+ """Resolve the agent ID used for stats attribution.
550
+
551
+ Precedence, highest first:
552
+
553
+ 1. **Cosh-NG runtime detection.** The shared extension manifest sets
554
+ ``TOKENLESS_AGENT_ID=copilot-shell``, and since Cosh-NG honours hook
555
+ ``env`` (#1617) an unconditional read would attribute Cosh-NG sessions
556
+ to copilot-shell. ``COSH_RUNTIME`` / ``COSH_NG_VERSION`` are injected by
557
+ the host after the manifest env, so they win.
558
+ 2. **``--agent-id`` command argument** — the robust declared channel, since
559
+ the host always honours the hook ``command`` string.
560
+ 3. **``TOKENLESS_AGENT_ID`` environment variable** — the legacy declared
561
+ channel, kept for backward compatibility but not always propagated.
562
+ 4. **``default``** — a visible ``"unknown"`` sentinel rather than the tool's
563
+ own name, so an attribution gap is observable in stats instead of
564
+ masquerading as a real ``tokenless`` agent.
565
+
566
+ This is attribution only. The signal is cooperative — a manifest controls
567
+ its own ``command`` and env and could reassign these — so it must never
568
+ gate anything security-relevant.
569
+ """
570
+ if detect_cosh_ng_runtime() is not None:
571
+ return "cosh-ng"
572
+ return (
573
+ _agent_id_from_argv(argv)
574
+ or os.environ.get("TOKENLESS_AGENT_ID")
575
+ or default
576
+ )
577
+
578
+
579
+ def parse_version(version_str: str) -> tuple | None:
580
+ """Parse a version string like '0.35.0' into a (major, minor, patch) tuple."""
581
+ m = re.search(r"(\d+)\.(\d+)\.(\d+)", version_str)
582
+ if m:
583
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
584
+ return None