feat: add qyl-continuation plugin (smart auto-continuation, v1.0.0) (#158)#158
feat: add qyl-continuation plugin (smart auto-continuation, v1.0.0) (#158)#158
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
Cache: Disabled due to data retention organization setting Knowledge base: Disabled due to data retention organization setting 📝 WalkthroughWalkthroughThe PR introduces a new "qyl-continuation" plugin that implements smart auto-continuation for Claude Code using a two-phase mechanism: heuristic-based pre-filtering to eliminate unnecessary LLM calls, followed by a Haiku judge for inconclusive cases. The plugin is registered in the marketplace, includes configuration and documentation, and implements throttling logic to limit continuations. Changes
Sequence DiagramsequenceDiagram
participant User as Claude Session
participant Hook as Stop Hook
participant Judge as stop-judge.py
participant Heuristic as Phase 1: Heuristics
participant Haiku as Phase 2: Haiku Judge
participant Throttle as Throttle State
User->>Hook: Trigger Stop Hook
Hook->>Judge: Execute with transcript
Judge->>Judge: Read transcript tail (last 6 lines)
Judge->>Heuristic: Analyze message patterns
alt Heuristic Match
Heuristic->>Judge: Decision reached
Judge->>Judge: Approve stop
else Heuristic Inconclusive
Judge->>Haiku: Send context to Haiku judge
alt Judge Success
Haiku->>Judge: Return continue/stop decision
Judge->>Throttle: Check throttle state
alt Should Continue & Within Limit
Throttle->>Judge: Increment counter
Judge->>Judge: Block with reason
else Limit Exceeded or Should Stop
Throttle->>Judge: Clear counter
Judge->>Judge: Approve stop
end
else Judge Timeout/Error
Judge->>Judge: Fallback to approve stop
end
end
Judge->>Hook: Return decision (approve/block)
Hook->>User: Control flow decision
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request integrates a new Highlights
Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new plugin, qyl-continuation, for smart auto-continuation of Claude conversations, implemented with a two-phase approach involving heuristic checks and an LLM-based judge. A security audit of the stop-judge.py script revealed critical vulnerabilities, specifically an insecure temporary file vulnerability in /tmp that could enable symlink attacks and arbitrary file overwrites, and a path traversal vulnerability due to insufficient validation of the transcript_path input. Furthermore, the plugin's overall robustness can be enhanced by improving data handling and parsing.
| event = json.loads(sys.stdin.read()) | ||
| transcript_path = event.get("transcript_path", "") | ||
| session_id = event.get("session_id", "unknown") | ||
| throttle_file = Path(f"/tmp/.qyl-continue-{session_id.replace('/', '_')}") |
There was a problem hiding this comment.
The script creates a throttle file in /tmp with a predictable name based on the session_id. In multi-user environments, an attacker can pre-create this file as a symlink to an arbitrary file owned by the user, leading to an arbitrary file overwrite when the script writes to the throttle file. Additionally, an attacker can manipulate the throttle state for other users. It is recommended to use a user-specific directory for such files, such as ~/.claude/qyl-continuation/.
| throttle_file = Path(f"/tmp/.qyl-continue-{session_id.replace('/', '_')}") | |
| work_dir = Path.home() / ".claude" / "qyl-continuation" | |
| work_dir.mkdir(parents=True, exist_ok=True) | |
| throttle_file = work_dir / f"throttle-{session_id.replace('/', '_')}" |
| if not transcript_path or not Path(transcript_path).exists(): | ||
| approve("No transcript") |
There was a problem hiding this comment.
The transcript_path provided in the event input is used directly to read a file without validation. An attacker who can influence the event data could potentially point this to arbitrary files on the system. While the script only reads the last few lines and expects JSONL format, it still constitutes a path traversal vulnerability. Other plugins in this repository (like hookify) explicitly validate this path to prevent access outside of intended directories.
| if not transcript_path or not Path(transcript_path).exists(): | |
| approve("No transcript") | |
| if not transcript_path or not Path(transcript_path).exists() or os.path.isabs(transcript_path) or '..' in transcript_path.split(os.path.sep): | |
| approve("Invalid or missing transcript") |
|
|
||
| # --- Phase 2: Haiku judge --- | ||
|
|
||
| transcript_json = json.dumps(messages, ensure_ascii=False)[:MAX_CONTEXT_BYTES] |
There was a problem hiding this comment.
Slicing a JSON string by bytes with [:MAX_CONTEXT_BYTES] is unsafe as it can create invalid JSON by cutting in the middle of a multi-byte character or a structural element. This could cause the downstream claude process to fail when parsing its input. A safer approach is to encode to bytes, slice, and then decode while ignoring errors. This is still not perfectly safe for the JSON structure but does handle character encoding issues correctly.
| transcript_json = json.dumps(messages, ensure_ascii=False)[:MAX_CONTEXT_BYTES] | |
| transcript_json = json.dumps(messages, ensure_ascii=False).encode('utf-8')[:MAX_CONTEXT_BYTES].decode('utf-8', 'ignore') |
| evaluation = json.loads(proc.stdout).get("structured_output", {}) | ||
| except (json.JSONDecodeError, AttributeError, TypeError): | ||
| approve("Judge response unparseable, allowing stop") |
There was a problem hiding this comment.
The parsing of the judge's output can be made more robust. If structured_output is present but null, get("structured_output", {}) returns None, causing an AttributeError on the next line. While the broad except block catches this, handling this case explicitly would make the code clearer and less reliant on exception handling for control flow.
| evaluation = json.loads(proc.stdout).get("structured_output", {}) | |
| except (json.JSONDecodeError, AttributeError, TypeError): | |
| approve("Judge response unparseable, allowing stop") | |
| output = json.loads(proc.stdout) | |
| evaluation = (output.get("structured_output") or {}) if isinstance(output, dict) else {} | |
| except json.JSONDecodeError: | |
| approve("Judge response unparseable, allowing stop") |
There was a problem hiding this comment.
Pull request overview
Adds a new qyl-continuation plugin to the marketplace to provide “smart auto-continuation” behavior for Claude Code via a two-phase Stop hook (heuristics first, then an LLM judge with throttling).
Changes:
- Introduces
plugins/qyl-continuation/with a Stop hook implementation (stop-judge.py) and hook registration (hooks.json). - Registers the new plugin in
.claude-plugin/marketplace.json. - Documents the addition in
CHANGELOG.mdand adds plugin-level README/config docs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/qyl-continuation/hooks/stop-judge.py | Implements heuristic + judge-based Stop hook with throttling and transcript parsing. |
| plugins/qyl-continuation/hooks/hooks.json | Registers the Stop hook command to run the judge script. |
| plugins/qyl-continuation/README.md | Documents behavior and environment configuration. |
| plugins/qyl-continuation/.claude-plugin/plugin.json | Adds plugin manifest metadata for distribution. |
| CHANGELOG.md | Adds an “Unreleased” entry describing the new plugin. |
| .claude-plugin/marketplace.json | Updates marketplace metadata/counts and registers the new plugin source. |
|
|
||
|
|
||
| def has_block_type(msg: dict, block_type: str) -> bool: | ||
| content = msg.get("content", "") |
There was a problem hiding this comment.
has_block_type() checks msg.get("content"), but transcript entries for tool_use/tool_result blocks are typically nested under message.content. This makes the tool-result/pending-tool heuristics (H3/H4) effectively inoperative. Consider checking both msg.get("message", {}).get("content") and msg.get("content") for compatibility.
| content = msg.get("content", "") | |
| content = msg.get("content", "") | |
| if not isinstance(content, list): | |
| # Some transcript entries nest blocks under message.content | |
| content = msg.get("message", {}).get("content", "") |
| if event.get("stop_hook_active", False): | ||
| count, last_time = read_throttle() | ||
| if time.time() - last_time > WINDOW_SECONDS: | ||
| count = 0 | ||
| if count >= MAX_CONTINUATIONS: | ||
| clear_throttle() | ||
| approve("Max continuation cycles reached") |
There was a problem hiding this comment.
The throttle guard is gated on event.get("stop_hook_active"), but this field doesn’t appear to be part of the Stop hook payload elsewhere in the repo (no other hooks reference it). If it’s absent at runtime, this early-exit will never run, and continuation cycles may never be capped. Recommend enforcing the throttle based on the persisted counter alone (or gate on a verified input field) so the max-cycles protection is reliable.
| if event.get("stop_hook_active", False): | |
| count, last_time = read_throttle() | |
| if time.time() - last_time > WINDOW_SECONDS: | |
| count = 0 | |
| if count >= MAX_CONTINUATIONS: | |
| clear_throttle() | |
| approve("Max continuation cycles reached") | |
| count, last_time = read_throttle() | |
| if time.time() - last_time > WINDOW_SECONDS: | |
| count = 0 | |
| if count >= MAX_CONTINUATIONS: | |
| clear_throttle() | |
| approve("Max continuation cycles reached") |
|
|
||
| # --- Phase 2: Haiku judge --- | ||
|
|
||
| transcript_json = json.dumps(messages, ensure_ascii=False)[:MAX_CONTEXT_BYTES] |
There was a problem hiding this comment.
MAX_CONTEXT_BYTES is enforced via json.dumps(... )[:MAX_CONTEXT_BYTES], but slicing a Python str limits characters, not UTF-8 bytes. With non-ASCII content this can exceed the intended byte budget (or cut mid-codepoint). If you want a true byte limit, encode to UTF-8, slice bytes, then decode with errors="ignore"/"replace" (or rename the constant to reflect char-count semantics).
| transcript_json = json.dumps(messages, ensure_ascii=False)[:MAX_CONTEXT_BYTES] | |
| raw_transcript = json.dumps(messages, ensure_ascii=False) | |
| transcript_bytes = raw_transcript.encode("utf-8") | |
| if len(transcript_bytes) > MAX_CONTEXT_BYTES: | |
| transcript_json = transcript_bytes[:MAX_CONTEXT_BYTES].decode("utf-8", errors="ignore") | |
| else: | |
| transcript_json = raw_transcript |
| | Environment Variable | Default | Description | | ||
| |---------------------|---------|-------------| | ||
| | `QYL_CONTINUATION_TIMEOUT` | `30` | Haiku judge timeout (seconds) | | ||
| | `QYL_CONTINUATION_MODEL` | `haiku` | Judge model | |
There was a problem hiding this comment.
The configuration table uses || to start rows, which breaks standard Markdown table syntax (it should be single | ... | ... | ... |). As-is, this won’t render as a table in most Markdown viewers/linters.
| def text_of(msg: dict) -> str: | ||
| content = msg.get("content", "") | ||
| if isinstance(content, str): | ||
| return content | ||
| if isinstance(content, list): | ||
| return " ".join( | ||
| b.get("text", "") or b.get("content", "") | ||
| for b in content | ||
| if isinstance(b, dict) and b.get("type") in ("text", "tool_result") | ||
| ) | ||
| return "" |
There was a problem hiding this comment.
text_of() reads msg.get("content"), but the Claude transcript JSONL entries appear to store the payload under message.content (see e.g. metacognitive-guard Stop hook parsing ... | .message.content[]). As written, last_assistant will usually be empty, so H1/H2/H4 won’t trigger and the judge prompt context will be less accurate. Update text_of() to extract text/tool_result blocks from msg.get("message", {}).get("content", ...) (and keep the current fallback for any legacy formats).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42cf6e2b9c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if addressed and last_role == "assistant": | ||
| clear_throttle() | ||
| approve("Heuristic: tool results already addressed") |
There was a problem hiding this comment.
Allow continuation when assistant states pending next action
This heuristic unconditionally approves stopping whenever any tool result has been followed by a long assistant message and the last message is from the assistant, but it never checks for explicit pending-next-step language (e.g., “Next I’ll run tests”). In that common flow, the hook exits here before the Haiku judge runs, so the session stops even though the assistant explicitly indicated autonomous work remains.
Useful? React with 👍 / 👎.
| tail = subprocess.run( | ||
| ["tail", "-n", str(TAIL_LINES), transcript_path], | ||
| capture_output=True, text=True, timeout=5 |
There was a problem hiding this comment.
Remove shell tail dependency from transcript loading
The hook shells out to tail to read transcript lines; on environments without that binary (notably non-Unix setups), this path falls into the exception handler and approves stopping with "Failed to read transcript", effectively disabling continuation for the entire plugin. Reading the file tail in Python would avoid this platform-specific failure mode.
Useful? React with 👍 / 👎.
qyl-continuation plugin (1.0.0)
Smart auto-continuation for Claude Code. Two-phase stop hook:
Phase 1 — Heuristics (no LLM call, ~80% of stops):
Phase 2 — Haiku judge (~20% of cases):
should_continue+reasoningBased on double-shot-latte (MIT).
Changes
plugins/qyl-continuation/marketplace.json(12 → 13 plugins)Summary by CodeRabbit