feat(plugins)!: make Sidekick exclusive to Claude Code (#7278)
This commit is contained in:
@@ -23,7 +23,7 @@ integrations/
|
||||
|
||||
Each native directory owns only its manifest, native hooks or adapter, tests, and `plugin-build.json`. Its `core/` and `skills/` directories are generated from this module. They are committed because clients install a self-contained plugin directory and the Agent Plugins specification forbids package files from resolving outside the plugin root.
|
||||
|
||||
Claude Code remains the behavioral source of truth. Its sidekick stays at `claude-code-plugin/agents/sidekick.md` and is not generated or copied to hosts without a compatible native subagent interface.
|
||||
Sidekick belongs only to Claude Code. Its agent definition is in `claude-code-plugin/agents/sidekick.md`; its hooks are in `claude-code-plugin/adapters/claude/hook.py`. Other plugins must not register Sidekick. The shared core handles memory and native subagent tracking for Claude Code and Codex.
|
||||
|
||||
TypeScript integrations (`openclaw`, `opencode-plugin`, `pi-agent-plugin`, and `deepseek-plugin`) import `typescript/src/` at build time. Their package builders include the shared implementation in their normal output; they do not carry checked-in copies.
|
||||
|
||||
@@ -106,19 +106,21 @@ For another native Python host:
|
||||
|
||||
Keep capture, recall, memory scoping, redaction, skill text, and telemetry in this shared module. Host directories should contain only behavior required by their native SDK.
|
||||
|
||||
For a TypeScript host, import the shared lifecycle modules directly and keep only native SDK registration in the integration. Do not advertise capture, compaction, or sidekick behavior unless the host exposes the necessary lifecycle seam.
|
||||
For a TypeScript host, import the shared lifecycle modules directly and keep only native SDK registration in the integration. Do not advertise capture or compaction behavior unless the host exposes the necessary lifecycle seam. Sidekick is limited to Claude Code.
|
||||
|
||||
## Host capture capabilities
|
||||
|
||||
| Host | Conversation capture | Tool outcomes | Subagent context and correlation |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude Code | Incremental active transcript branch | Native success/failure hooks | Parent context; native agent ID |
|
||||
| Cursor | Prompt and response hooks; duplicate responses suppressed | Native success/failure hooks | Sidekick searches itself; no parent-injection claim |
|
||||
| Cursor | Prompt and response hooks; duplicate responses suppressed | Native success/failure hooks | No plugin subagent hooks or agent declaration |
|
||||
| Codex | Native prompt and final-response fields | Structured failure indicators when present; otherwise unknown | Parent context; native agent ID |
|
||||
| Kimi | Prompt hooks and completed v2 wire output | Native success/failure hooks | Parent context; without an ID, a stop matches only one unambiguous active run |
|
||||
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | Sidekick searches itself; no worktree-isolation claim |
|
||||
| Kimi | Prompt hooks and completed v2 wire output | Native success/failure hooks | No plugin subagent hooks or agent declaration |
|
||||
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | No plugin subagent hooks or agent declaration |
|
||||
| Portable v1 | Explicit memory skills | No native lifecycle hooks | No native subagent declaration |
|
||||
|
||||
Python status uses `subagent_runs` and `last_subagent`. Legacy SQLite names and event handling keep existing records and running workers compatible.
|
||||
|
||||
An uncorrelated subagent completion is kept as its own record; the plugin never guesses which overlapping run completed. Codex's documented hook fields already match the shared input contract, so no speculative field aliases or unsupported failure event are registered.
|
||||
|
||||
Offline conformance exercises the adapters and MCP servers with native-shaped payloads and builds each distributable package. It does not establish that every installed editor or Harness version loads the plugin correctly; those checks require smoke tests in the actual hosts.
|
||||
|
||||
@@ -11,10 +11,10 @@ import telemetry
|
||||
from memory_core import (
|
||||
EvidenceStore,
|
||||
api_key,
|
||||
configure_harness,
|
||||
data_dir,
|
||||
doctor,
|
||||
forget_remote_repo,
|
||||
configure_harness,
|
||||
resolve_repo,
|
||||
user_id,
|
||||
)
|
||||
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
|
||||
)
|
||||
print(
|
||||
f"Used in this repository: {value['retrievals']} memories returned, "
|
||||
f"{value['sidekick_runs']} sidekick runs"
|
||||
f"{value['subagent_runs']} subagent runs"
|
||||
)
|
||||
if last:
|
||||
item_label = ""
|
||||
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
|
||||
f"{'succeeded' if last['success'] else 'failed'} "
|
||||
f"({last['duration_ms']:.1f} ms{item_label})"
|
||||
)
|
||||
sidekick = value.get("last_sidekick") or {}
|
||||
if sidekick:
|
||||
state = "finished" if sidekick.get("stopped_at") else "started"
|
||||
subagent = value.get("last_subagent") or {}
|
||||
if subagent:
|
||||
state = "finished" if subagent.get("stopped_at") else "started"
|
||||
print(
|
||||
"Last sidekick: "
|
||||
f"{state}, received {sidekick['context_chars']} characters of memory, "
|
||||
f"agent {sidekick['agent_id']}"
|
||||
"Last subagent: "
|
||||
f"{state}, received {subagent['context_chars']} characters of memory, "
|
||||
f"agent {subagent['agent_id']}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
|
||||
if text:
|
||||
return text
|
||||
return redact(payload.get("text", "")).strip()
|
||||
if kind == "sidekick_stop":
|
||||
if kind in {"subagent_stop", "sidekick_stop"}:
|
||||
return redact(payload.get("final_message", "")).strip()
|
||||
return ""
|
||||
|
||||
@@ -596,6 +596,7 @@ class EvidenceStore:
|
||||
self.conn.close()
|
||||
|
||||
def _migrate(self) -> None:
|
||||
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
|
||||
self.conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
@@ -688,8 +689,7 @@ class EvidenceStore:
|
||||
|
||||
"""
|
||||
)
|
||||
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
|
||||
# sidekick is a native Claude Code agent and stores no state in this DB.
|
||||
# Remove the pre-0.1.1 snapshot implementation.
|
||||
self.conn.executescript(
|
||||
"""
|
||||
DROP TABLE IF EXISTS sidekick_calls;
|
||||
@@ -1041,7 +1041,7 @@ class EvidenceStore:
|
||||
if row["memory_text"]
|
||||
]
|
||||
|
||||
def start_sidekick(
|
||||
def start_subagent(
|
||||
self,
|
||||
repo: RepoContext,
|
||||
session_id: str,
|
||||
@@ -1049,7 +1049,7 @@ class EvidenceStore:
|
||||
agent_type: str,
|
||||
context_chars: int,
|
||||
) -> bool:
|
||||
"""Record one native sidekick instance and whether context was first sent."""
|
||||
"""Record one native subagent instance and whether context was first sent."""
|
||||
with self.conn:
|
||||
cursor = self.conn.execute(
|
||||
"""INSERT OR IGNORE INTO sidekick_runs
|
||||
@@ -1067,7 +1067,7 @@ class EvidenceStore:
|
||||
)
|
||||
return int(cursor.rowcount) > 0
|
||||
|
||||
def stop_sidekick(
|
||||
def stop_subagent(
|
||||
self,
|
||||
repo: RepoContext,
|
||||
session_id: str,
|
||||
@@ -1222,7 +1222,7 @@ class EvidenceStore:
|
||||
cursor = self.conn.execute(
|
||||
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
|
||||
)
|
||||
removed[table] = max(int(cursor.rowcount), 0)
|
||||
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
|
||||
return removed
|
||||
|
||||
def status(self, repo_id: str) -> dict[str, Any]:
|
||||
@@ -1238,7 +1238,7 @@ class EvidenceStore:
|
||||
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
|
||||
(repo_id,),
|
||||
).fetchone()
|
||||
last_sidekick = self.conn.execute(
|
||||
last_subagent = self.conn.execute(
|
||||
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
|
||||
context_chars
|
||||
FROM sidekick_runs WHERE repo_id = ?
|
||||
@@ -1250,9 +1250,9 @@ class EvidenceStore:
|
||||
"events": count("events"),
|
||||
"flushes": count("flushes"),
|
||||
"retrievals": count("retrievals"),
|
||||
"sidekick_runs": count("sidekick_runs"),
|
||||
"subagent_runs": count("sidekick_runs"),
|
||||
"last_operation": dict(last_operation) if last_operation else None,
|
||||
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
|
||||
"last_subagent": dict(last_subagent) if last_subagent else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
|
||||
"tool": name,
|
||||
"failed": failed,
|
||||
"duration_ms": hook_input.get("duration_ms"),
|
||||
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
|
||||
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
|
||||
}
|
||||
if hook_input.get("agent_id"):
|
||||
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
|
||||
@@ -1384,35 +1384,33 @@ def record_tool(
|
||||
)
|
||||
|
||||
|
||||
def record_sidekick_start(
|
||||
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
|
||||
def record_subagent_start(
|
||||
store: EvidenceStore, hook_input: dict[str, Any]
|
||||
) -> str:
|
||||
"""Record a native sidekick and reuse the main turn's retrieved memories."""
|
||||
"""Record a native subagent and reuse the main turn's retrieved memories."""
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
|
||||
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
|
||||
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
|
||||
context = combine_context(
|
||||
format_context(store.injected_memories(session_id, repo.identity))
|
||||
)
|
||||
if not inject_context:
|
||||
context = ""
|
||||
first_start = store.start_sidekick(
|
||||
first_start = store.start_subagent(
|
||||
repo, session_id, agent_id, agent_type, len(context)
|
||||
)
|
||||
store.record_event(
|
||||
repo,
|
||||
session_id,
|
||||
"sidekick_start",
|
||||
"subagent_start",
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
"context_chars": len(context) if first_start else 0,
|
||||
"worktree_root": bounded(repo.root, 2000),
|
||||
"repo_root": bounded(repo.root, 2000),
|
||||
},
|
||||
)
|
||||
telemetry.record(
|
||||
"sidekick",
|
||||
"subagent",
|
||||
repo=repo,
|
||||
session_id=session_id,
|
||||
phase="start",
|
||||
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
|
||||
return context if first_start else ""
|
||||
|
||||
|
||||
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
|
||||
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
|
||||
session_id = _session_id(hook_input)
|
||||
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
|
||||
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
|
||||
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
|
||||
agent_id = bounded(hook_input.get("agent_id", ""), 200)
|
||||
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
|
||||
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
|
||||
agent_id = store.stop_sidekick(
|
||||
agent_id = store.stop_subagent(
|
||||
repo,
|
||||
session_id,
|
||||
agent_id,
|
||||
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
|
||||
store.record_event(
|
||||
repo,
|
||||
session_id,
|
||||
"sidekick_stop",
|
||||
"subagent_stop",
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
|
||||
},
|
||||
)
|
||||
telemetry.record(
|
||||
"sidekick",
|
||||
"subagent",
|
||||
repo=repo,
|
||||
session_id=session_id,
|
||||
phase="stop",
|
||||
@@ -1513,10 +1511,10 @@ def build_episode(
|
||||
for e in events
|
||||
if e["kind"] == "assistant_stop" and e["payload"].get("text")
|
||||
]
|
||||
sidekick_outcomes = [
|
||||
subagent_outcomes = [
|
||||
redact(e["payload"].get("final_message", "")).strip()
|
||||
for e in events
|
||||
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
|
||||
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
|
||||
]
|
||||
tools = [
|
||||
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
|
||||
@@ -1597,7 +1595,7 @@ def build_episode(
|
||||
}
|
||||
)
|
||||
pending_user_messages = []
|
||||
elif event["kind"] == "sidekick_stop":
|
||||
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
|
||||
pass
|
||||
extraction_messages.extend(pending_user_messages)
|
||||
|
||||
@@ -1613,7 +1611,7 @@ def build_episode(
|
||||
"assistant_conclusion": conclusion,
|
||||
"user_messages": prompts,
|
||||
"assistant_outcomes": assistant_conclusions,
|
||||
"sidekick_outcomes": sidekick_outcomes,
|
||||
"subagent_outcomes": subagent_outcomes,
|
||||
"extraction_messages": extraction_messages,
|
||||
"files_read": read_paths[:50],
|
||||
"files_modified": modified_paths[:50],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -10,7 +10,12 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = ROOT.parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from build.build import build, bundle_drift, render_template, replace_output # noqa: E402
|
||||
from build.build import ( # noqa: E402
|
||||
build,
|
||||
bundle_drift,
|
||||
render_template,
|
||||
replace_output,
|
||||
)
|
||||
from build.validate import validate_bundle # noqa: E402
|
||||
|
||||
|
||||
@@ -57,6 +62,7 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non
|
||||
assert "env" not in server
|
||||
assert not (root / "core" / "hook_runner.py").exists()
|
||||
assert not (root / "core" / "flush_worker.py").exists()
|
||||
assert not (root / "agents").exists()
|
||||
for skill in (root / "skills").glob("*/SKILL.md"):
|
||||
frontmatter = skill.read_text(encoding="utf-8").split("---", 2)[1]
|
||||
keys = {line.split(":", 1)[0] for line in frontmatter.splitlines() if ":" in line}
|
||||
@@ -73,6 +79,19 @@ def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_only_claude_bundles_sidekick(host: str, tmp_path: Path) -> None:
|
||||
root = build(host, "native", tmp_path / host)
|
||||
if host == "claude-code":
|
||||
agent = (root / "agents" / "sidekick.md").read_text()
|
||||
assert "model: sonnet" in agent
|
||||
assert "isolation: worktree" in agent
|
||||
else:
|
||||
assert not (root / "agents").exists()
|
||||
for path in root.rglob("*.json"):
|
||||
assert "sidekick" not in path.read_text().lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_native_control_skills_select_the_host_store(host: str, tmp_path: Path) -> None:
|
||||
root = build(host, "native", tmp_path / host)
|
||||
|
||||
+42
-23
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -11,11 +12,43 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "python"))
|
||||
|
||||
from memory_core import EvidenceStore # noqa: E402
|
||||
from memory_core import EvidenceStore, RepoContext, checkpoint_stats # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "codex", "kimi", "cursor"])
|
||||
def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path, host):
|
||||
def test_generic_tracking_preserves_legacy_runs_and_can_finish_them(tmp_path):
|
||||
database = tmp_path / "evidence.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.executescript("""
|
||||
CREATE TABLE sidekick_runs (
|
||||
repo_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL, agent_type TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL, stopped_at TEXT, transcript_path TEXT,
|
||||
context_chars INTEGER NOT NULL DEFAULT 0, final_message TEXT,
|
||||
PRIMARY KEY(repo_id, session_id, agent_id)
|
||||
);
|
||||
INSERT INTO sidekick_runs VALUES
|
||||
('repo', 'session', 'worker', 'default', '2026-09-10', NULL, NULL, 123, NULL);
|
||||
""")
|
||||
for _ in range(2):
|
||||
store = EvidenceStore(database)
|
||||
try:
|
||||
assert store.status("repo")["subagent_runs"] == 1
|
||||
row = store.conn.execute("SELECT * FROM sidekick_runs").fetchone()
|
||||
assert row["agent_id"] == "worker"
|
||||
assert row["context_chars"] == 123
|
||||
repo = RepoContext(str(tmp_path), str(tmp_path), "repo", "app", "main", "")
|
||||
store.stop_subagent(repo, "session", "worker", "default", "", "Completed after upgrade.")
|
||||
assert store.status("repo")["last_subagent"]["stopped_at"]
|
||||
assert store.conn.execute("SELECT final_message FROM sidekick_runs").fetchone()[0] == (
|
||||
"Completed after upgrade."
|
||||
)
|
||||
finally:
|
||||
store.close()
|
||||
assert checkpoint_stats([{"kind": "sidekick_stop", "payload": {"final_message": "Legacy result."}}]) == (0, 1, 14)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "codex"])
|
||||
def test_subagent_hooks_preserve_parent_scope_and_correlate_completion(tmp_path, host):
|
||||
parent = tmp_path / "parent"
|
||||
child = tmp_path / "child-worktree"
|
||||
parent.mkdir()
|
||||
@@ -30,21 +63,10 @@ def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path,
|
||||
plugin = ROOT.parent / f"{host}-plugin"
|
||||
adapter = plugin / ("adapters/claude/hook.py" if host == "claude-code" else "hooks/adapter.py")
|
||||
start, stop = "sidekick-start", "sidekick-stop"
|
||||
payload = {"session_id": "parent-session", "cwd": str(child), "agent_id": "worker", "agent_type": "sidekick"}
|
||||
payload = {"session_id": "parent-session", "cwd": str(child), "agent_id": "worker", "agent_type": "default"}
|
||||
response_key = "last_assistant_message"
|
||||
if host == "kimi":
|
||||
start, stop = "SubagentStart", "SubagentStop"
|
||||
payload["agent_name"] = payload.pop("agent_type")
|
||||
response_key = "response"
|
||||
elif host == "cursor":
|
||||
start, stop = "subagentStart", "subagentStop"
|
||||
payload = {
|
||||
"conversation_id": "parent-session",
|
||||
"workspace_roots": [str(child)],
|
||||
"subagent_id": "worker",
|
||||
"subagent_type": "sidekick",
|
||||
}
|
||||
response_key = "summary"
|
||||
if host == "codex":
|
||||
start, stop = "subagent-start", "subagent-stop"
|
||||
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith(("MEM0_", "CLAUDE_PLUGIN_"))}
|
||||
env.update(MEM0_CODE_DATA_DIR=str(database.parent), MEM0_TELEMETRY="false", MEM0_API_URL="http://127.0.0.1:1")
|
||||
@@ -62,11 +84,8 @@ def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path,
|
||||
return result.stdout
|
||||
|
||||
output = invoke(start, payload)
|
||||
if host == "cursor":
|
||||
assert json.loads(output) == {"permission": "allow"}
|
||||
else:
|
||||
context = output if host == "kimi" else json.loads(output)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "Parent memory marker." in context
|
||||
context = json.loads(output)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "Parent memory marker." in context
|
||||
assert "Foreign memory marker." not in output
|
||||
assert "Parent memory marker." not in invoke(start, payload)
|
||||
invoke(stop, {**payload, response_key: "Finished the delegated task."})
|
||||
@@ -80,4 +99,4 @@ def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path,
|
||||
)
|
||||
assert runs[0]["stopped_at"]
|
||||
assert runs[0]["final_message"] == "Finished the delegated task."
|
||||
assert (runs[0]["context_chars"] > 0) == (host != "cursor")
|
||||
assert runs[0]["context_chars"] > 0
|
||||
Reference in New Issue
Block a user