External-Agent Plugin — Decisions Log
This file is append-only. The orchestrator updates it after each phase ships. Phase agents do not read this file — they receive only the curated "Contracts established/changed" section from prior phases.
The purpose of this log is to give a human auditable trail of what each phase committed to and to let the orchestrator look up prior decisions when fielding follow-up questions.
Format
After each shipped phase, append a section in this shape:
## Phase N — <one-line summary> (YYYY-MM-DD)
<one paragraph in human terms: what was built, what behavior is now available,
any notable decisions taken that did not change a shared contract>
### Contracts established/changed
<the "Contracts established/changed" block from the phase agent's handoff,
copied verbatim>
### Open questions resolved
<if applicable: questions from the agent's handoff plus the user's answers>
Phase 1 — Claude Code adapter + Tier-0 bones (2026-05-16)
Shipped the end-to-end vertical slice for claude_code(...): subprocess spawn via Tier-0 sandbox, technical-user auth, full AgentEvent event surface emitted to the UI bus, [Subagent ID: …] envelope, scratchpad-backed session resume, auth probe route, transcript card. make test clean at 4929 passed / 141 skipped. Real claude CLI was exercised end-to-end as a manual probe and returned a wrapped answer. Methodology note: phase 1 was implemented in one shot (not test-first as the orchestrator intends from phase 2 onward) — the work is high-quality so we keep it; phases 2+ will follow test-first.
Contracts established/changed
AgentAdapter Protocol (matches index §4.1 exactly):
class AgentAdapter(Protocol):
name: str # e.g. "claude-code"
binary: str # default executable
supports_resume: bool
supports_stream_json: bool
def build_argv(self, *, prompt: str, session_id: Optional[str], cwd: Path) -> List[str]: ...
def build_env(self, *, creds: Credentials, home: Optional[Path]) -> Dict[str, str]: ...
async def parse_stream(self, stdout: AsyncIterator[bytes]) -> AsyncIterator[AgentEvent]: ...
def parse_oneshot(self, stdout: str, stderr: str, rc: int) -> AgentResult: ...
def extract_session_id(self, evt: AgentEvent) -> Optional[str]: ...
async def probe_auth(self, env: Dict[str, str]) -> AuthStatus: ...
Supporting dataclasses in adapter_base.py:
Credentials(api_key: Optional[str], home: Optional[Path], extra_env: Dict[str, str])AuthStatus(installed: bool, authenticated: bool, error: Optional[str])AgentResult(success: bool, final_text: str, session_id: Optional[str], input_tokens: int, output_tokens: int, total_cost_usd: Optional[float], error: Optional[str], raw: Dict[str, Any])
AgentEvent kinds and payload field names:
session_started:{session_id: str}message_delta:{text: str, message_id: Optional[str]}tool_use:{tool_call_id: str, name: str, input: dict}tool_result:{tool_call_id: str, content: str, is_error: bool}thinking:{text: str}status:{text: str, details: dict}(reserved; no adapter emits this in phase 1)completed:{final_text: str, input_tokens: int, output_tokens: int, total_cost_usd: Optional[float], stop_reason: Optional[str]}error:{message: str, details: Optional[dict]}
Claude Code argv:
claude --print --output-format stream-json --input-format text --verbose [--resume <session-id>]
Prompt is piped via stdin (closed after write). --resume appended only when a backend session id exists for the subagent. --verbose is required by the CLI when --print is combined with --output-format stream-json.
Scratchpad key path:
scratchpad["external_agent"]["session_map"][<subagent_id>] = {
"backend": str,
"backend_session_id": str | None,
"created_at": iso8601 UTC,
"last_used_at": iso8601 UTC,
}
from codumentor.plugins.external_agent.scratchpad import get_session, set_session, NAMESPACE, SESSION_MAP_KEY.
Tool argument JSON schema (locked — do not widen):
{
"type": "object",
"properties": { "query": {"type": "string"}, "subagent_id": {"type": "string"} },
"required": ["query"]
}
Tool result envelope (matches index §4.4 exactly):
[Subagent ID: <id> - Use this ID to continue this conversation] <final assistant text>
Memory plugin's strip regex re.sub(r'\[Subagent ID:.?\]\s', '', content, flags=re.DOTALL) reduces this to the bare final text (verified in test_external_agent_tool.py).
Emitted UI event names and payload fields:
- Event names:
plugin.external_agent.<kind>for everyAgentEvent.kindfrom the union above. - Every payload includes:
{subagent_id: str, backend: str, event_group_id: str, align: "right", status: "pending"|"success"|"error", data: <kind-specific AgentEvent.payload>}. event_group_idisext_<hex12>minted once pertool.execute()call so the UI merges all events into one card.statusmapping:completed→"success",error→"error", everything else →"pending".
Plugin module/class for codumentor.yaml:
plugins:
- module: codumentor.plugins.external_agent
class: ExternalAgentPlugin
args:
enabled: true
default_sandbox_tier: 0
target_agents: main # main | subagent | all
backends:
claude-code:
binary: claude
mode: technical_user
api_key: ${env:ANTHROPIC_API_KEY}
extra_env: {}
default_sandbox_tier: 1 configs are silently downgraded to tier 0 with a warning until phase 5.
HTTP route (mounted under /ui/plugins/external_agent/):
GET /auth/probe?backend=<name>→{backend, installed, authenticated, error}, 404 on unknown backend.
Permission contract:
Tool.permission_request()returnsPermissionRequest(permission_type="external_agent.invoke", details={"backend", "tool_name", "query_preview"}). One approval per delegation; the external agent's internal tool calls are not re-prompted.
UI plugin manifest (ui/plugins/external_agent/manifest.json):
plugin_id: "external_agent"- Custom element:
x-external-agent-card - Bundle:
ui/plugins/external_agent/index.js(built fromsrc/index.tsvia esbuild, like other plugins).
Behavioural notes worth carrying forward:
- Claude Code's
assistantevents can carry multiple content blocks per message (thinking + tool_use in the same message); the adapter yields oneAgentEventper content block, not per assistant event. This convention should be preserved for other adapters that have similar structure. - The adapter drops unknown event kinds (
rate_limit_event, etc.) silently. Adapters MUST NOT crash on unknown kinds.
Open questions resolved
None.
Phase 2 — Codex adapter (2026-05-16)
Shipped the second backend. Codex's CLI surface is quite different from Claude Code's (subcommand-style invocation, positional resume, flatter event schema), but the phase-1 AgentAdapter / AgentEvent abstraction absorbed every difference cleanly — no Protocol changes, no new event kinds, no test relaxations. make test jumps from 4929 → 4952 (delta = 23 new Codex adapter tests). Methodology note: phase-2 tests were written by a Codumentor CLI run that landed in the wrong worktree (selfdev.yaml's repos_dir: ".." + repo name Codumentor → /home/kschaffer/Codumentor/ instead of this worktree); the resulting tests were salvaged and moved over. Impl was done by a Claude Code Agent subagent for reliability. Codumentor's worktree-routing should be revisited before later phases.
Contracts established/changed
Registry entry:
ADAPTER_REGISTRY["codex"] = CodexAdapter
Codex adapter attributes:
CodexAdapter.name = "codex"
CodexAdapter.binary = "codex"
CodexAdapter.supports_resume = True
CodexAdapter.supports_stream_json = True
Codex argv (build_argv output shape):
- Fresh:
["codex", "exec", "--json", "--sandbox", "workspace-write", <prompt>] - Resume:
["codex", "exec", "resume", <session_id>, "--json", "--sandbox", "workspace-write", <prompt>] - Prompt is positional argv (not stdin, unlike Claude Code).
resumeis a positional sub-command with the session id immediately after it (not a--resume <id>flag).
Codex env (build_env output):
- Whitelist:
{PATH, HOME, LANG, CODEX_API_KEY, OPENAI_API_KEY}. - When
creds.api_keyis set, bothCODEX_API_KEYandOPENAI_API_KEYare populated with the same value. creds.extra_envoverrides baseline;home(when not None) overridesHOME.
Codex event → AgentEvent mapping:
Codex type | AgentEvent kind | Notes |
|---|---|---|
thread.started | session_started | session_id copied verbatim |
turn.started | (dropped) | no analogue |
item.reasoning | thinking | content → text |
item.agent_message | message_delta | content → text, item_id → message_id; also cached for completed.final_text |
item.command_execution | tool_use + tool_result | both events share the same item_id as tool_call_id; synthetic name = "Bash"; command → input.command; output → content; exit_code != 0 → is_error |
item.file_changes | (dropped) | reserved; no kind change |
item.mcp_tool_calls | (dropped) | reserved; no kind change |
turn.completed | completed | usage tokens, cost, stop_reason copied verbatim; final_text sourced from the most recent item.agent_message.content seen |
error | error | message / error → message; remaining keys → details |
| anything else | (dropped silently) | per contract |
Adapter-level absorption of phase-1 contract fields Codex doesn't emit directly:
completed.payload["final_text"]— accumulated from the latestitem.agent_message.contentseen in the stream.tool_use.payload["name"]— synthesised as"Bash"foritem.command_execution(Codex doesn't tag command executions with a tool name).
Tool wiring:
CodexTool.backend = "codex", tool name"codex".CodexTool.__init__mirrorsClaudeCodeToolexactly:(*, creds, cwd, binary=None, timeout=None). Default timeout 5 min.ExternalAgentPlugin._get_or_build_tool:backend_name == "codex"→CodexTool(...).
Backend config schema (additive to phase-1):
external_agent:
backends:
codex:
binary: codex # optional
mode: technical_user
api_key: ${env:OPENAI_API_KEY} # propagated to both CODEX_API_KEY and OPENAI_API_KEY
Open questions resolved
None blocking. Two non-blocking flagged for future:
item.file_changes/item.mcp_tool_callsare dropped in phase 2; phase 4 (OpenCode/Gemini) may want a backward-compatible new kind (file_change?). Defer until forced.- Claude Code adapter relies on
result.resultfor final text rather than the Codex-style "accumulate the latest message_delta" pattern; if Claude's surface drifts we may want to mirror Codex's robustness. Low-priority.
Phase 3 — BYO API key via user_secrets (2026-05-16)
Shipped per-user BYO API keys backed by EncryptedKVStore through the existing user_secrets plugin. New resolver, new credential-resolution priority chain in ExternalAgentTool, new CRUD routes, new settings panel custom element. make test 4963 (4952 + 11). One pre-existing TUI tier-2 flake confirmed independent. Test-first methodology held cleanly: tests written first (10 failing as designed + 1 already-passing fallback), impl drove them to green with no contract-weakening adjustments.
Contracts established/changed
Resolver module (codumentor.plugins.external_agent.auth.byo_key):
def secret_name(backend: str) -> str # "external_agent/<backend>/api_key"
async def resolve(*, store, user_id: str, backend: str) -> Optional[Credentials]
storeexposesasync get(user_id, name)returning an object with a.valueattribute (matchesEncryptedKVStore).- Returns
Credentials(api_key=<value>)when present;Noneotherwise. - Keyword-only call sites.
Credential resolution priority (in ExternalAgentTool._resolve_credentials):
ctx["user_id"]+ctx["user_secrets_store"]→ ifbyo_key.resolvereturns non-None, returnCredentials(api_key=<per_user>, home=self.creds.home, extra_env=copy(self.creds.extra_env)).- Otherwise
self.credsif it has anapi_key. - Otherwise
None→ emiterrorevent withreason: "auth_required", return failedToolResult, do not spawn.
Auth-required error event:
- Event name:
plugin.external_agent.error(same as phase 1). - Top-level payload extends phase-1 envelope with
reason: "auth_required". datablock stays phase-1 shape:{message: str, details: {backend: str}}.ToolResult.success = False;ToolResult.metadatacarriesauth_required: True.
EncryptedKVStore key shape (locked):
user_id: invoking user's id.name:external_agent/<backend>/api_key(literal, produced viabyo_key.secret_name(backend)).value: raw API key string.metadata(when written via routes):{"backend": <backend>, "source": "external_agent.byo_key"}.
HTTP routes (mounted under /ui/plugins/external_agent/):
GET /backends—user_secrets:manage—{backends: [{name, has_system_key, has_user_key}]}.PUT /credentials/{backend}body{api_key: str}—user_secrets:manage—{backend, status: "set"}; 400 empty key, 404 unknown backend, 503 user_secrets unloaded.DELETE /credentials/{backend}—user_secrets:manage—{backend, status: "deleted"|"absent"}.GET /auth/probe?backend=<name>— now requiresuser_secrets:manage(was anonymous in phase 1). Layers calling user's BYO key on top of system creds before probing.
Behaviour change: probe auth. The phase-1 anonymous probe is now gated. Rationale: per-user resolution needs the authenticated user's id, and anonymous probes leak configured-backend topology. No tests broke. Operators relying on anonymous probes must authenticate.
Dependency resolution stance: plugin.yaml's dependencies: [user_secrets] is soft — if user_secrets is not loaded, BYO routes return 503, the resolver returns None, and the technical-user fallback runs. Matches phase-1's ${secret:…} stance.
UI widget registration convention (carry forward to later phases):
- Custom-element tags prefixed
x-are dispatched automatically byPluginSettingsPanel.tsx. NoWIDGET_REGISTRYentry required. ui_manifest()must declare bothsettings_widget.web_component: "x-..."ANDui.custom_elements: ["x-..."].- The UI bundle must self-register via
customElements.define(). - A single bundle may register multiple tags (we now register
x-external-agent-card+x-external-agent-settingsfromindex.js); side-effect import (import "./settings.js") keeps both registered.
ctx population:
ExternalAgentPlugin._on_context_readydiscovers theuser_secretsplugin viactx["plugin_host"].pluginsand stashes itsEncryptedKVStoreon bothctx["user_secrets_store"](for the tool) and a plugin-instance attribute (for the HTTP routes via astore_resolverclosure).
Open questions resolved
- Probe auth: keep authenticated. Anonymous would leak backend topology.
- Dependency declaration: soft. Matches phase-1
${secret:…}posture.
Phase 4 — OpenCode + Gemini adapters (2026-05-16)
Shipped two more backends. OpenCode is streaming JSONL (mirrors Codex's shape closely); Gemini is non-streaming (uses parse_oneshot, with both plain-text and --output-format json paths). Both fit the eight-kind AgentEvent union with zero Protocol pressure — the phase-doc prediction "this is the last chance to evolve the abstraction" turned out not to apply. make test 5017 (4963 + 54 net — 55 new tests, 1 pre-existing load-sensitive flake; see below). Test-first methodology held; impl made the test files pass without weakening any contract assertion.
Methodology note: the two test-writing agents both crashed with API 500 at handoff time, but their on-disk work was complete (the crash happened during final report emission, not during the work). Tests were verified to fail with the expected ImportError before launching impl.
Contracts established/changed
Registry entries:
ADAPTER_REGISTRY["opencode"] = OpenCodeAdapter
ADAPTER_REGISTRY["gemini"] = GeminiAdapter
OpenCode adapter attributes:
OpenCodeAdapter.name = "opencode"
OpenCodeAdapter.binary = "opencode"
OpenCodeAdapter.supports_resume = True
OpenCodeAdapter.supports_stream_json = True
OpenCode argv (build_argv output):
- Fresh:
["opencode", "run", "--format", "json", <prompt>] - Resume:
["opencode", "run", "--format", "json", "--session", <session_id>, <prompt>] - Prompt is positional argv (final element). Resume flag is
--session <id>.
OpenCode env (build_env output):
- Whitelist when api_key set:
{PATH, HOME, LANG, OPENCODE_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY}. All six*_API_KEYvars are mirrored to the same value (OpenCode is provider-pluggable). - When
api_keyisNone, no*_API_KEYvars are injected (avoid masking user env). extra_envoverrides baseline;homeoverridesHOME.
OpenCode event → AgentEvent mapping:
OpenCode type | AgentEvent kind |
|---|---|
session.start | session_started (session_id verbatim) |
reasoning | thinking (text → text) |
message (assistant) | message_delta + cache for completed.final_text |
tool.call | tool_use (tool_call_id, name, input verbatim) |
tool.output | tool_result (content stringified, is_error from event) |
session.complete | completed (tokens, cost, stop_reason verbatim; final_text from latest message) |
error | error (message field → message; rest → details) |
| anything else | (dropped silently) |
Gemini adapter attributes:
GeminiAdapter.name = "gemini"
GeminiAdapter.binary = "gemini"
GeminiAdapter.supports_resume = False
GeminiAdapter.supports_stream_json = False
Gemini argv: always ["gemini", "--prompt", <prompt>]; session_id silently ignored.
Gemini env:
- Whitelist when api_key set:
{PATH, HOME, LANG, GEMINI_API_KEY, GOOGLE_API_KEY}. Both vars mirrored.
Gemini parse_oneshot:
rc != 0→AgentResult(success=False, error=<stderr> or "gemini exited with rc=…").rc == 0plain text →AgentResult(success=True, final_text=<stdout>.strip()).rc == 0JSON object stdout (detected by leading{+responsekey) → extractsresponseasfinal_text; sumsstats.models[*].tokens.{prompt,candidates}intoinput_tokens/output_tokens; raw dict onresult.raw.
Gemini extract_session_id always returns None (no session-id surface). Continuity falls back to recap-in-prompt for resuming subagent calls — the runner's scratchpad simply has no backend_session_id to pass to a subsequent build_argv call. This is contract-allowed (session_id: Optional[str] is None-permissive).
Gemini parse_stream: no-op async generator (Protocol stub). The runner does not call it when supports_stream_json = False.
Tool wiring:
OpenCodeTool.backend = "opencode", name"opencode". Constructor signature mirrorsCodexToolexactly.GeminiTool.backend = "gemini", name"gemini". Same shape.ExternalAgentPlugin._get_or_build_toolextended with two new branches.
Backend config schema (additive):
external_agent:
backends:
opencode: { binary: opencode, mode: technical_user, api_key: ${env:OPENCODE_API_KEY} }
gemini: { binary: gemini, mode: technical_user, api_key: ${env:GEMINI_API_KEY} }
Known issue (not phase-4-introduced)
tests/test_mock_chat_model.py::TestMockChatModelFixtureDelay::test_per_fixture_streaming_delay_is_slower is a load-sensitive timing test that fails under full-suite make test on this hardware but passes in isolation. File's git log shows no recent touches; the flake is unrelated to phase-4 changes. Recommend a separate triage task to add @slow / tolerance widening; do not gate phase progression on it.
Open questions resolved
None blocking.
Phase 5 — Tier-1 sandbox (2026-05-16)
Shipped the opt-in Tier-1 sandbox: bwrap-based overlay isolation with optional systemd-run --user --scope for memory caps, reusing workspace_isolation's IsolatedExecutionStrategy._build_bwrap_command. 16/16 phase-5 tests pass, no regressions in workspace_isolation (155 passed, 1 skipped — unchanged). Test-first held; impl made the tests pass without renaming the egress seam, so no test edits were necessary. make test 5034 passed (5020 baseline + 14 unlocked tier-1 tests — 2 of the 16 don't require bwrap).
Contracts established/changed
sandbox/tier1.py public surface:
make_spawn_fn(*, plugin_storage: Path, repo_path: Path,
allowed_egress: list[str],
limits: Optional[ResourceLimits]) -> SpawnFn
@dataclass
class ResourceLimits:
memory_max_bytes: int = 2 * 1024 ** 3 # 2 GiB
wall_clock_seconds: float = 300.0 # 5 min
SpawnFn = Callable[..., Awaitable[asyncio.subprocess.Process]]
def _apply_egress_policy(allowed_egress: list[str],
env: dict[str, str]) -> dict[str, str]
SpawnFn callable signature matches tier0.spawn exactly:
async def spawn(argv, *, env, cwd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
-> asyncio.subprocess.Process
Reuse decision (option a — direct cross-plugin import):
tier1.pyimportsIsolatedExecutionStrategyandSandboxPolicydirectly fromcodumentor.plugins.workspace_isolation.strategies.- Reuses
_build_bwrap_command(private). Documented wart; acceptable while both modules live in the same repo. A future refactor to a sharedcodumentor.sandboxmodule is still an option but not required.
Egress implementation (soft enforcement in v1):
- Empty
allowed_egress⇒--unshare-net(no network at all). - Non-empty ⇒ network namespace stays shared,
_apply_egress_policyinjectsCODUMENTOR_ALLOWED_EGRESSenv hint into the subprocess. - v1 does NOT enforce per-host on a populated list — it relies on agent-side cooperation. Future phases can swap in a host-side HTTPS proxy without changing the public seam.
Plugin storage layout for sandboxes:
<plugin_storage>/sandboxes/<invocation_uuid>/{upper,work}/- Inside the sandbox, repo appears at
/workspace/<repo_name>/…. - Both
upperandworkare removed after the process exits (success OR failure). No 30-day TTL retention (unlikeworkspace_isolation). - Cleanup uses a top-down chmod pre-walk because bwrap's overlayfs
work/worksubdirectory is mode000.
Per-adapter default_allowed_egress lists (final):
ClaudeCodeAdapter.default_allowed_egress = ["api.anthropic.com", "statsig.anthropic.com"]
CodexAdapter.default_allowed_egress = ["api.openai.com", "auth.openai.com"]
OpenCodeAdapter.default_allowed_egress = ["api.anthropic.com", "api.openai.com",
"openrouter.ai",
"generativelanguage.googleapis.com"]
GeminiAdapter.default_allowed_egress = ["generativelanguage.googleapis.com",
"oauth2.googleapis.com"]
Tool wiring:
ExternalAgentTool.__init__gains optionalspawn_fn: Optional[SpawnFn] = None. Threaded toagent_runner.run(spawn_fn=…).- All four concrete tool classes (
ClaudeCodeTool,CodexTool,OpenCodeTool,GeminiTool) accept and forwardspawn_fn.
Plugin wiring:
ExternalAgentPlugin.__init__gains optionaldefault_limits: dict | None.- Removed phase-1 tier-1 downgrade block.
- New helpers:
_build_spawn_fn(backend_name, backend_cfg, cwd),_resolve_limits(backend_cfg),_resolve_plugin_storage(). - When
default_sandbox_tier == 1,_get_or_build_toolbuilds a Tier-1 spawn_fn viamake_spawn_fnand injects it into the tool. When tier == 0, tool gets no spawn_fn (runner defaults totier0.spawn).
Resolution order for limits and egress (additive to phase-1 schema):
external_agent:
default_sandbox_tier: 0 | 1
default_limits: # optional global fallback
memory_max_bytes: 2147483648
wall_clock_seconds: 300
backends:
<name>:
allowed_egress: [hostname, ...] # optional; falls back to adapter default
limits: # optional per-backend override
memory_max_bytes: ...
wall_clock_seconds: ...
Resolution order:
- Limits: per-backend → global default → hardcoded
ResourceLimitsdefaults (2 GiB / 300s). - Egress: per-backend
allowed_egress→ adapter'sdefault_allowed_egress.
Environment requirements for systemd-run memory cap:
- When
MemoryMaxis enforced viasystemd-run --user --scope, the spawn caller passesXDG_RUNTIME_DIRandDBUS_SESSION_BUS_ADDRESSfromos.environinto the systemd-run parent env (NOT the bwrap child env — bwrap clears env explicitly). - If
systemd-run --useris unavailable, the spawn logs a warning and runs without the memory cap. Wall-clock cap is unaffected (lives inrunner.runtimeout).
Operational notes worth remembering
- This system has two
bwrapinstalls (/usr/bin/bwrap0.9.0 lacks overlay-src;/usr/local/bin/bwrap0.11.0 has it).tier1.pyresolves bwrap viashutil.which("bwrap")at spawn time to pick the overlay-capable build. - bwrap's overlayfs creates
work/workwith mode000;_safe_rmtreechmods every subdir top-down beforermtree.
Open questions resolved
None blocking. Forward-looking flag for phase 7: the MCP-bridge work will face the same egress allow-list question — when an external agent calls back into Codumentor via MCP, the Tier-1 sandbox must permit that loopback. Plan to allow localhost / Unix-socket exception in _apply_egress_policy when the MCP bridge is configured.
Phase 6 — BYO subscription via per-user HOME (2026-05-16)
Shipped per-user HOME directory allocation that lets users connect OAuth-based subscriptions (Claude Pro/Max) without us reimplementing each provider's login. The CLI's own /login flow runs with HOME=<per-user-path>; resulting auth state lands in that directory; subsequent invocations reuse it. 26/26 phase-6 tests pass. make test 5060 (5034 + 26). Test-first held — impl made all 26 tests pass without weakening any contract assertion.
Contracts established/changed
New module — codumentor.plugins.external_agent.auth.byo_subscription:
def get_home(*, plugin_storage: Path, user_id: str, backend: str) -> Path
def is_home_primed(home: Path) -> bool
def probe_with_home(*, adapter, plugin_storage, user_id, backend) -> AuthStatus # async
def disconnect(*, plugin_storage, user_id, backend) -> None
All keyword-only. disconnect is idempotent (no error when HOME never existed).
Storage layout: <plugin_storage>/homes/<user_id>/<backend>/ where <plugin_storage> is ExternalAgentPlugin._resolve_plugin_storage() (already adds an external_agent/ segment). So absolute path: <host>/external_agent/homes/<user_id>/<backend>/. Mode 0o700. No double external_agent/ namespacing.
Primed-HOME signal: any regular file present under HOME (recursive rglob("*") finds a .is_file()). Stricter per-CLI signals avoided — keeps Codumentor decoupled from each CLI's internal layout.
Extended credential resolution priority (4 levels, top wins):
- NEW:
backend.mode == "byo_subscription"AND per-user HOME is primed →Credentials(api_key=None, home=<per-user HOME>, extra_env=copy(self.creds.extra_env)). - Per-user BYO key (phase 3, unchanged).
- Technical-user key (phase 1, unchanged).
- None → auth_required error (phase 3, unchanged).
ctx keys read by the tool (production + test):
ctx["external_agent_backend_mode"]: str— per-call override (tests use this).ctx["external_agent_backend_modes"]: dict[str, str]— production: keyed by backend name (populated by_on_context_ready).ctx["external_agent_plugin_storage"]: Path— plugin storage root.
Adapter opt-in flag:
class <Adapter>:
supports_byo_subscription: bool = False # default
ClaudeCodeAdapter.supports_byo_subscription = True(validated/loginflow).CodexAdapter,OpenCodeAdapter,GeminiAdapter=Falseuntil each is validated.
HTTP routes (mounted under /ui/plugins/external_agent/):
GET /credentials/{backend}/connect-info→{backend, home_path, command, mode, instructions}.commandformat:HOME=<quoted-path> <quoted-binary> /login(shlex-quoted for paste safety).DELETE /credentials/{backend}/connection(NEW sibling — does NOT extend the phase-3DELETE /credentials/{backend}) →{backend, status: "disconnected"}. Idempotent. 503 if plugin storage unavailable.GET /auth/probe?backend=<name>(EXTENDED): when configured backend mode isbyo_subscriptionAND plugin storage is wired, layers per-user HOME on adapter env viabyo_subscription.probe_with_home. Otherwise unchanged from phase 3.DELETE /credentials/{backend}(phase 3, unchanged): still only clears BYO key; does NOT touch the HOME.
build_router signature: gained optional plugin_storage_resolver: Optional[Callable[[], Path]] = None. Phase-3 call sites keep working unchanged.
Login flow UX: option A (guided manual setup) — phase-doc default. User runs the shown command in their own terminal; clicks "I've finished" to re-probe. No in-app terminal needed for v1.
Open questions resolved
- Probe helper name:
probe_with_home. - Disconnect helper name:
disconnect. - Disconnect endpoint shape: new sibling
DELETE /credentials/{backend}/connection, not an extension ofDELETE /credentials/{backend}. Keeps phase-3 contract intact. - Login UX: option A.
- Primed-HOME signal: simple "any regular file" check.
Deferred to follow-up
- UI "Connect / Disconnect" affordance in
settings.ts. Backend routes are ready; UI extension is non-trivial without Playwright tests and was outside this phase's brief. Can land as a 6.5 sub-phase or alongside phase 7. - Opt-in
supports_byo_subscription = Truefor Codex/OpenCode/Gemini — each requires per-CLI OAuth-flow validation. Defer until needed.
Phase 7 — Codumentor self-adapter + MCP bridge (2026-05-16)
Shipped the final phase: a self-adapter that talks to another (or the same) Codumentor instance over HTTP, plus an MCP bridge that exposes a curated set of Codumentor tools to external agents inside their sandbox. The phase-1 AgentAdapter Protocol absorbed the HTTP transport cleanly via an additive transport: Literal["subprocess", "http"] discriminant — no TransportAdapter abstraction was needed. 35 phase-7 tests all pass. make test 5095 (5060 + 35). Test-first held; impl made every locked contract pass without weakening assertions.
Contracts established/changed
AgentAdapter Protocol extension (additive, backward-compatible):
class AgentAdapter(Protocol):
transport: Literal["subprocess", "http"] # default "subprocess"
async def call_http(self, *, prompt, session_id, creds) -> AsyncIterator[AgentEvent]
Existing adapters explicitly declare transport: str = "subprocess". Adapters omitting the attribute are treated as subprocess by the runner (getattr(adapter, "transport", "subprocess") — backward compat for any out-of-tree adapters).
Runner HTTP-transport dispatch (runner.py):
transport == "http"→ calladapter.call_http(prompt=…, session_id=… if supports_resume else None, creds=…); skipspawn_fn/build_argv/build_env/parse_streamentirely.- Same session-id capture,
completed-event handling, timeout/error fallback, andAgentResultshape as the subprocess path. [Subagent ID: …]envelope and event emission preserved across both transports.
CodumentorAdapter (adapters/codumentor.py):
name = "codumentor",binary = "n/a",transport = "http",supports_resume = True,supports_stream_json = True,supports_byo_subscription = False.default_allowed_egress = ["127.0.0.1", "localhost"].- HTTP client:
httpx.AsyncClient(existing project dep). - HTTP endpoints called:
POST <base_url>/v1/chat/completionswith{messages: [{role:"user", content:<prompt>}], stream: true, session_id?: <id>}body andAuthorization: Bearer <api_key>header.GET <base_url>/api/healthforprobe_auth.- Seams (picked by impl):
_stream_events(event iter),_probe_request(probe). build_argv/build_env/parse_stream/parse_oneshotare inert stubs (returns[]/{}/ no-op generator / failed AgentResult).
CodumentorTool:
backend = "codumentor", tool name"codumentor".- Constructor signature parity with the four subprocess-backed tools, plus optional
base_url.
MCP bridge (mcp_bridge.py):
LOCKED_TOOLS = ("memory_store", "memory_search", "read_file", "list_files")— hard ceiling, not config-bypassable._TOOL_IMPL: dict[str, async (arguments: dict) -> dict]— currently stubs that echoarguments. Real implementations to be wired in a follow-up sub-phase. The seam is the contract; only the bodies need filling.dispatch_tool(*, tool_name, arguments, permission_provider, configured_tools, tool_call_id="mcp-call") -> dict.- Permission type:
permission_type = f"external_agent.mcp.{tool_name}"(parallels phase-1'sexternal_agent.invoke). - Refusal cases (none invoke
_TOOL_IMPL): 1.tool_name not in LOCKED_TOOLS→{is_error: True, error: …}. 2.tool_name not in configured_tools→ same. 3.permission_provider.request(...)returned False → same. 4. On approval → return_TOOL_IMPLtool_nameresult. start_server(*, tools, permission_provider)— async context manager.- On enter: allocates
<tmpdir>/codumentor-mcp/sock-<uuid>/mcp.sock(mode 0o700 / 0o600), startsasyncio.start_unix_server, yields the socketPath. - On exit: closes server, unlinks socket, removes parent dir.
- Wire framing: newline-delimited JSON
{id, tool, arguments}→{id, ...dispatch_tool result}.
Sandbox MCP-socket injection:
tier0.spawn(..., mcp_socket_path: Optional[Path] = None)— when non-None, copies env (does not mutate caller's dict) and setsCODUMENTOR_MCP_SOCKET=<host-path>.tier1.make_spawn_fn(..., mcp_socket_path: Optional[Path] = None)— when non-None, bwrap argv contains--bind <host> /mcp.sock --setenv CODUMENTOR_MCP_SOCKET /mcp.sock(inserted right after argv[0]).
Per-backend opt-in:
ExternalAgentPlugin._mcp_tools_for(backend_name) -> list[str]reads_backends_cfg[backend].get("expose_mcp_tools", []).ExternalAgentTool.__init__gainedmcp_tools: Optional[list] = None.- When
mcp_toolsis non-empty ANDctx["permission_provider"]is set,execute()entersmcp_bridge.start_server(...)and wrapsspawn_fnvia_wrap_spawn_fn_with_mcpto thread the live socket path into Tier-0 spawn.
Registry:
ADAPTER_REGISTRY["codumentor"] = CodumentorAdapter
Backend config schema (additive):
external_agent:
backends:
codumentor:
mode: byo_key | technical_user
api_key: ${secret:codumentor_token} # bearer for the peer instance
base_url: http://127.0.0.1:8765 # optional override
expose_mcp_tools: [] # inert for HTTP-backed; shape parity
claude-code:
# ...
expose_mcp_tools: [memory_search] # phase-7 opt-in per backend
Open questions resolved (orchestrator pre-decided)
- MCP
_TOOL_IMPLregistry seam made required — denial-without-side-effect is verifiable. - MCP permission-type string locked as
external_agent.mcp.<tool_name>for consistency with phase-1external_agent.invoke. - Protocol extension shape:
transportdiscriminant +call_httpdefault-stub. NOTransportAdapterabstraction.
Deferred to follow-ups
- Real
_TOOL_IMPLbodies — currently stubs echoingarguments. Wirememory_store/memory_searchto the Memory plugin andread_file/list_filesto host-side file ops, all gated by the existing permission provider. - Tier-1 + MCP end-to-end — Tier-1's
make_spawn_fntakes the socket path at factory time, so per-call dynamic-socket use requires either rebuilding the spawn_fn per call or extending Tier-1 with a dynamic-socket seam. Tier-0 + MCP works end-to-end today via_wrap_spawn_fn_with_mcp. - MCP expose-set expansion beyond the four locked tools — out of scope for phase 7; would need a new design pass.
- Cross-instance Codumentor swarms (multiple instances calling each other in a graph) — would need a v2 design pass; current self-adapter handles single-peer.
- Known load-sensitive flake
tests/test_mock_chat_model.py::TestMockChatModelFixtureDelay::test_per_fixture_streaming_delay_is_slower— pre-existing, file's git log shows no recent touches; recommend separate triage with@slowmarker or tolerance widening. Did NOT fire in this run.