@oneciel-ai/claude-any 0.1.62 → 0.1.64

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.
package/README.md CHANGED
@@ -48,7 +48,7 @@ arguments through unchanged.
48
48
 
49
49
  Credits: One Ciel LLC
50
50
 
51
- Current version: `0.1.62`
51
+ Current version: `0.1.64`
52
52
 
53
53
  ## Why This Exists
54
54
 
@@ -385,6 +385,27 @@ steps under that larger model's supervision.
385
385
 
386
386
  ## Changelog
387
387
 
388
+ ### 0.1.64
389
+
390
+ - **Model-aware native auto-compact**: claude-any now injects
391
+ `CLAUDE_CODE_AUTO_COMPACT_WINDOW` at launch using the selected provider/model
392
+ context window, including the cached Ollama/Ollama Cloud model catalog. Smaller
393
+ custom models now let Claude Code's native auto-compact trigger against their
394
+ real context budget instead of falling back to Claude Code's generic 200K
395
+ assumption.
396
+
397
+ ### 0.1.63
398
+
399
+ - **Plan Mode stop guard**: when a non-Anthropic model is already in Plan Mode
400
+ and stops after a short acknowledgement without a tool call, the Stop hook
401
+ now returns structured JSON feedback so Claude Code continues with a
402
+ plan-mode-safe tool instead of leaking text into the prompt box.
403
+ - **Guard-feedback filtering**: claude-any filters its own plan-guard marker
404
+ from router history for all roles, preventing Stop hook recovery messages from
405
+ being sent back to upstream models.
406
+ - **Safer retry budget**: the Stop guard retry counter now resets once a real
407
+ tool call is attempted, while `SubagentStop` events are kept observational.
408
+
388
409
  ### 0.1.62
389
410
 
390
411
  - **Ollama context catalog**: added `claude-any ollama-catalog`, which downloads
@@ -57,6 +57,7 @@ TOOL_HINTS = {
57
57
  "Grep": "Use Grep with pattern, path, glob, type, output_mode, context, head_limit, or multiline only.",
58
58
  "TaskUpdate": "Use TaskUpdate with taskId and status.",
59
59
  }
60
+ PLAN_GUARD_MARKER = "[claude-any-plan-guard]"
60
61
 
61
62
 
62
63
  def active() -> bool:
@@ -299,6 +300,82 @@ def transcript_plan_mode_active(transcript_path: str | None) -> bool:
299
300
  return active
300
301
 
301
302
 
303
+ def message_text(message: dict[str, Any]) -> str:
304
+ content = message.get("content")
305
+ if isinstance(content, str):
306
+ return content.strip()
307
+ if not isinstance(content, list):
308
+ return ""
309
+ parts: list[str] = []
310
+ for block in content:
311
+ if isinstance(block, str):
312
+ parts.append(block)
313
+ elif isinstance(block, dict) and block.get("type") == "text":
314
+ parts.append(str(block.get("text") or ""))
315
+ return "\n".join(part for part in parts if part).strip()
316
+
317
+
318
+ def message_has_tool_use(message: dict[str, Any]) -> bool:
319
+ content = message.get("content")
320
+ if not isinstance(content, list):
321
+ return False
322
+ return any(isinstance(block, dict) and block.get("type") == "tool_use" for block in content)
323
+
324
+
325
+ def transcript_latest_turn(transcript_path: str | None) -> dict[str, Any]:
326
+ if not transcript_path:
327
+ return {}
328
+ path = Path(transcript_path)
329
+ if not path.exists():
330
+ return {}
331
+ try:
332
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()[-160:]
333
+ except Exception:
334
+ return {}
335
+
336
+ latest_assistant: dict[str, Any] | None = None
337
+ latest_assistant_index = -1
338
+ parsed: list[dict[str, Any]] = []
339
+ for line in lines:
340
+ try:
341
+ data = json.loads(line)
342
+ except Exception:
343
+ continue
344
+ parsed.append(data)
345
+ message = data.get("message")
346
+ if isinstance(message, dict) and message.get("role") == "assistant":
347
+ latest_assistant = message
348
+ latest_assistant_index = len(parsed) - 1
349
+
350
+ if not latest_assistant:
351
+ return {}
352
+
353
+ latest_user_text = ""
354
+ for data in reversed(parsed[:latest_assistant_index]):
355
+ if data.get("type") != "user":
356
+ continue
357
+ message = data.get("message")
358
+ if not isinstance(message, dict):
359
+ continue
360
+ if message.get("isMeta") is True:
361
+ continue
362
+ text = message_text(message)
363
+ if not text:
364
+ continue
365
+ if text.startswith("Stop hook feedback:") or PLAN_GUARD_MARKER in text:
366
+ continue
367
+ if text.startswith("Claude Any plan guard:"):
368
+ continue
369
+ latest_user_text = text
370
+ break
371
+
372
+ return {
373
+ "assistant_text": message_text(latest_assistant),
374
+ "assistant_has_tool_use": message_has_tool_use(latest_assistant),
375
+ "user_text": latest_user_text,
376
+ }
377
+
378
+
302
379
  def short_resume_prompt(text: str) -> bool:
