Codumentor logo Codumentor

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:

AgentEvent kinds and payload field names:

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:

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/):

Permission contract:

UI plugin manifest (ui/plugins/external_agent/manifest.json):

Behavioural notes worth carrying forward:

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):

Codex env (build_env output):

Codex event → AgentEvent mapping:

Codex typeAgentEvent kindNotes
thread.startedsession_startedsession_id copied verbatim
turn.started(dropped)no analogue
item.reasoningthinkingcontenttext
item.agent_messagemessage_deltacontenttext, item_idmessage_id; also cached for completed.final_text
item.command_executiontool_use + tool_resultboth events share the same item_id as tool_call_id; synthetic name = "Bash"; commandinput.command; outputcontent; exit_code != 0is_error
item.file_changes(dropped)reserved; no kind change
item.mcp_tool_calls(dropped)reserved; no kind change
turn.completedcompletedusage tokens, cost, stop_reason copied verbatim; final_text sourced from the most recent item.agent_message.content seen
errorerrormessage / errormessage; remaining keys → details
anything else(dropped silently)per contract

Adapter-level absorption of phase-1 contract fields Codex doesn't emit directly:

Tool wiring:

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:

  1. item.file_changes / item.mcp_tool_calls are dropped in phase 2; phase 4 (OpenCode/Gemini) may want a backward-compatible new kind (file_change?). Defer until forced.
  2. Claude Code adapter relies on result.result for 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]

Credential resolution priority (in ExternalAgentTool._resolve_credentials):

  1. ctx["user_id"] + ctx["user_secrets_store"] → if byo_key.resolve returns non-None, return Credentials(api_key=<per_user>, home=self.creds.home, extra_env=copy(self.creds.extra_env)).
  2. Otherwise self.creds if it has an api_key.
  3. Otherwise None → emit error event with reason: "auth_required", return failed ToolResult, do not spawn.

Auth-required error event:

EncryptedKVStore key shape (locked):

HTTP routes (mounted under /ui/plugins/external_agent/):

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):

ctx population:

Open questions resolved

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):

OpenCode env (build_env output):

OpenCode event → AgentEvent mapping:

OpenCode typeAgentEvent kind
session.startsession_started (session_id verbatim)
reasoningthinking (text → text)
message (assistant)message_delta + cache for completed.final_text
tool.calltool_use (tool_call_id, name, input verbatim)
tool.outputtool_result (content stringified, is_error from event)
session.completecompleted (tokens, cost, stop_reason verbatim; final_text from latest message)
errorerror (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:

Gemini parse_oneshot:

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:

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):

Egress implementation (soft enforcement in v1):

Plugin storage layout for sandboxes:

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:

Plugin wiring:

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:

Environment requirements for systemd-run memory cap:

Operational notes worth remembering

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):

  1. 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)).
  2. Per-user BYO key (phase 3, unchanged).
  3. Technical-user key (phase 1, unchanged).
  4. None → auth_required error (phase 3, unchanged).

ctx keys read by the tool (production + test):

Adapter opt-in flag:

class <Adapter>:
    supports_byo_subscription: bool = False   # default

HTTP routes (mounted under /ui/plugins/external_agent/):

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

Deferred to follow-up

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):

CodumentorAdapter (adapters/codumentor.py):

CodumentorTool:

MCP bridge (mcp_bridge.py):

Sandbox MCP-socket injection:

Per-backend opt-in:

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)

Deferred to follow-ups

  1. Real _TOOL_IMPL bodies — currently stubs echoing arguments. Wire memory_store/memory_search to the Memory plugin and read_file/list_files to host-side file ops, all gated by the existing permission provider.
  2. Tier-1 + MCP end-to-end — Tier-1's make_spawn_fn takes 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.
  3. MCP expose-set expansion beyond the four locked tools — out of scope for phase 7; would need a new design pass.
  4. Cross-instance Codumentor swarms (multiple instances calling each other in a graph) — would need a v2 design pass; current self-adapter handles single-peer.
  5. 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 @slow marker or tolerance widening. Did NOT fire in this run.