@oneciel-ai/claude-any 0.1.62 → 0.1.63
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 +13 -1
- package/claude-any-tool-guard.py +136 -7
- package/claude_any.py +25 -15
- package/docs/README.ja.md +12 -1
- package/docs/README.ko.md +12 -1
- package/docs/README.zh.md +11 -1
- package/docs/manual.md +1 -1
- package/package.json +1 -1
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.
|
|
51
|
+
Current version: `0.1.63`
|
|
52
52
|
|
|
53
53
|
## Why This Exists
|
|
54
54
|
|
|
@@ -385,6 +385,18 @@ steps under that larger model's supervision.
|
|
|
385
385
|
|
|
386
386
|
## Changelog
|
|
387
387
|
|
|
388
|
+
### 0.1.63
|
|
389
|
+
|
|
390
|
+
- **Plan Mode stop guard**: when a non-Anthropic model is already in Plan Mode
|
|
391
|
+
and stops after a short acknowledgement without a tool call, the Stop hook
|
|
392
|
+
now returns structured JSON feedback so Claude Code continues with a
|
|
393
|
+
plan-mode-safe tool instead of leaking text into the prompt box.
|
|
394
|
+
- **Guard-feedback filtering**: claude-any filters its own plan-guard marker
|
|
395
|
+
from router history for all roles, preventing Stop hook recovery messages from
|
|
396
|
+
being sent back to upstream models.
|
|
397
|
+
- **Safer retry budget**: the Stop guard retry counter now resets once a real
|
|
398
|
+
tool call is attempted, while `SubagentStop` events are kept observational.
|
|
399
|
+
|
|
388
400
|
### 0.1.62
|
|
389
401
|
|
|
390
402
|
- **Ollama context catalog**: added `claude-any ollama-catalog`, which downloads
|
package/claude-any-tool-guard.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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.
|
|
93
|
+
VERSION = "0.1.63"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
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.
|
|
50
|
+
現在のバージョン: `0.1.63`
|
|
51
51
|
|
|
52
52
|
## 作られた理由
|
|
53
53
|
|
|
@@ -351,6 +351,17 @@ Windows/Linux 管理、クリーンアップスクリプト、定期的なセキ
|
|
|
351
351
|
|
|
352
352
|
## 変更履歴
|
|
353
353
|
|
|
354
|
+
### 0.1.63
|
|
355
|
+
|
|
356
|
+
- **Plan Mode stop guard**: non-Anthropic モデルが Plan Mode 中に短い確認文だけで
|
|
357
|
+
tool call なしに停止した場合、Stop hook が構造化 JSON フィードバックを返し、
|
|
358
|
+
Claude Code が plan-mode-safe tool で続行できるようにしました。
|
|
359
|
+
- **Guard feedback filtering**: claude-any の plan-guard marker をすべての role
|
|
360
|
+
の router history から除外し、Stop hook の復旧メッセージが upstream モデルへ
|
|
361
|
+
戻らないようにしました。
|
|
362
|
+
- **より安全な retry budget**: 実際の tool call が試行されたら Stop guard の
|
|
363
|
+
カウンターをリセットし、`SubagentStop` は観察専用のままにします。
|
|
364
|
+
|
|
354
365
|
### 0.1.62
|
|
355
366
|
|
|
356
367
|
- **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.
|
|
50
|
+
현재 버전: `0.1.63`
|
|
51
51
|
|
|
52
52
|
## 왜 만들었나
|
|
53
53
|
|
|
@@ -351,6 +351,17 @@ Windows 이벤트 로그 리뷰, 바이러스/랜섬웨어 침입 시도 정리,
|
|
|
351
351
|
|
|
352
352
|
## 변경 이력
|
|
353
353
|
|
|
354
|
+
### 0.1.63
|
|
355
|
+
|
|
356
|
+
- **Plan Mode stop guard**: non-Anthropic 모델이 Plan Mode 안에서 짧은 확인
|
|
357
|
+
문장만 내고 tool call 없이 멈추는 경우, Stop hook이 구조화된 JSON 피드백을
|
|
358
|
+
반환해 Claude Code가 plan-mode-safe tool로 계속 진행하도록 했습니다.
|
|
359
|
+
- **Guard 피드백 필터링**: claude-any의 plan-guard marker를 모든 role의 router
|
|
360
|
+
history에서 제거하여, Stop hook 복구 메시지가 upstream 모델로 다시 전달되지
|
|
361
|
+
않게 했습니다.
|
|
362
|
+
- **더 안전한 retry budget**: 실제 tool call이 시도되면 Stop guard 카운터를
|
|
363
|
+
리셋하고, `SubagentStop` 이벤트는 관찰 전용으로 유지합니다.
|
|
364
|
+
|
|
354
365
|
### 0.1.62
|
|
355
366
|
|
|
356
367
|
- **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.
|
|
50
|
+
当前版本: `0.1.63`
|
|
51
51
|
|
|
52
52
|
## 为什么存在
|
|
53
53
|
|
|
@@ -337,6 +337,16 @@ Hermes 格式模型或部分较旧的 Qwen tool template。
|
|
|
337
337
|
|
|
338
338
|
## 更新日志
|
|
339
339
|
|
|
340
|
+
### 0.1.63
|
|
341
|
+
|
|
342
|
+
- **Plan Mode stop guard**:当 non-Anthropic 模型已经处于 Plan Mode,却只输出
|
|
343
|
+
简短确认文本且没有 tool call 就停止时,Stop hook 现在会返回结构化 JSON
|
|
344
|
+
反馈,让 Claude Code 继续调用 plan-mode-safe tool。
|
|
345
|
+
- **Guard feedback filtering**:claude-any 会从所有 role 的 router history 中过滤
|
|
346
|
+
自己的 plan-guard marker,避免 Stop hook 恢复消息再次发送给 upstream 模型。
|
|
347
|
+
- **更安全的 retry budget**:一旦真正的 tool call 被尝试,Stop guard 计数器会
|
|
348
|
+
重置;`SubagentStop` 事件保持仅观察模式。
|
|
349
|
+
|
|
340
350
|
### 0.1.62
|
|
341
351
|
|
|
342
352
|
- **Ollama 上下文目录**:新增 `claude-any ollama-catalog` 命令。它会下载
|
package/docs/manual.md
CHANGED
package/package.json
CHANGED