303
380
  normalized = re.sub(r"\s+", " ", text or "").strip()
304
381
  if not normalized or len(normalized) > 32:
@@ -307,16 +384,43 @@ def short_resume_prompt(text: str) -> bool:
307
384
 
308
385
 
309
386
  def non_actionable_stop_text(text: str) -> bool:
310
- normalized = re.sub(r"\s+", " ", text or "").strip()
387
+ stripped = (text or "").strip()
388
+ normalized = re.sub(r"\s+", " ", stripped).strip()
311
389
  if not normalized or len(normalized) > 220:
312
390
  return False
313
- if "\n" in text:
391
+ if "\n" in stripped:
314
392
  return False
315
393
  if re.search(r"[`{};/\\\\]|https?://", normalized):
316
394
  return False
317
395
  return True
318
396
 
319
397
 
398
+ def should_block_plan_stop(transcript_path: str | None) -> tuple[bool, str]:
399
+ if not transcript_plan_mode_active(transcript_path):
400
+ return False, ""
401
+ turn = transcript_latest_turn(transcript_path)
402
+ assistant_text = str(turn.get("assistant_text") or "")
403
+ user_text = str(turn.get("user_text") or "")
404
+ if turn.get("assistant_has_tool_use"):
405
+ return False, ""
406
+ if not non_actionable_stop_text(assistant_text):
407
+ return False, ""
408
+ if re.search(r"[??]", assistant_text):
409
+ return False, ""
410
+ if not short_resume_prompt(user_text):
411
+ return False, ""
412
+ reason = (
413
+ f"{PLAN_GUARD_MARKER} Claude Any plan guard: Claude Code is still in plan mode, "
414
+ "but the latest response ended as a short "
415
+ "acknowledgement without any concrete tool call. Continue now by calling the next required Claude Code "
416
+ "plan-mode-safe tool, such as Read, Glob, Grep, or ExitPlanMode. Use TaskUpdate only when an existing "
417
+ "task is being updated. If mutation is required, call ExitPlanMode with the plan first. Do not put the "
418
+ "next step into the user input box and do not wait for the user unless you are asking a real "
419
+ "clarification question."
420
+ )
421
+ return True, reason
422
+
423
+
320
424
  def stop_block_count_path(session_id: str) -> Path:
321
425
  return cache_dir() / f"stop-block-{session_id or 'unknown'}.json"
322
426
 
@@ -331,17 +435,41 @@ def increment_stop_block_count(session_id: str | None, text: str) -> int:
331
435
  except Exception:
332
436
  data = {}
333
437
  count = int(data.get(key) or 0) + 1
334
- path.write_text(json.dumps({key: count}, ensure_ascii=False) + "\n", encoding="utf-8")
438
+ data[key] = count
439
+ tmp = path.with_suffix(".tmp")
440
+ tmp.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
441
+ tmp.replace(path)
335
442
  return count
336
443
 
337
444
 
445
+ def reset_stop_block_count(session_id: str | None) -> None:
446
+ if not session_id:
447
+ return
448
+ path = stop_block_count_path(session_id)
449
+ try:
450
+ path.unlink(missing_ok=True)
451
+ except Exception:
452
+ pass
453
+
454
+
338
455
  def handle_stop(event: dict[str, Any]) -> int:
339
456
  log_json_event(event)
340
- # Claude Code 2.1.x records Stop hook stderr as a suggestion
341
- # (`preventedContinuation: false`) in some interactive flows. That pollutes
342
- # the transcript and can leak into the input buffer, so keep Stop events
343
- # observational and do continuation control in the router instead.
457
+ if str(event.get("hook_event_name") or "") == "SubagentStop":
458
+ log_event(f"SubagentStop guard observed session={event.get('session_id') or ''}")
459
+ return 0
344
460
  session_id = str(event.get("session_id") or "")
