Install
$ agentstack add skill-humanerd-drew-opencode-drewgent-acp-thinking-spinner ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
ACP Thinking Spinner — Tool-Card Pattern
When an ACP server ({{AGENT_NAME}}'s acp_adapter/, Hermes, or any Agent Client Protocol implementation) needs to show a working indicator during the LLM API call phase, the standard approach is to synthesize a virtual tool call with kind="think". This places a "Thinking..." card in the client's tool-call stack — the same place real tool calls appear, which is the most reliable UI surface.
Why this pattern (root-cause: ACP spec gap)
ACP has no dedicated channel for "agent is generating a response." It only has:
| Channel | Where it renders | Problem | |---------|------------------|---------| | update_agent_thought_text | Client's "Thinking" area | Most clients render this as static text — JetBrains got stuck on "Thinking" with rawInput updates, others don't blink. Unreliable for "is it stuck?" UX. | | update_agent_message_text | Answer streaming area | Only fires once streaming actually starts; doesn't cover the 0-30s gap before first delta. | | ToolCallStart / ToolCallUpdate | Tool-call stack (openCode-style) | The most reliable UI surface — clients render an animated spinner here for any tool call. Synthesizing a virtual think-kind call works. | | Status bar | Client-specific | Not standardized in ACP — see agentclientprotocol.com/rfds/session-usage (RFD, not yet spec). Cannot trigger from the server. |
Conclusion: For a "is it stuck or working?" signal in ACP, the only reliable channel is the tool-call stack. Use a virtual kind="think" card.
State machine
start → ToolCallStart(kind="think", title="Thinking...")
heartbeat → ToolCallUpdate(status="in_progress", elapsed text) # at 30s, 60s, ...
complete → ToolCallUpdate(status="completed")
Hook sites in the LLM call lifecycle:
- Before
_interruptible_api_call()/_interruptible_streaming_api_call()→start - First streamed delta (
on_first_deltacallback) →complete - Long-running timer (e.g.
threading.Timer(30.0, ...)) →heartbeat; cancel on complete
If a real tool call is dispatched before the LLM finishes, the thinking card is implicitly replaced by the real tool's card (ACP client stack semantics). No need to manually close.
{{AGENT_NAME}} reference implementation
Files modified ({{AGENT_NAME}} ACP server at ~/.{{AGENT_NAME_LOWER}}/source/{{AGENT_NAME_LOWER}}-agent/):
acp_adapter/tools.py — three helpers
build_thinking_start(tool_call_id) -> ToolCallStartbuild_thinking_progress(tool_call_id, elapsed) -> ToolCallProgressbuild_thinking_complete(tool_call_id) -> ToolCallProgress
All use kind="think" and acp.start_tool_call / acp.update_tool_call.
acp_adapter/events.py — callback factory
make_agent_progress_cb(conn, session_id, loop) returns a callable matching the signature:
agent_progress_callback(state: str, name: str, args: dict = None)
Stores name → tool_call_id in a closure dict. State machine: start/heartbeat/complete.
acp_adapter/server.py — wire
Alongside existing thinking_cb, add:
agent_progress_cb = make_agent_progress_cb(conn, session_id, loop)
agent.agent_progress_callback = agent_progress_cb
run_agent.py — three hook sites + callback attr
- Add
agent_progress_callback: callable = NonetoAIAgent.__init__signature self.agent_progress_callback = agent_progress_callbackin the body- Hook A (LLM call start, right before
_interruptible_*_api_call):
``python if self.agent_progress_callback: try: self.agent_progress_callback("start", "llm_response", {}) except Exception: pass ``
- Hook B (first delta, inside the
on_first_deltaclosure):
``python if self.agent_progress_callback: try: self.agent_progress_callback("complete", "llm_response", {}) except Exception: pass ``
- Hook C (30s+ timer callback):
``python def _llm_heartbeat(): elapsed = time.time() - api_start_time if self.agent_progress_callback: try: self.agent_progress_callback("heartbeat", "llm_response", {"elapsed": elapsed}) except Exception: pass # fallback: thinking_callback (text), _vprint (raw stdout) _llm_heartbeat_timer = threading.Timer(30.0, _llm_heartbeat) ``
Channel routing rule (stdout breaks ACP)
ACP is JSON-RPC over stdio. Writing raw text to stdout (e.g. print() or _vprint) breaks the protocol. Three channels, in priority order:
| Channel | Path | When | |---------|------|------| | agent_progress_callback | ToolCallStart/Update (tool card) | ACP client available — preferred | | thinking_callback | update_agent_thought_text | Fallback for non-ACP paths; some clients (JetBrains) render this as static "Thinking..." | | _vprint (raw stdout) | Plain text | Last-resort for non-ACP environments (gateway streaming, plain CLI) |
Always check agent_progress_callback first, then thinking_callback, then _vprint. Never print() directly in agent code — it leaks through the JSON-RPC pipe.
Env var
DREW_LLM_HEARTBEAT_DISABLED=1 disables the 30s+ timer. Use for tests / CI to avoid timing flakiness.
Verification
After patching:
python3 -c "import ast; ast.parse(open('').read())"for each modified file- AST check that the 3 hook sites are present:
``python hook_count = body_src.count('self.agent_progress_callback(') assert hook_count == 3, f"expected 3 hook sites, got {hook_count}" ``
- Restart the ACP server (e.g. exit the CLI process; new
{{AGENT_NAME_LOWER}}invocation picks up the patch) - Trigger a long-running LLM call and confirm the client shows the spinner card in the tool-call stack
Pitfalls
- Don't put text content in
update_agent_thought_textthinking channel if the user said "I don't want text — I want an indicator." Use the tool-card channel instead. - Don't write to
sys.stdoutdirectly in ACP mode — breaks JSON-RPC. Always go through a registered callback or the tool-card helper. - Don't fire
heartbeatwithout a priorstart— the closure dict won't have a toolcallid, the call is a no-op. This is intentional safety. - Mock client skips streaming —
if isinstance(client, Mock): _use_streaming = Falsealready exists; heartbeat only fires on streaming, so mock tests are unaffected.
Related
- ACP spec: https://agentclientprotocol.com/protocol/v1/tool-calls
- ACP status/usage RFD (in progress, not yet spec): https://agentclientprotocol.com/rfds/session-usage
- openCode ACP: https://opencode.ai/docs/acp/
- Zed Agent Panel: https://zed.dev/docs/ai/agent-panel
- JetBrains ACP client (stuck on "Thinking" issue): https://youtrack.jetbrains.com/projects/WI/issues/WI-83745
- {{AGENT_NAME}}'s own ACP source:
acp_adapter/{server,events,tools,session}.py
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: humanerd-drew
- Source: humanerd-drew/opencode-drewgent
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.