Summary
run_claude_code_agent() disconnects its ClaudeSDKClient in a finally at the end of every turn, which SIGTERM/SIGKILLs the underlying claude CLI subprocess. Any background/Task-tool agent spawned during that turn is an in-process async task inside that subprocess, so it is destroyed at the turn boundary regardless of how much work it had left. Every turn also pays a full process spawn plus session replay.
Verified still present in v0.9.15 — cptr/utils/agents/claude_code.py is byte-identical between the v0.9.10 and v0.9.15 tags.
Symptom
A background agent started in one turn never returns results. The user sees a <task-notification status="stopped"> with wording that hedges about whether the process exited — "It may have been stopped, or it may have been running when the previous Claude Code process exited". In practice the exit is deterministic, not ambiguous: it happens at the end of every turn, in-flight work or not.
Root cause
The client is constructed fresh, connected, given exactly one query(), streamed to ResultMessage, then unconditionally torn down (L146–147 and L241–242):
client = sdk.ClaudeSDKClient(options)
await client.connect()
finally:
await client.disconnect()
disconnect() → Query.close() → SubprocessCLITransport.close(), which closes stdin then escalates SIGTERM→SIGKILL within ~10s.
Observed directly on a running instance: walking /proc during normal use showed multiple claude CLI subprocesses alive simultaneously — one with --resume=<uuid> and one without — i.e. a new subprocess per turn rather than one long-lived process per conversation.
claude_agent_sdk's own ClaudeSDKClient.query() docstring documents the intended usage as one long-lived client across multiple query() calls, with set_model() / set_permission_mode() to change things mid-conversation.
Suggested fix
Cache the connected client per Claude session id — cptr already threads session_id through resume_state on every call — and reuse it across turns, disconnecting only on error, on a workspace/cwd change (which can't be applied to a live process), or after an idle timeout.
Known trade-off, which is why this is filed as a problem statement rather than a PR
system_prompt is a CLI launch flag fixed at connect() time; there is no way to update it on a live client. So on a cache hit the per-turn dynamic system-prompt scaffolding (memory recall, conversation-summary / plan-mode / voice-mode injections) stops being refreshed.
Conversation content is unaffected — cptr only ever sends latest_user_text() per turn and relies on the CLI's own resumed session for history — so this touches prompt scaffolding only. But it does need a real answer from upstream (re-inject the scaffolding as a user-turn preamble? accept staleness? reconnect only when the scaffolding actually changed?) rather than a decision made in a patch.
I have run the caching approach locally and it behaves as described, including surviving background agents across turn boundaries. Glad to turn it into a PR if you have a preference on how to handle the system_prompt question.
Summary
run_claude_code_agent()disconnects itsClaudeSDKClientin afinallyat the end of every turn, which SIGTERM/SIGKILLs the underlyingclaudeCLI subprocess. Any background/Task-tool agent spawned during that turn is an in-process async task inside that subprocess, so it is destroyed at the turn boundary regardless of how much work it had left. Every turn also pays a full process spawn plus session replay.Verified still present in v0.9.15 —
cptr/utils/agents/claude_code.pyis byte-identical between thev0.9.10andv0.9.15tags.Symptom
A background agent started in one turn never returns results. The user sees a
<task-notification status="stopped">with wording that hedges about whether the process exited — "It may have been stopped, or it may have been running when the previous Claude Code process exited". In practice the exit is deterministic, not ambiguous: it happens at the end of every turn, in-flight work or not.Root cause
The client is constructed fresh, connected, given exactly one
query(), streamed toResultMessage, then unconditionally torn down (L146–147 and L241–242):disconnect()→Query.close()→SubprocessCLITransport.close(), which closes stdin then escalates SIGTERM→SIGKILL within ~10s.Observed directly on a running instance: walking
/procduring normal use showed multipleclaudeCLI subprocesses alive simultaneously — one with--resume=<uuid>and one without — i.e. a new subprocess per turn rather than one long-lived process per conversation.claude_agent_sdk's ownClaudeSDKClient.query()docstring documents the intended usage as one long-lived client across multiplequery()calls, withset_model()/set_permission_mode()to change things mid-conversation.Suggested fix
Cache the connected client per Claude session id — cptr already threads
session_idthroughresume_stateon every call — and reuse it across turns, disconnecting only on error, on a workspace/cwd change (which can't be applied to a live process), or after an idle timeout.Known trade-off, which is why this is filed as a problem statement rather than a PR
system_promptis a CLI launch flag fixed atconnect()time; there is no way to update it on a live client. So on a cache hit the per-turn dynamic system-prompt scaffolding (memory recall, conversation-summary / plan-mode / voice-mode injections) stops being refreshed.Conversation content is unaffected — cptr only ever sends
latest_user_text()per turn and relies on the CLI's own resumed session for history — so this touches prompt scaffolding only. But it does need a real answer from upstream (re-inject the scaffolding as a user-turn preamble? accept staleness? reconnect only when the scaffolding actually changed?) rather than a decision made in a patch.I have run the caching approach locally and it behaves as described, including surviving background agents across turn boundaries. Glad to turn it into a PR if you have a preference on how to handle the
system_promptquestion.