461
+ transcript_path = str(event.get("transcript_path") or "")
462
+ if active():
463
+ should_block, reason = should_block_plan_stop(transcript_path)
464
+ if should_block:
465
+ count = increment_stop_block_count(session_id, reason)
466
+ if count <= 3:
467
+ out = {"decision": "block", "reason": reason, "suppressOutput": True}
468
+ log_json_event(event, out)
469
+ log_event(f"Stop guard blocked plan idle session={session_id} count={count} transcript={transcript_path}")
470
+ emit(out)
471
+ return 0
472
+ log_event(f"Stop guard allowed repeated plan idle session={session_id} count={count} transcript={transcript_path}")
345
473
  log_event(f"Stop guard observed session={session_id}")
346
474
  return 0
347
475
 
@@ -405,6 +533,7 @@ def handle_pre_tool(event: dict[str, Any]) -> None:
405
533
  if tool.startswith("mcp__"):
406
534
  return
407
535
  log_json_event(event)
536
+ reset_stop_block_count(str(event.get("session_id") or ""))
408
537
  raw = event.get("tool_input")
409
538
  if not isinstance(raw, dict):
410
539
  pre_deny(
package/claude_any.py CHANGED
@@ -90,7 +90,7 @@ PROVIDER_LABELS = {
90
90
  "self-hosted-nim": "Self Hosted NIM",
91
91
  }
92
92
  APP_NAME = "Claude Any"
93
- VERSION = "0.1.62"
93
+ VERSION = "0.1.64"
94
94
  CREDITS = "Credits: One Ciel LLC"
95
95
 
96
96
  LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
@@ -106,6 +106,7 @@ _RATE_LIMIT_LOCK = threading.Lock()
106
106
  _CHAT_CONDITION = threading.Condition()
107
107
  _CHAT_NEXT_ID: int | None = None
108
108
  ADVISOR_FEEDBACK_MARKER = "CLAUDE_ANY_ADVISOR_FEEDBACK"
109
+ PLAN_GUARD_MARKER = "[claude-any-plan-guard]"
109
110
 
110
111
  # Tools Claude Code injects into every model's tool list that misfire when called
111
112
  # by non-Anthropic models. See docs/notes from anthropics/claude-code issues
@@ -2110,19 +2111,28 @@ def plan_mode_tool_name_for_emit(body: dict[str, Any], name: str, tool_input: di
2110
2111
  router_log("WARN", "dropped ExitPlanMode while plan mode is not active")
2111
2112
  return None, tool_input
2112
2113
  return name, tool_input
2113
-
2114
-
2115
- def latest_user_text(body: dict[str, Any]) -> str:
2114
+
2115
+
2116
+ def is_guard_feedback_text(text: str) -> bool:
2117
+ stripped = (text or "").strip()
2118
+ return (
2119
+ stripped.startswith("Stop hook feedback:")
2120
+ or stripped.startswith("Claude Any plan guard:")
2121
+ or PLAN_GUARD_MARKER in stripped
2122
+ )
2123
+
2124
+
2125
+ def latest_user_text(body: dict[str, Any]) -> str:
2116
2126
  for message in reversed(body.get("messages") or []):
2117
2127
  if not isinstance(message, dict) or message.get("role") != "user":
2118
2128
  continue
2119
2129
  if message.get("isMeta") is True:
2120
2130
  continue
2121
- content = message.get("content")
2122
- if isinstance(content, str):
2123
- if content.startswith("Stop hook feedback:"):
2124
- continue
2125
- return content
2131
+ content = message.get("content")
2132
+ if isinstance(content, str):
2133
+ if is_guard_feedback_text(content):
2134
+ continue
2135
+ return content
2126
2136
  if not isinstance(content, list):
2127
2137
  # Claude Code can inject user-role attachment records such as
2128
2138
  # plan_mode_exit. They are state metadata, not new user intent.
@@ -2133,11 +2143,11 @@ def latest_user_text(body: dict[str, Any]) -> str:
2133
2143
  text_blocks = [
2134
2144
  block for block in content
2135
2145
  if isinstance(block, str) or (isinstance(block, dict) and block.get("type") == "text")
2136
- ]
2137
- text = anthropic_content_to_text(text_blocks)
2138
- if not text or text.startswith("Stop hook feedback:"):
2139
- continue
2140
- return text
2146
+ ]
2147
+ text = anthropic_content_to_text(text_blocks)
2148
+ if not text or is_guard_feedback_text(text):
2149
+ continue
2150
+ return text
2141
2151
  return ""
2142
2152
 
2143
2153
 
@@ -3910,7 +3920,7 @@ def should_skip_upstream_message(message: dict[str, Any]) -> bool:
3910
3920
  if role == "user" and message.get("isMeta") is True:
3911
3921
  return True
3912
3922
  text = anthropic_content_to_text(content).strip()
3913
- if role == "user" and text.startswith("Stop hook feedback:"):
3923
+ if is_guard_feedback_text(text):
3914
3924
  return True
3915
3925
  # Router diagnostics must never be fed back to the upstream model. In Claude
3916
3926
  # Code they can also appear in the prompt input after a malformed/empty turn.
@@ -8764,6 +8774,13 @@ def claude_code_output_token_limit(provider: str, pcfg: dict[str, Any]) -> int |
8764
8774
  return None
8765
8775
 
8766
8776
 
8777
+ def claude_code_auto_compact_window(provider: str, pcfg: dict[str, Any]) -> int | None:
8778
+ limit = context_limit_for_status(provider, pcfg)
8779
+ if limit:
8780
+ return limit
8781
+ return None
8782
+
8783
+
8767
8784
  def claude_code_context_model_alias(provider: str, pcfg: dict[str, Any], model: str) -> str:
8768
8785
  model = strip_claude_context_suffix(model)
8769
8786
  limit = context_limit_for_status(provider, pcfg)
@@ -8780,6 +8797,9 @@ def apply_common_claude_env(provider: str, pcfg: dict[str, Any], env: dict[str,
8780
8797
  output_tokens = claude_code_output_token_limit(provider, pcfg)
8781
8798
  if output_tokens:
8782
8799
  env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = str(output_tokens)
8800
+ compact_window = claude_code_auto_compact_window(provider, pcfg)
8801
+ if compact_window:
8802
+ env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(compact_window)
8783
8803
  advisor_model = str(pcfg.get("advisor_model") or "").strip()
8784
8804
  if advisor_model:
8785
8805
  env["CLAUDE_ANY_ADVISOR_MODEL"] = advisor_model
@@ -8870,6 +8890,7 @@ def cmd_env(_: argparse.Namespace) -> None:
8870
8890
  "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",
8871
8891
  "CLAUDE_CODE_ATTRIBUTION_HEADER",
8872
8892
  "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
8893
+ "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
8873
8894
  "ANTHROPIC_MODEL",
8874
8895
  "ANTHROPIC_CUSTOM_MODEL_OPTION",
8875
8896
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
package/docs/README.ja.md CHANGED
@@ -47,7 +47,7 @@ vLLM、NVIDIA hosted、self-hosted NIM を選択し、通常の Claude Code 引
47
47
 
48
48
  Credits: One Ciel LLC
49
49
 
50
- 現在のバージョン: `0.1.62`
50
+ 現在のバージョン: `0.1.64`
51
51
 
52
52
  ## 作られた理由
53
53
 
@@ -351,6 +351,25 @@ Windows/Linux 管理、クリーンアップスクリプト、定期的なセキ
351
351
 
352
352
  ## 変更履歴
353
353
 
354
+ ### 0.1.64
355
+
356
+ - **モデル context 対応の native auto-compact**: claude-any は起動時に、選択中の
357
+ provider/model の context window を使って `CLAUDE_CODE_AUTO_COMPACT_WINDOW`
358
+ を注入します。Ollama/Ollama Cloud ではディスクにキャッシュした model catalog
359
+ も利用するため、小さい custom model でも Claude Code の汎用 200K 仮定ではなく、
360
+ 実際の context budget に合わせて native auto-compact が発火します。
361
+
362
+ ### 0.1.63
363
+
364
+ - **Plan Mode stop guard**: non-Anthropic モデルが Plan Mode 中に短い確認文だけで
365
+ tool call なしに停止した場合、Stop hook が構造化 JSON フィードバックを返し、
366
+ Claude Code が plan-mode-safe tool で続行できるようにしました。
367
+ - **Guard feedback filtering**: claude-any の plan-guard marker をすべての role
368
+ の router history から除外し、Stop hook の復旧メッセージが upstream モデルへ
369
+ 戻らないようにしました。
370
+ - **より安全な retry budget**: 実際の tool call が試行されたら Stop guard の
371
+ カウンターをリセットし、`SubagentStop` は観察専用のままにします。
372
+
354
373
  ### 0.1.62
355
374
 
356
375
  - **Ollama context catalog**: `claude-any ollama-catalog` を追加しました。
package/docs/README.ko.md CHANGED
@@ -47,7 +47,7 @@ NVIDIA hosted, self-hosted NIM을 선택하고, Claude Code의 일반 인자는
47
47
 
48
48
  Credits: One Ciel LLC
49
49
 
50
- 현재 버전: `0.1.62`
50
+ 현재 버전: `0.1.64`
51
51
 
52
52
  ## 왜 만들었나
53
53
 
@@ -351,6 +351,25 @@ Windows 이벤트 로그 리뷰, 바이러스/랜섬웨어 침입 시도 정리,
351
351
 
352
352
  ## 변경 이력
353
353
 
354
+ ### 0.1.64
355
+
356
+ - **모델 컨텍스트 인식 native auto-compact**: claude-any가 실행 시 선택된
357
+ provider/model의 context window를 기준으로 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`를
358
+ 주입합니다. Ollama/Ollama Cloud는 디스크에 캐시된 model catalog도 활용하므로,
359
+ 작은 커스텀 모델도 Claude Code의 기본 200K 가정이 아니라 실제 context budget에
360
+ 맞춰 native auto-compact가 발동됩니다.
361
+
362
+ ### 0.1.63
363
+
364
+ - **Plan Mode stop guard**: non-Anthropic 모델이 Plan Mode 안에서 짧은 확인
365
+ 문장만 내고 tool call 없이 멈추는 경우, Stop hook이 구조화된 JSON 피드백을
366
+ 반환해 Claude Code가 plan-mode-safe tool로 계속 진행하도록 했습니다.
367
+ - **Guard 피드백 필터링**: claude-any의 plan-guard marker를 모든 role의 router
368
+ history에서 제거하여, Stop hook 복구 메시지가 upstream 모델로 다시 전달되지
369
+ 않게 했습니다.
370
+ - **더 안전한 retry budget**: 실제 tool call이 시도되면 Stop guard 카운터를
371
+ 리셋하고, `SubagentStop` 이벤트는 관찰 전용으로 유지합니다.
372
+
354
373
  ### 0.1.62
355
374
 
356
375
  - **Ollama 컨텍스트 카탈로그**: `claude-any ollama-catalog` 명령을 추가했습니다.
package/docs/README.zh.md CHANGED
@@ -47,7 +47,7 @@ NIM,并把普通 Claude Code 参数原样传递。
47
47
 
48
48
  Credits: One Ciel LLC
49
49
 
50
- 当前版本: `0.1.62`
50
+ 当前版本: `0.1.64`
51
51
 
52
52
  ## 为什么存在
53
53
 
@@ -337,6 +337,24 @@ Hermes 格式模型或部分较旧的 Qwen tool template。
337
337
 
338
338
  ## 更新日志
339
339
 
340
+ ### 0.1.64
341
+
342
+ - **按模型上下文触发 native auto-compact**:claude-any 启动时会根据当前
343
+ provider/model 的 context window 注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`。
344
+ Ollama/Ollama Cloud 会同时使用磁盘缓存的 model catalog,因此较小的 custom
345
+ model 也会按真实 context budget 触发 Claude Code 原生 auto-compact,而不是
346
+ 退回到 Claude Code 通用的 200K 假设。
347
+
348
+ ### 0.1.63
349
+
350
+ - **Plan Mode stop guard**:当 non-Anthropic 模型已经处于 Plan Mode,却只输出
351
+ 简短确认文本且没有 tool call 就停止时,Stop hook 现在会返回结构化 JSON
352
+ 反馈,让 Claude Code 继续调用 plan-mode-safe tool。
353
+ - **Guard feedback filtering**:claude-any 会从所有 role 的 router history 中过滤
354
+ 自己的 plan-guard marker,避免 Stop hook 恢复消息再次发送给 upstream 模型。
355
+ - **更安全的 retry budget**:一旦真正的 tool call 被尝试,Stop guard 计数器会
356
+ 重置;`SubagentStop` 事件保持仅观察模式。
357
+
340
358
  ### 0.1.62
341
359
 
342
360
  - **Ollama 上下文目录**:新增 `claude-any ollama-catalog` 命令。它会下载
package/docs/manual.md CHANGED
@@ -10,7 +10,7 @@ Code starts, while passing normal Claude Code arguments through unchanged.
10
10
 
11
11
  Credits: One Ciel LLC
12
12
 
13
- Current version: `0.1.62`
13
+ Current version: `0.1.64`
14
14
 
15
15
  ## Install
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/claude-any",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "description": "Claude Code provider selector for Anthropic, Ollama, Ollama Cloud, vLLM, NVIDIA hosted, and self-hosted NIM.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",